Project import generated by Copybara
Companion SDK version: 1.4.6
Compatible Android Mobile Version: 1.0.0
Compatible IOS Mobile Version: 1.0.0
Release-Id: aae-companiondevice-android_20230424.01_RC00
Change-Id: I45e2e5250d74df34caa3e0a6123bf85d11d222d7
diff --git a/companiondevice/AndroidManifest.xml b/companiondevice/AndroidManifest.xml
index a3796cb..4e921f9 100644
--- a/companiondevice/AndroidManifest.xml
+++ b/companiondevice/AndroidManifest.xml
@@ -223,8 +223,6 @@
android:resource="@string/trusted_device_notification_title"/>
<meta-data android:name="com.google.android.connecteddevice.trust.enrollment_notification_content"
android:resource="@string/trusted_device_notification_content"/>
- <meta-data android:name="com.google.android.connecteddevice.trust.enrollment_notification_color"
- android:resource="@color/car_red_300"/>
</service>
<activity
android:name=".trust.TrustedDeviceActivity"
diff --git a/companiondevice/build.gradle b/companiondevice/build.gradle
index e87477e..c48dc67 100644
--- a/companiondevice/build.gradle
+++ b/companiondevice/build.gradle
@@ -14,7 +14,7 @@
applicationId "com.google.android.companiondevicesupport"
minSdkVersion 29
targetSdkVersion 33
- versionCode 1981
+ versionCode 2077
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
diff --git a/companiondevice/res/layout-w1240dp-land/suw_companion_landing_fragment.xml b/companiondevice/res/layout-w1240dp-land/suw_companion_landing_fragment.xml
deleted file mode 100644
index 44ee88d..0000000
--- a/companiondevice/res/layout-w1240dp-land/suw_companion_landing_fragment.xml
+++ /dev/null
@@ -1,71 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
- ~ Copyright (C) 2021 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.
- -->
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="horizontal"
- android:layout_marginHorizontal="?attr/pageMarginHorizontal">
- <ScrollView
- android:layout_width="0dp"
- android:layout_height="match_parent"
- android:layout_weight="@integer/suw_title_column_weight">
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="vertical"
- android:paddingVertical="?attr/pageMarginVertical"
- android:layout_marginEnd="@dimen/suw_column_inner_padding_horizontal">
- <ImageView
- android:id="@+id/device_icon"
- android:layout_width="?attr/companionPrimaryIconSize"
- android:layout_height="?attr/companionPrimaryIconSize"
- android:src="@drawable/ic_smartphone_24dp"
- android:tint="?attr/companionColorAccent"
- tools:ignore="ContentDescription" />
- <TextView
- android:id="@+id/add_associated_device_title"
- android:text="@string/add_associated_device_title"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginVertical="?attr/contentMarginVertical"
- style="@style/CompanionTitleTextStyle" />
- <TextView
- android:id="@+id/add_associated_device_subtitle"
- android:text="@string/add_associated_device_subtitle"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- style="@style/CompanionSubtitleTextStyle" />
- <View
- android:layout_marginTop="?attr/companionDividerMargin"
- style="@style/HorizontalDividerStyle" />
- <include layout="@layout/add_associated_device_button"/>
- <View style="@style/HorizontalDividerStyle" />
- </LinearLayout>
- </ScrollView>
- <View style="@style/SuwVerticalDividerStyle"/>
- <ScrollView
- android:layout_width="0dp"
- android:layout_height="match_parent"
- android:layout_weight="@integer/suw_content_column_weight">
- <include layout="@layout/association_instructions_vertical"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginVertical="?attr/pageMarginVertical"
- android:layout_marginStart="@dimen/suw_column_inner_padding_horizontal" />
- </ScrollView>
-</LinearLayout>
diff --git a/companiondevice/res/layout-w1240dp-land/suw_companion_setup_profile_fragment.xml b/companiondevice/res/layout-w1240dp-land/suw_companion_setup_profile_fragment.xml
index ebb110e..a333398 100644
--- a/companiondevice/res/layout-w1240dp-land/suw_companion_setup_profile_fragment.xml
+++ b/companiondevice/res/layout-w1240dp-land/suw_companion_setup_profile_fragment.xml
@@ -28,6 +28,14 @@
android:layout_weight="@integer/suw_title_column_weight"
android:paddingVertical="?attr/pageMarginVertical"
android:layout_marginEnd="@dimen/suw_column_inner_padding_horizontal">
+ <ImageView
+ android:id="@+id/device_icon"
+ android:layout_width="?attr/companionPrimaryIconSize"
+ android:layout_height="?attr/companionPrimaryIconSize"
+ android:gravity="center"
+ android:src="@drawable/ic_smartphone_24dp"
+ android:tint="?attr/companionColorAccent"
+ tools:ignore="ContentDescription" />
<TextView
android:id="@+id/suw_setup_profile_title"
android:text="@string/suw_setup_profile_title"
@@ -44,10 +52,9 @@
android:gravity="left"
style="@style/CompanionSubtitleTextStyle" />
</LinearLayout>
- <View style="@style/SuwVerticalDividerStyle"/>
<LinearLayout
android:layout_width="0dp"
- android:layout_height="wrap_content"
+ android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:layout_weight="@integer/suw_content_column_weight"
@@ -61,7 +68,7 @@
<TextView
android:id="@+id/connect_to_car_instruction"
android:text="@string/suw_qr_instruction_text"
- android:layout_width="match_parent"
+ android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginVertical="?attr/contentMarginVertical"
android:gravity="center"
diff --git a/companiondevice/res/layout-w1240dp-land/suw_confirm_code_fragment.xml b/companiondevice/res/layout-w1240dp-land/suw_confirm_code_fragment.xml
index 9272112..e6f32a0 100644
--- a/companiondevice/res/layout-w1240dp-land/suw_confirm_code_fragment.xml
+++ b/companiondevice/res/layout-w1240dp-land/suw_confirm_code_fragment.xml
@@ -52,7 +52,6 @@
style="@style/CompanionSubtitleTextStyle" />
</LinearLayout>
</ScrollView>
- <View style="@style/SuwVerticalDividerStyle"/>
<LinearLayout
android:orientation="vertical"
android:layout_width="0dp"
diff --git a/companiondevice/res/layout/companion_landing_fragment.xml b/companiondevice/res/layout/companion_landing_fragment.xml
deleted file mode 100644
index c79058f..0000000
--- a/companiondevice/res/layout/companion_landing_fragment.xml
+++ /dev/null
@@ -1,62 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
- ~ Copyright (C) 2021 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.
- -->
-
-<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:fillViewport="true">
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="vertical"
- android:gravity="center_horizontal"
- android:paddingVertical="?attr/pageMarginVertical"
- android:layout_marginHorizontal="?attr/pageMarginHorizontal">
- <ImageView
- android:id="@+id/device_icon"
- android:layout_width="?attr/companionPrimaryIconSize"
- android:layout_height="?attr/companionPrimaryIconSize"
- android:gravity="center"
- android:src="@drawable/ic_smartphone_24dp"
- android:tint="?attr/companionColorAccent"
- tools:ignore="ContentDescription" />
- <TextView
- android:id="@+id/add_associated_device_title"
- android:text="@string/add_associated_device_title"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:gravity="center"
- android:layout_marginVertical="?attr/contentMarginVertical"
- style="@style/CompanionTitleTextStyle" />
- <TextView
- android:id="@+id/add_associated_device_subtitle"
- android:text="@string/add_associated_device_subtitle"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:gravity="center"
- style="@style/CompanionSubtitleTextStyle" />
- <View
- android:layout_marginTop="?attr/companionDividerMargin"
- style="@style/HorizontalDividerStyle" />
- <include layout="@layout/add_associated_device_button"/>
- <View
- android:layout_marginBottom="?attr/companionDividerMargin"
- style="@style/HorizontalDividerStyle" />
- <include layout="@layout/association_instructions_horizontal"/>
- </LinearLayout>
-</ScrollView>
diff --git a/companiondevice/res/layout/suw_companion_landing_fragment.xml b/companiondevice/res/layout/suw_companion_landing_fragment.xml
deleted file mode 100644
index e110246..0000000
--- a/companiondevice/res/layout/suw_companion_landing_fragment.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
- ~ Copyright (C) 2021 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.
- -->
-
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="match_parent"
- android:layout_height="match_parent" >
- <include layout="@layout/companion_landing_fragment" />
-</FrameLayout>
diff --git a/companiondevice/res/layout/suw_companion_setup_profile_fragment.xml b/companiondevice/res/layout/suw_companion_setup_profile_fragment.xml
index a1a12d2..20d03ad 100644
--- a/companiondevice/res/layout/suw_companion_setup_profile_fragment.xml
+++ b/companiondevice/res/layout/suw_companion_setup_profile_fragment.xml
@@ -27,6 +27,14 @@
android:gravity="center_horizontal"
android:paddingVertical="?attr/pageMarginVertical"
android:layout_marginHorizontal="?attr/pageMarginHorizontal">
+ <ImageView
+ android:id="@+id/device_icon"
+ android:layout_width="?attr/companionPrimaryIconSize"
+ android:layout_height="?attr/companionPrimaryIconSize"
+ android:gravity="center"
+ android:src="@drawable/ic_smartphone_24dp"
+ android:tint="?attr/companionColorAccent"
+ tools:ignore="ContentDescription" />
<TextView
android:id="@+id/suw_setup_profile_title"
android:text="@string/suw_setup_profile_title"
@@ -42,9 +50,6 @@
android:layout_height="wrap_content"
android:gravity="center"
style="@style/CompanionSubtitleTextStyle" />
- <View
- android:layout_marginBottom="?attr/companionDividerMargin"
- style="@style/HorizontalDividerStyle" />
<ImageView
android:id="@+id/qr_code"
android:layout_width="@dimen/qr_code_size"
diff --git a/companiondevice/res/values/colors.xml b/companiondevice/res/values/colors.xml
index 14520d0..26fe15f 100644
--- a/companiondevice/res/values/colors.xml
+++ b/companiondevice/res/values/colors.xml
@@ -21,7 +21,6 @@
<color name="connection_color_not_detected">#C4C4C4</color>
<color name="connection_color_detected">#FFC107</color>
- <color name="car_red_300">@*android:color/car_red_300</color>
<color name="divider_color">#5F6368</color>
<!-- Settings -->
diff --git a/companiondevice/res/values/config.xml b/companiondevice/res/values/config.xml
index 9a9c95c..5b089c2 100644
--- a/companiondevice/res/values/config.xml
+++ b/companiondevice/res/values/config.xml
@@ -54,6 +54,5 @@
</string-array>
<bool name="enable_proxy">false</bool>
- <bool name="enable_qr_code">true</bool>
<bool name="enable_passenger">false</bool>
</resources>
diff --git a/companiondevice/res/values/strings.xml b/companiondevice/res/values/strings.xml
index 2044a5a..44921fd 100644
--- a/companiondevice/res/values/strings.xml
+++ b/companiondevice/res/values/strings.xml
@@ -39,7 +39,7 @@
<!-- Instruction for connecting to car [CHAR LIMIT=100] -->
<string name="connect_to_targe_car_instruction_text">Connect to <b><xliff:g id="car_name" example="MyVehicle">%1$s</xliff:g></b> <xliff:g id="advertised_car_name" example="(Vehicle 0000)">%2$s</xliff:g></string>
<!-- Instruction for scanning QR code [CHAR LIMIT=150] -->
- <string name="qr_instruction_text">Use your phone to scan the QR code or open MyCompanion to connect to <b><xliff:g id="car_name" example="MyVehicle">%1$s</xliff:g></b> <xliff:g id="advertised_car_name" example="(Vehicle 0000)">%2$s</xliff:g></string>
+ <string name="qr_instruction_text">Use your phone to scan the QR code or open MyCompanion to connect to <b><xliff:g id="advertised_car_name" example="Vehicle 0000">%1$s</xliff:g></b></string>
<!-- Instruction for connecting to car [CHAR LIMIT=100] -->
<string name="connect_to_car_instruction_text">Connect to the car</string>
<!-- Title for confirm pairing code fragment [CHAR LIMIT=60]-->
@@ -207,12 +207,12 @@
<!-- QR code uri path. -->
<string name="uri_path" translatable="false">associate</string>
- <!-- Car SUW setup profile page title. [CHAR LIMIT=40]-->
- <string name="suw_setup_profile_title">Setup profile with phone</string>
+ <!-- Car SUW setup profile page title. [CHAR LIMIT=100]-->
+ <string name="suw_setup_profile_title">Finish setup on your phone or car</string>
<!-- Car SUW setup profile page content. [CHAR LIMIT=200]-->
- <string name="suw_setup_profile_content">Scan QR code to get started. If you don\'t have a MyCompanion app, you can download it on Google Play and the App Store.</string>
+ <string name="suw_setup_profile_content">If you\’ve already started set up on your phone, open your Companion App and scan the QR code.\n\nOr, you can finish setting up your profile in your car.</string>
<!-- Car SUW setup profile page instructions for user to scan the QR code. [CHAR LIMIT=150]-->
- <string name="suw_qr_instruction_text">Scan QR code to connect to<br /> <b><xliff:g id="car_name" example="MyVehicle">%1$s</xliff:g></b> <xliff:g id="advertised_car_name" example="(Vehicle 0000)">%2$s</xliff:g></string>
+ <string name="suw_qr_instruction_text">Scan to connect to <b><xliff:g id="advertised_car_name" example="Vehicle 0000">%1$s</xliff:g></b></string>
<!-- Setting passenger strings' translatable to false until reference UI has been defined. -->
<!-- Text for a claim button on a device that has been claimed by the current user. [CHAR LIMIT=30] -->
diff --git a/companiondevice/src/com/google/android/companiondevicesupport/AssociatedDeviceDetailFragment.java b/companiondevice/src/com/google/android/companiondevicesupport/AssociatedDeviceDetailFragment.java
index 754a491..5a1619d 100644
--- a/companiondevice/src/com/google/android/companiondevicesupport/AssociatedDeviceDetailFragment.java
+++ b/companiondevice/src/com/google/android/companiondevicesupport/AssociatedDeviceDetailFragment.java
@@ -169,17 +169,16 @@
private void setDisabledConnectionStatus(AssociatedDeviceDetails deviceDetails) {
if (deviceDetails.getConnectionState() == ConnectionState.CONNECTED) {
- // The connection status will remain connected during the disconnecting process until the
- // device get disconnected, which means when the connection state is
- // {@code ConnectionState.DISCONNECTED}.
+ // The connection status will changed to disconnecting until the device get disconnected.
setConnectionStatus(
- ContextCompat.getColor(context, R.color.connection_color_connected),
- getString(R.string.connected),
- ContextCompat.getDrawable(context, R.drawable.ic_phonelink_erase_24dp),
- getString(R.string.disconnecting));
+ ContextCompat.getColor(context, R.color.connection_color_disconnected),
+ getString(R.string.disconnecting),
+ ContextCompat.getDrawable(context, R.drawable.ic_phonelink_ring_24dp),
+ getString(R.string.connect));
// Disable the button to avoid user action interrupting the process. The button will be
// re-enabled when the connection state is updated.
this.connectionButton.setEnabled(false);
+ this.connectionButton.setAlpha(.46f);
} else {
setConnectionStatus(
ContextCompat.getColor(context, R.color.connection_color_disconnected),
@@ -202,6 +201,10 @@
getString(R.string.detected),
ContextCompat.getDrawable(context, R.drawable.ic_phonelink_erase_24dp),
getString(R.string.disconnect));
+ // Disable the button to avoid user action interrupting the process. The button will be
+ // re-enabled when the connection state is updated.
+ this.connectionButton.setEnabled(false);
+ this.connectionButton.setAlpha(.46f);
} else {
setConnectionStatus(
ContextCompat.getColor(context, R.color.connection_color_not_detected),
@@ -217,6 +220,7 @@
Drawable connectionIcon,
String connectionText) {
this.connectionButton.setEnabled(true);
+ this.connectionButton.setAlpha(1.0f);
this.connectionStatusText.setText(connectionStatusText);
connectionStatusIndicator.setColorFilter(connectionStatusColor);
this.connectionText.setText(connectionText);
diff --git a/companiondevice/src/com/google/android/companiondevicesupport/AssociationBaseActivity.java b/companiondevice/src/com/google/android/companiondevicesupport/AssociationBaseActivity.java
index ddb63a0..22d256b 100644
--- a/companiondevice/src/com/google/android/companiondevicesupport/AssociationBaseActivity.java
+++ b/companiondevice/src/com/google/android/companiondevicesupport/AssociationBaseActivity.java
@@ -160,9 +160,7 @@
@Override
protected void onStop() {
super.onStop();
- // Resets the UI/model when activity goes into the background during association.
model.stopAssociation();
- finish();
}
/** Companion activity is not supported under guest profile. */
@@ -256,7 +254,7 @@
runOnUiThread(
() ->
Toast.makeText(
- getApplicationContext(),
+ this,
getString(R.string.continue_setup_toast_text),
Toast.LENGTH_SHORT)
.show());
@@ -451,24 +449,7 @@
private void showCompanionLandingFragment() {
maybeClearDetailsFragmentFromBackstack();
-
- if (getResources().getBoolean(R.bool.enable_qr_code)) {
- logd(TAG, "Showing LandingFragment with QR code.");
- showCompanionQrCodeLandingFragment();
- return;
- }
- CompanionLandingFragment fragment =
- (CompanionLandingFragment)
- getSupportFragmentManager().findFragmentByTag(COMPANION_LANDING_FRAGMENT_TAG);
- if (fragment != null && fragment.isVisible()) {
- return;
- }
- dismissButtons();
- fragment = CompanionLandingFragment.newInstance(isStartedForSuw);
- launchFragment(fragment, COMPANION_LANDING_FRAGMENT_TAG);
- }
-
- private void showCompanionQrCodeLandingFragment() {
+ logd(TAG, "Showing LandingFragment with QR code.");
CompanionQrCodeLandingFragment fragment =
(CompanionQrCodeLandingFragment)
getSupportFragmentManager().findFragmentByTag(COMPANION_LANDING_FRAGMENT_TAG);
diff --git a/companiondevice/src/com/google/android/companiondevicesupport/CompanionLandingFragment.java b/companiondevice/src/com/google/android/companiondevicesupport/CompanionLandingFragment.java
deleted file mode 100644
index 2d21bb3..0000000
--- a/companiondevice/src/com/google/android/companiondevicesupport/CompanionLandingFragment.java
+++ /dev/null
@@ -1,108 +0,0 @@
-/*
- * Copyright (C) 2021 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.companiondevicesupport;
-
-import static com.google.android.connecteddevice.util.SafeLog.loge;
-
-import android.bluetooth.BluetoothAdapter;
-import android.os.Bundle;
-import androidx.fragment.app.Fragment;
-import android.text.Html;
-import android.text.Spanned;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.TextView;
-import androidx.annotation.LayoutRes;
-import androidx.lifecycle.ViewModelProvider;
-import com.google.android.connecteddevice.model.TransportProtocols;
-import com.google.android.connecteddevice.ui.AssociatedDeviceViewModel;
-import com.google.android.connecteddevice.ui.AssociatedDeviceViewModelFactory;
-import java.util.Arrays;
-import java.util.List;
-
-/** Fragment that provides association instructions. */
-public class CompanionLandingFragment extends Fragment {
- private static final String IS_STARTED_FOR_SUW_KEY = "isStartedForSuw";
- private static final String TAG = "CompanionLandingFragment";
-
- /**
- * Creates a new instance of {@link CompanionLandingFragment}.
- *
- * @param isStartedForSUW If the fragment is created for car setup wizard.
- * @return {@link CompanionLandingFragment} instance.
- */
- static CompanionLandingFragment newInstance(boolean isStartedForSUW) {
- Bundle bundle = new Bundle();
- bundle.putBoolean(IS_STARTED_FOR_SUW_KEY, isStartedForSUW);
- CompanionLandingFragment fragment = new CompanionLandingFragment();
- fragment.setArguments(bundle);
- return fragment;
- }
-
- @Override
- public View onCreateView(
- LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
- @LayoutRes
- int layout =
- getArguments().getBoolean(IS_STARTED_FOR_SUW_KEY)
- ? R.layout.suw_companion_landing_fragment
- : R.layout.companion_landing_fragment;
- return inflater.inflate(layout, container, false);
- }
-
- @Override
- public void onViewCreated(View view, Bundle bundle) {
- List<String> transportProtocols =
- Arrays.asList(getResources().getStringArray(R.array.transport_protocols));
- AssociatedDeviceViewModel model =
- new ViewModelProvider(
- requireActivity(),
- new AssociatedDeviceViewModelFactory(
- requireActivity().getApplication(),
- transportProtocols.contains(TransportProtocols.PROTOCOL_SPP),
- getResources().getString(R.string.ble_device_name_prefix),
- getResources().getBoolean(R.bool.enable_passenger)))
- .get(AssociatedDeviceViewModel.class);
- TextView connectToCarTextView = view.findViewById(R.id.connect_to_car_text);
- model
- .getAdvertisedCarName()
- .observe(/* owner= */ this, carName -> setCarName(connectToCarTextView, carName));
- view.findViewById(R.id.add_associated_device_button)
- .setOnClickListener(l -> ((AssociationBaseActivity) getActivity()).startAssociation());
- }
-
- private void setCarName(TextView textView, String carName) {
- if (textView == null) {
- loge(TAG, "No valid TextView to show device name.");
- return;
- }
- if (carName == null) {
- return;
- }
- if (!carName.isEmpty()) {
- // Embedded BLE name inside the parenthesis indicating it is just another representatives of
- // the device.
- carName = "(" + carName + ")";
- }
- String bluetoothName = BluetoothAdapter.getDefaultAdapter().getName();
- String connectToCarText =
- getString(R.string.connect_to_targe_car_instruction_text, bluetoothName, carName);
- Spanned styledConnectToCarText = Html.fromHtml(connectToCarText, Html.FROM_HTML_MODE_LEGACY);
- textView.setText(styledConnectToCarText);
- }
-}
diff --git a/companiondevice/src/com/google/android/companiondevicesupport/CompanionQrCodeLandingFragment.java b/companiondevice/src/com/google/android/companiondevicesupport/CompanionQrCodeLandingFragment.java
index ac04238..80d9a2a 100644
--- a/companiondevice/src/com/google/android/companiondevicesupport/CompanionQrCodeLandingFragment.java
+++ b/companiondevice/src/com/google/android/companiondevicesupport/CompanionQrCodeLandingFragment.java
@@ -19,7 +19,6 @@
import static com.google.android.connecteddevice.util.SafeLog.logd;
import static com.google.android.connecteddevice.util.SafeLog.loge;
-import android.bluetooth.BluetoothAdapter;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Bundle;
@@ -127,15 +126,9 @@
if (carName == null) {
return;
}
- if (!carName.isEmpty()) {
- // Embedded BLE name inside the parenthesis indicating it is just another representatives of
- // the device.
- carName = "(" + carName + ")";
- }
- String bluetoothName = BluetoothAdapter.getDefaultAdapter().getName();
int textId =
isStartedForSetupProfile ? R.string.suw_qr_instruction_text : R.string.qr_instruction_text;
- String connectToCarText = getString(textId, bluetoothName, carName);
+ String connectToCarText = getString(textId, carName);
Spanned styledConnectToCarText = Html.fromHtml(connectToCarText, Html.FROM_HTML_MODE_LEGACY);
textView.setText(styledConnectToCarText);
}
diff --git a/libs/companionprotos/src/android_build.gradle b/libs/companionprotos/src/android_build.gradle
new file mode 100644
index 0000000..2954677
--- /dev/null
+++ b/libs/companionprotos/src/android_build.gradle
@@ -0,0 +1,34 @@
+apply plugin: 'java-library'
+apply plugin: 'com.google.protobuf'
+
+sourceCompatibility = '1.8'
+
+sourceSets {
+ main {
+ proto {
+ srcDir 'src'
+ }
+ }
+}
+
+dependencies {
+ implementation "com.google.protobuf:protobuf-java:3.21.12"
+}
+
+protobuf {
+ protoc {
+ artifact = rootProject.ext.protocVersion
+ }
+ generateProtoTasks {
+ all().each { task ->
+ task.builtins {
+ java {
+ option "lite"
+ }
+ kotlin {
+ option "lite"
+ }
+ }
+ }
+ }
+}
diff --git a/libs/companionprotos/src/system_query.proto b/libs/companionprotos/src/system_query.proto
index 286a96e..c02cace 100644
--- a/libs/companionprotos/src/system_query.proto
+++ b/libs/companionprotos/src/system_query.proto
@@ -19,7 +19,6 @@
package com.google.companionprotos;
option java_package = "com.google.android.companionprotos";
-
option java_multiple_files = true;
// Potential system query types.
@@ -38,12 +37,21 @@
// Query that returns the current role of the user.
USER_ROLE = 3;
+
+ // Query that returns whether the companion platform on the other side
+ // (phone/IHU) supports the queried features. Field |payloads| in the
+ // SystemQuery proto will be a list of strings that each represents a
+ // feature recipient UUID.
+ IS_FEATURE_SUPPORTED = 4;
}
// Definition proto for a system query.
message SystemQuery {
// Type indication for this query.
SystemQueryType type = 1;
+
+ // Payload that accompanies the query.
+ repeated bytes payloads = 2;
}
// Potential user roles.
@@ -67,3 +75,17 @@
// The user's current role.
SystemUserRole role = 1;
}
+
+// Response to a feature support status query.
+message FeatureSupportResponse {
+ repeated FeatureSupportStatus statuses = 1;
+}
+
+// The support status of a feature.
+message FeatureSupportStatus {
+ // The feature that is being queried.
+ string feature_id = 1;
+
+ // Whether the feature is supported.
+ bool is_supported = 2;
+}
diff --git a/libs/connecteddevice/res/values/config.xml b/libs/connecteddevice/res/values/config.xml
index 9496359..03290c8 100644
--- a/libs/connecteddevice/res/values/config.xml
+++ b/libs/connecteddevice/res/values/config.xml
@@ -17,7 +17,7 @@
<resources>
<!-- Current version of the SDK -->
- <string name="hu_companion_sdk_version" translatable="false">1.4.5</string>
+ <string name="hu_companion_sdk_version" translatable="false">1.4.6</string>
<integer name="hu_companion_binder_version" translatable="false">0</integer>
<!-- Mobile SDK of later version should be compatible with the HU SDK. -->
<string name="compatible_min_mobile_version_android" translatable="false">1.0.0</string>
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/api/CompanionApiProxy.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/api/CompanionApiProxy.kt
new file mode 100644
index 0000000..98c39c0
--- /dev/null
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/api/CompanionApiProxy.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2023 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.api
+
+import android.os.ParcelUuid
+import com.google.android.connecteddevice.api.external.ISafeOnLogRequestedListener
+import com.google.android.connecteddevice.api.SafeConnector.QueryCallback
+import android.os.IInterface
+
+/** Wrapper class for ensuring any Feature Coordinator APIs called are version-compliant. */
+interface CompanionApiProxy {
+
+ val queryCallbacks: MutableMap<Int, QueryCallback>
+
+ val queryResponseRecipients: MutableMap<Int, ParcelUuid>
+
+ val listener: ISafeOnLogRequestedListener
+
+ /**
+ * Retrieves connected devices associated with the current user.
+ *
+ * @return list of identifiers of connected devices associated with the current user, null if this
+ * action is not supported by the on-device companion platform.
+ */
+ fun getConnectedDevices(): List<String>?
+
+ /**
+ * Sends a message to a connected device.
+ *
+ * @param deviceId Identifier for the recipient connected device.
+ * @param message Message to send to the connected device.
+ * @return true if on-device companion platform supports this action, false otherwise.
+ */
+ fun sendMessage(deviceId: String, message: ByteArray): Boolean
+
+ /**
+ * Processes log records in the logger with given identifier so it can be combined with log
+ * records from other loggers.
+ *
+ * @param loggerId of the logger.
+ * @param logRecords to process.
+ * @return true if on-device companion platform supports this action, false otherwise.
+ */
+ fun processLogRecords(loggerId: Int, logRecords: ByteArray): Boolean
+
+ /**
+ * Retrieves all associated devices.
+ *
+ * @param listener that will be notified when the associated devices are retrieved.
+ * @return true if on-device companion platform supports this action, false otherwise.
+ */
+ fun retrieveAssociatedDevices(listener: IInterface): Boolean
+
+ /**
+ * Clears internal status, no Companion events will be notified after this call.
+ */
+ fun cleanUp()
+
+ companion object {
+ /** All legacy APIs will be designated version zero. */
+ const val LEGACY_VERSION = 0
+ }
+}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/api/CompanionConnector.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/api/CompanionConnector.kt
index b8a7fd2..781062c 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/api/CompanionConnector.kt
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/api/CompanionConnector.kt
@@ -27,6 +27,7 @@
import android.os.RemoteException
import androidx.annotation.GuardedBy
import androidx.annotation.VisibleForTesting
+import com.google.android.companionprotos.FeatureSupportResponse
import com.google.android.companionprotos.Query
import com.google.android.companionprotos.QueryResponse
import com.google.android.companionprotos.SystemQuery
@@ -48,16 +49,23 @@
import com.google.android.connecteddevice.util.Logger
import com.google.android.connecteddevice.util.SafeLog
import com.google.android.connecteddevice.util.aliveOrNull
+import com.google.common.util.concurrent.ListenableFuture
import com.google.protobuf.ByteString
import com.google.protobuf.ExtensionRegistryLite
import com.google.protobuf.InvalidProtocolBufferException
import java.nio.charset.StandardCharsets
import java.time.Duration
+import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
+import kotlin.coroutines.resume
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.guava.future
+import kotlinx.coroutines.suspendCancellableCoroutine
/**
* Class for establishing and maintaining a connection to the companion device platform.
@@ -440,6 +448,79 @@
sendQuerySecurely(device, request, parameters, callback)
}
+ override suspend fun isFeatureSupported(device: ConnectedDevice): Boolean {
+ val queriedFeatureId = featureId?.uuid ?: return false
+ val status =
+ queryFeatureSupportStatuses(device, listOf(queriedFeatureId)).firstOrNull {
+ it.first == queriedFeatureId
+ }
+ return status?.second ?: false
+ }
+
+ override suspend fun queryFeatureSupportStatuses(
+ device: ConnectedDevice,
+ queriedFeatures: List<UUID>
+ ): List<Pair<UUID, Boolean>> {
+ val payloads =
+ queriedFeatures.map {
+ logd("Batch querying support status for feature $it.")
+ ByteString.copyFrom(it.toString().toByteArray(StandardCharsets.UTF_8))
+ }
+
+ val systemQuery =
+ SystemQuery.newBuilder().run {
+ setType(SystemQueryType.IS_FEATURE_SUPPORTED)
+ addAllPayloads(payloads)
+ build()
+ }
+
+ return suspendCancellableCoroutine<List<Pair<UUID, Boolean>>> { continuation ->
+ sendQuerySecurelyInternal(
+ device,
+ SYSTEM_FEATURE_ID,
+ systemQuery.toByteArray(),
+ parameters = null,
+ object : QueryCallback {
+ override fun onSuccess(response: ByteArray) {
+ if (response.isEmpty()) {
+ loge("Received an empty response for feature support query.")
+ continuation.resume(emptyList())
+ return
+ }
+
+ val supportResponse =
+ try {
+ FeatureSupportResponse.parseFrom(response)
+ } catch (e: InvalidProtocolBufferException) {
+ loge("Could not parse query response as proto.", e)
+ continuation.resume(emptyList())
+ return
+ }
+ val statuses =
+ supportResponse.statusesList.map { status ->
+ Pair(UUID.fromString(status.featureId), status.isSupported)
+ }
+ continuation.resume(statuses)
+ }
+
+ override fun onError(response: ByteArray) {
+ loge("Received an error response when querying for feature support.")
+ continuation.resume(emptyList())
+ }
+
+ override fun onQueryFailedToSend(isTransient: Boolean) {
+ loge("Failed to send the query for the feature support status.")
+ continuation.resume(emptyList())
+ }
+ }
+ )
+ }
+ }
+
+ override fun isFeatureSupportedFuture(device: ConnectedDevice): ListenableFuture<Boolean> {
+ return CoroutineScope(Dispatchers.Main).future { isFeatureSupported(device) }
+ }
+
override fun sendQuerySecurely(
device: ConnectedDevice,
request: ByteArray,
@@ -562,7 +643,7 @@
systemQuery.toByteArray(),
parameters = null,
object : QueryCallback {
- override fun onSuccess(response: ByteArray?) {
+ override fun onSuccess(response: ByteArray) {
if (response == null || response.isEmpty()) {
loge("Received a null or empty response for the application name.")
callback.onError()
@@ -573,7 +654,7 @@
callback.onNameReceived(appName)
}
- override fun onError(response: ByteArray?) {
+ override fun onError(response: ByteArray) {
loge("Received an error response when querying for application name.")
callback.onError()
}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/api/Connector.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/api/Connector.kt
index 0ccbbdc..d1c74a6 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/api/Connector.kt
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/api/Connector.kt
@@ -22,6 +22,8 @@
import androidx.annotation.IntDef
import com.google.android.connecteddevice.model.AssociatedDevice
import com.google.android.connecteddevice.model.ConnectedDevice
+import com.google.common.util.concurrent.ListenableFuture
+import java.util.UUID
/** Class for establishing and maintaining a connection to the companion device platform. */
interface Connector {
@@ -57,6 +59,29 @@
/** Securely sends message to a device. */
fun sendMessageSecurely(device: ConnectedDevice, message: ByteArray)
+ /** Returns whether this feature is supported by the [device]. */
+ suspend fun isFeatureSupported(device: ConnectedDevice): Boolean
+
+ /**
+ * Checks whether this feature is supported by the [device].
+ *
+ * This method is added for java-compatibility; prefer the suspend version in Kotlin.
+ */
+ fun isFeatureSupportedFuture(device: ConnectedDevice): ListenableFuture<Boolean>
+
+ /**
+ * Batch queries whether the listed features are supported by the [device].
+ *
+ * Returns an empty list if there was any error during the query.
+ *
+ * Use [isFeatureSupported] instead. This API is intended for the companion platform
+ * functionality.
+ */
+ suspend fun queryFeatureSupportStatuses(
+ device: ConnectedDevice,
+ queriedFeatures: List<UUID>
+ ): List<Pair<UUID, Boolean>>
+
/** Securely send a query to a device and registers a [QueryCallback] for a response. */
fun sendQuerySecurely(
deviceId: String,
@@ -105,7 +130,7 @@
/** Stops the association process. */
fun stopAssociation()
- /** Confirms the paring code. */
+ /** Confirms the pairing code. */
fun acceptVerification()
/** Remove the associated device of the given [deviceId]. */
@@ -172,7 +197,7 @@
* @param deviceId Id of the device the message failed to send to.
* @param message Message to send.
* @param isTransient `true` if cause of failure is transient and can be retried. `false` if
- * failure is permanent.
+ * failure is permanent.
*/
fun onMessageFailedToSend(deviceId: String, message: ByteArray, isTransient: Boolean) {}
@@ -203,10 +228,10 @@
/** Callback for a query response. */
interface QueryCallback {
/** Invoked with a successful response to a query. */
- fun onSuccess(response: ByteArray?) {}
+ fun onSuccess(response: ByteArray) {}
/** Invoked with an unsuccessful response to a query. */
- fun onError(response: ByteArray?) {}
+ fun onError(response: ByteArray) {}
/**
* Invoked when a query failed to send to the device. `isTransient` is set to `true` if cause of
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/api/FeatureConnector.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/api/FeatureConnector.kt
new file mode 100644
index 0000000..0056128
--- /dev/null
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/api/FeatureConnector.kt
@@ -0,0 +1,423 @@
+/*
+ * Copyright (C) 2023 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.api
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.content.pm.PackageManager
+import android.os.Handler
+import android.os.IBinder
+import android.os.IInterface
+import android.os.Looper
+import android.os.ParcelUuid
+import android.os.RemoteException
+import androidx.annotation.VisibleForTesting
+import com.google.android.companionprotos.Query
+import com.google.android.companionprotos.QueryResponse
+import com.google.android.companionprotos.SystemQuery
+import com.google.android.companionprotos.SystemQueryType
+import com.google.android.connecteddevice.api.SafeConnector.AppNameCallback
+import com.google.android.connecteddevice.api.SafeConnector.Companion.ACTION_BIND_FEATURE_COORDINATOR
+import com.google.android.connecteddevice.api.SafeConnector.Companion.ACTION_QUERY_API_VERSION
+import com.google.android.connecteddevice.api.SafeConnector.QueryCallback
+import com.google.android.connecteddevice.api.external.ISafeBinderVersion
+import com.google.android.connecteddevice.api.external.ISafeFeatureCoordinator
+import com.google.android.connecteddevice.api.external.ISafeOnAssociatedDevicesRetrievedListener
+import com.google.android.connecteddevice.api.external.ISafeOnLogRequestedListener
+import com.google.android.connecteddevice.util.ByteUtils
+import com.google.android.connecteddevice.util.Logger
+import com.google.android.connecteddevice.util.SafeLog
+import com.google.protobuf.ByteString
+import java.nio.charset.StandardCharsets
+import java.time.Duration
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.locks.ReentrantLock
+
+/**
+ * Class for establishing and maintaining a connection between external features and the companion
+ * device platform.
+ *
+ * @param context [Context] of the hosting process.
+ * @param featureId Identifier of the feature that is running this connector.
+ * @param callback Callback associated with this connector.
+ * @param minSupportedVersion External feature's minimum supported Companion API version.
+ */
+class FeatureConnector (
+ private val context: Context,
+ override val featureId: ParcelUuid,
+ override val callback: SafeConnector.Callback,
+ private val minSupportedVersion: Int = 0
+) : SafeConnector {
+
+ @VisibleForTesting internal var bindAttempts = 0
+
+ private val lock = ReentrantLock()
+
+ private val retryHandler = Handler(Looper.getMainLooper())
+
+ private val loggerId = Logger.getLogger().loggerId
+
+ private val waitingForConnection = AtomicBoolean(true)
+
+ private val queryIdGenerator = QueryIdGenerator()
+
+ override val connectedDevices: List<String>
+ get() = coordinatorProxy?.getConnectedDevices() ?: emptyList()
+
+ /** [CompanionApiProxy] acting as a wrapper for feature coordinator calls */
+ @VisibleForTesting internal var coordinatorProxy: CompanionApiProxy? = null
+
+ @VisibleForTesting internal var platformVersion: Int? = null
+
+ @VisibleForTesting
+ internal val versionCheckConnection =
+ object : ServiceConnection {
+ override fun onServiceConnected(name: ComponentName, service: IBinder) {
+ if (service !is ISafeBinderVersion) {
+ logd("Unexpected binder received from platform. Aborting.")
+ callback.onFailedToConnect()
+ return
+ } else if (minSupportedVersion > service.getVersion()) {
+ loge("Incompatible client and platform versions detected. Aborting.")
+ callback.onApiNotSupported()
+ return
+ } else {
+ platformVersion = service.getVersion()
+ bindAttempts = 0
+ bindToService(ACTION_BIND_FEATURE_COORDINATOR, featureCoordinatorConnection)
+ }
+ }
+
+ override fun onServiceDisconnected(name: ComponentName) {
+ this@FeatureConnector.onServiceDisconnected()
+ }
+
+ override fun onNullBinding(name: ComponentName) {
+ logd("Connecting to a companion platform of version 0.")
+ platformVersion = 0
+ bindAttempts = 0
+ bindToService(ACTION_BIND_FEATURE_COORDINATOR, featureCoordinatorConnection)
+ }
+
+ override fun onBindingDied(name: ComponentName?) {
+ this@FeatureConnector.onBindingDied()
+ }
+ }
+
+ @VisibleForTesting
+ internal val featureCoordinatorConnection =
+ object : ServiceConnection {
+ override fun onServiceConnected(name: ComponentName, service: IBinder) {
+ logd("Feature coordinator binder connected.")
+ val platformVersion = platformVersion
+ if (platformVersion == null) {
+ loge("Incompatible companion platform version. Aborting.")
+ return
+ }
+ coordinatorProxy =
+ when {
+ platformVersion > 0 ->
+ SafeApiProxy(
+ ISafeFeatureCoordinator.Stub.asInterface(service),
+ featureId,
+ callback,
+ loggerId,
+ platformVersion
+ )
+ platformVersion == 0 ->
+ LegacyApiProxy(
+ IFeatureCoordinator.Stub.asInterface(service),
+ featureId,
+ callback,
+ loggerId,
+ platformVersion
+ )
+ else -> {
+ loge("Incompatible companion platform version. Aborting.")
+ return
+ }
+ }
+ logd("FeatureCoordinator initialized.")
+ waitingForConnection.set(false)
+ callback.onConnected()
+ }
+
+ override fun onServiceDisconnected(name: ComponentName) {
+ this@FeatureConnector.onServiceDisconnected()
+ }
+
+ override fun onNullBinding(name: ComponentName) {
+ this@FeatureConnector.onNullBinding()
+ }
+
+ override fun onBindingDied(name: ComponentName?) {
+ this@FeatureConnector.onBindingDied()
+ }
+ }
+
+ init {
+ logd("Initiating connection to companion platform.")
+ bindToService(ACTION_QUERY_API_VERSION, versionCheckConnection)
+ }
+
+ private fun bindToService(action: String, serviceConnection: ServiceConnection) {
+ val intent = resolveIntent(action)
+
+ if (intent == null) {
+ loge("No services found supporting companion device. Aborting.")
+ callback.onFailedToConnect()
+ return
+ }
+
+ val success = context.bindService(intent, serviceConnection, /* flag= */ 0)
+ if (success) {
+ logd("Successfully started binding with ${intent.action}.")
+ return
+ }
+
+ bindAttempts++
+ if (bindAttempts > MAX_BIND_ATTEMPTS) {
+ loge("Failed to bind to service after $bindAttempts attempts. Aborting.")
+ waitingForConnection.set(false)
+ callback.onFailedToConnect()
+ return
+ }
+ retryHandler.postDelayed(
+ {
+ logw("Unable to bind to service with action ${intent.action}. Trying again.")
+ bindToService(action, serviceConnection)
+ },
+ BIND_RETRY_DURATION.toMillis()
+ )
+ }
+
+ private fun resolveIntent(action: String): Intent? {
+ val packageManager = context.packageManager
+ val intent = Intent(action)
+ val services = packageManager.queryIntentServices(intent, PackageManager.MATCH_DEFAULT_ONLY)
+ if (services.isEmpty()) {
+ logw("There are no services supporting the $action action installed on this device.")
+ return null
+ }
+ logd("Found ${services.size} service(s) supporting $action. Choosing the first one.")
+ val service = services[0]
+ return intent.apply {
+ component = ComponentName(service.serviceInfo.packageName, service.serviceInfo.name)
+ }
+ }
+
+ override fun cleanUp() {
+ logd("Disconnecting from the companion platform.")
+ coordinatorProxy?.cleanUp()
+ coordinatorProxy = null
+ unbindFromService()
+ callback.onDisconnected()
+ }
+
+ private fun unbindFromService() {
+ retryHandler.removeCallbacksAndMessages(/* token= */ null)
+ try {
+ context.unbindService(featureCoordinatorConnection)
+ } catch (e: IllegalArgumentException) {
+ logw("Attempted to unbind an already unbound service.")
+ }
+ waitingForConnection.set(false)
+ }
+
+ private fun onServiceDisconnected() {
+ logd("Service has disconnected. Cleaning up.")
+ cleanUp()
+ }
+
+ private fun onNullBinding() {
+ loge("Received a null binding for FeatureCoordinator. Unbinding service.")
+ unbindFromService()
+ callback.onFailedToConnect()
+ }
+
+ private fun onBindingDied() {
+ logw("FeatureCoordinator binding died. Unbinding service.")
+ unbindFromService()
+ callback.onDisconnected()
+ }
+
+ override fun sendMessage(deviceId: String, message: ByteArray) {
+ val coordinatorProxy = coordinatorProxy
+ if (coordinatorProxy == null) {
+ loge("Unable to send message with a null feature coordinator.")
+ callback.onMessageFailedToSend(deviceId, message, isTransient = true)
+ return
+ }
+ if (!connectedDevices.contains(deviceId)) {
+ loge("No matching device found with id $deviceId when trying to send secure message.")
+ callback.onMessageFailedToSend(deviceId, message, isTransient = false)
+ return
+ }
+ if (!coordinatorProxy.sendMessage(deviceId, message)) {
+ loge("Feature coordinator failed to send message.")
+ callback.onMessageFailedToSend(deviceId, message, isTransient = false)
+ }
+ }
+
+ override fun sendQuery(
+ deviceId: String,
+ request: ByteArray,
+ parameters: ByteArray?,
+ queryCallback: QueryCallback
+ ) {
+ val coordinatorProxy = coordinatorProxy
+ if (coordinatorProxy == null) {
+ loge("Unable to send query with a null feature coordinator.")
+ queryCallback.onQueryFailedToSend(isTransient = false)
+ return
+ }
+ if (!connectedDevices.contains(deviceId)) {
+ loge("No matching device found with id $deviceId when trying to send a query.")
+ queryCallback.onQueryFailedToSend(isTransient = false)
+ return
+ }
+ val id = queryIdGenerator.next()
+ val builder =
+ Query.newBuilder()
+ .setId(id)
+ .setSender(ByteString.copyFrom(ByteUtils.uuidToBytes(featureId.uuid)))
+ .setRequest(ByteString.copyFrom(request))
+ if (parameters != null) {
+ builder.parameters = ByteString.copyFrom(parameters)
+ }
+ logd("Sending secure query with id $id.")
+ if (!coordinatorProxy.sendMessage(deviceId, builder.build().toByteArray())) {
+ loge("Error while sending query.")
+ queryCallback.onQueryFailedToSend(isTransient = false)
+ }
+ coordinatorProxy.queryCallbacks[id] = queryCallback
+ }
+
+ override fun respondToQuery(
+ deviceId: String,
+ queryId: Int,
+ success: Boolean,
+ response: ByteArray?
+ ) {
+ val coordinatorProxy = coordinatorProxy
+ if (coordinatorProxy == null) {
+ loge("Unable to respond to query with a null feature coordinator.")
+ return
+ }
+ val recipientId = coordinatorProxy.queryResponseRecipients.remove(queryId)
+ if (recipientId == null) {
+ loge("Unable to send response to unrecognized query $queryId.")
+ return
+ }
+ val builder = QueryResponse.newBuilder().setQueryId(queryId).setSuccess(success)
+ if (response != null) {
+ builder.response = ByteString.copyFrom(response)
+ }
+ val queryResponse = builder.build()
+ logd("Sending response to query $queryId to $recipientId.")
+ if (!coordinatorProxy.sendMessage(deviceId, queryResponse.toByteArray())) {
+ loge("Feature coordinator failed to send query.")
+ callback.onMessageFailedToSend(deviceId, queryResponse.toByteArray(), isTransient = false)
+ }
+ }
+
+ override fun retrieveCompanionApplicationName(
+ deviceId: String,
+ appNameCallback: AppNameCallback
+ ) {
+ val systemQuery = SystemQuery.newBuilder().setType(SystemQueryType.APP_NAME).build()
+ sendQuery(
+ deviceId,
+ systemQuery.toByteArray(),
+ parameters = null,
+ object : QueryCallback {
+ override fun onSuccess(response: ByteArray?) {
+ if (response == null || response.isEmpty()) {
+ loge("Received a null or empty response for the application name.")
+ appNameCallback.onError()
+ return
+ }
+ val appName = String(response, StandardCharsets.UTF_8)
+ logd("Received successful app name query response of $appName.")
+ appNameCallback.onNameReceived(appName)
+ }
+
+ override fun onError(response: ByteArray?) {
+ loge("Received an error response when querying for application name.")
+ appNameCallback.onError()
+ }
+
+ override fun onQueryFailedToSend(isTransient: Boolean) {
+ loge("Failed to send the query for the application name.")
+ appNameCallback.onError()
+ }
+ }
+ )
+ }
+
+ override fun retrieveAssociatedDevices(listener: IOnAssociatedDevicesRetrievedListener) {
+ retrieveAssociatedDevicesInternal(listener)
+ }
+
+ override fun retrieveAssociatedDevices(listener: ISafeOnAssociatedDevicesRetrievedListener) {
+ retrieveAssociatedDevicesInternal(listener)
+ }
+
+ private fun retrieveAssociatedDevicesInternal(listener: IInterface) {
+ val coordinatorProxy = coordinatorProxy
+ if (coordinatorProxy == null) {
+ loge("Unable to retrieve associated devices with a null feature coordinator.")
+ return
+ }
+ if (!coordinatorProxy.retrieveAssociatedDevices(listener)) {
+ logw("Failed to retrieve associated devices.")
+ }
+ }
+
+ private fun logd(message: String) {
+ SafeLog.logd(TAG, "$message [Feature ID: $featureId]")
+ }
+
+ private fun logw(message: String) {
+ SafeLog.logw(TAG, "$message [Feature ID: $featureId]")
+ }
+
+ private fun loge(message: String, e: Exception? = null) {
+ SafeLog.loge(TAG, "$message [Feature ID: $featureId]", e)
+ }
+
+ companion object {
+ private const val TAG = "FeatureConnector"
+
+ private val BIND_RETRY_DURATION = Duration.ofSeconds(1)
+
+ @VisibleForTesting internal const val MAX_BIND_ATTEMPTS = 3
+
+ // TODO(alwa) Move this (and QueryIdGenerator in CompanionConnector) to its own internal class.
+ /** A generator of unique IDs for queries. */
+ private class QueryIdGenerator {
+ private val messageId = AtomicInteger(0)
+ fun next(): Int {
+ val current = messageId.getAndIncrement()
+ messageId.compareAndSet(Int.MAX_VALUE, 0)
+ return current
+ }
+ }
+ }
+}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/api/IFeatureCoordinator.aidl b/libs/connecteddevice/src/com/google/android/connecteddevice/api/IFeatureCoordinator.aidl
index d77cedd..590498c 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/api/IFeatureCoordinator.aidl
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/api/IFeatureCoordinator.aidl
@@ -85,6 +85,9 @@
/**
* Registers a callback for a specific connectedDevice and recipient.
*
+ * Duplicate registration with the same [recipientId] will block the
+ * recipient and prevent it from receiving callbacks.
+ *
* @param connectedDevice {@link ConnectedDevice} to register triggers on.
* @param recipientId {@link ParcelUuid} to register as recipient of.
* @param callback {@link IDeviceCallback} to register.
@@ -112,7 +115,7 @@
in DeviceMessage message) = 9;
/**
- * Registers a callback for associated devic related events.
+ * Registers a callback for associated device related events.
*
* @param callback {@link IDeviceAssociationCallback} to register.
*/
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/api/LegacyApiProxy.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/api/LegacyApiProxy.kt
new file mode 100644
index 0000000..2705cd2
--- /dev/null
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/api/LegacyApiProxy.kt
@@ -0,0 +1,259 @@
+/*
+ * Copyright (C) 2023 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.api
+
+import android.os.IInterface
+import android.os.ParcelUuid
+import android.os.RemoteException
+import androidx.annotation.VisibleForTesting
+import com.google.android.companionprotos.Query
+import com.google.android.companionprotos.QueryResponse
+import com.google.android.connecteddevice.api.CompanionApiProxy.Companion.LEGACY_VERSION
+import com.google.android.connecteddevice.api.SafeConnector.QueryCallback
+import com.google.android.connecteddevice.api.external.ISafeOnLogRequestedListener
+import com.google.android.connecteddevice.model.ConnectedDevice
+import com.google.android.connecteddevice.model.DeviceMessage
+import com.google.android.connecteddevice.util.ByteUtils
+import com.google.android.connecteddevice.util.Logger
+import com.google.android.connecteddevice.util.SafeLog.logd
+import com.google.android.connecteddevice.util.SafeLog.loge
+import com.google.android.connecteddevice.util.SafeLog.logw
+import com.google.protobuf.InvalidProtocolBufferException
+import java.util.concurrent.ConcurrentHashMap
+import java.util.UUID
+
+/**
+ * Wrapper for FeatureCoordinator APIs used to make sure that the Companion app's version is
+ * compatible with the APIs it is attempting to call before actually making the call. This class
+ * allows for backwards compatibility with legacy Companion platform versions, and will be deprecated
+ * once all devices have Companion platform updated to version 1.
+ */
+class LegacyApiProxy (
+ private val featureCoordinator: IFeatureCoordinator,
+ private val recipientId: ParcelUuid,
+ private val connectorCallback: SafeConnector.Callback,
+ private val loggerId: Int,
+ private val platformVersion: Int
+) : CompanionApiProxy {
+
+ // queryId -> callback
+ override val queryCallbacks: MutableMap<Int, QueryCallback> = ConcurrentHashMap()
+
+ // queryId -> original sender for response
+ override val queryResponseRecipients: MutableMap<Int, ParcelUuid> = ConcurrentHashMap()
+
+ private val connectionCallback =
+ object : IConnectionCallback.Stub() {
+ override fun onDeviceConnected(device: ConnectedDevice) {
+ logd(TAG, "Device ${device.deviceId} has connected. Notifying callback.")
+ connectorCallback.onDeviceConnected(device.deviceId)
+
+ if (device.hasSecureChannel()) {
+ connectorCallback.onSecureChannelEstablished(device.deviceId)
+ }
+
+ logd(TAG, "Registering device callback for $recipientId on device ${device.deviceId}.")
+ featureCoordinator.registerDeviceCallback(device, recipientId, deviceCallback)
+ }
+
+ override fun onDeviceDisconnected(device: ConnectedDevice) {
+ logd(TAG, "Device ${device.deviceId} has disconnected. Notifying callback.")
+ connectorCallback.onDeviceDisconnected(device.deviceId)
+
+ logd(TAG, "Unregistering device callback for $recipientId on device ${device.deviceId}.")
+ featureCoordinator.unregisterDeviceCallback(device, recipientId, deviceCallback)
+ }
+ }
+
+ @VisibleForTesting
+ internal val deviceCallback =
+ object : IDeviceCallback.Stub() {
+ override fun onSecureChannelEstablished(device: ConnectedDevice) {
+ logd(TAG, "Secure channel has been established on ${device.deviceId}. Notifying callback.")
+ connectorCallback.onSecureChannelEstablished(device.deviceId)
+ }
+
+ override fun onMessageReceived(device: ConnectedDevice, message: DeviceMessage) {
+ processIncomingMessage(device.deviceId, message)
+ }
+
+ override fun onDeviceError(device: ConnectedDevice, error: Int) {
+ logw(TAG, "Received a device error of $error from ${device.deviceId}.")
+ connectorCallback.onDeviceError(device.deviceId, error)
+ }
+ }
+
+ override val listener =
+ object : ISafeOnLogRequestedListener.Stub() {
+ override fun onLogRecordsRequested() {
+ val loggerBytes = Logger.getLogger().toByteArray()
+ if (!processLogRecords(loggerId, loggerBytes)) {
+ logw(TAG, "Failed to process log records for logger $loggerId.")
+ }
+ }
+ }
+
+ init {
+ if (platformVersion >= LEGACY_VERSION) {
+ featureCoordinator.registerAllConnectionCallback(connectionCallback)
+
+ val connectedDevices = getConnectedDevices() ?: emptyList()
+ for (deviceId in connectedDevices) {
+ val connectedDevice = getConnectedDeviceById(deviceId)
+ featureCoordinator.registerDeviceCallback(connectedDevice, recipientId, deviceCallback)
+ }
+
+ featureCoordinator.registerOnLogRequestedListener(loggerId, listener)
+ } else {
+ logw(TAG, "Incompatible Companion platform version.")
+ }
+ }
+
+ override fun getConnectedDevices(): List<String>? {
+ if (platformVersion < LEGACY_VERSION) {
+ logd(TAG, "getConnectedDevices invoked by outdated Companion platform.")
+ return null
+ }
+ return featureCoordinator.getConnectedDevicesForDriver().map { it.deviceId }
+ }
+
+ override fun sendMessage(deviceId: String, message: ByteArray): Boolean {
+ if (platformVersion < LEGACY_VERSION) {
+ logd(TAG, "sendMessage invoked by outdated Companion platform.")
+ return false
+ }
+ val connectedDevice = getConnectedDeviceById(deviceId)
+ if (connectedDevice == null) {
+ logw(TAG, "No connected device found with deviceId $deviceId. Cannot send message.")
+ return false
+ }
+ val deviceMessage =
+ DeviceMessage.createOutgoingMessage(
+ UUID.fromString(deviceId),
+ /* isMessageEncrypted= */ true,
+ DeviceMessage.OperationType.CLIENT_MESSAGE,
+ message
+ )
+ return try {
+ featureCoordinator.sendMessage(connectedDevice, deviceMessage)
+ } catch (e: RemoteException) {
+ loge(TAG, "sendMessage failed with RemoteException.", e)
+ false
+ }
+ }
+
+ private fun getConnectedDeviceById(deviceId: String): ConnectedDevice? {
+ return featureCoordinator
+ .getConnectedDevicesForDriver()
+ .firstOrNull { it.deviceId == deviceId }
+ }
+
+ override fun processLogRecords(loggerId: Int, logRecords: ByteArray): Boolean {
+ if (platformVersion < LEGACY_VERSION) {
+ logd(TAG, "processLogRecords invoked by outdated Companion platform.")
+ return false
+ }
+ try {
+ featureCoordinator.processLogRecords(loggerId, logRecords)
+ } catch (e: RemoteException) {
+ loge(TAG, "Failed to send log records for logger $loggerId.", e)
+ return false
+ }
+ return true
+ }
+
+ override fun retrieveAssociatedDevices(listener: IInterface): Boolean {
+ if (listener !is IOnAssociatedDevicesRetrievedListener) {
+ logd(TAG, "retrieveAssociatedDevices invoked with incorrect callback type.")
+ return false
+ }
+ if (platformVersion < LEGACY_VERSION) {
+ logd(TAG, "retrieveAssociatedDevices invoked by outdated Companion platform.")
+ return false
+ }
+ featureCoordinator.retrieveAssociatedDevices(listener)
+ return true
+ }
+
+ private fun processIncomingMessage(deviceId: String, deviceMessage: DeviceMessage) {
+ val operationType = deviceMessage.operationType
+ val message = deviceMessage.message
+ when (operationType) {
+ DeviceMessage.OperationType.CLIENT_MESSAGE -> {
+ logd(TAG, "Received client message. Passing on to feature.")
+ connectorCallback.onMessageReceived(deviceId, message)
+ return
+ }
+ DeviceMessage.OperationType.QUERY -> {
+ try {
+ val query = Query.parseFrom(message)
+ logd(TAG, "Received a new query with id ${query.id}. Passing on to feature.")
+ val sender = ParcelUuid(ByteUtils.bytesToUUID(query.sender.toByteArray()))
+ queryResponseRecipients[query.id] = sender
+ connectorCallback.onQueryReceived(
+ deviceId,
+ query.id,
+ query.request.toByteArray(),
+ query.parameters.toByteArray()
+ )
+ } catch (e: InvalidProtocolBufferException) {
+ loge(TAG, "Unable to parse query.", e)
+ }
+ return
+ }
+ DeviceMessage.OperationType.QUERY_RESPONSE -> {
+ try {
+ val response = QueryResponse.parseFrom(message)
+ logd(TAG, "Received a query response. Issuing registered callback.")
+ val callback = queryCallbacks.remove(response.queryId)
+ if (callback == null) {
+ loge(TAG, "Unable to locate callback for query ${response.queryId}.")
+ return
+ }
+ if (response.success) {
+ callback.onSuccess(response.response.toByteArray())
+ } else {
+ callback.onError(response.response.toByteArray())
+ }
+ } catch (e: InvalidProtocolBufferException) {
+ loge(TAG, "Unable to parse query response.", e)
+ }
+ return
+ }
+ else -> loge(TAG, "Received unknown type of message: $operationType. Ignoring.")
+ }
+ }
+
+ override fun cleanUp() {
+ if (platformVersion >= LEGACY_VERSION) {
+ featureCoordinator.unregisterConnectionCallback(connectionCallback)
+
+ val connectedDevices = getConnectedDevices() ?: emptyList()
+ for (deviceId in connectedDevices) {
+ val connectedDevice = getConnectedDeviceById(deviceId)
+ featureCoordinator.unregisterDeviceCallback(connectedDevice, recipientId, deviceCallback)
+ }
+
+ featureCoordinator.unregisterOnLogRequestedListener(loggerId, listener)
+ } else {
+ logw(TAG, "Incompatible Companion platform version.")
+ }
+ }
+
+ companion object {
+ private const val TAG = "LegacyApiProxy"
+ }
+}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/api/SafeApiProxy.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/api/SafeApiProxy.kt
new file mode 100644
index 0000000..915f26f
--- /dev/null
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/api/SafeApiProxy.kt
@@ -0,0 +1,249 @@
+/*
+ * Copyright (C) 2023 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.api
+
+import android.os.IInterface
+import android.os.ParcelUuid
+import android.os.RemoteException
+import androidx.annotation.VisibleForTesting
+import com.google.android.companionprotos.DeviceMessageProto
+import com.google.android.companionprotos.Query
+import com.google.android.companionprotos.QueryResponse
+import com.google.android.connecteddevice.api.SafeConnector.QueryCallback
+import com.google.android.connecteddevice.api.external.ISafeConnectionCallback
+import com.google.android.connecteddevice.api.external.ISafeDeviceCallback
+import com.google.android.connecteddevice.api.external.ISafeFeatureCoordinator
+import com.google.android.connecteddevice.api.external.ISafeOnAssociatedDevicesRetrievedListener
+import com.google.android.connecteddevice.api.external.ISafeOnLogRequestedListener
+import com.google.android.connecteddevice.model.DeviceMessage
+import com.google.android.connecteddevice.util.ByteUtils
+import com.google.android.connecteddevice.util.Logger
+import com.google.android.connecteddevice.util.SafeLog.logd
+import com.google.android.connecteddevice.util.SafeLog.loge
+import com.google.android.connecteddevice.util.SafeLog.logw
+import com.google.protobuf.InvalidProtocolBufferException
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Wrapper for SafeFeatureCoordinator APIs used to make sure that the Companion app's version is
+ * compatible with the APIs it is attempting to call before actually making the call.
+ */
+class SafeApiProxy (
+ private val featureCoordinator: ISafeFeatureCoordinator,
+ private val recipientId: ParcelUuid,
+ private val connectorCallback: SafeConnector.Callback,
+ private val loggerId: Int,
+ private val platformVersion: Int
+) : CompanionApiProxy {
+
+ // queryId -> callback
+ override val queryCallbacks: MutableMap<Int, QueryCallback> = ConcurrentHashMap()
+
+ // queryId -> original sender for response
+ override val queryResponseRecipients: MutableMap<Int, ParcelUuid> = ConcurrentHashMap()
+
+ private val connectionCallback =
+ object : ISafeConnectionCallback.Stub() {
+ override fun onDeviceConnected(deviceId: String) {
+ logd(TAG, "Device ${deviceId} has connected. Notifying callback.")
+ connectorCallback.onDeviceConnected(deviceId)
+
+ logd(TAG, "Registering device callback for $recipientId on device ${deviceId}.")
+ featureCoordinator.registerDeviceCallback(deviceId, recipientId, deviceCallback)
+ }
+
+ override fun onDeviceDisconnected(deviceId: String) {
+ logd(TAG, "Device ${deviceId} has disconnected. Notifying callback.")
+ connectorCallback.onDeviceDisconnected(deviceId)
+
+ logd(TAG, "Unregistering device callback for $recipientId on device ${deviceId}.")
+ featureCoordinator.unregisterDeviceCallback(deviceId, recipientId, deviceCallback)
+ }
+ }
+
+ @VisibleForTesting
+ internal val deviceCallback =
+ object : ISafeDeviceCallback.Stub() {
+ override fun onSecureChannelEstablished(deviceId: String) {
+ logd(TAG, "Secure channel has been established on ${deviceId}. Notifying callback.")
+ connectorCallback.onSecureChannelEstablished(deviceId)
+ }
+
+ override fun onMessageReceived(deviceId: String, deviceMessage: ByteArray) {
+ processIncomingMessage(deviceId, deviceMessage)
+ }
+
+ override fun onDeviceError(deviceId: String, error: Int) {
+ logw(TAG, "Received a device error of $error from ${deviceId}.")
+ connectorCallback.onDeviceError(deviceId, error)
+ }
+ }
+
+ private fun processIncomingMessage(deviceId: String, deviceMessage: ByteArray) {
+ val parsedMessage =
+ try {
+ DeviceMessageProto.Message.parseFrom(deviceMessage)
+ } catch (e: InvalidProtocolBufferException) {
+ loge(TAG, "Cannot parse device message to send.", e)
+ return
+ }
+ val operationType = DeviceMessage.OperationType.fromValue(parsedMessage.operation.number)
+ val message = parsedMessage.payload.toByteArray()
+ when (operationType) {
+ DeviceMessage.OperationType.CLIENT_MESSAGE -> {
+ logd(TAG, "Received client message. Passing on to feature.")
+ connectorCallback.onMessageReceived(deviceId, message)
+ return
+ }
+ DeviceMessage.OperationType.QUERY -> {
+ try {
+ val query = Query.parseFrom(message)
+ logd(TAG, "Received a new query with id ${query.id}. Passing on to feature.")
+ val sender = ParcelUuid(ByteUtils.bytesToUUID(query.sender.toByteArray()))
+ queryResponseRecipients[query.id] = sender
+ connectorCallback.onQueryReceived(
+ deviceId,
+ query.id,
+ query.request.toByteArray(),
+ query.parameters.toByteArray()
+ )
+ } catch (e: InvalidProtocolBufferException) {
+ loge(TAG, "Unable to parse query.", e)
+ }
+ return
+ }
+ DeviceMessage.OperationType.QUERY_RESPONSE -> {
+ try {
+ val response = QueryResponse.parseFrom(message)
+ logd(TAG, "Received a query response. Issuing registered callback.")
+ val callback = queryCallbacks.remove(response.queryId)
+ if (callback == null) {
+ loge(TAG, "Unable to locate callback for query ${response.queryId}.")
+ return
+ }
+ if (response.success) {
+ callback.onSuccess(response.response.toByteArray())
+ } else {
+ callback.onError(response.response.toByteArray())
+ }
+ } catch (e: InvalidProtocolBufferException) {
+ loge(TAG, "Unable to parse query response.", e)
+ }
+ return
+ }
+ else -> loge(TAG, "Received unknown type of message: $operationType. Ignoring.")
+ }
+ }
+
+ override val listener =
+ object : ISafeOnLogRequestedListener.Stub() {
+ override fun onLogRecordsRequested() {
+ val loggerBytes = Logger.getLogger().toByteArray()
+ if (!processLogRecords(loggerId, loggerBytes)) {
+ logw(TAG, "Failed to process log records for logger $loggerId.")
+ }
+ }
+ }
+
+ init {
+ if (platformVersion >= 1) {
+ featureCoordinator.registerConnectionCallback(connectionCallback)
+
+ val connectedDevices = getConnectedDevices() ?: emptyList()
+ for (deviceId in connectedDevices) {
+ featureCoordinator.registerDeviceCallback(deviceId, recipientId, deviceCallback)
+ }
+
+ featureCoordinator.registerOnLogRequestedListener(loggerId, listener)
+ } else {
+ logd(TAG, "Feature coordinator created by outdated Companion platform.")
+ }
+ }
+
+ override fun getConnectedDevices(): List<String>? {
+ val introducedVersion = 1
+ if (platformVersion < introducedVersion) {
+ logd(TAG, "getConnectedDevices invoked by outdated Companion platform.")
+ return null
+ }
+ return featureCoordinator.getConnectedDevices()
+ }
+
+ override fun sendMessage(deviceId: String, message: ByteArray): Boolean {
+ val introducedVersion = 1
+ if (platformVersion < introducedVersion) {
+ logd(TAG, "sendMessage invoked by outdated Companion platform.")
+ return false
+ }
+ return try {
+ featureCoordinator.sendMessage(deviceId, message)
+ } catch (e: RemoteException) {
+ loge(TAG, "sendMessage failed with RemoteException.", e)
+ false
+ }
+ }
+
+ override fun processLogRecords(loggerId: Int, logRecords: ByteArray): Boolean {
+ val introducedVersion = 1
+ if (platformVersion < introducedVersion) {
+ logd(TAG, "processLogRecords invoked by outdated Companion platform.")
+ return false
+ }
+ try {
+ featureCoordinator.processLogRecords(loggerId, logRecords)
+ } catch (e: RemoteException) {
+ loge(TAG, "Failed to send log records for logger $loggerId.", e)
+ return false
+ }
+ return true
+ }
+
+ override fun retrieveAssociatedDevices(listener: IInterface): Boolean {
+ val introducedVersion = 1
+ if (listener !is ISafeOnAssociatedDevicesRetrievedListener) {
+ logd(TAG, "retrieveAssociatedDevices invoked with incorrect callback type.")
+ return false
+ }
+ if (platformVersion < introducedVersion) {
+ logd(TAG, "retrieveAssociatedDevices invoked by outdated Companion platform.")
+ return false
+ }
+ featureCoordinator.retrieveAssociatedDevices(listener)
+ return true
+ }
+
+ override fun cleanUp() {
+ if (platformVersion >= 1) {
+ featureCoordinator.unregisterConnectionCallback(connectionCallback)
+
+ val connectedDevices = getConnectedDevices() ?: emptyList()
+ for (deviceId in connectedDevices) {
+ featureCoordinator.unregisterDeviceCallback(deviceId, recipientId, deviceCallback)
+ }
+
+ featureCoordinator.unregisterOnLogRequestedListener(loggerId, listener)
+ } else {
+ logd(
+ TAG,
+ "Attempting to clean up feature coordinator through an outdated Companion platform."
+ )
+ }
+ }
+
+ companion object {
+ private const val TAG = "SafeApiProxy"
+ }
+}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/api/SafeConnector.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/api/SafeConnector.kt
new file mode 100644
index 0000000..43d7309
--- /dev/null
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/api/SafeConnector.kt
@@ -0,0 +1,177 @@
+/*
+ * Copyright (C) 2023 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.api
+
+import android.content.Context
+import android.content.Intent
+import android.os.ParcelUuid
+import com.google.android.connecteddevice.api.external.ISafeOnAssociatedDevicesRetrievedListener
+import com.google.android.connecteddevice.model.AssociatedDevice
+
+/**
+ * Interface for establishing and maintaining a connection to the companion device platform. This is
+ * a subset of Connector, only implementing feature-related functions. It is meant for external
+ * feature usage. Fully backward and forward compatible to ensure robust version mismatch handling.
+ */
+interface SafeConnector {
+
+ /** Feature id for sending and receiving messages. */
+ val featureId: ParcelUuid
+
+ /** [Callback] for connection events. */
+ val callback: Callback
+
+ /** List of ids for the currently connected devices. */
+ val connectedDevices: List<String>
+
+ /**
+ * Cleans up services and feature coordinators attached to the companion platform.
+ * Calling cleanUp will disconnect the feature from the platform and prevent it from receiving
+ * Companion related events. Expectation is that cleanUp will only be called before the
+ * SafeConnector object is disposed.
+ */
+ fun cleanUp()
+
+ /** Sends message to a device. */
+ fun sendMessage(deviceId: String, message: ByteArray)
+
+ /** Sends a query to a device and registers a [QueryCallback] for a response. */
+ fun sendQuery(
+ deviceId: String,
+ request: ByteArray,
+ parameters: ByteArray?,
+ queryCallback: QueryCallback
+ )
+
+ /** Sends a response to a query with an indication of whether it was successful. */
+ fun respondToQuery(deviceId: String, queryId: Int, success: Boolean, response: ByteArray?)
+
+ /** Queries the [ConnectedDevice] for its companion application name. */
+ fun retrieveCompanionApplicationName(deviceId: String, appNameCallback: AppNameCallback)
+
+ /**
+ * Retrieves all associated devices with a [listener] that will be notified when the associated
+ * devices are retrieved.
+ */
+ fun retrieveAssociatedDevices(listener: IOnAssociatedDevicesRetrievedListener)
+
+ /**
+ * Retrieves all associated devices with a [listener] that will be notified when the associated
+ * devices are retrieved.
+ */
+ fun retrieveAssociatedDevices(listener: ISafeOnAssociatedDevicesRetrievedListener)
+
+ /** Callbacks invoked on connection events. */
+ interface Callback {
+ /** Invoked when a connection has been successfully established. */
+ fun onConnected() {}
+
+ /** Invoked when the connection to the platform has been lost. */
+ fun onDisconnected() {}
+
+ /** Invoked when no connection to the platform could be established. */
+ fun onFailedToConnect() {}
+
+ /** Invoked when the platform version is older than the minimum supported version. */
+ fun onApiNotSupported() {}
+
+ /** Invoked when a new connected device with id deviceId is connected. */
+ fun onDeviceConnected(deviceId: String) {}
+
+ /** Invoked when a connected device with id deviceId disconnects. */
+ fun onDeviceDisconnected(deviceId: String) {}
+
+ /**
+ * Invoked when a secure channel has been established with a connected device with id deviceId.
+ */
+ fun onSecureChannelEstablished(deviceId: String) {}
+
+ /**
+ * Invoked when a message fails to send to a device.
+ *
+ * @param deviceId Id of the device the message failed to send to.
+ * @param message Message to send.
+ * @param isTransient `true` if cause of failure is transient and can be retried. `false` if
+ * failure is permanent.
+ */
+ fun onMessageFailedToSend(deviceId: String, message: ByteArray, isTransient: Boolean) {}
+
+ /** Invoked when a new [byte[]] message is received for this feature. */
+ fun onMessageReceived(deviceId: String, message: ByteArray) {}
+
+ /** Invoked when a new query is received for this feature. */
+ fun onQueryReceived(
+ deviceId: String,
+ queryId: Int,
+ request: ByteArray,
+ parameters: ByteArray?
+ ) {}
+
+ /** Invoked when an error has occurred with the connection. */
+ fun onDeviceError(deviceId: String, error: Int) {}
+
+ /** Invoked when a new [AssociatedDevice] is added for the given user. */
+ fun onAssociatedDeviceAdded(device: AssociatedDevice) {}
+
+ /** Invoked when an [AssociatedDevice] is removed for the given user. */
+ fun onAssociatedDeviceRemoved(device: AssociatedDevice) {}
+
+ /** Invoked when an [AssociatedDevice] is updated for the given user. */
+ fun onAssociatedDeviceUpdated(device: AssociatedDevice) {}
+ }
+
+ /** Callback for a query response. */
+ interface QueryCallback {
+ /** Invoked with a successful response to a query. */
+ fun onSuccess(response: ByteArray?) {}
+
+ /** Invoked with an unsuccessful response to a query. */
+ fun onError(response: ByteArray?) {}
+
+ /**
+ * Invoked when a query failed to send to the device. `isTransient` is set to `true` if cause of
+ * failure is transient and can be retried, or `false` if failure is permanent.
+ */
+ fun onQueryFailedToSend(isTransient: Boolean) {}
+ }
+
+ /** Callback for a query for the name of the companion application on the connected device. */
+ interface AppNameCallback {
+ /** Invoked with the name of the companion application on the connected device. */
+ fun onNameReceived(appName: String) {}
+
+ /** Invoked when the name failed to be retrieved from the connected device. */
+ fun onError() {}
+ }
+
+ companion object {
+ /**
+ * When a client calls [Context.bindService] to get the [IFeatureCoordinator], this action is
+ * required in the param [Intent].
+ */
+ const val ACTION_BIND_FEATURE_COORDINATOR =
+ "com.google.android.connecteddevice.api.BIND_FEATURE_COORDINATOR"
+
+ /**
+ * When a client queries for the platform's API version, this action is required in the param
+ * [Intent]
+ */
+ const val ACTION_QUERY_API_VERSION = "com.google.android.connecteddevice.api.QUERY_API_VERSION"
+
+ /** Id for the system query feature. */
+ val SYSTEM_FEATURE_ID = ParcelUuid.fromString("892ac5d9-e9a5-48dc-874a-c01e3cb00d5d")
+ }
+}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/api/external/ISafeFeatureCoordinator.aidl b/libs/connecteddevice/src/com/google/android/connecteddevice/api/external/ISafeFeatureCoordinator.aidl
index bf029c4..fdd4771 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/api/external/ISafeFeatureCoordinator.aidl
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/api/external/ISafeFeatureCoordinator.aidl
@@ -62,6 +62,9 @@
/**
* Registers a callback for a specific connectedDevice and recipient.
*
+ * Duplicate registration with the same [recipientId] will block the
+ * recipient and prevent it from receiving callbacks.
+ *
* @param deviceId {@link String} to register triggers on.
* @param recipientId {@link ParcelUuid} to register as recipient of.
* @param callback {@link ISafeDeviceCallback} to register.
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/api/external/ISafeFeatureCoordinatorListener.aidl b/libs/connecteddevice/src/com/google/android/connecteddevice/api/external/ISafeFeatureCoordinatorListener.aidl
new file mode 100644
index 0000000..01aee6f
--- /dev/null
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/api/external/ISafeFeatureCoordinatorListener.aidl
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2023 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.api.external;
+
+import com.google.android.connecteddevice.api.external.ISafeFeatureCoordinator;
+
+/**
+ * Listener for feature coordinator initialization.
+ *
+ * Only make additive changes to maintain backward compatibility.
+ * The added function needs to be assigned the transaction value noted below,
+ * and the value needs to be appropriately incremented.
+ *
+ * Next transaction value: 1
+ */
+interface ISafeFeatureCoordinatorListener {
+ /** Callback when feature coordinator is initialized. */
+ void onFeatureCoordinatorInitialized(
+ ISafeFeatureCoordinator featureCoordinator) = 0;
+}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/api/external/ISafeFeatureCoordinatorStatusNotifier.aidl b/libs/connecteddevice/src/com/google/android/connecteddevice/api/external/ISafeFeatureCoordinatorStatusNotifier.aidl
new file mode 100644
index 0000000..fae24ef
--- /dev/null
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/api/external/ISafeFeatureCoordinatorStatusNotifier.aidl
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2023 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.api.external;
+
+import com.google.android.connecteddevice.api.external.ISafeFeatureCoordinatorListener;
+
+/**
+ * Creates listeners for feature coordinator initialization.
+ *
+ * Only make additive changes to maintain backward compatibility.
+ * The added function needs to be assigned the transaction value noted below,
+ * and the value needs to be appropriately incremented.
+ *
+ * Next transaction value: 2
+ */
+interface ISafeFeatureCoordinatorStatusNotifier {
+ /**
+ * Registers listeners to be notified when feature coordinator is
+ * initialized. Eeach listener will receive a feature coordinator upon
+ * notification.
+ */
+ void registerFeatureCoordinatorListener(
+ ISafeFeatureCoordinatorListener listener) = 0;
+
+ /** Unregisters listener. */
+ void unregisterFeatureCoordinatorListener(
+ ISafeFeatureCoordinatorListener listeners) = 1;
+}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/CalendarSyncService.java b/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/CalendarSyncService.java
index a241204..564823d 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/CalendarSyncService.java
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/CalendarSyncService.java
@@ -77,8 +77,9 @@
StrictMode.setThreadPolicy(
new StrictMode.ThreadPolicy.Builder().detectAll().penaltyDialog().build());
- // Settings for the entire application process.
- StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder().detectAll().penaltyDeath().build());
+ // Settings for the entire application process. StrictMode.VmPolicy.Builder().penaltyDeath()
+ // will block general companion testing.
+ StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder().detectAll().penaltyLog().build());
}
}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/android/EventContentDelegate.java b/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/android/EventContentDelegate.java
index ed7456c..6794328 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/android/EventContentDelegate.java
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/android/EventContentDelegate.java
@@ -445,6 +445,7 @@
/** The type of event regarding recurrence. */
private enum RecurrenceType {
+ // LINT.IfChange
/** An event that only occurs once. */
SINGLE("S"),
@@ -456,6 +457,7 @@
* regular meeting.
*/
EXCEPTION("X");
+ // LINT.ThenChange(//depot/google3/third_party/swift/AndroidAutoCalendarSync/Sources/AndroidAutoCalendarSync/EKEventStore+Extensions.swift)
/** The code to include in the key. */
final String code;
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/common/BaseCalendarSync.java b/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/common/BaseCalendarSync.java
index 065ab48..b6f059a 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/common/BaseCalendarSync.java
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/common/BaseCalendarSync.java
@@ -1,3 +1,19 @@
+/*
+ * Copyright (C) 2023 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.calendarsync.common;
import static com.google.common.collect.Sets.difference;
@@ -115,6 +131,7 @@
}
DeviceState state = getOrCreateState(deviceId);
state.version = update.getVersion();
+ logger.info("The state version is " + state.version);
logger.info(
"Received %s with %d calendars and size %d from device %s",
@@ -168,7 +185,7 @@
* <p>Must be called before using the other methods of this class.
*/
public void start() {
- logger.debug("Start");
+ logger.debug("Start.");
calendarsObservationHandle = calendarsObservable.observe(this::onCalendarsChanged);
}
@@ -178,7 +195,7 @@
* <p>Updating the calendar can result in many change events being fired in rapid succession.
*/
private void onCalendarsChanged() {
- logger.debug("Calendars changed");
+ logger.debug("Calendars changed.");
if (delayedChangeUpdate != null) {
delayedChangeUpdate.cancel();
}
@@ -235,6 +252,7 @@
calendarStore.store(state.deviceId, currentCalendar);
currentCalendars.add(currentCalendar);
}
+ logger.info("Send device change update.");
// Keep the store up-to-date with the calendars on the remove device.
SetView<String> removedCalendarKeys =
@@ -299,7 +317,7 @@
private final Map<String, Range<Instant>> calendarKeyToTimeRange = new HashMap<>();
/**
- * The protocol version used by this remove device.
+ * The protocol version used by this remote device.
*
* <p>Until a response is received from the remote device the version is assumed to be version 0
* which does not support sending updates.
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/common/update.proto b/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/common/update.proto
index 85bb29a..1585698 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/common/update.proto
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/common/update.proto
@@ -1,10 +1,26 @@
/*
+ * Copyright (C) 2023 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.
+ */
+
+/*
* This file must remain backwards wire compatible with ../proto/calendar.proto
*/
syntax = "proto3";
-package com.google.android.connecteddevice.calendarsync;
+package aae.calendarsync;
option java_package = "com.google.android.connecteddevice.calendarsync";
option java_multiple_files = true;
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/proto/calendar.proto b/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/proto/calendar.proto
index ef24ff1..dcda1e3 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/proto/calendar.proto
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/calendarsync/proto/calendar.proto
@@ -16,7 +16,7 @@
syntax = "proto3";
-package aae.calendarsync;
+package aae.calendarsync.deprecated;
option java_package = "com.google.android.connecteddevice.calendarsync.proto";
option java_multiple_files = true;
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/core/FeatureCoordinator.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/core/FeatureCoordinator.kt
index dba9723..8e17584 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/core/FeatureCoordinator.kt
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/core/FeatureCoordinator.kt
@@ -59,8 +59,9 @@
constructor(
private val controller: DeviceController,
private val storage: ConnectedDeviceStorage,
+ private val systemQueryCache: SystemQueryCache = SystemQueryCache.create(),
private val loggingManager: LoggingManager,
- private val callbackExecutor: Executor = Executors.newCachedThreadPool()
+ private val callbackExecutor: Executor = Executors.newCachedThreadPool(),
) : IFeatureCoordinator.Stub() {
private val deviceAssociationCallbacks = AidlThreadSafeCallbacks<IDeviceAssociationCallback>()
@@ -159,6 +160,11 @@
}
override fun sendMessage(deviceId: String, message: ByteArray): Boolean {
+ val connectedDevice = controller.connectedDevices.firstOrNull { it.deviceId == deviceId }
+ if (connectedDevice == null) {
+ loge(TAG, "Device $deviceId not found. Unable to send message.")
+ return false
+ }
// TODO(b/265862484): Deprecate DeviceMessage in favor of byte arrays.
val parsedMessage =
try {
@@ -174,6 +180,20 @@
DeviceMessage.OperationType.fromValue(parsedMessage.operation.number),
parsedMessage.payload.toByteArray()
)
+ val cachedResponse = systemQueryCache.getCachedResponse(connectedDevice, deviceMessage)
+ if (cachedResponse != null) {
+ // If a system query has a cached answer, short-circuit the query/response flow by faking
+ // a response. Using the cached response allows us to speed up queries by features when
+ // the response time is limited (e.g. time for SecondDeviceSignInUrlFeature to be
+ // "ready").
+ //
+ // Schedule the response callback on a different executor to avoid the callback is
+ // delivered before this sendMessage method completes/returns.
+ callbackExecutor.execute {
+ onMessageReceivedInternal(connectedDevice, cachedResponse, shouldCacheMessage = false)
+ }
+ return true
+ }
return controller.sendMessage(UUID.fromString(deviceId), deviceMessage)
}
@@ -309,12 +329,9 @@
}
}
- @Suppress("UNCHECKED_CAST") // Cast will always succeed because of the type check above.
val previousCallback =
- (recipientCallbacks as? MutableMap<ParcelUuid, IInterface>)?.putIfAbsent(
- recipientId,
- callback
- )
+ deviceCallbacks[connectedDevice.deviceId]?.get(recipientId)
+ ?: safeDeviceCallbacks[connectedDevice.deviceId]?.get(recipientId)
// Device already has a callback registered with this recipient UUID. For the
// protection of the user, this UUID is now deny listed from future subscriptions
@@ -346,7 +363,8 @@
TAG,
"New callback registered on device ${connectedDevice.deviceId} for recipient $recipientId."
)
-
+ @Suppress("UNCHECKED_CAST") // Cast will always succeed because of the type check above.
+ (recipientCallbacks as? MutableMap<ParcelUuid, IInterface>)?.put(recipientId, callback)
return true
}
@@ -455,8 +473,22 @@
logd(TAG, "Device callback unregistered on device ${deviceId} for recipient " + "$recipientId.")
}
- override fun sendMessage(connectedDevice: ConnectedDevice, message: DeviceMessage): Boolean =
- controller.sendMessage(UUID.fromString(connectedDevice.deviceId), message)
+ override fun sendMessage(connectedDevice: ConnectedDevice, message: DeviceMessage): Boolean {
+ val cachedResponse = systemQueryCache.getCachedResponse(connectedDevice, message)
+ if (cachedResponse != null) {
+ // If a system query has a cached answer, short-circuit the query/response flow by faking a
+ // response. Using the cached response allows us to speed up queries by features when the
+ // response time is limited (e.g. time for SecondDeviceSignInUrlFeature to be "ready").
+ //
+ // Schedule the response callback on a different executor to avoid the callback is delivered
+ // before this sendMessage method completes/returns.
+ callbackExecutor.execute {
+ onMessageReceivedInternal(connectedDevice, cachedResponse, shouldCacheMessage = false)
+ }
+ return true
+ }
+ return controller.sendMessage(UUID.fromString(connectedDevice.deviceId), message)
+ }
override fun registerDeviceAssociationCallback(callback: IDeviceAssociationCallback) {
deviceAssociationCallbacks.add(callback, callbackExecutor)
@@ -590,6 +622,8 @@
@VisibleForTesting
internal fun onDeviceDisconnectedInternal(connectedDevice: ConnectedDevice) {
+ systemQueryCache.clearCache(connectedDevice)
+
if (connectedDevice.isAssociatedWithDriver) {
logd(TAG, "Notifying callbacks that a device has disconnected for the driver.")
driverConnectionCallbacks.invoke { it.onDeviceDisconnected(connectedDevice) }
@@ -598,6 +632,8 @@
passengerConnectionCallbacks.invoke { it.onDeviceDisconnected(connectedDevice) }
}
allConnectionCallbacks.invoke { it.onDeviceDisconnected(connectedDevice) }
+ // Clear blocked recipients for the next connection so the state is easier to recover.
+ lock.withLock { blockedRecipients.clear() }
}
@VisibleForTesting
@@ -630,7 +666,16 @@
}
@VisibleForTesting
- internal fun onMessageReceivedInternal(connectedDevice: ConnectedDevice, message: DeviceMessage) {
+ internal fun onMessageReceivedInternal(
+ connectedDevice: ConnectedDevice,
+ message: DeviceMessage,
+ shouldCacheMessage: Boolean = true
+ ) {
+ if (shouldCacheMessage) {
+ // Cache the received message for a faster response if queried again by another feature.
+ systemQueryCache.maybeCacheResponse(connectedDevice, message)
+ }
+
if (message.recipient == null) {
loge(
TAG,
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/core/MultiProtocolDeviceController.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/core/MultiProtocolDeviceController.kt
index ec9b4e1..a19ada8 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/core/MultiProtocolDeviceController.kt
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/core/MultiProtocolDeviceController.kt
@@ -15,6 +15,7 @@
*/
package com.google.android.connecteddevice.core
+import android.content.Context
import android.database.sqlite.SQLiteCantOpenDatabaseException
import android.os.ParcelUuid
import androidx.annotation.GuardedBy
@@ -26,6 +27,7 @@
import com.google.android.connecteddevice.connection.MultiProtocolSecureChannel.ShowVerificationCodeListener
import com.google.android.connecteddevice.connection.ProtocolStream
import com.google.android.connecteddevice.core.DeviceController.Callback
+import com.google.android.connecteddevice.metrics.EventMetricLogger
import com.google.android.connecteddevice.model.AssociatedDevice
import com.google.android.connecteddevice.model.ConnectedDevice
import com.google.android.connecteddevice.model.DeviceMessage
@@ -72,17 +74,18 @@
* @property storage Storage necessary to generate reconnect challenge.
* @property enablePassenger Whether passenger devices automatically connect. When `true`, newly
* associated devices will remain unclaimed by default.
- * @property callbackExecutor Executor on which callbacks are executed.
+ * @property storageExecutor Executor on which storage related tasks are executed.
*/
class MultiProtocolDeviceController
@JvmOverloads
constructor(
+ private val context: Context,
private val protocolDelegate: ProtocolDelegate,
private val storage: ConnectedDeviceStorage,
private val oobRunner: OobRunner,
private val associationServiceUuid: UUID,
private val enablePassenger: Boolean,
- private val callbackExecutor: Executor = Executors.newSingleThreadExecutor()
+ private val storageExecutor: Executor = Executors.newSingleThreadExecutor()
) : DeviceController {
private val connectedRemoteDevices = ConcurrentHashMap<UUID, ConnectedRemoteDevice>()
@@ -91,24 +94,29 @@
@VisibleForTesting internal val associationPendingDeviceId = AtomicReference<UUID?>(null)
private val lock = ReentrantLock()
+ private val metricLogger = EventMetricLogger(context)
@GuardedBy("lock") private val associatedDevices = mutableListOf<AssociatedDevice>()
@GuardedBy("lock") private val driverDevices = mutableListOf<AssociatedDevice>()
+ @GuardedBy("lock") private val passengerDevices = mutableListOf<AssociatedDevice>()
private val storageCallback =
object : ConnectedDeviceStorage.AssociatedDeviceCallback {
override fun onAssociatedDeviceAdded(device: AssociatedDevice) {
- // Device population is handled locally when a new device is added.
+ logd(TAG, "An associated device has been added. Repopulating devices from storage.")
+ populateDevicesWithExecutor()
+ // Make sure the internal status are synched from storage before invoking callbacks.
+ storageExecutor.execute { invokeCallbacksWithAssociatedDevice(device) }
}
override fun onAssociatedDeviceRemoved(device: AssociatedDevice) {
logd(TAG, "An associated device has been removed. Repopulating devices from storage.")
- populateDevices()
+ populateDevicesWithExecutor()
}
override fun onAssociatedDeviceUpdated(device: AssociatedDevice) {
logd(TAG, "An associated device has been updated. Repopulating devices from storage.")
- populateDevices()
+ populateDevicesWithExecutor()
}
}
@@ -149,7 +157,7 @@
override fun start() {
logd(TAG, "Starting controller and initiating connections with driver devices.")
- populateDevices()
+ populateDevicesWithExecutor()
val driverDevices = storage.driverAssociatedDevices
for (device in driverDevices) {
if (device.isConnectionEnabled) {
@@ -324,20 +332,29 @@
callbacks.remove(callback)
}
- private fun populateDevices() {
- callbackExecutor.execute {
+ /**
+ * Populates associated devices from the storage.
+ *
+ * Any logic following this which relies on the data refreshness needs to run on the
+ * same executor to avoid race conditions.
+ */
+ private fun populateDevicesWithExecutor() {
+ storageExecutor.execute {
while (true) {
try {
logd(TAG, "Populating associated devices from storage.")
// Fetch devices prior to applying lock to reduce lock time.
val driverOnlyDevices = storage.driverAssociatedDevices
+ val passengerOnlyDevices = storage.passengerAssociatedDevices
val allDevices = storage.allAssociatedDevices
lock.withLock {
associatedDevices.clear()
associatedDevices.addAll(allDevices)
driverDevices.clear()
driverDevices.addAll(driverOnlyDevices)
+ passengerDevices.clear()
+ passengerDevices.addAll(passengerOnlyDevices)
}
logd(TAG, "Devices populated successfully.")
break
@@ -589,7 +606,7 @@
invokeCallbacksWithDevice(device) { connectedDevice, callback ->
callback.onDeviceDisconnected(connectedDevice)
}
- callbackExecutor.execute {
+ storageExecutor.execute {
val associatedDevice = storage.getAssociatedDevice(disconnectedDeviceId.toString())
if (associatedDevice == null) {
loge(
@@ -643,6 +660,7 @@
callback.onSecureChannelEstablished(connectedDevice)
}
EventLog.onSecureChannelEstablished()
+ metricLogger.onSecureChannelEstablished()
}
override fun onEstablishSecureChannelFailure(error: ChannelError) {
@@ -693,36 +711,33 @@
handleAssociationError(ChannelError.CHANNEL_ERROR_INVALID_DEVICE_ID.ordinal, device)
return
}
-
- connectedRemoteDevices.computeIfAbsent(deviceId) {
- logd(TAG, "Assigning newly-associated device to its real device id.")
- val newDevice = convertTempAssociationDeviceToRealDevice(device, deviceId)
- logd(TAG, "Received device id and secret from $deviceId.")
- try {
- storage.saveChallengeSecret(
- deviceId.toString(),
- deviceMessage.message.copyOfRange(DEVICE_ID_BYTES, deviceMessage.message.size)
- )
- } catch (e: InvalidParameterException) {
- loge(TAG, "Error saving challenge secret.", e)
- // Call on old device since it had the original association callback.
- handleAssociationError(ChannelError.CHANNEL_ERROR_INVALID_ENCRYPTION_KEY.ordinal, device)
- return@computeIfAbsent newDevice
- }
- connectedRemoteDevices.remove(pendingDeviceId)
- associationPendingDeviceId.set(null)
- oobRunner.reset()
- newDevice.secureChannel?.setDeviceIdDuringAssociation(deviceId)
- persistAssociatedDevice(deviceId.toString())
- newDevice.callback?.onAssociationCompleted()
- invokeCallbacksWithDevice(newDevice) { connectedDevice, callback ->
- callback.onDeviceConnected(connectedDevice)
- }
- invokeCallbacksWithDevice(newDevice) { connectedDevice, callback ->
- callback.onSecureChannelEstablished(connectedDevice)
- }
- newDevice
+ if (connectedRemoteDevices.containsKey(deviceId)) {
+ logd(TAG, "This device $deviceId already exist in the connected device list. Ignore.")
+ return
}
+ logd(TAG, "Assigning newly-associated device to its real device id.")
+ val newDevice = convertTempAssociationDeviceToRealDevice(device, deviceId)
+ logd(TAG, "Received device id and secret from $deviceId.")
+ try {
+ storage.saveChallengeSecret(
+ deviceId.toString(),
+ deviceMessage.message.copyOfRange(DEVICE_ID_BYTES, deviceMessage.message.size)
+ )
+ } catch (e: InvalidParameterException) {
+ loge(TAG, "Error saving challenge secret.", e)
+ // Call on old device since it had the original association callback.
+ handleAssociationError(ChannelError.CHANNEL_ERROR_INVALID_ENCRYPTION_KEY.ordinal, device)
+ return
+ }
+ connectedRemoteDevices.remove(pendingDeviceId)
+ associationPendingDeviceId.set(null)
+ connectedRemoteDevices.put(deviceId, newDevice)
+ oobRunner.reset()
+ newDevice.secureChannel?.setDeviceIdDuringAssociation(deviceId)
+ persistAssociatedDevice(deviceId.toString())
+
+ // Invoke callbacks after internal states are updated to avoid race conditions.
+ device.callback?.onAssociationCompleted()
}
private fun convertTempAssociationDeviceToRealDevice(
@@ -771,10 +786,29 @@
device: ConnectedRemoteDevice,
onCallback: (ConnectedDevice, Callback) -> Unit
) {
- val connectedDevice = lock.withLock { device.toConnectedDevice(driverDevices) }
+ val connectedDevice = lock.withLock { device.toConnectedDevice(passengerDevices) }
callbacks.invoke { onCallback(connectedDevice, it) }
}
+ private fun invokeCallbacksWithAssociatedDevice(associatedDevice: AssociatedDevice) {
+ logd(TAG, "Invovke callbacks with associated device")
+ val hasSecureChannel =
+ connectedRemoteDevices.get(UUID.fromString(associatedDevice.deviceId))?.secureChannel != null
+ lock.withLock {
+ val belongsToDriver =
+ passengerDevices.none { device -> device.deviceId == associatedDevice.deviceId }
+ val connectedDevice =
+ ConnectedDevice(
+ associatedDevice.deviceId,
+ associatedDevice.deviceName,
+ belongsToDriver,
+ hasSecureChannel
+ )
+ callbacks.invoke { it.onDeviceConnected(connectedDevice) }
+ callbacks.invoke { it.onSecureChannelEstablished(connectedDevice) }
+ }
+ }
+
/** Container class to hold information about a connected device. */
internal data class ConnectedRemoteDevice(
val deviceId: UUID,
@@ -786,8 +820,12 @@
var channelResolver: ChannelResolver? = null
/** Returns the [ConnectedDevice] equivalent or `null` if the conversion failed. */
- fun toConnectedDevice(driverDevices: List<AssociatedDevice>): ConnectedDevice {
- val belongsToDriver = driverDevices.any { device -> device.deviceId == deviceId.toString() }
+ fun toConnectedDevice(passengerDevices: List<AssociatedDevice>): ConnectedDevice {
+ // TODO(b/269484772): During device removal, the device record might have already been cleared
+ // so we have to do a reversed check against passenger device. This value may still be true if
+ // device is already disassociated.
+ val belongsToDriver =
+ passengerDevices.none { device -> device.deviceId == deviceId.toString() }
val hasSecureChannel = secureChannel != null
return ConnectedDevice(deviceId.toString(), name, belongsToDriver, hasSecureChannel)
}
@@ -807,6 +845,7 @@
return newDevice
}
}
+
companion object {
private const val TAG = "MultiProtocolDeviceController"
private const val SALT_BYTES = 8
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/core/SystemQueryCache.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/core/SystemQueryCache.kt
new file mode 100644
index 0000000..d59e5ce
--- /dev/null
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/core/SystemQueryCache.kt
@@ -0,0 +1,359 @@
+/*
+ * Copyright (C) 2023 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.core
+
+import androidx.annotation.VisibleForTesting
+import com.google.android.companionprotos.FeatureSupportResponse
+import com.google.android.companionprotos.FeatureSupportStatus
+import com.google.android.companionprotos.Query
+import com.google.android.companionprotos.QueryResponse
+import com.google.android.companionprotos.SystemQuery
+import com.google.android.companionprotos.SystemQueryType
+import com.google.android.connecteddevice.api.Connector.Companion.SYSTEM_FEATURE_ID
+import com.google.android.connecteddevice.model.ConnectedDevice
+import com.google.android.connecteddevice.model.DeviceMessage
+import com.google.android.connecteddevice.model.DeviceMessage.OperationType
+import com.google.android.connecteddevice.util.ByteUtils
+import com.google.android.connecteddevice.util.SafeLog.loge
+import com.google.android.connecteddevice.util.SafeLog.logi
+import com.google.protobuf.ByteString
+import com.google.protobuf.InvalidProtocolBufferException
+import java.nio.charset.StandardCharsets
+import java.util.UUID
+
+/**
+ * A cache for System Queries.
+ *
+ * We assume the responses for system query from a remote device are stable across queries, so their
+ * results can be cached on IHU. Caching the responses for system queries enables short-circuiting
+ * the query/response message flow when it's queried again.
+ *
+ * Responses of value `null` are ignored.
+ */
+interface SystemQueryCache {
+ /**
+ * Optionally caches the device message.
+ *
+ * Inspects the device message to check if it is a system query.
+ */
+ fun maybeCacheResponse(device: ConnectedDevice, message: DeviceMessage)
+
+ /**
+ * Checks if the message can be responded to by cached query response.
+ *
+ * Generates a response for the device message if a response has been cached. Returns `null` if
+ * there is no cached response, or the message is not a query.
+ */
+ fun getCachedResponse(device: ConnectedDevice, message: DeviceMessage): DeviceMessage?
+
+ /** Clears cached response from device. */
+ fun clearCache(device: ConnectedDevice)
+
+ companion object {
+ fun create(): SystemQueryCache = SystemQueryCacheImpl()
+ }
+}
+
+internal class SystemQueryCacheImpl : SystemQueryCache {
+ @VisibleForTesting internal val deviceCaches = mutableMapOf<UUID, DeviceSystemQueryCache>()
+
+ override fun maybeCacheResponse(device: ConnectedDevice, message: DeviceMessage) {
+ deviceCaches
+ .getOrPut(UUID.fromString(device.deviceId)) { DeviceSystemQueryCache() }
+ .cache(message)
+ }
+
+ override fun getCachedResponse(device: ConnectedDevice, message: DeviceMessage): DeviceMessage? {
+ val deviceCache =
+ deviceCaches.getOrPut(UUID.fromString(device.deviceId)) { DeviceSystemQueryCache() }
+ return deviceCache.getCached(message)
+ }
+
+ override fun clearCache(device: ConnectedDevice) {
+ logi(TAG, "Clearing cache for $device.")
+ val deviceCache = deviceCaches.remove(UUID.fromString(device.deviceId))
+ deviceCache?.clear()
+ }
+
+ companion object {
+ private const val TAG = "SystemQueryCache"
+ }
+}
+
+/** Caches system query responses from a device. */
+internal class DeviceSystemQueryCache {
+ @VisibleForTesting internal var appName: String? = null
+ @VisibleForTesting internal var deviceName: String? = null
+ @VisibleForTesting internal val supportedFeatures = mutableSetOf<UUID>()
+
+ // This map tracks the SystemQueryType sent by each <featureId, queryId>.
+ //
+ // This map is necessary due to the way the queries are answered: when a feature makes a system
+ // query, it sends the query with its featureId as `query.sender`, and internally tracks the
+ // answer with `queryId`. When the response is received, it's routed to `query.sender` with the
+ // same `queryId`. The type of the system query is only known to the sender.
+ // So here by inspecting the query, we can track the type of the response to parse it properly.
+ @VisibleForTesting
+ internal val trackedQueryTypes = mutableMapOf<Pair<UUID, Int>, SystemQueryType>()
+
+ /**
+ * Attempts to cache an inbound message if it was a system query response.
+ *
+ * Parses the message to inspect its message type. Messages that are not system query responses
+ * will be ignored. Infers the type of the system query response based on previous outbound system
+ * queries. Caches the response if the type can be inferred.
+ */
+ fun cache(message: DeviceMessage) {
+ if (!message.isSystemQueryResponse()) {
+ // Do not log here - we check every message. It'd be very spammy.
+ return
+ }
+
+ val queryResponse = message.parseMessageAsQueryResponse() ?: return
+
+ val querySender = message.recipient
+ val queryId = queryResponse.queryId
+
+ val systemQueryType = trackedQueryTypes.remove(Pair(querySender, queryId))
+ if (systemQueryType == null) {
+ // All outgoing system queries should be cached so an unrecognized query response is
+ // unexpected. Log in detail to debug.
+ loge(
+ TAG,
+ "Received SystemQuery response with unrecognized query type. " +
+ "Intended receiver is $querySender with $queryId."
+ )
+ return
+ }
+
+ // Only query types intended for the phone side are listed.
+ when (systemQueryType) {
+ SystemQueryType.APP_NAME -> {
+ appName = String(queryResponse.response.toByteArray(), StandardCharsets.UTF_8)
+ logi(TAG, "Caching app name $appName.")
+ }
+ SystemQueryType.DEVICE_NAME -> {
+ deviceName = String(queryResponse.response.toByteArray(), StandardCharsets.UTF_8)
+ logi(TAG, "Caching device name $deviceName.")
+ }
+ SystemQueryType.IS_FEATURE_SUPPORTED -> {
+ val featureSupportResponse = FeatureSupportResponse.parseFrom(queryResponse.response)
+ logi(TAG, "Caching feature support response $featureSupportResponse.")
+ for (status in featureSupportResponse.getStatusesList()) {
+ if (status.isSupported) {
+ supportedFeatures.add(UUID.fromString(status.featureId))
+ logi(TAG, "Caching feature ${status.featureId} is supported.")
+ } else {
+ logi(TAG, "Feature ${status.featureId} is not supported.")
+ }
+ }
+ }
+ else -> {
+ loge(TAG, "Could not cache unspported system query type $systemQueryType.")
+ }
+ }
+ }
+
+ /**
+ * Attempts to generate an inbound message as the response to the outbound system query.
+ *
+ * Returns `null` if there is no cached response, or the message is not a system query.
+ *
+ * Parses the message to inspect its message type. Messages that are not system queries are
+ * ignored. If the query does not have a cached response, internally tracks the query type so that
+ * the eventual inbound response can be cached.
+ */
+ fun getCached(message: DeviceMessage): DeviceMessage? {
+ if (!message.isSystemQuery()) {
+ // Do not log here - we check every message. It'd be very spammy.
+ return null
+ }
+ logi(TAG, "Looking for cached response for $message.")
+
+ val query = message.parseMessageAsQuery() ?: return null
+ val queryId = query.id
+ val sender = ByteUtils.bytesToUUID(query.sender.toByteArray()) ?: return null
+
+ val systemQuery = query.parseRequestAsSystemQuery() ?: return null
+ val type = systemQuery.type
+
+ val cachedResponse: DeviceMessage? =
+ when (type) {
+ SystemQueryType.APP_NAME -> handleAppNameSystemQuery(sender, queryId)
+ SystemQueryType.DEVICE_NAME -> handleDeviceNameSystemQuery(sender, queryId)
+ SystemQueryType.IS_FEATURE_SUPPORTED ->
+ handleIsFeatureSupportedSystemQuery(sender, queryId, systemQuery)
+ else -> {
+ loge(TAG, "Unspported system query ${type} in cache.")
+ null
+ }
+ }
+
+ if (cachedResponse != null) {
+ logi(TAG, "Returning cached response for $type to <$sender, $queryId>.")
+ return cachedResponse
+ }
+
+ // The query hasn't been cached yet, track the query type so that when the response is
+ // received, it can be properly parsed.
+ logi(TAG, "No cached response for $type. Tracking by <$sender, $queryId>.")
+ trackedQueryTypes[Pair(sender, queryId)] = type
+
+ return null
+ }
+
+ private fun handleAppNameSystemQuery(sender: UUID, queryId: Int): DeviceMessage? {
+ val cached = appName
+ if (cached == null) {
+ logi(TAG, "No cached response for system query AppName.")
+ return null
+ }
+
+ logi(TAG, "Returning cached app name: $cached")
+ return createCachedResponseForString(cached, sender, queryId)
+ }
+
+ private fun handleDeviceNameSystemQuery(sender: UUID, queryId: Int): DeviceMessage? {
+ val cached = deviceName
+ if (cached == null) {
+ logi(TAG, "No cached response for system query DeviceName.")
+ return null
+ }
+
+ logi(TAG, "Returning cached device name: $cached")
+ return createCachedResponseForString(cached, sender, queryId)
+ }
+
+ private fun createCachedResponseForString(
+ cached: String,
+ sender: UUID,
+ queryId: Int
+ ): DeviceMessage {
+ // Generate a device message that contains a QueryResponse that contains the cached value.
+ val queryResponse =
+ QueryResponse.newBuilder()
+ .setQueryId(queryId)
+ .setSuccess(true)
+ .setResponse(ByteString.copyFrom(cached.toByteArray()))
+ .build()
+
+ val deviceMessage =
+ DeviceMessage.createIncomingMessage(
+ /* recipient= */ sender,
+ /* isMessageEncrypted= */ false,
+ /* operationType= */ OperationType.QUERY_RESPONSE,
+ /* message= */ queryResponse.toByteArray(),
+ // OK to ignore original message size.
+ /* originalMessageSize= */ 0,
+ )
+
+ return deviceMessage
+ }
+
+ private fun handleIsFeatureSupportedSystemQuery(
+ sender: UUID,
+ queryId: Int,
+ systemQuery: SystemQuery
+ ): DeviceMessage? {
+ val queriedFeatureIds =
+ systemQuery.getPayloadsList().map {
+ UUID.fromString(String(it.toByteArray(), StandardCharsets.UTF_8))
+ }
+ for (featureId in queriedFeatureIds) {
+ // Default to false to allow features with a cached unsupported status to be queried again.
+ if (featureId !in supportedFeatures) {
+ logi(TAG, "Queried feature $featureId is not supported or cached. Returning null.")
+ return null
+ }
+ }
+
+ val cachedStatuses =
+ queriedFeatureIds.map {
+ logi(TAG, "Cached support status for feature $it.")
+ FeatureSupportStatus.newBuilder()
+ .setFeatureId(it.toString())
+ // Since we ignore "unsupported" feature support status, all cached results are true.
+ .setIsSupported(true)
+ .build()
+ }
+ val featureSupportResponse =
+ FeatureSupportResponse.newBuilder().addAllStatuses(cachedStatuses).build()
+
+ val queryResponse =
+ QueryResponse.newBuilder()
+ .setQueryId(queryId)
+ .setSuccess(true)
+ .setResponse(ByteString.copyFrom(featureSupportResponse.toByteArray()))
+ .build()
+
+ val deviceMessage =
+ DeviceMessage.createIncomingMessage(
+ /* recipient= */ sender,
+ /* isMessageEncrypted= */ false,
+ /* operationType= */ OperationType.QUERY_RESPONSE,
+ /* message= */ queryResponse.toByteArray(),
+ // OK to ignore original message size.
+ /* originalMessageSize= */ 0,
+ )
+ logi(TAG, "Returning cached support status for $queriedFeatureIds.")
+ return deviceMessage
+ }
+
+ private fun DeviceMessage.parseMessageAsQuery(): Query? =
+ try {
+ Query.parseFrom(message)
+ } catch (e: InvalidProtocolBufferException) {
+ loge(TAG, "Unable to parse DeviceMessage as Query.", e)
+ null
+ }
+
+ private fun DeviceMessage.isSystemQueryResponse(): Boolean =
+ operationType == OperationType.QUERY_RESPONSE
+
+ private fun DeviceMessage.isSystemQuery(): Boolean =
+ operationType == OperationType.QUERY && recipient == SYSTEM_FEATURE
+
+ private fun DeviceMessage.parseMessageAsQueryResponse(): QueryResponse? =
+ try {
+ QueryResponse.parseFrom(message)
+ } catch (e: InvalidProtocolBufferException) {
+ loge(TAG, "Unable to parse DeviceMessage as QueryResponse.", e)
+ null
+ }
+
+ private fun Query.parseRequestAsSystemQuery(): SystemQuery? =
+ try {
+ SystemQuery.parseFrom(request)
+ } catch (e: InvalidProtocolBufferException) {
+ loge(TAG, "Unable to parse Query as SystemQuery.", e)
+ null
+ }
+
+ fun clear() {
+ logi(TAG, "Clearing cached responses.")
+
+ trackedQueryTypes.clear()
+
+ appName = null
+ deviceName = null
+ supportedFeatures.clear()
+ }
+
+ companion object {
+ private const val TAG = "DeviceSystemQueryCache"
+ private val SYSTEM_FEATURE: UUID = SYSTEM_FEATURE_ID.uuid
+ }
+}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/metrics/EventMetricLogger.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/metrics/EventMetricLogger.kt
new file mode 100644
index 0000000..bd3b013
--- /dev/null
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/metrics/EventMetricLogger.kt
@@ -0,0 +1,19 @@
+package com.google.android.connecteddevice.metrics
+
+import android.content.Context
+import android.os.Build
+
+/** Logger to report Companion events to statsd */
+class EventMetricLogger(private val context: Context) {
+ private val uid = context.packageManager.getPackageUid(context.packageName, 0)
+ fun onSecureChannelEstablished() {
+ // TODO(b/273968556): unable to find related System API on Android Q build.
+ if(Build.VERSION.SDK_INT < Build.VERSION_CODES.R ) return
+ CompanionStatsLog.write(
+ CompanionStatsLog.COMPANION_STATUS_CHANGED,
+ uid,
+ CompanionStatsLog.COMPANION_STATUS_CHANGED__COMPANION_STATUS__SECURE_CHANNEL_ESTABLISHED,
+ 0
+ )
+ }
+}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/notificationmsg/NotificationMsgDelegate.java b/libs/connecteddevice/src/com/google/android/connecteddevice/notificationmsg/NotificationMsgDelegate.java
index fac77e2..ff1f229 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/notificationmsg/NotificationMsgDelegate.java
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/notificationmsg/NotificationMsgDelegate.java
@@ -39,7 +39,6 @@
import com.google.android.connecteddevice.notificationmsg.common.SenderKey;
import com.google.android.connecteddevice.notificationmsg.common.Utils;
import com.google.android.connecteddevice.notificationmsg.proto.NotificationMsg.Action;
-import com.google.android.connecteddevice.notificationmsg.proto.NotificationMsg.AvatarIconSync;
import com.google.android.connecteddevice.notificationmsg.proto.NotificationMsg.CarToPhoneMessage;
import com.google.android.connecteddevice.notificationmsg.proto.NotificationMsg.ClearAppDataRequest;
import com.google.android.connecteddevice.notificationmsg.proto.NotificationMsg.ConversationNotification;
@@ -75,8 +74,6 @@
* ConnectedDevice#getDeviceId()}.
*/
private String connectedDeviceBluetoothAddress;
- /** Maps a Bitmap of a sender's Large Icon to the sender's unique key for 1-1 conversations. */
- protected final Map<SenderKey, Bitmap> oneOnOneConversationAvatarMap = new HashMap<>();
/** Tracks whether a projection application is active in the foreground. */
private ProjectionStateListener projectionStateListener;
@@ -136,9 +133,7 @@
// TODO (b/144924164): implement Action Request tracking logic.
return;
case AVATAR_ICON_SYNC:
- storeIcon(
- new ConversationKey(device.getDeviceId(), notificationKey),
- message.getAvatarIconSync());
+ // Deprecated
return;
case PHONE_METADATA:
connectedDeviceBluetoothAddress = message.getPhoneMetadata().getBluetoothDeviceAddress();
@@ -203,7 +198,6 @@
// after the feature is stopped.
cleanupMessagesAndNotifications(key -> true);
projectionStateListener.destroy();
- oneOnOneConversationAvatarMap.clear();
appNameToChannel.clear();
connectedDeviceBluetoothAddress = null;
}
@@ -211,9 +205,6 @@
protected void onDeviceDisconnected(String deviceId) {
connectedDeviceBluetoothAddress = null;
cleanupMessagesAndNotifications(key -> key.matches(deviceId));
- oneOnOneConversationAvatarMap
- .entrySet()
- .removeIf(conversationKey -> conversationKey.getKey().matches(deviceId));
}
private void initializeNewConversation(
@@ -252,7 +243,7 @@
convoKey,
convoInfo,
getChannelId(appDisplayName),
- getAvatarIcon(convoKey, latestMessage),
+ getAvatarIcon(latestMessage),
contentTextResourceId,
defaultDisplayName,
groupTitleSeparator,
@@ -285,7 +276,7 @@
convoKey,
convoInfo,
getChannelId(convoInfo.getAppDisplayName()),
- getAvatarIcon(convoKey, messagingStyleMessage),
+ getAvatarIcon(messagingStyleMessage),
contentTextResourceId,
defaultDisplayName,
groupTitleSeparator,
@@ -299,40 +290,14 @@
}
@Nullable
- private Bitmap getAvatarIcon(ConversationKey convoKey, MessagingStyleMessage message) {
- ConversationNotificationInfo notificationInfo = notificationInfos.get(convoKey);
- if (!notificationInfo.isGroupConvo()) {
- return oneOnOneConversationAvatarMap.get(
- SenderKey.createSenderKey(convoKey, message.getSender()));
- } else if (!message.getSender().getAvatar().isEmpty()) {
+ private Bitmap getAvatarIcon(MessagingStyleMessage message) {
+ if (!message.getSender().getAvatar().isEmpty()) {
byte[] iconArray = message.getSender().getAvatar().toByteArray();
return BitmapFactory.decodeByteArray(iconArray, 0, iconArray.length);
}
return null;
}
- private void storeIcon(ConversationKey convoKey, AvatarIconSync iconSync) {
- if (!Utils.isValidAvatarIconSync(iconSync)) {
- logw(TAG, "storeIcon: invalid AvatarIconSync obj.");
- return;
- }
- if (!notificationInfos.containsKey(convoKey)) {
- logw(TAG, "storeIcon: no conversation found.");
- return;
- }
- if (notificationInfos.get(convoKey).isGroupConvo()) {
- return;
- }
- byte[] iconArray = iconSync.getPerson().getAvatar().toByteArray();
- Bitmap bitmap = BitmapFactory.decodeByteArray(iconArray, /* offset= */ 0, iconArray.length);
- if (bitmap != null) {
- oneOnOneConversationAvatarMap.put(
- SenderKey.createSenderKey(convoKey, iconSync.getPerson()), bitmap);
- } else {
- logw(TAG, "storeIcon: Bitmap could not be created from byteArray");
- }
- }
-
private String getChannelId(String appDisplayName) {
if (!appNameToChannel.containsKey(appDisplayName)) {
appNameToChannel.put(appDisplayName, new NotificationChannelWrapper(appDisplayName));
@@ -346,19 +311,12 @@
private void createNewMessage(
String deviceAddress, MessagingStyleMessage messagingStyleMessage, ConversationKey convoKey) {
- String appPackageName = notificationInfos.get(convoKey).getAppPackageName();
Message message =
Message.parseFromMessage(
deviceAddress,
messagingStyleMessage,
SenderKey.createSenderKey(convoKey, messagingStyleMessage.getSender()));
addMessageToNotificationInfo(message, convoKey);
- AvatarIconSync iconSync =
- AvatarIconSync.newBuilder()
- .setPerson(messagingStyleMessage.getSender())
- .setMessagingAppPackageName(appPackageName)
- .build();
- storeIcon(convoKey, iconSync);
}
private void clearAppData(String deviceId, String packageName) {
@@ -369,9 +327,6 @@
return;
}
cleanupMessagesAndNotifications(key -> key.matches(deviceId));
- oneOnOneConversationAvatarMap
- .entrySet()
- .removeIf(conversationKey -> conversationKey.getKey().matches(deviceId));
}
/** Creates notification channels per unique messaging application. */
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/notificationmsg/NotificationMsgFeature.java b/libs/connecteddevice/src/com/google/android/connecteddevice/notificationmsg/NotificationMsgFeature.java
index 81469c4..45a371c 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/notificationmsg/NotificationMsgFeature.java
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/notificationmsg/NotificationMsgFeature.java
@@ -18,6 +18,7 @@
import static com.google.android.connecteddevice.util.SafeLog.logd;
import static com.google.android.connecteddevice.util.SafeLog.loge;
+import static com.google.android.connecteddevice.util.SafeLog.logi;
import static com.google.android.connecteddevice.util.SafeLog.logw;
import android.content.Context;
@@ -58,6 +59,7 @@
// clear all notifications and local data on start of the feature.
notificationMsgDelegate.cleanupMessagesAndNotifications(key -> true);
super.start();
+ logi(TAG, "Feature started");
}
@Override
@@ -66,6 +68,17 @@
// after the feature is stopped.
notificationMsgDelegate.onDestroy();
super.stop();
+ logi(TAG, "Feature stopped");
+ }
+
+ @Override
+ public void onReady() {
+ logi(TAG, "Feature ready");
+ }
+
+ @Override
+ public void onNotReady() {
+ logi(TAG, "Feature not ready");
}
@Override
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/service/ConnectedDeviceFgUserService.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/service/ConnectedDeviceFgUserService.kt
index f3c4f47..c011372 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/service/ConnectedDeviceFgUserService.kt
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/service/ConnectedDeviceFgUserService.kt
@@ -26,6 +26,7 @@
import android.os.UserManager
import com.google.android.connecteddevice.api.CompanionConnector
import com.google.android.connecteddevice.api.Connector
+import com.google.android.connecteddevice.api.SafeConnector
import com.google.android.connecteddevice.util.SafeLog.logd
import com.google.android.connecteddevice.util.SafeLog.loge
import java.time.Duration
@@ -91,7 +92,7 @@
override fun onBind(intent: Intent): IBinder? {
logd(TAG, "Service bound. Action: ${intent.action}")
val action = intent.action ?: return null
- if (action == ACTION_QUERY_API_VERSION) {
+ if (action == SafeConnector.ACTION_QUERY_API_VERSION) {
logd(TAG, "Return binder version to remote process")
return binderVersion.asBinder()
}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/service/ConnectedDeviceService.java b/libs/connecteddevice/src/com/google/android/connecteddevice/service/ConnectedDeviceService.java
index 045a3e8..bd36f00 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/service/ConnectedDeviceService.java
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/service/ConnectedDeviceService.java
@@ -19,13 +19,19 @@
import static com.google.android.connecteddevice.util.SafeLog.logd;
import static com.google.android.connecteddevice.util.SafeLog.loge;
+import android.annotation.SuppressLint;
+import android.content.BroadcastReceiver;
+import android.content.Context;
import android.content.Intent;
+import android.content.IntentFilter;
import android.os.IBinder;
+import android.os.UserHandle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.connecteddevice.R;
import com.google.android.connecteddevice.api.CompanionConnector;
import com.google.android.connecteddevice.api.Connector;
+import com.google.android.connecteddevice.api.FeatureConnector;
import com.google.android.connecteddevice.core.DeviceController;
import com.google.android.connecteddevice.core.FeatureCoordinator;
import com.google.android.connecteddevice.core.MultiProtocolDeviceController;
@@ -39,9 +45,10 @@
import com.google.android.connecteddevice.util.EventLog;
import java.io.FileDescriptor;
import java.io.PrintWriter;
+import java.util.List;
import java.util.UUID;
+import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.atomic.AtomicBoolean;
/** Early start service that hosts the core companion platform. */
@@ -69,11 +76,20 @@
private final AtomicBoolean isEveryFeatureInitialized = new AtomicBoolean(false);
- private final ScheduledExecutorService scheduledExecutorService =
- Executors.newSingleThreadScheduledExecutor();
+ private final ExecutorService databaseExecutor = Executors.newSingleThreadExecutor();
private final ProtocolDelegate protocolDelegate = new ProtocolDelegate();
+ private final BroadcastReceiver userRemovedBroadcastReceiver =
+ new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ logd(TAG, "Received USER_REMOVED broadcast.");
+ UserHandle userHandle = intent.getParcelableExtra(Intent.EXTRA_USER);
+ onUserRemoved(userHandle);
+ }
+ };
+
private LoggingManager loggingManager;
private FeatureCoordinator featureCoordinator;
@@ -85,6 +101,7 @@
private LoggingFeature loggingFeature;
@Override
+ @SuppressLint("UnprotectedReceiver") // ACTION_USER_REMOVED is a protected broadcast.
public void onCreate() {
super.onCreate();
logd(
@@ -113,8 +130,9 @@
storage = new ConnectedDeviceStorage(this);
initializeFeatureCoordinator();
-
populateFeatures();
+ logd(TAG, "Registering broadcast receiver for intent " + Intent.ACTION_USER_REMOVED);
+ registerReceiver(userRemovedBroadcastReceiver, new IntentFilter(Intent.ACTION_USER_REMOVED));
}
private void initializeFeatureCoordinator() {
@@ -129,7 +147,7 @@
OobRunner oobRunner = new OobRunner(protocolDelegate, oobProtocolName);
DeviceController deviceController =
new MultiProtocolDeviceController(
- protocolDelegate, storage, oobRunner, associationUuid, enablePassenger);
+ this, protocolDelegate, storage, oobRunner, associationUuid, enablePassenger);
featureCoordinator = new FeatureCoordinator(deviceController, storage, loggingManager);
logd(TAG, "Wrapping FeatureCoordinator in legacy binders for backwards compatibility.");
}
@@ -150,6 +168,24 @@
this, Connector.USER_TYPE_ALL, featureCoordinator));
}
+ private void onUserRemoved(UserHandle userHandle) {
+ databaseExecutor.execute(
+ () -> {
+ FeatureCoordinator featurecoordinator = this.featureCoordinator;
+ if (featurecoordinator == null) {
+ logd(TAG, "User removed before feature coordinator is initiated. Ignored");
+ return;
+ }
+
+ int userId = userHandle.getIdentifier();
+ List<String> deviceIds = storage.getAssociatedDeviceIdsForUser(userId);
+ for (String deviceId : deviceIds) {
+ logd(TAG, "Delete data from database; userId=" + userId + ", deviceId=" + deviceId);
+ featurecoordinator.removeAssociatedDevice(deviceId);
+ }
+ });
+ }
+
@Nullable
@Override
public IBinder onBind(Intent intent) {
@@ -164,7 +200,7 @@
return protocolDelegate;
case CompanionConnector.ACTION_BIND_FEATURE_COORDINATOR:
return featureCoordinator;
- case ACTION_QUERY_API_VERSION:
+ case FeatureConnector.ACTION_QUERY_API_VERSION:
logd(TAG, "Return binder version to remote process");
return binderVersion.asBinder();
default:
@@ -176,7 +212,8 @@
@Override
public void onDestroy() {
logd(TAG, "Service was destroyed.");
- scheduledExecutorService.shutdown();
+ unregisterReceiver(userRemovedBroadcastReceiver);
+ databaseExecutor.shutdown();
cleanup();
super.onDestroy();
}
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/service/TrunkService.java b/libs/connecteddevice/src/com/google/android/connecteddevice/service/TrunkService.java
index f6d2b14..f278259 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/service/TrunkService.java
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/service/TrunkService.java
@@ -50,10 +50,6 @@
@VisibleForTesting
static final int MAX_BIND_ATTEMPTS = 3;
- // TODO(b/263392730): Move this to FeatureConnector so that client app has access.
- protected static final String ACTION_QUERY_API_VERSION =
- "com.google.android.connecteddevice.api.QUERY_API_VERSION";
-
private static final Duration BIND_RETRY_DURATION = Duration.ofSeconds(1);
private final Multiset<String> bindAttempts = HashMultiset.create();
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/system/SystemFeature.kt b/libs/connecteddevice/src/com/google/android/connecteddevice/system/SystemFeature.kt
index c5e47a6..e9fec7f 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/system/SystemFeature.kt
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/system/SystemFeature.kt
@@ -33,17 +33,48 @@
import com.google.protobuf.ExtensionRegistryLite
import com.google.protobuf.InvalidProtocolBufferException
import java.nio.charset.StandardCharsets
+import java.util.UUID
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
-/** Feature responsible for system queries. */
-open class SystemFeature(
+/**
+ * Feature responsible for system queries.
+ *
+ * @param queryFeatureSupportOnConnection Feature IDs that should be queried when this SystemFeature
+ * is notified of a connected device. The support status should be cached internally so that when
+ * the support status is queried again later (by the actual feature), the result is immediately
+ * available.
+ */
+open class SystemFeature
+// @VisibleForTesting
+internal constructor(
context: Context,
private val storage: ConnectedDeviceStorage,
- private val connector: Connector
+ private val connector: Connector,
+ private val queryFeatureSupportOnConnection: List<UUID>,
) {
private val bluetoothAdapter: BluetoothAdapter =
context.getSystemService(BluetoothManager::class.java).adapter
+ constructor(
+ context: Context,
+ storage: ConnectedDeviceStorage,
+ connector: Connector,
+ ) : this(
+ context,
+ storage,
+ connector,
+ listOf(
+ // SecondDeviceSignInUrlFeature
+ // This feature is started by UI (not always running in the background), so it has a limited
+ // amount of time to initialize. We can speed up the process by preheating the system query
+ // cache, namely querying the feature status here so when the feature checks, the response
+ // is cached.
+ UUID.fromString("524a5d28-b208-449c-bb54-cd89498d3b1b"),
+ ),
+ )
+
init {
connector.featureId = SYSTEM_FEATURE_ID
connector.callback =
@@ -73,16 +104,22 @@
}
private fun onSecureChannelEstablishedInternal(device: ConnectedDevice) {
- logd(TAG, "Secure channel has been established. Issuing device name query.")
+ logd(TAG, "Secure channel has been established. ")
+ queryDeviceName(device)
+ queryFeatureSupportStatusToPreheatCache(device)
+ }
+
+ private fun queryDeviceName(device: ConnectedDevice) {
+ logd(TAG, "Issuing device name query.")
val deviceNameQuery = SystemQuery.newBuilder().setType(DEVICE_NAME).build()
connector.sendQuerySecurely(
device,
deviceNameQuery.toByteArray(),
- /* parameters= */ null,
+ parameters = null,
object : QueryCallback {
- override fun onSuccess(response: ByteArray?) {
- if (response?.isNotEmpty() != true) {
- loge(TAG, "Received a null or empty device name query response. Ignoring.")
+ override fun onSuccess(response: ByteArray) {
+ if (response.isEmpty()) {
+ loge(TAG, "Received an empty device name query response. Ignoring.")
return
}
val deviceName = String(response, StandardCharsets.UTF_8)
@@ -93,6 +130,14 @@
)
}
+ private fun queryFeatureSupportStatusToPreheatCache(device: ConnectedDevice) {
+ logd(TAG, "Issuing query for feature support status.")
+ // Ignore the result because we are only calling to preheat the status cache.
+ MainScope().launch {
+ val unused = connector.queryFeatureSupportStatuses(device, queryFeatureSupportOnConnection)
+ }
+ }
+
private fun onQueryReceivedInternal(
device: ConnectedDevice,
queryId: Int,
@@ -133,16 +178,11 @@
if (device.isAssociatedWithDriver) SystemUserRole.DRIVER else SystemUserRole.PASSENGER
val response = SystemUserRoleResponse.newBuilder().setRole(role).build()
logd(TAG, "Responding to query for user role with $role.")
- connector.respondToQuerySecurely(
- device,
- queryId,
- success = true,
- response.toByteArray()
- )
+ connector.respondToQuerySecurely(device, queryId, success = true, response.toByteArray())
}
private fun respondWithError(device: ConnectedDevice, queryId: Int) =
- connector.respondToQuerySecurely(device, queryId, /* success= */ false, /* response= */ null)
+ connector.respondToQuerySecurely(device, queryId, success = false, response = null)
companion object {
private const val TAG = "SystemFeature"
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/trust/TrustedDeviceAgentService.java b/libs/connecteddevice/src/com/google/android/connecteddevice/trust/TrustedDeviceAgentService.java
index f7b799c..c5e56e9 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/trust/TrustedDeviceAgentService.java
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/trust/TrustedDeviceAgentService.java
@@ -29,6 +29,7 @@
import android.content.Intent;
import android.content.IntentFilter;
import android.content.ServiceConnection;
+import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
@@ -36,6 +37,7 @@
import android.os.RemoteException;
import android.os.UserHandle;
import android.os.UserManager;
+import android.service.trust.GrantTrustResult;
import android.service.trust.TrustAgentService;
import androidx.annotation.VisibleForTesting;
import com.google.android.connecteddevice.trust.api.ITrustedDeviceAgentDelegate;
@@ -157,15 +159,26 @@
return;
}
logd(TAG, "Dismissing the lockscreen.");
- grantTrust(
- "Granting trust from escrow token for user.",
- TRUST_DURATION_MS,
- FLAG_GRANT_TRUST_DISMISS_KEYGUARD);
- setManagingTrust(false);
- if (trustedDeviceManager == null) {
- loge(TAG, "Manager was null when device was unlocked. Ignoring.");
- return;
- }
+ // To avoid unreliable system lock status check impacting automated test metrics, invoking the
+ // user unlocked event before granting trust.
+ TrustedDeviceEventLog.onUserUnlocked();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ logd(TAG, "Grant trust with result callback.");
+ grantTrust(
+ "Granting trust from escrow token for user.",
+ TRUST_DURATION_MS,
+ FLAG_GRANT_TRUST_DISMISS_KEYGUARD,
+ (result) -> {
+ logd(TAG, "GrantTrust return with Result: " + result.getStatus() + ".");
+ if (result.getStatus() == GrantTrustResult.STATUS_UNLOCKED_BY_GRANT) {
+ notifyLockScreenDismissed();
+ }
+ });
+ } else {
+ grantTrust(
+ "Granting trust from escrow token for user.",
+ TRUST_DURATION_MS,
+ FLAG_GRANT_TRUST_DISMISS_KEYGUARD);
// Other locking schemas, e.g. primary authentication, might keep the device locked even after
// granting trust.
if (keyguardManager == null || keyguardManager.isDeviceLocked()) {
@@ -175,7 +188,16 @@
+ "Skip the ACK message to the phone.");
return;
}
- TrustedDeviceEventLog.onUserUnlocked();
+ notifyLockScreenDismissed();
+ }
+ setManagingTrust(false);
+ }
+
+ private void notifyLockScreenDismissed() {
+ if (trustedDeviceManager == null) {
+ loge(TAG, "Manager was null when device was unlocked. Ignoring.");
+ return;
+ }
try {
trustedDeviceManager.onUserUnlocked();
} catch (RemoteException e) {
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/trust/TrustedDeviceUiDelegateService.java b/libs/connecteddevice/src/com/google/android/connecteddevice/trust/TrustedDeviceUiDelegateService.java
index 2d50787..3625605 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/trust/TrustedDeviceUiDelegateService.java
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/trust/TrustedDeviceUiDelegateService.java
@@ -31,7 +31,6 @@
import android.os.IBinder;
import android.os.RemoteException;
import androidx.annotation.Nullable;
-import androidx.core.content.ContextCompat;
import com.google.android.connecteddevice.service.MetaDataService;
import com.google.android.connecteddevice.trust.api.IOnTrustedDeviceEnrollmentNotificationRequestListener;
import com.google.android.connecteddevice.trust.api.ITrustedDeviceManager;
@@ -54,9 +53,6 @@
/** {@code String} Content for the enrollment notification. */
private static final String META_ENROLLMENT_NOTIFICATION_CONTENT =
"com.google.android.connecteddevice.trust.enrollment_notification_content";
- /** {@code Color} Color for the enrollment notification. */
- private static final String META_ENROLLMENT_NOTIFICATION_COLOR =
- "com.google.android.connecteddevice.trust.enrollment_notification_color";
private NotificationManager notificationManager;
private ITrustedDeviceManager trustedDeviceManager;
@@ -102,9 +98,6 @@
Notification notification =
new Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(requireMetaResourceId(META_ENROLLMENT_NOTIFICATION_ICON))
- .setColor(
- ContextCompat.getColor(
- getBaseContext(), requireMetaResourceId(META_ENROLLMENT_NOTIFICATION_COLOR)))
.setContentTitle(requireMetaString(META_ENROLLMENT_NOTIFICATION_TITLE))
.setContentText(requireMetaString(META_ENROLLMENT_NOTIFICATION_CONTENT))
.setContentIntent(pendingIntent)
diff --git a/libs/connecteddevice/src/com/google/android/connecteddevice/trust/TrustedDeviceViewModel.java b/libs/connecteddevice/src/com/google/android/connecteddevice/trust/TrustedDeviceViewModel.java
index 6ebabf5..4a25e53 100644
--- a/libs/connecteddevice/src/com/google/android/connecteddevice/trust/TrustedDeviceViewModel.java
+++ b/libs/connecteddevice/src/com/google/android/connecteddevice/trust/TrustedDeviceViewModel.java
@@ -241,6 +241,7 @@
* @param device The associated device to be enrolled.
*/
public void enrollTrustedDevice(AssociatedDevice device) {
+ updateTrustedDevicesFromServer();
attemptInitiatingEnrollment(device);
}
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/api/CompanionConnectorTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/api/CompanionConnectorTest.kt
index 9742d70..482bffc 100644
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/api/CompanionConnectorTest.kt
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/api/CompanionConnectorTest.kt
@@ -11,6 +11,8 @@
import android.os.ParcelUuid
import android.os.RemoteException
import androidx.test.core.app.ApplicationProvider
+import com.google.android.companionprotos.FeatureSupportResponse
+import com.google.android.companionprotos.FeatureSupportStatus
import com.google.android.companionprotos.Query
import com.google.android.companionprotos.QueryResponse
import com.google.android.companionprotos.SystemQuery
@@ -39,6 +41,11 @@
import java.nio.charset.StandardCharsets
import java.util.UUID
import kotlin.test.fail
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -58,15 +65,17 @@
private val context = FakeContext(mockPackageManager)
+ private lateinit var featureId: UUID
private lateinit var defaultConnector: CompanionConnector
@Before
fun setUp() {
+ featureId = UUID.randomUUID()
defaultConnector =
CompanionConnector(context).apply {
featureCoordinator = mockFeatureCoordinator
callback = mockCallback
- featureId = ParcelUuid(UUID.randomUUID())
+ featureId = ParcelUuid(this@CompanionConnectorTest.featureId)
}
}
@@ -346,13 +355,7 @@
featureCoordinator = mockFeatureCoordinator
this.featureId = featureId
}
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ false
- )
+ val device = createConnectedDevice()
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
connector.connect()
@@ -402,13 +405,7 @@
featureCoordinator = mockFeatureCoordinator
this.featureId = featureId
}
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ false
- )
+ val device = createConnectedDevice()
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
connector.connect()
@@ -424,13 +421,7 @@
featureCoordinator = mockFeatureCoordinator
this.featureId = featureId
}
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ false,
- /* hasSecureChannel= */ false
- )
+ val device = createConnectedDevice(belongsToDriver = false)
whenever(mockFeatureCoordinator.connectedDevicesForPassengers).thenReturn(listOf(device))
connector.connect()
@@ -446,13 +437,7 @@
featureCoordinator = mockFeatureCoordinator
this.featureId = featureId
}
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ false
- )
+ val device = createConnectedDevice()
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
connector.connect()
@@ -463,13 +448,7 @@
@Test
fun connect_deviceCallbacksNotRegisteredWhenMissingFeatureId() {
defaultConnector.featureId = null
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ false
- )
+ val device = createConnectedDevice()
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
defaultConnector.connect()
@@ -479,13 +458,7 @@
@Test
fun disconnect_unregistersFeatureCoordinatorCallbacks() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ false
- )
+ val device = createConnectedDevice()
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
defaultConnector.disconnect()
@@ -500,13 +473,7 @@
@Test
fun disconnect_featureCoordinatorWithoutFeatureIdDoesNotThrow() {
defaultConnector.featureId = null
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ false
- )
+ val device = createConnectedDevice()
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
defaultConnector.disconnect()
@@ -514,13 +481,7 @@
@Test
fun disconnect_featureCoordinatorRemoteExceptionIsCaught() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ false
- )
+ val device = createConnectedDevice()
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
whenever(mockFeatureCoordinator.unregisterConnectionCallback(any()))
.thenThrow(RemoteException())
@@ -530,13 +491,7 @@
@Test
fun onDeviceConnected_registersDeviceCallbackWithFeatureId() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ false
- )
+ val device = createConnectedDevice()
defaultConnector.connect()
@@ -551,13 +506,7 @@
@Test
fun onDeviceConnected_doesNotRegisterDeviceCallbackWithoutFeatureId() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ false
- )
+ val device = createConnectedDevice()
defaultConnector.featureId = null
defaultConnector.connect()
@@ -571,13 +520,7 @@
@Test
fun onDeviceDisconnected_invokedWhenDeviceDisconnects() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ false
- )
+ val device = createConnectedDevice()
defaultConnector.connect()
argumentCaptor<IConnectionCallback> {
@@ -590,13 +533,7 @@
@Test
fun onDeviceDisconnected_unregistersDeviceCallback() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ false
- )
+ val device = createConnectedDevice()
defaultConnector.connect()
argumentCaptor<IConnectionCallback> {
@@ -610,20 +547,8 @@
@Test
fun onSecureChannelEstablished_invokedWhenChannelEstablished() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ false
- )
- val secureDevice =
- ConnectedDevice(
- device.deviceId,
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice()
+ val secureDevice = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
defaultConnector.connect()
@@ -637,13 +562,7 @@
@Test
fun onSecureChannelEstablished_invokedOnStartupIfChannelAlreadyEstablished() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
defaultConnector.connect()
@@ -652,13 +571,7 @@
@Test
fun onSecureChannelEstablished_invokedOnDeviceConnectedIfChannelAlreadyEstablished() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
defaultConnector.connect()
argumentCaptor<IConnectionCallback> {
@@ -670,13 +583,7 @@
@Test
fun onDeviceError_invokedOnError() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ false
- )
+ val device = createConnectedDevice()
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
defaultConnector.connect()
val error = -1
@@ -748,13 +655,7 @@
@Test
fun sendMessageSecurelyWithId_sendsMessageSecurelyToDevice() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
defaultConnector.connect()
val message = ByteUtils.randomBytes(10)
@@ -773,13 +674,7 @@
@Test
fun sendMessageSecurely_sendsMessageSecurelyToDevice() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
defaultConnector.connect()
val message = ByteUtils.randomBytes(10)
@@ -809,13 +704,7 @@
@Test
fun onMessageFailedToSend_invokedWhenRemoteExceptionThrownId() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
whenever(mockFeatureCoordinator.sendMessage(any(), any())).thenThrow(RemoteException())
val message = ByteUtils.randomBytes(10)
@@ -828,13 +717,7 @@
@Test
fun onMessageFailedToSend_invokedWhenRemoteExceptionThrown() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
whenever(mockFeatureCoordinator.sendMessage(any(), any())).thenThrow(RemoteException())
val message = ByteUtils.randomBytes(10)
@@ -871,13 +754,7 @@
@Test
fun onMessageFailedToSend_invokedWhenSendMessageCalledBeforeServiceConnection() {
defaultConnector.featureCoordinator = null
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
val message = ByteUtils.randomBytes(10)
defaultConnector.sendMessageSecurely(device, message)
@@ -887,13 +764,7 @@
@Test
fun onMessageReceived_invokedWhenGenericMessageReceived() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
val message = ByteUtils.randomBytes(10)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
defaultConnector.connect()
@@ -916,13 +787,7 @@
@Test
fun getConnectedDeviceById_returnsDeviceWhenConnected() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ false
- )
+ val device = createConnectedDevice()
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
defaultConnector.connect()
@@ -931,13 +796,7 @@
@Test
fun getConnectedDeviceById_returnsNullWhenNotConnected() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ false
- )
+ val device = createConnectedDevice()
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
defaultConnector.connect()
@@ -961,13 +820,7 @@
@Test
fun sendQuerySecurely_sendsQueryToOwnFeatureId() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
val request = ByteUtils.randomBytes(10)
val parameters = ByteUtils.randomBytes(10)
@@ -986,13 +839,7 @@
@Test
fun sendQuery_queryCallbackOnSuccessInvoked() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
val request = ByteUtils.randomBytes(10)
val response = ByteUtils.randomBytes(10)
val parameters = ByteUtils.randomBytes(10)
@@ -1033,13 +880,7 @@
@Test
fun sendQuery_queryCallbackOnErrorInvoked() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
defaultConnector.connect()
@@ -1080,13 +921,7 @@
@Test
fun sendQuery_queryCallbackNotInvokedOnDifferentQueryIdResponse() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
defaultConnector.connect()
@@ -1142,13 +977,7 @@
@Test
fun sendQuery_queryCallbackOnQueryNotSentInvokedBeforeServiceConnectionWithDevice() {
defaultConnector.featureCoordinator = null
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
val request = ByteUtils.randomBytes(10)
val parameters = ByteUtils.randomBytes(10)
val callback = mock<Connector.QueryCallback>()
@@ -1160,13 +989,7 @@
@Test
fun sendQuery_queryCallbackOnQueryNotSentInvokedIfSendMessageThrowsRemoteException() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
whenever(mockFeatureCoordinator.sendMessage(any(), any())).thenThrow(RemoteException())
@@ -1182,13 +1005,7 @@
@Test
fun sendQuery_queryCallbackOnQueryNotSentInvokedIfDeviceNotFound() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
defaultConnector.connect()
val request = ByteUtils.randomBytes(10)
val parameters = ByteUtils.randomBytes(10)
@@ -1201,13 +1018,7 @@
@Test
fun onQueryReceived_invokedWithQueryFields() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
defaultConnector.connect()
@@ -1241,38 +1052,21 @@
@Test
fun respondToQuery_doesNotSendResponseWithUnrecognizedQueryId() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
defaultConnector.connect()
val nonExistentQueryId = 0
val response = ByteUtils.randomBytes(10)
- defaultConnector.respondToQuerySecurely(
- device,
- nonExistentQueryId,
- success = true,
- response
- )
+ defaultConnector.respondToQuerySecurely(device, nonExistentQueryId, success = true, response)
verify(mockFeatureCoordinator, never()).sendMessage(any(), any())
}
@Test
fun respondToQuery_sendsResponseToSenderIfSameFeatureId() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
defaultConnector.connect()
@@ -1320,13 +1114,7 @@
@Test
fun respondToQuery_sendResponseToSenderIfDifferentFeatureId() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
defaultConnector.connect()
@@ -1376,13 +1164,7 @@
@Test
fun respondToQuery_doesNotThrowBeforeServiceConnectionWithDevice() {
defaultConnector.featureCoordinator = null
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
val queryId = 0
val response = ByteUtils.randomBytes(10)
@@ -1391,13 +1173,7 @@
@Test
fun retrieveCompanionApplicationName_sendsAppNameQueryToSystemFeature() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
defaultConnector.connect()
@@ -1420,13 +1196,7 @@
@Test
fun retrieveCompanionApplicationName_appNameCallbackOnNameReceivedInvokedWithName() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
defaultConnector.connect()
@@ -1466,13 +1236,7 @@
@Test
fun retrieveCompanionApplicationName_appNameCallbackOnErrorInvokedWithEmptyResponse() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
defaultConnector.connect()
val callbackCaptor =
@@ -1512,13 +1276,7 @@
@Test
fun retrieveCompanionApplicationName_appNameCallbackOnErrorInvokedWithErrorResponse() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
defaultConnector.connect()
val callbackCaptor =
@@ -1557,13 +1315,7 @@
@Test
fun retrieveCompanionApplicationName_appNameCallbackOnErrorInvokedWhenQueryFailedToSend() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
whenever(mockFeatureCoordinator.sendMessage(any(), any())).thenThrow(RemoteException())
@@ -1577,13 +1329,7 @@
@Test
fun createLocalConnector_createsConnectorWithFeatureCoordinator() {
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
val connector =
@@ -1607,13 +1353,7 @@
CompanionConnector(context, userType = USER_TYPE_DRIVER).apply {
featureCoordinator = mockFeatureCoordinator
}
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
assertThat(connector.connectedDevices).containsExactly(device)
@@ -1625,13 +1365,7 @@
CompanionConnector(context, userType = USER_TYPE_PASSENGER).apply {
featureCoordinator = mockFeatureCoordinator
}
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.connectedDevicesForPassengers).thenReturn(listOf(device))
assertThat(connector.connectedDevices).containsExactly(device)
@@ -1643,13 +1377,7 @@
CompanionConnector(context, userType = USER_TYPE_ALL).apply {
featureCoordinator = mockFeatureCoordinator
}
- val device =
- ConnectedDevice(
- UUID.randomUUID().toString(),
- /* deviceName= */ "",
- /* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
- )
+ val device = createConnectedDevice(hasSecureChannel = true)
whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
assertThat(connector.connectedDevices).containsExactly(device)
@@ -1904,6 +1632,250 @@
verify(mockCallback).onFailedToConnect()
}
+ @Test
+ fun isFeatureSupported_noFeatureId_notSupported() = runBlocking {
+ val connector = CompanionConnector(context)
+ val device = createConnectedDevice()
+
+ assertThat(connector.isFeatureSupported(device)).isFalse()
+ }
+
+ @Test
+ fun isFeatureSupported_sendsSystemQuery() = runBlocking {
+ val device = createConnectedDevice(hasSecureChannel = true)
+ whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
+ whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
+ defaultConnector.connect()
+
+ // Immediate execution to send out the query.
+ CoroutineScope(Dispatchers.Main.immediate).launch {
+ val unused = defaultConnector.isFeatureSupported(device)
+ }
+
+ // Assertion: successfully parse the outbound message as a SystemQuery for the feature status.
+ val messageCaptor =
+ argumentCaptor<DeviceMessage> {
+ verify(mockFeatureCoordinator).sendMessage(eq(device), capture())
+ }
+ val query =
+ Query.parseFrom(messageCaptor.firstValue.message, ExtensionRegistryLite.getEmptyRegistry())
+ val systemQuery = SystemQuery.parseFrom(query.request, ExtensionRegistryLite.getEmptyRegistry())
+ assertThat(systemQuery.type).isEqualTo(SystemQueryType.IS_FEATURE_SUPPORTED)
+ assertThat(systemQuery.payloadsList.size).isEqualTo(1)
+
+ val queriedFeatureId =
+ UUID.fromString(
+ systemQuery.payloadsList.first().toByteArray().toString(StandardCharsets.UTF_8)
+ )
+ assertThat(queriedFeatureId).isEqualTo(featureId)
+ }
+
+ @Test
+ fun isFeatureSupported_emptyResponse_notSupported() {
+ // Arrange.
+ val device = createConnectedDevice(hasSecureChannel = true)
+ whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
+ whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
+ defaultConnector.connect()
+
+ // Action - immediate execution to send out the query.
+ val deferred =
+ CoroutineScope(Dispatchers.Main.immediate).async {
+ defaultConnector.isFeatureSupported(device)
+ }
+
+ // Generate an empty response.
+ val response = ByteArray(0)
+
+ val messageCaptor =
+ argumentCaptor<DeviceMessage> {
+ verify(mockFeatureCoordinator).sendMessage(eq(device), capture())
+ }
+ val query =
+ Query.parseFrom(messageCaptor.firstValue.message, ExtensionRegistryLite.getEmptyRegistry())
+ val queryResponse =
+ QueryResponse.newBuilder()
+ .setQueryId(query.id)
+ .setSuccess(true)
+ .setResponse(ByteString.copyFrom(response))
+ .build()
+ val deviceMessage =
+ DeviceMessage.createOutgoingMessage(
+ defaultConnector.featureId?.uuid,
+ /* isMessageEncrypted= */ true,
+ DeviceMessage.OperationType.QUERY_RESPONSE,
+ queryResponse.toByteArray()
+ )
+ val callbackCaptor =
+ argumentCaptor<IDeviceCallback> {
+ verify(mockFeatureCoordinator)
+ .registerDeviceCallback(eq(device), eq(defaultConnector.featureId), capture())
+ }
+ callbackCaptor.firstValue.onMessageReceived(device, deviceMessage)
+
+ // Assert
+ runBlocking { assertThat(deferred.await()).isFalse() }
+ }
+
+ @Test
+ fun isFeatureSupported_nonProtoResponse_notSupported() {
+ // Arrange.
+ val device = createConnectedDevice(hasSecureChannel = true)
+ whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
+ whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
+ defaultConnector.connect()
+
+ // Action - immediate execution to send out the query.
+ val deferred =
+ CoroutineScope(Dispatchers.Main.immediate).async {
+ defaultConnector.isFeatureSupported(device)
+ }
+
+ // Generate a response that cannot be parsed (should at least contain UUID, which is 16 bytes).
+ val response = ByteUtils.randomBytes(10)
+
+ val messageCaptor =
+ argumentCaptor<DeviceMessage> {
+ verify(mockFeatureCoordinator).sendMessage(eq(device), capture())
+ }
+ val query =
+ Query.parseFrom(messageCaptor.firstValue.message, ExtensionRegistryLite.getEmptyRegistry())
+ val queryResponse =
+ QueryResponse.newBuilder()
+ .setQueryId(query.id)
+ .setSuccess(true)
+ .setResponse(ByteString.copyFrom(response))
+ .build()
+ val deviceMessage =
+ DeviceMessage.createOutgoingMessage(
+ defaultConnector.featureId?.uuid,
+ /* isMessageEncrypted= */ true,
+ DeviceMessage.OperationType.QUERY_RESPONSE,
+ queryResponse.toByteArray()
+ )
+ val callbackCaptor =
+ argumentCaptor<IDeviceCallback> {
+ verify(mockFeatureCoordinator)
+ .registerDeviceCallback(eq(device), eq(defaultConnector.featureId), capture())
+ }
+ callbackCaptor.firstValue.onMessageReceived(device, deviceMessage)
+
+ // Assert
+ runBlocking { assertThat(deferred.await()).isFalse() }
+ }
+
+ @Test
+ fun isFeatureSupported_notSupportedResponse_notSupported() {
+ // Arrange.
+ val device = createConnectedDevice(hasSecureChannel = true)
+ whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
+ whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
+ defaultConnector.connect()
+
+ // Action - immediate execution to send out the query.
+ val deferred =
+ CoroutineScope(Dispatchers.Main.immediate).async {
+ defaultConnector.isFeatureSupported(device)
+ }
+
+ val status =
+ FeatureSupportStatus.newBuilder().run {
+ featureId = defaultConnector.featureId!!.uuid.toString()
+ isSupported = false
+ build()
+ }
+ val response =
+ FeatureSupportResponse.newBuilder().run {
+ addStatuses(status)
+ build()
+ }
+
+ val messageCaptor =
+ argumentCaptor<DeviceMessage> {
+ verify(mockFeatureCoordinator).sendMessage(eq(device), capture())
+ }
+ val query =
+ Query.parseFrom(messageCaptor.firstValue.message, ExtensionRegistryLite.getEmptyRegistry())
+ val queryResponse =
+ QueryResponse.newBuilder()
+ .setQueryId(query.id)
+ .setSuccess(true)
+ .setResponse(ByteString.copyFrom(response.toByteArray()))
+ .build()
+ val deviceMessage =
+ DeviceMessage.createOutgoingMessage(
+ defaultConnector.featureId?.uuid,
+ /* isMessageEncrypted= */ true,
+ DeviceMessage.OperationType.QUERY_RESPONSE,
+ queryResponse.toByteArray()
+ )
+ val callbackCaptor =
+ argumentCaptor<IDeviceCallback> {
+ verify(mockFeatureCoordinator)
+ .registerDeviceCallback(eq(device), eq(defaultConnector.featureId), capture())
+ }
+ callbackCaptor.firstValue.onMessageReceived(device, deviceMessage)
+
+ // Assert
+ runBlocking { assertThat(deferred.await()).isFalse() }
+ }
+
+ @Test
+ fun isFeatureSupported_supportedResponse_featureSupported() {
+ // Arrange.
+ val device = createConnectedDevice(hasSecureChannel = true)
+ whenever(mockFeatureCoordinator.connectedDevicesForDriver).thenReturn(listOf(device))
+ whenever(mockFeatureCoordinator.allConnectedDevices).thenReturn(listOf(device))
+ defaultConnector.connect()
+
+ // Action - immediate execution to send out the query.
+ val deferred =
+ CoroutineScope(Dispatchers.Main.immediate).async {
+ defaultConnector.isFeatureSupported(device)
+ }
+
+ val status =
+ FeatureSupportStatus.newBuilder().run {
+ featureId = defaultConnector.featureId!!.uuid.toString()
+ isSupported = true
+ build()
+ }
+ val response =
+ FeatureSupportResponse.newBuilder().run {
+ addStatuses(status)
+ build()
+ }
+
+ val messageCaptor =
+ argumentCaptor<DeviceMessage> {
+ verify(mockFeatureCoordinator).sendMessage(eq(device), capture())
+ }
+ val query =
+ Query.parseFrom(messageCaptor.firstValue.message, ExtensionRegistryLite.getEmptyRegistry())
+ val queryResponse =
+ QueryResponse.newBuilder()
+ .setQueryId(query.id)
+ .setSuccess(true)
+ .setResponse(ByteString.copyFrom(response.toByteArray()))
+ .build()
+ val deviceMessage =
+ DeviceMessage.createOutgoingMessage(
+ defaultConnector.featureId?.uuid,
+ /* isMessageEncrypted= */ true,
+ DeviceMessage.OperationType.QUERY_RESPONSE,
+ queryResponse.toByteArray()
+ )
+ val callbackCaptor =
+ argumentCaptor<IDeviceCallback> {
+ verify(mockFeatureCoordinator)
+ .registerDeviceCallback(eq(device), eq(defaultConnector.featureId), capture())
+ }
+ callbackCaptor.firstValue.onMessageReceived(device, deviceMessage)
+
+ // Assert
+ runBlocking { assertThat(deferred.await()).isTrue() }
+ }
+
private fun setQueryIntentServicesAnswer(answer: Answer<List<ResolveInfo>>) {
whenever(mockPackageManager.queryIntentServices(any(), any<Int>())).thenAnswer(answer)
}
@@ -1968,5 +1940,16 @@
private const val PACKAGE_NAME = "com.test.package"
private const val BG_NAME = "background"
private const val FG_NAME = "foreground"
+
+ private fun createConnectedDevice(
+ belongsToDriver: Boolean = true,
+ hasSecureChannel: Boolean = false
+ ) =
+ ConnectedDevice(
+ UUID.randomUUID().toString(),
+ /* deviceName= */ "",
+ /* belongsToDriver= */ belongsToDriver,
+ hasSecureChannel,
+ )
}
}
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/api/FakeConnector.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/api/FakeConnector.kt
index 538743a..36802e4 100644
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/api/FakeConnector.kt
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/api/FakeConnector.kt
@@ -4,6 +4,11 @@
import android.os.ParcelUuid
import com.google.android.connecteddevice.model.AssociatedDevice
import com.google.android.connecteddevice.model.ConnectedDevice
+import com.google.common.util.concurrent.ListenableFuture
+import java.util.UUID
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.guava.future
/** Fake implementation of a [Connector] to be used in tests. */
open class FakeConnector : Connector {
@@ -64,6 +69,17 @@
response: ByteArray?
) {}
+ override suspend fun isFeatureSupported(device: ConnectedDevice): Boolean = featureId != null
+
+ override suspend fun queryFeatureSupportStatuses(
+ device: ConnectedDevice,
+ queriedFeatures: List<UUID>
+ ): List<Pair<UUID, Boolean>> = emptyList()
+
+ override fun isFeatureSupportedFuture(device: ConnectedDevice): ListenableFuture<Boolean> {
+ return CoroutineScope(Dispatchers.Main).future { isFeatureSupported(device) }
+ }
+
override fun getConnectedDeviceById(deviceId: String): ConnectedDevice? {
return connectedDevices.find { it.deviceId == deviceId }
}
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/api/FeatureConnectorTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/api/FeatureConnectorTest.kt
new file mode 100644
index 0000000..1d547b9
--- /dev/null
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/api/FeatureConnectorTest.kt
@@ -0,0 +1,856 @@
+package com.google.android.connecteddevice.api
+
+import android.content.ComponentName
+import android.content.ContextWrapper
+import android.content.Intent
+import android.content.ServiceConnection
+import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
+import android.content.pm.ServiceInfo
+import android.os.IInterface
+import android.os.Looper
+import android.os.ParcelUuid
+import androidx.test.core.app.ApplicationProvider
+import com.google.android.companionprotos.Query
+import com.google.android.companionprotos.QueryResponse
+import com.google.android.companionprotos.SystemQuery
+import com.google.android.companionprotos.SystemQueryType
+import com.google.android.connecteddevice.api.SafeConnector.Companion.ACTION_BIND_FEATURE_COORDINATOR
+import com.google.android.connecteddevice.api.SafeConnector.Companion.ACTION_QUERY_API_VERSION
+import com.google.android.connecteddevice.api.SafeConnector.QueryCallback
+import com.google.android.connecteddevice.api.external.ISafeBinderVersion
+import com.google.android.connecteddevice.api.external.ISafeFeatureCoordinator
+import com.google.android.connecteddevice.api.external.ISafeOnAssociatedDevicesRetrievedListener
+import com.google.android.connecteddevice.api.external.ISafeOnLogRequestedListener
+import com.google.android.connecteddevice.core.util.mockToBeAlive
+import com.google.android.connecteddevice.util.ByteUtils
+import com.google.common.truth.Truth.assertThat
+import com.google.protobuf.ExtensionRegistryLite
+import com.nhaarman.mockitokotlin2.any
+import com.nhaarman.mockitokotlin2.argumentCaptor
+import com.nhaarman.mockitokotlin2.eq
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.never
+import com.nhaarman.mockitokotlin2.spy
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.whenever
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.stubbing.Answer
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows
+
+@RunWith(RobolectricTestRunner::class)
+class FeatureConnectorTest {
+ private val mockPackageManager = mock<PackageManager>()
+
+ private val context = FakeContext(mockPackageManager)
+
+ private val mockCallback = mock<SafeConnector.Callback>()
+
+ private val mockSafeFeatureCoordinator = mockToBeAlive<ISafeFeatureCoordinator>()
+
+ private val mockFeatureCoordinator = mockToBeAlive<IFeatureCoordinator>()
+
+ protected var mockPlatformVersion = 1
+
+ private val testFeatureId = ParcelUuid(UUID.randomUUID())
+
+ private val testDeviceId = UUID.randomUUID().toString()
+
+ private val testMessage = ByteUtils.randomBytes(10)
+
+ private val testCoordinatorProxy = spy(TestCompanionApiProxy(true, listOf(testDeviceId)))
+
+ @Test
+ fun onInit_negativeVersions_aborts() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = -1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, -1)
+
+ assertThat(context.bindingActions)
+ .containsExactly(ACTION_QUERY_API_VERSION, ACTION_BIND_FEATURE_COORDINATOR)
+ assertThat(connector.coordinatorProxy).isNull()
+ }
+
+ @Test
+ fun onInit_clientV1_platformV0_bindToFeatureCoordinator() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 0
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ assertThat(connector.coordinatorProxy is LegacyApiProxy).isTrue()
+ assertThat(context.bindingActions)
+ .containsExactly(ACTION_QUERY_API_VERSION, ACTION_BIND_FEATURE_COORDINATOR)
+ }
+
+ @Test
+ fun onInit_clientV1_platformV1_bindToFeatureCoordinator() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ assertThat(connector.coordinatorProxy is SafeApiProxy).isTrue()
+ assertThat(context.bindingActions)
+ .containsExactly(ACTION_QUERY_API_VERSION, ACTION_BIND_FEATURE_COORDINATOR)
+ }
+
+ @Test
+ fun onInit_clientV1_platformV2_bindToFeatureCoordinator() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 2
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ assertThat(connector.coordinatorProxy is SafeApiProxy).isTrue()
+ assertThat(context.bindingActions)
+ .containsExactly(ACTION_QUERY_API_VERSION, ACTION_BIND_FEATURE_COORDINATOR)
+ }
+
+ @Test
+ fun onInit_clientV2_platformV0_bindToFeatureCoordinator() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 0
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 2)
+
+ assertThat(connector.coordinatorProxy is LegacyApiProxy).isTrue()
+ assertThat(context.bindingActions)
+ .containsExactly(ACTION_QUERY_API_VERSION, ACTION_BIND_FEATURE_COORDINATOR)
+ }
+
+ @Test
+ fun onInit_clientV1_platformV1_incorrectServiceIssuesApiNotSupportedCallback() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val incorrectServiceContext = IncorrectServiceContext(mockPackageManager)
+ val connector = FeatureConnector(incorrectServiceContext, testFeatureId, mockCallback, 1)
+
+ assertThat(connector.coordinatorProxy).isNull()
+ assertThat(connector.platformVersion).isNull()
+ assertThat(incorrectServiceContext.bindingActions).containsExactly(ACTION_QUERY_API_VERSION)
+ verify(mockCallback).onFailedToConnect()
+ }
+
+ @Test
+ fun onInit_clientV2_platformV1_issuesApiNotSupportedCallback() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 2)
+
+ assertThat(connector.coordinatorProxy).isNull()
+ assertThat(connector.platformVersion).isNull()
+ assertThat(context.bindingActions).containsExactly(ACTION_QUERY_API_VERSION)
+ verify(mockCallback).onApiNotSupported()
+ }
+
+ @Test
+ fun onInit_clientV2_platformV2_bindToFeatureCoordinator() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 2
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 2)
+
+ assertThat(connector.coordinatorProxy is SafeApiProxy).isTrue()
+ assertThat(context.bindingActions)
+ .containsExactly(ACTION_QUERY_API_VERSION, ACTION_BIND_FEATURE_COORDINATOR)
+ }
+
+ @Test
+ fun onInit_clientV2_platformV3_bindToFeatureCoordinator() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 3
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 2)
+
+ assertThat(connector.coordinatorProxy is SafeApiProxy).isTrue()
+ assertThat(context.bindingActions)
+ .containsExactly(ACTION_QUERY_API_VERSION, ACTION_BIND_FEATURE_COORDINATOR)
+ }
+
+ @Test
+ fun onFailedToConnect_invokedIfNullIntent() {
+ val nullIntentAnswer = Answer {
+ val intent = it.arguments.first() as Intent
+ val resolveInfo =
+ ResolveInfo().apply { serviceInfo = ServiceInfo().apply { packageName = PACKAGE_NAME } }
+ when (intent.action) {
+ ACTION_BIND_FEATURE_COORDINATOR -> {
+ resolveInfo.serviceInfo.name = FC_NAME
+ listOf(resolveInfo)
+ }
+ else -> listOf()
+ }
+ }
+ setQueryIntentServicesAnswer(nullIntentAnswer)
+ mockPlatformVersion = 0
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ assertThat(connector.coordinatorProxy).isNull()
+ verify(mockCallback).onFailedToConnect()
+ }
+
+ @Test
+ fun onSuccessfulInit_doesNotRetryBind() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ assertThat(connector.bindAttempts == 0).isTrue()
+ assertThat(context.bindingActions)
+ .containsExactly(ACTION_QUERY_API_VERSION, ACTION_BIND_FEATURE_COORDINATOR)
+ }
+
+ @Test
+ fun onFailedToConnect_invokedAfterBindingRetryLimitExceeded() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val failingContext = FailingContext(mockPackageManager)
+ val shadowLooper = Shadows.shadowOf(Looper.getMainLooper())
+ val connector = FeatureConnector(failingContext, testFeatureId, mockCallback, 1)
+
+ repeat(FeatureConnector.MAX_BIND_ATTEMPTS) { shadowLooper.runToEndOfTasks() }
+
+ assertThat(connector.coordinatorProxy).isNull()
+ assertThat(failingContext.serviceConnection).contains(connector.versionCheckConnection)
+ verify(mockCallback).onFailedToConnect()
+ }
+
+ @Test
+ fun onFailedToConnect_invokedAfterBindingRetryLimitExceeded_afterSuccessfulVersionQueryBind() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 0
+ val flakyContext = FailingLegacyContext(mockPackageManager)
+ val shadowLooper = Shadows.shadowOf(Looper.getMainLooper())
+ val connector = FeatureConnector(flakyContext, testFeatureId, mockCallback, 1)
+
+ repeat(FeatureConnector.MAX_BIND_ATTEMPTS) { shadowLooper.runToEndOfTasks() }
+
+ assertThat(connector.coordinatorProxy).isNull()
+ assertThat(connector.bindAttempts).isEqualTo(FeatureConnector.MAX_BIND_ATTEMPTS + 1)
+ assertThat(flakyContext.serviceConnection).contains(connector.versionCheckConnection)
+ assertThat(flakyContext.serviceConnection).contains(connector.featureCoordinatorConnection)
+ verify(mockCallback).onFailedToConnect()
+ }
+
+ @Test
+ fun bindAttemptsRetainsCorrectValue_afterVersionQueryBindAndFeatureCoordinatorBindExperienceFailures() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 0
+ val flakyContext = FlakyLegacyContext(mockPackageManager)
+ val shadowLooper = Shadows.shadowOf(Looper.getMainLooper())
+ val connector = FeatureConnector(flakyContext, testFeatureId, mockCallback, 1)
+
+ // Should repeat once for version query bind attempt, then reset, then MAX_BIND_ATTEMPTS times
+ // for feature coordinator bind attempt.
+ repeat(FeatureConnector.MAX_BIND_ATTEMPTS + 1) { shadowLooper.runToEndOfTasks() }
+
+ assertThat(connector.coordinatorProxy).isNull()
+ assertThat(connector.bindAttempts).isEqualTo(FeatureConnector.MAX_BIND_ATTEMPTS + 1)
+ assertThat(flakyContext.serviceConnection)
+ .containsExactly(
+ connector.versionCheckConnection,
+ connector.versionCheckConnection,
+ connector.featureCoordinatorConnection,
+ connector.featureCoordinatorConnection,
+ connector.featureCoordinatorConnection,
+ connector.featureCoordinatorConnection
+ )
+ verify(mockCallback).onFailedToConnect()
+ }
+
+ @Test
+ fun onDisconnected_invokedOnVersionCheckServiceDisconnected() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ connector.versionCheckConnection.onServiceDisconnected(
+ ComponentName(PACKAGE_NAME, VERSION_NAME)
+ )
+ verify(mockCallback).onDisconnected()
+ }
+
+ @Test
+ fun onDisconnected_invokedOnVersionCheckDeadBinding() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ connector.versionCheckConnection.onBindingDied(ComponentName(PACKAGE_NAME, VERSION_NAME))
+ verify(mockCallback).onDisconnected()
+ }
+
+ @Test
+ fun onDisconnected_invokedOnFeatureCoordinatorServiceDisconnected() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ connector.featureCoordinatorConnection.onServiceDisconnected(
+ ComponentName(PACKAGE_NAME, FC_NAME)
+ )
+ verify(mockCallback).onDisconnected()
+ }
+
+ @Test
+ fun onFailedToConnect_invokedOnFeatureCoordinatorNullBinding() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ connector.featureCoordinatorConnection.onNullBinding(ComponentName(PACKAGE_NAME, FC_NAME))
+ verify(mockCallback).onFailedToConnect()
+ }
+
+ @Test
+ fun onDisconnected_invokedOnFeatureCoordinatorDeadBinding() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ connector.featureCoordinatorConnection.onBindingDied(ComponentName(PACKAGE_NAME, FC_NAME))
+ verify(mockCallback).onDisconnected()
+ }
+
+ @Test
+ fun onConnected_invokedAfterSuccessfulBind() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ assertThat(connector.coordinatorProxy is SafeApiProxy).isTrue()
+ verify(mockCallback).onConnected()
+ }
+
+ @Test
+ fun onConnected_invokedAfterSuccessfulLegacyBind() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 0
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ assertThat(connector.coordinatorProxy is LegacyApiProxy).isTrue()
+ verify(mockCallback).onConnected()
+ }
+
+ @Test
+ fun sendMessage_sendsMessage() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ connector.coordinatorProxy = testCoordinatorProxy
+ connector.sendMessage(testDeviceId, testMessage)
+
+ verify(testCoordinatorProxy).sendMessage(testDeviceId, testMessage)
+ }
+
+ @Test
+ fun onMessageFailedToSend_invokedOnNullProxy() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ connector.coordinatorProxy = null
+ connector.sendMessage(testDeviceId, testMessage)
+
+ verify(mockCallback).onMessageFailedToSend(testDeviceId, testMessage, isTransient = true)
+ }
+
+ @Test
+ fun onMessageFailedToSend_invokedWhenDeviceNotFound() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ connector.coordinatorProxy = testCoordinatorProxy
+ val unexpectedDevice = UUID.randomUUID().toString()
+ connector.sendMessage(unexpectedDevice, testMessage)
+
+ assertThat(connector.connectedDevices.contains(unexpectedDevice)).isFalse()
+ verify(mockCallback).onMessageFailedToSend(unexpectedDevice, testMessage, isTransient = false)
+ verify(testCoordinatorProxy, never()).sendMessage(unexpectedDevice, testMessage)
+ }
+
+ @Test
+ fun onMessageFailedToSend_invokedWhenSendMessageFails() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ val coordinatorProxy = TestCompanionApiProxy(false, listOf(testDeviceId))
+ connector.coordinatorProxy = coordinatorProxy
+ connector.sendMessage(testDeviceId, testMessage)
+
+ verify(mockCallback).onMessageFailedToSend(testDeviceId, testMessage, isTransient = false)
+ }
+
+ @Test
+ fun sendMessage_onMessageFailedToSend_invokedOnVersionMismatch() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 0
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ connector.sendMessage(testDeviceId, testMessage)
+
+ verify(mockCallback).onMessageFailedToSend(testDeviceId, testMessage, isTransient = false)
+ }
+
+ @Test
+ fun sendQuery_sendsQueryToOwnFeatureId() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ connector.coordinatorProxy = testCoordinatorProxy
+ val request = ByteUtils.randomBytes(10)
+ val parameters = ByteUtils.randomBytes(10)
+ val queryCallback = mock<SafeConnector.QueryCallback>()
+ connector.sendQuery(testDeviceId, request, parameters, queryCallback)
+
+ argumentCaptor<ByteArray> {
+ verify(testCoordinatorProxy).sendMessage(eq(testDeviceId), capture())
+ val query = Query.parseFrom(firstValue, ExtensionRegistryLite.getEmptyRegistry())
+ assertThat(query.request.toByteArray()).isEqualTo(request)
+ assertThat(query.parameters.toByteArray()).isEqualTo(parameters)
+ }
+ }
+
+ @Test
+ fun sendQuery_addsCallbackToCallbacksMap() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ connector.coordinatorProxy = testCoordinatorProxy
+ val request = ByteUtils.randomBytes(10)
+ val parameters = ByteUtils.randomBytes(10)
+ val queryCallback = mock<SafeConnector.QueryCallback>()
+ connector.sendQuery(testDeviceId, request, parameters, queryCallback)
+
+ assertThat(testCoordinatorProxy.queryCallbacks.containsValue(queryCallback)).isTrue()
+ }
+
+ @Test
+ fun sendQuery_onQueryFailedToSend_invokedOnNullProxy() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ connector.coordinatorProxy = null
+ val request = ByteUtils.randomBytes(10)
+ val parameters = ByteUtils.randomBytes(10)
+ val queryCallback = mock<SafeConnector.QueryCallback>()
+ connector.sendQuery(testDeviceId, request, parameters, queryCallback)
+
+ verify(queryCallback).onQueryFailedToSend(isTransient = false)
+ }
+
+ @Test
+ fun sendQuery_onQueryFailedToSend_invokedOnDeviceIdNotFound() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ connector.coordinatorProxy = testCoordinatorProxy
+ val request = ByteUtils.randomBytes(10)
+ val parameters = ByteUtils.randomBytes(10)
+ val queryCallback = mock<SafeConnector.QueryCallback>()
+ connector.sendQuery(UUID.randomUUID().toString(), request, parameters, queryCallback)
+
+ verify(queryCallback).onQueryFailedToSend(isTransient = false)
+ }
+
+ @Test
+ fun respondToQuery_doesNotSendResponseWithNullProxy() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ connector.coordinatorProxy = null
+ val response = ByteUtils.randomBytes(10)
+ connector.respondToQuery(testDeviceId, 1, success = true, response)
+
+ verify(testCoordinatorProxy, never()).sendMessage(any(), any())
+ }
+
+ @Test
+ fun respondToQuery_doesNotSendResponseWithUnrecognizedQueryId() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ connector.coordinatorProxy = testCoordinatorProxy
+ val nonExistentQueryId = 0
+ val response = ByteUtils.randomBytes(10)
+ connector.respondToQuery(testDeviceId, nonExistentQueryId, success = true, response)
+
+ verify(testCoordinatorProxy, never()).sendMessage(any(), any())
+ }
+
+ @Test
+ fun respondToQuery_sendsMessageWithNonNullResponse() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val queryId = 1
+ val response = ByteUtils.randomBytes(10)
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ connector.coordinatorProxy = testCoordinatorProxy
+ testCoordinatorProxy.queryResponseRecipients.put(queryId, testFeatureId)
+ connector.respondToQuery(testDeviceId, queryId, success = true, response)
+
+ argumentCaptor<ByteArray> {
+ verify(testCoordinatorProxy).sendMessage(any(), capture())
+ val queryResponse =
+ QueryResponse.parseFrom(firstValue, ExtensionRegistryLite.getEmptyRegistry())
+ assertThat(queryResponse.response.toByteArray()).isEqualTo(response)
+ }
+ }
+
+ @Test
+ fun respondToQuery_sendsMessageWithNullResponse() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val queryId = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ connector.coordinatorProxy = testCoordinatorProxy
+ testCoordinatorProxy.queryResponseRecipients.put(queryId, testFeatureId)
+ connector.respondToQuery(testDeviceId, queryId, success = true, null)
+
+ argumentCaptor<ByteArray> {
+ verify(testCoordinatorProxy).sendMessage(any(), capture())
+ val queryResponse =
+ QueryResponse.parseFrom(firstValue, ExtensionRegistryLite.getEmptyRegistry())
+ assertThat(queryResponse.response).isEmpty()
+ }
+ }
+
+ @Test
+ fun respondToQuery_onMessageFailedToSend_invokedWhenSendMessageFailed() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ val proxy = spy(TestCompanionApiProxy(false, listOf(testDeviceId)))
+ connector.coordinatorProxy = proxy
+ val queryId = 1
+ proxy.queryResponseRecipients.put(queryId, testFeatureId)
+ val response = ByteUtils.randomBytes(10)
+ connector.respondToQuery(testDeviceId, queryId, success = true, response)
+
+ verify(mockCallback).onMessageFailedToSend(eq(testDeviceId), any(), eq(false))
+ }
+
+ @Test
+ fun retrieveCompanionApplicationName_sendsAppNameQueryToSystemFeature() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ connector.coordinatorProxy = testCoordinatorProxy
+ val callback = mock<SafeConnector.AppNameCallback>()
+
+ connector.retrieveCompanionApplicationName(testDeviceId, callback)
+ argumentCaptor<ByteArray> {
+ verify(testCoordinatorProxy).sendMessage(eq(testDeviceId), capture())
+ val query = Query.parseFrom(firstValue, ExtensionRegistryLite.getEmptyRegistry())
+ assertThat(query.sender.toByteArray())
+ .isEqualTo(ByteUtils.uuidToBytes(connector.featureId.uuid))
+ val systemQuery =
+ SystemQuery.parseFrom(query.request, ExtensionRegistryLite.getEmptyRegistry())
+ assertThat(systemQuery.type).isEqualTo(SystemQueryType.APP_NAME)
+ }
+ }
+
+ @Test
+ fun retrieveCompanionApplicationName_onMessageFailedToSend_invokedWhenSendMessageFailed() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ val proxy = spy(TestCompanionApiProxy(false, listOf(testDeviceId)))
+ connector.coordinatorProxy = proxy
+ val appNameCallback = mock<SafeConnector.AppNameCallback>()
+ connector.retrieveCompanionApplicationName(testDeviceId, appNameCallback)
+
+ verify(appNameCallback).onError()
+ }
+
+ @Test
+ fun retrieveAssociatedDevices_worksWithSafeListener() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ val mockListener = mock<ISafeOnAssociatedDevicesRetrievedListener>()
+ connector.coordinatorProxy = testCoordinatorProxy
+
+ connector.retrieveAssociatedDevices(mockListener)
+
+ verify(testCoordinatorProxy).retrieveAssociatedDevices(mockListener)
+ }
+
+ @Test
+ fun retrieveAssociatedDevices_worksWithLegacyListener() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 0
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ val mockListener = mock<IOnAssociatedDevicesRetrievedListener>()
+ connector.coordinatorProxy = testCoordinatorProxy
+
+ connector.retrieveAssociatedDevices(mockListener)
+
+ verify(testCoordinatorProxy).retrieveAssociatedDevices(mockListener)
+ }
+
+ @Test
+ fun retrieveAssociatedDevices_returnsSilentlyOnNullCoordinator() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+ val mockListener = mock<ISafeOnAssociatedDevicesRetrievedListener>()
+ connector.coordinatorProxy = null
+
+ connector.retrieveAssociatedDevices(mockListener)
+
+ verify(testCoordinatorProxy, never()).retrieveAssociatedDevices(mockListener)
+ }
+
+ @Test
+ fun onCleanUp_cleansUpFeatureCoordinator() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ assertThat(connector.coordinatorProxy).isNotNull()
+ connector.cleanUp()
+ assertThat(connector.coordinatorProxy).isNull()
+ }
+
+ @Test
+ fun onCleanUp_cleansUpFeatureCoordinator_legacyPlatform() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 0
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ assertThat(connector.coordinatorProxy).isNotNull()
+ connector.cleanUp()
+ assertThat(connector.coordinatorProxy).isNull()
+ }
+
+ @Test
+ fun onCleanUp_callsCallbackDisconnected() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ connector.cleanUp()
+ verify(mockCallback).onDisconnected()
+ }
+
+ @Test
+ fun onCleanUp_callsCallbackDisconnected_legacyPlatform() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 0
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ connector.cleanUp()
+ verify(mockCallback).onDisconnected()
+ }
+
+ @Test
+ fun onCleanUp_unbindsFromContext() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 1
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ connector.cleanUp()
+ assertThat(context.unbindServiceConnection)
+ .containsExactly(connector.featureCoordinatorConnection)
+ }
+
+ @Test
+ fun onCleanUp_unbindsFromContext_legacyPlatform() {
+ setQueryIntentServicesAnswer(defaultServiceAnswer)
+ mockPlatformVersion = 0
+ val connector = FeatureConnector(context, testFeatureId, mockCallback, 1)
+
+ connector.cleanUp()
+ assertThat(context.unbindServiceConnection)
+ .containsExactly(connector.featureCoordinatorConnection)
+ }
+
+ private fun setQueryIntentServicesAnswer(answer: Answer<List<ResolveInfo>>) {
+ whenever(mockPackageManager.queryIntentServices(any(), any<Int>())).thenAnswer(answer)
+ }
+
+ private val defaultServiceAnswer = Answer {
+ val intent = it.arguments.first() as Intent
+ val resolveInfo =
+ ResolveInfo().apply { serviceInfo = ServiceInfo().apply { packageName = PACKAGE_NAME } }
+ when (intent.action) {
+ ACTION_QUERY_API_VERSION -> {
+ resolveInfo.serviceInfo.name = VERSION_NAME
+ listOf(resolveInfo)
+ }
+ ACTION_BIND_FEATURE_COORDINATOR -> {
+ resolveInfo.serviceInfo.name = FC_NAME
+ listOf(resolveInfo)
+ }
+ else -> listOf()
+ }
+ }
+
+ private open inner class FakeContext(val mockPackageManager: PackageManager) :
+ ContextWrapper(ApplicationProvider.getApplicationContext()) {
+
+ val bindingActions = mutableListOf<String>()
+
+ var serviceConnection = mutableListOf<ServiceConnection>()
+
+ var unbindServiceConnection = mutableListOf<ServiceConnection>()
+
+ override fun getPackageManager(): PackageManager = mockPackageManager
+
+ override fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean {
+ service.action?.let { bindingActions.add(it) }
+ serviceConnection.add(conn)
+
+ if (service.action == ACTION_QUERY_API_VERSION) {
+ bindVersionService(conn)
+ } else if (service.action == ACTION_BIND_FEATURE_COORDINATOR) {
+ bindFeatureCoordinatorService(conn)
+ }
+
+ return super.bindService(service, conn, flags)
+ }
+
+ protected fun bindVersionService(conn: ServiceConnection) {
+ val mockPlatformVersion = this@FeatureConnectorTest.mockPlatformVersion
+ val binder =
+ object : ISafeBinderVersion.Stub() {
+ override fun getVersion(): Int {
+ return mockPlatformVersion
+ }
+ }
+
+ val componentName = ComponentName(PACKAGE_NAME, VERSION_NAME)
+ if (mockPlatformVersion == 0) {
+ conn.onNullBinding(componentName)
+ } else {
+ conn.onServiceConnected(componentName, binder.asBinder())
+ }
+ }
+
+ private fun bindFeatureCoordinatorService(conn: ServiceConnection) {
+ val mockPlatformVersion = this@FeatureConnectorTest.mockPlatformVersion
+ val binder =
+ if (mockPlatformVersion == 0) {
+ mockFeatureCoordinator
+ } else {
+ mockSafeFeatureCoordinator
+ }
+
+ val componentName = ComponentName(PACKAGE_NAME, VERSION_NAME)
+ conn.onServiceConnected(componentName, binder.asBinder())
+ }
+
+ override fun unbindService(conn: ServiceConnection) {
+ unbindServiceConnection.add(conn)
+ super.unbindService(conn)
+ }
+ }
+
+ private inner class FailingContext(mockPackageManager: PackageManager) :
+ FakeContext(mockPackageManager) {
+
+ override fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean {
+ serviceConnection.add(conn)
+ return false
+ }
+
+ override fun unbindService(conn: ServiceConnection) {
+ throw IllegalArgumentException()
+ }
+ }
+
+ /** For testing a successful connection that returns an incorrect service */
+ private inner class IncorrectServiceContext(mockPackageManager: PackageManager) :
+ FakeContext(mockPackageManager) {
+
+ override fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean {
+ service.action?.let { bindingActions.add(it) }
+ serviceConnection.add(conn)
+ val binder = mockSafeFeatureCoordinator
+ val componentName = ComponentName(PACKAGE_NAME, VERSION_NAME)
+
+ conn.onServiceConnected(componentName, binder.asBinder())
+ return true
+ }
+ }
+
+ /**
+ * For testing successful connections during binds with ACTION_QUERY_API_VERSION but failures
+ * during binds using ACTION_BIND_FEATURE_COORDINATOR
+ */
+ private inner class FailingLegacyContext(mockPackageManager: PackageManager) :
+ FakeContext(mockPackageManager) {
+
+ override fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean {
+ service.action?.let { bindingActions.add(it) }
+ serviceConnection.add(conn)
+ if (service.action == ACTION_QUERY_API_VERSION) {
+ bindVersionService(conn)
+ return true
+ } else if (service.action == ACTION_BIND_FEATURE_COORDINATOR) {
+ return false
+ }
+ return super.bindService(service, conn, flags)
+ }
+
+ override fun unbindService(conn: ServiceConnection) {
+ throw IllegalArgumentException()
+ }
+ }
+
+ /**
+ * For testing binding with ACTION_QUERY_API_VERSION failing on the first attempt but succeeeding
+ * thereafter, as well as failing binds using ACTION_BIND_FEATURE_COORDINATOR
+ */
+ private inner class FlakyLegacyContext(mockPackageManager: PackageManager) :
+ FakeContext(mockPackageManager) {
+
+ private var bindAttempts = 0
+
+ override fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean {
+ service.action?.let { bindingActions.add(it) }
+ serviceConnection.add(conn)
+ if (service.action == ACTION_QUERY_API_VERSION) {
+ if (bindAttempts++ == 0) {
+ return false
+ } else {
+ bindVersionService(conn)
+ return true
+ }
+ } else if (service.action == ACTION_BIND_FEATURE_COORDINATOR) {
+ return false
+ }
+ return super.bindService(service, conn, flags)
+ }
+
+ override fun unbindService(conn: ServiceConnection) {
+ throw IllegalArgumentException()
+ }
+ }
+
+ private open class TestCompanionApiProxy(
+ val defaultReturnValue: Boolean = true,
+ val deviceIdList: List<String> = emptyList()
+ ) : CompanionApiProxy {
+
+ override val queryCallbacks = ConcurrentHashMap<Int, QueryCallback>()
+
+ override val queryResponseRecipients = ConcurrentHashMap<Int, ParcelUuid>()
+
+ override val listener: ISafeOnLogRequestedListener = mock()
+
+ override fun getConnectedDevices() = deviceIdList
+
+ override fun sendMessage(deviceId: String, message: ByteArray) = defaultReturnValue
+
+ override fun processLogRecords(loggerId: Int, logRecords: ByteArray) = defaultReturnValue
+
+ override fun retrieveAssociatedDevices(listener: IInterface) = defaultReturnValue
+
+ override fun cleanUp() {}
+ }
+
+ companion object {
+ private const val PACKAGE_NAME = "com.test.package"
+ private const val FC_NAME = "feature_coordinator"
+ private const val VERSION_NAME = "version"
+ }
+}
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/api/LegacyApiProxyTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/api/LegacyApiProxyTest.kt
new file mode 100644
index 0000000..c8d8bb9
--- /dev/null
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/api/LegacyApiProxyTest.kt
@@ -0,0 +1,450 @@
+package com.google.android.connecteddevice.api
+
+import android.os.ParcelUuid
+import android.os.RemoteException
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.android.companionprotos.OperationProto.OperationType
+import com.google.android.companionprotos.Query
+import com.google.android.companionprotos.QueryResponse
+import com.google.android.connecteddevice.api.SafeConnector.QueryCallback
+import com.google.android.connecteddevice.api.external.ISafeOnAssociatedDevicesRetrievedListener
+import com.google.android.connecteddevice.api.external.ISafeOnLogRequestedListener
+import com.google.android.connecteddevice.core.util.mockToBeAlive
+import com.google.android.connecteddevice.model.ConnectedDevice
+import com.google.android.connecteddevice.model.DeviceMessage
+import com.google.android.connecteddevice.model.DeviceMessage.OperationType.CLIENT_MESSAGE
+import com.google.android.connecteddevice.util.ByteUtils
+import com.google.common.truth.Truth.assertThat
+import com.google.protobuf.ByteString
+import com.nhaarman.mockitokotlin2.any
+import com.nhaarman.mockitokotlin2.eq
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.never
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.whenever
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class LegacyApiProxyTest {
+
+ private val negativeMockCoordinator: IFeatureCoordinator = mock()
+
+ private val versionZeroMockCoordinator: IFeatureCoordinator = mock()
+
+ private val versionOneMockCoordinator: IFeatureCoordinator = mock()
+
+ private val recipientId = ParcelUuid(UUID.randomUUID())
+
+ private val mockConnectorCallback: SafeConnector.Callback = mock()
+
+ private val testLoggerId = 0
+
+ private val mockListener: ISafeOnLogRequestedListener = mock()
+
+ private lateinit var defaultProxyVersionNegative: LegacyApiProxy
+
+ private lateinit var defaultProxyVersion0: LegacyApiProxy
+
+ private lateinit var defaultProxyVersion1: LegacyApiProxy
+
+ @Before
+ fun setUp() {
+ val connectedDevice =
+ ConnectedDevice(
+ UUID.randomUUID().toString(),
+ "driverDeviceName1",
+ /* belongsToDriver= */ true,
+ /* hasSecureChannel= */ true
+ )
+ whenever(versionZeroMockCoordinator.getConnectedDevicesForDriver())
+ .thenReturn(listOf(connectedDevice))
+ whenever(versionOneMockCoordinator.getConnectedDevicesForDriver())
+ .thenReturn(listOf(connectedDevice))
+ defaultProxyVersionNegative =
+ LegacyApiProxy(
+ negativeMockCoordinator,
+ recipientId,
+ mockConnectorCallback,
+ testLoggerId,
+ -1
+ )
+ defaultProxyVersion0 =
+ LegacyApiProxy(
+ versionZeroMockCoordinator,
+ recipientId,
+ mockConnectorCallback,
+ testLoggerId,
+ 0
+ )
+ defaultProxyVersion1 =
+ LegacyApiProxy(
+ versionOneMockCoordinator,
+ recipientId,
+ mockConnectorCallback,
+ testLoggerId,
+ 1
+ )
+ }
+
+ @Test
+ fun onInit_doesNotInvokeCoordinator_onPlatformVersionLessThanZero() {
+ verify(negativeMockCoordinator, never()).registerAllConnectionCallback(any())
+ verify(negativeMockCoordinator, never()).registerDeviceCallback(any(), any(), any())
+ verify(negativeMockCoordinator, never()).registerOnLogRequestedListener(any(), any())
+ }
+
+ @Test
+ fun onInit_correctlyInvokesCoordinator_onPlatformVersionZero() {
+ verify(versionZeroMockCoordinator).registerAllConnectionCallback(any())
+ verify(versionZeroMockCoordinator).registerDeviceCallback(any(), any(), any())
+ verify(versionZeroMockCoordinator).registerOnLogRequestedListener(any(), any())
+ }
+
+ @Test
+ fun onInit_correctlyInvokesCoordinator_onPlatformVersionOne() {
+ verify(versionOneMockCoordinator).registerAllConnectionCallback(any())
+ verify(versionOneMockCoordinator).registerDeviceCallback(any(), any(), any())
+ verify(versionOneMockCoordinator).registerOnLogRequestedListener(any(), any())
+ }
+
+ @Test
+ fun getConnectedDevices_returnsNullOnVersionLessThanZero() {
+ val device1 =
+ ConnectedDevice(
+ UUID.randomUUID().toString(),
+ "driverDeviceName1",
+ /* belongsToDriver= */ true,
+ /* hasSecureChannel= */ true
+ )
+ val device2 =
+ ConnectedDevice(
+ UUID.randomUUID().toString(),
+ "driverDeviceName2",
+ /* belongsToDriver= */ true,
+ /* hasSecureChannel= */ true
+ )
+ whenever(negativeMockCoordinator.getConnectedDevicesForDriver())
+ .thenReturn(listOf(device1, device2))
+ val devices = defaultProxyVersionNegative.getConnectedDevices()
+
+ assertThat(devices).isNull()
+ }
+
+ @Test
+ fun getConnectedDevices_returnsListOnVersionZero() {
+ val device1 =
+ ConnectedDevice(
+ UUID.randomUUID().toString(),
+ "driverDeviceName1",
+ /* belongsToDriver= */ true,
+ /* hasSecureChannel= */ true
+ )
+ val device2 =
+ ConnectedDevice(
+ UUID.randomUUID().toString(),
+ "driverDeviceName2",
+ /* belongsToDriver= */ true,
+ /* hasSecureChannel= */ true
+ )
+ whenever(versionZeroMockCoordinator.getConnectedDevicesForDriver())
+ .thenReturn(listOf(device1, device2))
+ val devices = defaultProxyVersion0.getConnectedDevices()
+
+ assertThat(devices).containsExactly(device1.deviceId, device2.deviceId)
+ }
+
+ @Test
+ fun getConnectedDevices_returnsListOnVersionGreaterThanZero() {
+ val device1 =
+ ConnectedDevice(
+ UUID.randomUUID().toString(),
+ "driverDeviceName1",
+ /* belongsToDriver= */ true,
+ /* hasSecureChannel= */ true
+ )
+ val device2 =
+ ConnectedDevice(
+ UUID.randomUUID().toString(),
+ "driverDeviceName2",
+ /* belongsToDriver= */ true,
+ /* hasSecureChannel= */ true
+ )
+ whenever(versionOneMockCoordinator.getConnectedDevicesForDriver())
+ .thenReturn(listOf(device1, device2))
+ val devices = defaultProxyVersion1.getConnectedDevices()
+
+ assertThat(devices).containsExactly(device1.deviceId, device2.deviceId)
+ }
+
+ @Test
+ fun sendMessage_returnsFalseOnVersionLessThanZero() {
+ val connectedDevice: ConnectedDevice = mock()
+ val deviceId = UUID.randomUUID().toString()
+ val message = ByteUtils.randomBytes(10)
+ val deviceMessage =
+ DeviceMessage.createOutgoingMessage(
+ UUID.fromString(deviceId),
+ /* isMessageEncrypted= */ true,
+ DeviceMessage.OperationType.CLIENT_MESSAGE,
+ message
+ )
+
+ whenever(connectedDevice.deviceId).thenReturn(deviceId)
+ whenever(negativeMockCoordinator.getConnectedDevicesForDriver())
+ .thenReturn(listOf(connectedDevice))
+ whenever(negativeMockCoordinator.sendMessage(connectedDevice, deviceMessage)).thenReturn(true)
+ val messageSent = defaultProxyVersionNegative.sendMessage(deviceId, message)
+
+ assertThat(messageSent).isFalse()
+ }
+
+ @Test
+ fun sendMessage_sendsMessageToControllerOnVersionZero() {
+ val connectedDevice: ConnectedDevice = mock()
+ val deviceId = UUID.randomUUID().toString()
+ val message = ByteUtils.randomBytes(10)
+ val deviceMessage =
+ DeviceMessage.createOutgoingMessage(
+ UUID.fromString(deviceId),
+ /* isMessageEncrypted= */ true,
+ DeviceMessage.OperationType.CLIENT_MESSAGE,
+ message
+ )
+
+ whenever(connectedDevice.deviceId).thenReturn(deviceId)
+ whenever(versionZeroMockCoordinator.getConnectedDevicesForDriver())
+ .thenReturn(listOf(connectedDevice))
+ whenever(versionZeroMockCoordinator.sendMessage(connectedDevice, deviceMessage))
+ .thenReturn(true)
+ val messageSent = defaultProxyVersion0.sendMessage(deviceId, message)
+
+ assertThat(messageSent).isTrue()
+ }
+
+ @Test
+ fun sendMessage_sendsMessageToControllerOnVersionGreaterThanZero() {
+ val connectedDevice: ConnectedDevice = mock()
+ val deviceId = UUID.randomUUID().toString()
+ val message = ByteUtils.randomBytes(10)
+ val deviceMessage =
+ DeviceMessage.createOutgoingMessage(
+ UUID.fromString(deviceId),
+ /* isMessageEncrypted= */ true,
+ DeviceMessage.OperationType.CLIENT_MESSAGE,
+ message
+ )
+
+ whenever(connectedDevice.deviceId).thenReturn(deviceId)
+ whenever(versionOneMockCoordinator.getConnectedDevicesForDriver())
+ .thenReturn(listOf(connectedDevice))
+ whenever(versionOneMockCoordinator.sendMessage(connectedDevice, deviceMessage)).thenReturn(true)
+ val messageSent = defaultProxyVersion1.sendMessage(deviceId, message)
+
+ assertThat(messageSent).isTrue()
+ }
+
+ @Test
+ fun sendMessage_failsToSendMessageUsingInvalidDeviceIdOnVersionZero() {
+ val connectedDevice: ConnectedDevice = mock()
+ val deviceId1 = "connectedDevice"
+ val deviceId2 = "disconnectedDevice"
+ val message = ByteUtils.randomBytes(10)
+ whenever(connectedDevice.deviceId).thenReturn(deviceId1)
+ whenever(versionZeroMockCoordinator.getConnectedDevicesForDriver())
+ .thenReturn(listOf(connectedDevice))
+ whenever(versionZeroMockCoordinator.sendMessage(any(), any())).thenReturn(true)
+ val messageSent = defaultProxyVersion0.sendMessage(deviceId2, message)
+
+ assertThat(messageSent).isFalse()
+ }
+
+ @Test
+ fun sendMessage_failsToSendMessageUsingInvalidDeviceIdOnVersionGreaterThanZero() {
+ val connectedDevice: ConnectedDevice = mock()
+ val deviceId1 = "connectedDevice"
+ val deviceId2 = "disconnectedDevice"
+ val message = ByteUtils.randomBytes(10)
+ whenever(connectedDevice.deviceId).thenReturn(deviceId1)
+ whenever(versionOneMockCoordinator.getConnectedDevicesForDriver())
+ .thenReturn(listOf(connectedDevice))
+ whenever(versionOneMockCoordinator.sendMessage(any(), any())).thenReturn(true)
+ val messageSent = defaultProxyVersion1.sendMessage(deviceId2, message)
+
+ assertThat(messageSent).isFalse()
+ }
+
+ @Test
+ fun sendMessage_failsIfCoordinatorThrowsException() {
+ val connectedDevice: ConnectedDevice = mock()
+ val deviceId = UUID.randomUUID().toString()
+ val message = ByteUtils.randomBytes(10)
+ val deviceMessage =
+ DeviceMessage.createOutgoingMessage(
+ UUID.fromString(deviceId),
+ /* isMessageEncrypted= */ true,
+ DeviceMessage.OperationType.CLIENT_MESSAGE,
+ message
+ )
+ whenever(connectedDevice.deviceId).thenReturn(deviceId)
+ whenever(versionOneMockCoordinator.getConnectedDevicesForDriver())
+ .thenReturn(listOf(connectedDevice))
+ whenever(versionZeroMockCoordinator.sendMessage(connectedDevice, deviceMessage))
+ .thenThrow(RemoteException())
+ val messageSent = defaultProxyVersion0.sendMessage(UUID.randomUUID().toString(), message)
+
+ assertThat(messageSent).isFalse()
+ }
+
+ @Test
+ fun processLogRecords_returnsFalseOnVersionLessThanZero() {
+ val testLogs = "test logs".toByteArray()
+ val success = defaultProxyVersionNegative.processLogRecords(testLoggerId, testLogs)
+
+ assertThat(success).isFalse()
+ }
+
+ @Test
+ fun processLogRecords_returnsTrueOnVersionZero() {
+ val testLogs = "test logs".toByteArray()
+ val success = defaultProxyVersion0.processLogRecords(testLoggerId, testLogs)
+
+ assertThat(success).isTrue()
+ }
+
+ @Test
+ fun processLogRecords_returnsTrueOnVersionGreaterThanZero() {
+ val testLogs = "test logs".toByteArray()
+ val success = defaultProxyVersion1.processLogRecords(testLoggerId, testLogs)
+
+ assertThat(success).isTrue()
+ }
+
+ @Test
+ fun retrieveAssociatedDevices_returnsFalseOnVersionLessThanZero() {
+ val mockListener: IOnAssociatedDevicesRetrievedListener = mockToBeAlive()
+ val success = defaultProxyVersionNegative.retrieveAssociatedDevices(mockListener)
+
+ assertThat(success).isFalse()
+ }
+
+ @Test
+ fun retrieveAssociatedDevices_returnsTrueOnVersionZero() {
+ val mockListener: IOnAssociatedDevicesRetrievedListener = mockToBeAlive()
+ val success = defaultProxyVersion0.retrieveAssociatedDevices(mockListener)
+
+ assertThat(success).isTrue()
+ }
+
+ @Test
+ fun retrieveAssociatedDevices_returnsTrueOnVersionGreaterThanZero() {
+ val mockListener: IOnAssociatedDevicesRetrievedListener = mockToBeAlive()
+ val success = defaultProxyVersion1.retrieveAssociatedDevices(mockListener)
+
+ assertThat(success).isTrue()
+ }
+
+ @Test
+ fun retrieveAssociatedDevices_returnsFalseIfIncorrectParam() {
+ val mockListener: ISafeOnAssociatedDevicesRetrievedListener = mockToBeAlive()
+ val success = defaultProxyVersion0.retrieveAssociatedDevices(mockListener)
+
+ assertThat(success).isFalse()
+ }
+
+ @Test
+ fun processIncomingMessage_worksWithClientMessage() {
+ val deviceId = UUID.randomUUID().toString()
+ val mockConnectedDevice: ConnectedDevice = mock()
+ whenever(mockConnectedDevice.deviceId).thenReturn(deviceId)
+ val payload = ByteString.copyFrom(ByteUtils.randomBytes(10)).toByteArray()
+ val message =
+ DeviceMessage.createOutgoingMessage(
+ UUID.randomUUID(),
+ /* isMessageEncrypted= */ true,
+ DeviceMessage.OperationType.CLIENT_MESSAGE,
+ payload
+ )
+ defaultProxyVersion0.deviceCallback.onMessageReceived(mockConnectedDevice, message)
+
+ verify(mockConnectorCallback).onMessageReceived(deviceId, payload)
+ }
+
+ @Test
+ fun processIncomingMessage_worksWithQuery() {
+ val deviceId = UUID.randomUUID().toString()
+ val mockConnectedDevice: ConnectedDevice = mock()
+ whenever(mockConnectedDevice.deviceId).thenReturn(deviceId)
+ val payload =
+ Query.newBuilder()
+ .setId(1)
+ .setSender(ByteString.copyFrom(ByteUtils.uuidToBytes(recipientId.uuid)))
+ .setRequest(ByteString.copyFrom(ByteUtils.randomBytes(10)))
+ .setParameters(ByteString.copyFrom(ByteUtils.randomBytes(10)))
+ .build()
+ .toByteArray()
+ val message =
+ DeviceMessage.createOutgoingMessage(
+ UUID.randomUUID(),
+ /* isMessageEncrypted= */ true,
+ DeviceMessage.OperationType.QUERY,
+ payload
+ )
+ defaultProxyVersion0.deviceCallback.onMessageReceived(mockConnectedDevice, message)
+
+ verify(mockConnectorCallback).onQueryReceived(eq(deviceId), any(), any(), any())
+ }
+
+ @Test
+ fun processIncomingMessage_worksWithQueryResponse() {
+ val deviceId = UUID.randomUUID().toString()
+ val mockConnectedDevice: ConnectedDevice = mock()
+ whenever(mockConnectedDevice.deviceId).thenReturn(deviceId)
+ val queryId = 1
+ val mockQueryCallback: QueryCallback = mock()
+ defaultProxyVersion0.queryCallbacks[queryId] = mockQueryCallback
+ val payload =
+ QueryResponse.newBuilder().setQueryId(queryId).setSuccess(true).build().toByteArray()
+ val message =
+ DeviceMessage.createOutgoingMessage(
+ UUID.randomUUID(),
+ /* isMessageEncrypted= */ true,
+ DeviceMessage.OperationType.QUERY_RESPONSE,
+ payload
+ )
+ defaultProxyVersion0.deviceCallback.onMessageReceived(mockConnectedDevice, message)
+
+ assertThat(defaultProxyVersion0.queryCallbacks.contains(queryId)).isFalse()
+ verify(mockQueryCallback).onSuccess(any())
+ }
+
+ @Test
+ fun onCleanUp_doesNotInvokeCoordinator_onPlatformVersionLessThanZero() {
+ defaultProxyVersionNegative.cleanUp()
+
+ verify(negativeMockCoordinator, never()).unregisterConnectionCallback(any())
+ verify(negativeMockCoordinator, never()).unregisterDeviceCallback(any(), any(), any())
+ verify(negativeMockCoordinator, never()).unregisterOnLogRequestedListener(any(), any())
+ }
+
+ @Test
+ fun onCleanUp_correctlyInvokesCoordinator_onPlatformVersionZero() {
+ defaultProxyVersion0.cleanUp()
+
+ verify(versionZeroMockCoordinator).unregisterConnectionCallback(any())
+ verify(versionZeroMockCoordinator).unregisterDeviceCallback(any(), any(), any())
+ verify(versionZeroMockCoordinator).unregisterOnLogRequestedListener(any(), any())
+ }
+
+ @Test
+ fun onCleanUp_correctlyInvokesCoordinator_onPlatformVersionGreaterThanZero() {
+ defaultProxyVersion1.cleanUp()
+
+ verify(versionOneMockCoordinator).unregisterConnectionCallback(any())
+ verify(versionOneMockCoordinator).unregisterDeviceCallback(any(), any(), any())
+ verify(versionOneMockCoordinator).unregisterOnLogRequestedListener(any(), any())
+ }
+}
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/api/SafeApiProxyTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/api/SafeApiProxyTest.kt
new file mode 100644
index 0000000..9a1d9a3
--- /dev/null
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/api/SafeApiProxyTest.kt
@@ -0,0 +1,355 @@
+package com.google.android.connecteddevice.api
+
+import android.os.ParcelUuid
+import android.os.RemoteException
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.android.companionprotos.DeviceMessageProto
+import com.google.android.companionprotos.OperationProto.OperationType
+import com.google.android.companionprotos.Query
+import com.google.android.companionprotos.QueryResponse
+import com.google.android.connecteddevice.api.SafeConnector.QueryCallback
+import com.google.android.connecteddevice.api.external.ISafeFeatureCoordinator
+import com.google.android.connecteddevice.api.external.ISafeOnAssociatedDevicesRetrievedListener
+import com.google.android.connecteddevice.api.external.ISafeOnLogRequestedListener
+import com.google.android.connecteddevice.core.util.mockToBeAlive
+import com.google.android.connecteddevice.util.ByteUtils
+import com.google.common.truth.Truth.assertThat
+import com.google.protobuf.ByteString
+import com.nhaarman.mockitokotlin2.any
+import com.nhaarman.mockitokotlin2.eq
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.never
+import com.nhaarman.mockitokotlin2.verify
+import com.nhaarman.mockitokotlin2.whenever
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SafeApiProxyTest {
+
+ private val versionZeroMockCoordinator: ISafeFeatureCoordinator = mock()
+
+ private val versionOneMockCoordinator: ISafeFeatureCoordinator = mock()
+
+ private val versionTwoMockCoordinator: ISafeFeatureCoordinator = mock()
+
+ private val recipientId = ParcelUuid(UUID.randomUUID())
+
+ private val mockConnectorCallback: SafeConnector.Callback = mock()
+
+ private val testLoggerId = 0
+
+ private val mockListener: ISafeOnLogRequestedListener = mock()
+
+ private lateinit var defaultProxyVersion0: SafeApiProxy
+
+ private lateinit var defaultProxyVersion1: SafeApiProxy
+
+ private lateinit var defaultProxyVersion2: SafeApiProxy
+
+ @Before
+ fun setUp() {
+ val device = "driverDeviceName1"
+ whenever(versionOneMockCoordinator.getConnectedDevices()).thenReturn(listOf(device))
+ whenever(versionTwoMockCoordinator.getConnectedDevices()).thenReturn(listOf(device))
+ defaultProxyVersion0 =
+ SafeApiProxy(
+ versionZeroMockCoordinator,
+ recipientId,
+ mockConnectorCallback,
+ testLoggerId,
+ 0
+ )
+ defaultProxyVersion1 =
+ SafeApiProxy(
+ versionOneMockCoordinator,
+ recipientId,
+ mockConnectorCallback,
+ testLoggerId,
+ 1
+ )
+ defaultProxyVersion2 =
+ SafeApiProxy(
+ versionTwoMockCoordinator,
+ recipientId,
+ mockConnectorCallback,
+ testLoggerId,
+ 2
+ )
+ }
+
+ @Test
+ fun onInit_doesNotInvokeCoordinator_onPlatformVersionZero() {
+ verify(versionZeroMockCoordinator, never()).registerConnectionCallback(any())
+ verify(versionZeroMockCoordinator, never()).registerDeviceCallback(any(), any(), any())
+ verify(versionZeroMockCoordinator, never()).registerOnLogRequestedListener(any(), any())
+ }
+
+ @Test
+ fun onInit_correctlyInvokesCoordinator_onPlatformVersionOne() {
+ verify(versionOneMockCoordinator).registerConnectionCallback(any())
+ verify(versionOneMockCoordinator).registerDeviceCallback(any(), any(), any())
+ verify(versionOneMockCoordinator).registerOnLogRequestedListener(any(), any())
+ }
+
+ @Test
+ fun onInit_correctlyInvokesCoordinator_onPlatformVersionTwo() {
+ verify(versionTwoMockCoordinator).registerConnectionCallback(any())
+ verify(versionTwoMockCoordinator).registerDeviceCallback(any(), any(), any())
+ verify(versionTwoMockCoordinator).registerOnLogRequestedListener(any(), any())
+ }
+
+ @Test
+ fun getConnectedDevices_returnsNullOnVersionZero() {
+ whenever(versionZeroMockCoordinator.getConnectedDevices())
+ .thenReturn(listOf("driverDeviceName1", "driverDeviceName2"))
+ val devices = defaultProxyVersion0.getConnectedDevices()
+
+ assertThat(devices).isNull()
+ }
+
+ @Test
+ fun getConnectedDevices_returnsListOnVersionOne() {
+ whenever(versionOneMockCoordinator.getConnectedDevices())
+ .thenReturn(listOf("driverDeviceName1", "driverDeviceName2"))
+ val devices = defaultProxyVersion1.getConnectedDevices()
+
+ assertThat(devices).containsExactly("driverDeviceName1", "driverDeviceName2")
+ }
+
+ @Test
+ fun getConnectedDevices_returnsListOnVersionTwo() {
+ whenever(versionTwoMockCoordinator.getConnectedDevices())
+ .thenReturn(listOf("driverDeviceName1", "driverDeviceName2"))
+ val devices = defaultProxyVersion2.getConnectedDevices()
+
+ assertThat(devices).containsExactly("driverDeviceName1", "driverDeviceName2")
+ }
+
+ @Test
+ fun sendMessage_returnsFalseOnVersionZero() {
+ val message =
+ DeviceMessageProto.Message.newBuilder()
+ .setRecipient(ByteString.copyFrom(ByteUtils.uuidToBytes(UUID.randomUUID())))
+ .setIsPayloadEncrypted(true)
+ .setOperation(
+ OperationType.forNumber(/* CLIENT_MESSAGE */ 4) ?: OperationType.OPERATION_TYPE_UNKNOWN
+ )
+ .setPayload(ByteString.copyFrom(ByteUtils.randomBytes(10)))
+ .build()
+ val rawBytes = message.toByteArray()
+ whenever(versionZeroMockCoordinator.sendMessage(any(), any())).thenReturn(true)
+ val messageSent = defaultProxyVersion0.sendMessage(UUID.randomUUID().toString(), rawBytes)
+
+ assertThat(messageSent).isFalse()
+ }
+
+ @Test
+ fun sendMessage_sendsMessageToControllerOnVersionOne() {
+ val message =
+ DeviceMessageProto.Message.newBuilder()
+ .setRecipient(ByteString.copyFrom(ByteUtils.uuidToBytes(UUID.randomUUID())))
+ .setIsPayloadEncrypted(true)
+ .setOperation(
+ OperationType.forNumber(/* CLIENT_MESSAGE */ 4) ?: OperationType.OPERATION_TYPE_UNKNOWN
+ )
+ .setPayload(ByteString.copyFrom(ByteUtils.randomBytes(10)))
+ .build()
+ val rawBytes = message.toByteArray()
+ whenever(versionOneMockCoordinator.sendMessage(any(), any())).thenReturn(true)
+ val messageSent = defaultProxyVersion1.sendMessage(UUID.randomUUID().toString(), rawBytes)
+
+ assertThat(messageSent).isTrue()
+ }
+
+ @Test
+ fun sendMessage_sendsMessageToControllerOnVersionTwo() {
+ val message =
+ DeviceMessageProto.Message.newBuilder()
+ .setRecipient(ByteString.copyFrom(ByteUtils.uuidToBytes(UUID.randomUUID())))
+ .setIsPayloadEncrypted(true)
+ .setOperation(
+ OperationType.forNumber(/* CLIENT_MESSAGE */ 4) ?: OperationType.OPERATION_TYPE_UNKNOWN
+ )
+ .setPayload(ByteString.copyFrom(ByteUtils.randomBytes(10)))
+ .build()
+ val rawBytes = message.toByteArray()
+ whenever(versionTwoMockCoordinator.sendMessage(any(), any())).thenReturn(true)
+ val messageSent = defaultProxyVersion2.sendMessage(UUID.randomUUID().toString(), rawBytes)
+
+ assertThat(messageSent).isTrue()
+ }
+
+ @Test
+ fun sendMessage_failsIfCoordinatorThrowsException() {
+ val message =
+ DeviceMessageProto.Message.newBuilder()
+ .setRecipient(ByteString.copyFrom(ByteUtils.uuidToBytes(UUID.randomUUID())))
+ .setIsPayloadEncrypted(true)
+ .setOperation(
+ OperationType.forNumber(/* CLIENT_MESSAGE */ 4) ?: OperationType.OPERATION_TYPE_UNKNOWN
+ )
+ .setPayload(ByteString.copyFrom(ByteUtils.randomBytes(10)))
+ .build()
+ val rawBytes = message.toByteArray()
+ whenever(versionOneMockCoordinator.sendMessage(any(), any())).thenThrow(RemoteException())
+ val messageSent = defaultProxyVersion1.sendMessage(UUID.randomUUID().toString(), rawBytes)
+
+ assertThat(messageSent).isFalse()
+ }
+
+ @Test
+ fun processLogRecords_returnsFalseOnVersionZero() {
+ val testLogs = "test logs".toByteArray()
+ val success = defaultProxyVersion0.processLogRecords(testLoggerId, testLogs)
+
+ assertThat(success).isFalse()
+ }
+
+ @Test
+ fun processLogRecords_returnsTrueOnVersionOne() {
+ val testLogs = "test logs".toByteArray()
+ val success = defaultProxyVersion1.processLogRecords(testLoggerId, testLogs)
+
+ assertThat(success).isTrue()
+ }
+
+ @Test
+ fun processLogRecords_returnsTrueOnVersionTwo() {
+ val testLogs = "test logs".toByteArray()
+ val success = defaultProxyVersion2.processLogRecords(testLoggerId, testLogs)
+
+ assertThat(success).isTrue()
+ }
+
+ @Test
+ fun retrieveAssociatedDevices_returnsFalseOnVersionZero() {
+ val mockListener: ISafeOnAssociatedDevicesRetrievedListener = mockToBeAlive()
+ val success = defaultProxyVersion0.retrieveAssociatedDevices(mockListener)
+
+ assertThat(success).isFalse()
+ }
+
+ @Test
+ fun retrieveAssociatedDevices_returnsTrueOnVersionOne() {
+ val mockListener: ISafeOnAssociatedDevicesRetrievedListener = mockToBeAlive()
+ val success = defaultProxyVersion1.retrieveAssociatedDevices(mockListener)
+
+ assertThat(success).isTrue()
+ }
+
+ @Test
+ fun retrieveAssociatedDevices_returnsTrueOnVersionTwo() {
+ val mockListener: ISafeOnAssociatedDevicesRetrievedListener = mockToBeAlive()
+ val success = defaultProxyVersion2.retrieveAssociatedDevices(mockListener)
+
+ assertThat(success).isTrue()
+ }
+
+ @Test
+ fun retrieveAssociatedDevices_returnsFalseIfIncorrectParam() {
+ val mockListener: IOnAssociatedDevicesRetrievedListener = mockToBeAlive()
+ val success = defaultProxyVersion1.retrieveAssociatedDevices(mockListener)
+
+ assertThat(success).isFalse()
+ }
+
+ @Test
+ fun processIncomingMessage_worksWithClientMessage() {
+ val deviceId = UUID.randomUUID().toString()
+ val payload = ByteString.copyFrom(ByteUtils.randomBytes(10))
+ val message =
+ DeviceMessageProto.Message.newBuilder()
+ .setRecipient(ByteString.copyFrom(ByteUtils.uuidToBytes(UUID.randomUUID())))
+ .setIsPayloadEncrypted(true)
+ .setOperation(
+ OperationType.forNumber(/* CLIENT_MESSAGE */ 4) ?: OperationType.OPERATION_TYPE_UNKNOWN
+ )
+ .setPayload(payload)
+ .build()
+ val rawBytes = message.toByteArray()
+ defaultProxyVersion0.deviceCallback.onMessageReceived(deviceId, rawBytes)
+
+ verify(mockConnectorCallback).onMessageReceived(deviceId, payload.toByteArray())
+ }
+
+ @Test
+ fun processIncomingMessage_worksWithQuery() {
+ val deviceId = UUID.randomUUID().toString()
+ val payload =
+ Query.newBuilder()
+ .setId(1)
+ .setSender(ByteString.copyFrom(ByteUtils.uuidToBytes(recipientId.uuid)))
+ .setRequest(ByteString.copyFrom(ByteUtils.randomBytes(10)))
+ .setParameters(ByteString.copyFrom(ByteUtils.randomBytes(10)))
+ .build()
+ .toByteString()
+ val message =
+ DeviceMessageProto.Message.newBuilder()
+ .setRecipient(ByteString.copyFrom(ByteUtils.uuidToBytes(UUID.randomUUID())))
+ .setIsPayloadEncrypted(true)
+ .setOperation(
+ OperationType.forNumber(/* QUERY */ 5) ?: OperationType.OPERATION_TYPE_UNKNOWN
+ )
+ .setPayload(payload)
+ .build()
+ val rawBytes = message.toByteArray()
+ defaultProxyVersion0.deviceCallback.onMessageReceived(deviceId, rawBytes)
+
+ verify(mockConnectorCallback).onQueryReceived(eq(deviceId), any(), any(), any())
+ }
+
+ @Test
+ fun processIncomingMessage_worksWithQueryResponse() {
+ val deviceId = UUID.randomUUID().toString()
+ val queryId = 1
+ val mockQueryCallback: QueryCallback = mock()
+ defaultProxyVersion0.queryCallbacks[queryId] = mockQueryCallback
+ val payload =
+ QueryResponse.newBuilder().setQueryId(queryId).setSuccess(true).build().toByteString()
+ val message =
+ DeviceMessageProto.Message.newBuilder()
+ .setRecipient(ByteString.copyFrom(ByteUtils.uuidToBytes(UUID.randomUUID())))
+ .setIsPayloadEncrypted(true)
+ .setOperation(
+ OperationType.forNumber(/* QUERY_RESPONSE */ 6) ?: OperationType.OPERATION_TYPE_UNKNOWN
+ )
+ .setPayload(payload)
+ .build()
+ val rawBytes = message.toByteArray()
+ defaultProxyVersion0.deviceCallback.onMessageReceived(deviceId, rawBytes)
+
+ assertThat(defaultProxyVersion0.queryCallbacks.contains(queryId)).isFalse()
+ verify(mockQueryCallback).onSuccess(any())
+ }
+
+ @Test
+ fun onCleanUp_doesNotInvokeCoordinator_onPlatformVersionZero() {
+ defaultProxyVersion0.cleanUp()
+
+ verify(versionZeroMockCoordinator, never()).unregisterConnectionCallback(any())
+ verify(versionZeroMockCoordinator, never()).unregisterDeviceCallback(any(), any(), any())
+ verify(versionZeroMockCoordinator, never()).unregisterOnLogRequestedListener(any(), any())
+ }
+
+ @Test
+ fun onCleanUp_correctlyInvokesCoordinator_onPlatformVersionOne() {
+ defaultProxyVersion1.cleanUp()
+
+ verify(versionOneMockCoordinator).unregisterConnectionCallback(any())
+ verify(versionOneMockCoordinator).unregisterDeviceCallback(any(), any(), any())
+ verify(versionOneMockCoordinator).unregisterOnLogRequestedListener(any(), any())
+ }
+
+ @Test
+ fun onCleanUp_correctlyInvokesCoordinator_onPlatformVersionTwo() {
+ defaultProxyVersion2.cleanUp()
+
+ verify(versionTwoMockCoordinator).unregisterConnectionCallback(any())
+ verify(versionTwoMockCoordinator).unregisterDeviceCallback(any(), any(), any())
+ verify(versionTwoMockCoordinator).unregisterOnLogRequestedListener(any(), any())
+ }
+}
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/DeviceSystemQueryCacheTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/DeviceSystemQueryCacheTest.kt
new file mode 100644
index 0000000..3fd2d9e
--- /dev/null
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/DeviceSystemQueryCacheTest.kt
@@ -0,0 +1,381 @@
+package com.google.android.connecteddevice.core
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.android.companionprotos.FeatureSupportResponse
+import com.google.android.companionprotos.FeatureSupportStatus
+import com.google.android.companionprotos.Query
+import com.google.android.companionprotos.QueryResponse
+import com.google.android.companionprotos.SystemQuery
+import com.google.android.companionprotos.SystemQueryType
+import com.google.android.connecteddevice.api.Connector.Companion.SYSTEM_FEATURE_ID
+import com.google.android.connecteddevice.model.DeviceMessage
+import com.google.android.connecteddevice.model.DeviceMessage.OperationType
+import com.google.android.connecteddevice.util.ByteUtils
+import com.google.common.truth.Truth.assertThat
+import com.google.protobuf.ByteString
+import java.nio.charset.StandardCharsets
+import java.util.UUID
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class DeviceSystemQueryCacheTest {
+
+ private lateinit var cache: DeviceSystemQueryCache
+
+ @Before
+ fun setUp() {
+ cache = DeviceSystemQueryCache()
+ }
+
+ @Test
+ fun getCached_notSystemFeatureRecipient_noCachedMessage() {
+ val message =
+ DeviceMessage.createOutgoingMessage(
+ /* recipient= */ UUID.randomUUID(),
+ /* isMessageEncrypted= */ true,
+ /* operationType= */ OperationType.QUERY,
+ /* message= */ ByteArray(0),
+ )
+
+ assertThat(cache.getCached(message)).isNull()
+ }
+
+ @Test
+ fun getCached_notQueryType_noCachedMessage() {
+ val message =
+ DeviceMessage.createOutgoingMessage(
+ /* recipient= */ SYSTEM_FEATURE,
+ /* isMessageEncrypted= */ true,
+ /* operationType= */ OperationType.CLIENT_MESSAGE,
+ /* message= */ ByteArray(0),
+ )
+
+ assertThat(cache.getCached(message)).isNull()
+ }
+
+ @Test
+ fun getCached_nonParseableQuery_noCachedMessage() {
+ val message =
+ DeviceMessage.createOutgoingMessage(
+ /* recipient= */ SYSTEM_FEATURE,
+ /* isMessageEncrypted= */ true,
+ /* operationType= */ OperationType.QUERY,
+ // Query proto has non-optional fields so its serialization cannot be empty.
+ /* message= */ ByteArray(0),
+ )
+
+ assertThat(cache.getCached(message)).isNull()
+ }
+
+ @Test
+ fun getCached_noCachedResponse_queryTypeIsTracked() {
+ val queryId = 4
+ val sender = UUID.randomUUID()
+ val message =
+ createSystemQueryMessage(
+ queryId = queryId,
+ querySender = sender,
+ systemQueryType = SystemQueryType.APP_NAME,
+ )
+
+ assertThat(cache.getCached(message)).isNull()
+
+ val key = cache.trackedQueryTypes.keys.first()
+ assertThat(key.first).isEqualTo(sender)
+ assertThat(key.second).isEqualTo(queryId)
+ assertThat(cache.trackedQueryTypes[key]!!).isEqualTo(SystemQueryType.APP_NAME)
+ }
+
+ @Test
+ fun getCached_queryAppName_returnsResponse() {
+ cache.appName = "appName"
+ val queryMessage =
+ createSystemQueryMessage(
+ queryId = 4,
+ querySender = UUID.randomUUID(),
+ systemQueryType = SystemQueryType.APP_NAME,
+ )
+
+ val cached = checkNotNull(cache.getCached(queryMessage))
+ val queryResponse = QueryResponse.parseFrom(cached.message)
+ val cachedAppName = String(queryResponse.response.toByteArray(), StandardCharsets.UTF_8)
+ assertThat(cachedAppName).isEqualTo("appName")
+ }
+
+ @Test
+ fun getCached_queryDeviceName_returnsResponse() {
+ cache.deviceName = "deviceName"
+ val queryMessage =
+ createSystemQueryMessage(
+ queryId = 4,
+ querySender = UUID.randomUUID(),
+ systemQueryType = SystemQueryType.DEVICE_NAME,
+ )
+
+ val cached = checkNotNull(cache.getCached(queryMessage))
+ val queryResponse = QueryResponse.parseFrom(cached.message)
+ val cachedDeviceName = String(queryResponse.response.toByteArray(), StandardCharsets.UTF_8)
+ assertThat(cachedDeviceName).isEqualTo("deviceName")
+ }
+
+ @Test
+ fun getCached_querySupportedFeature_returnsResponse() {
+ val supported = UUID.randomUUID()
+ cache.supportedFeatures.add(supported)
+ val queryMessage =
+ createSystemQueryMessage(
+ queryId = 4,
+ querySender = UUID.randomUUID(),
+ systemQueryType = SystemQueryType.IS_FEATURE_SUPPORTED,
+ systemQueryPayloads = listOf(supported.toString().toByteArray()),
+ )
+
+ val cached = checkNotNull(cache.getCached(queryMessage))
+
+ val queryResponse = QueryResponse.parseFrom(cached.message)
+ val featureSupportResponse = FeatureSupportResponse.parseFrom(queryResponse.response)
+ assertThat(featureSupportResponse.statusesList.size).isEqualTo(1)
+ val status = featureSupportResponse.statusesList.first()
+ assertThat(status.featureId).isEqualTo(supported.toString())
+ assertThat(status.isSupported).isTrue()
+ }
+
+ @Test
+ fun getCached_queryUnsupportedFeature_returnsNull() {
+ // This feature ID is not cached.
+ val unsupported = UUID.randomUUID()
+ val queryMessage =
+ createSystemQueryMessage(
+ queryId = 4,
+ querySender = UUID.randomUUID(),
+ systemQueryType = SystemQueryType.IS_FEATURE_SUPPORTED,
+ systemQueryPayloads = listOf(unsupported.toString().toByteArray()),
+ )
+
+ assertThat(cache.getCached(queryMessage)).isNull()
+ }
+
+ @Test
+ fun getCached_queryMixedFeatures_returnsNull() {
+ val supported = UUID.randomUUID()
+ cache.supportedFeatures.add(supported)
+ // This feature ID is not cached.
+ val unsupported = UUID.randomUUID()
+ val queryMessage =
+ createSystemQueryMessage(
+ queryId = 4,
+ querySender = UUID.randomUUID(),
+ systemQueryType = SystemQueryType.IS_FEATURE_SUPPORTED,
+ systemQueryPayloads =
+ listOf(unsupported.toString().toByteArray(), supported.toString().toByteArray()),
+ )
+
+ assertThat(cache.getCached(queryMessage)).isNull()
+ }
+
+ @Test
+ fun cache_nonSystemQueryResponse_notCached() {
+ val message =
+ DeviceMessage.createIncomingMessage(
+ /* recipient= */ UUID.randomUUID(),
+ /* isMessageEncrypted= */ true,
+ /* operationType= */ OperationType.CLIENT_MESSAGE,
+ /* message= */ ByteArray(0),
+ /* originalMessageSize= */ 0,
+ )
+ cache.cache(message)
+
+ assertNothingIsCached()
+ }
+
+ @Test
+ fun cache_nonParseableQueryResponse_notCached() {
+ val message =
+ DeviceMessage.createIncomingMessage(
+ /* recipient= */ UUID.randomUUID(),
+ /* isMessageEncrypted= */ true,
+ /* operationType= */ OperationType.QUERY_RESPONSE,
+ // Proto has non-optional fields so its serialization cannot be empty.
+ /* message= */ ByteArray(0),
+ /* originalMessageSize= */ 0,
+ )
+ cache.cache(message)
+
+ assertNothingIsCached()
+ }
+
+ @Test
+ fun cache_cachedAppName() {
+ val queryId = 4
+ val sender = UUID.randomUUID()
+ val queryMessage =
+ createSystemQueryMessage(
+ queryId = queryId,
+ querySender = sender,
+ systemQueryType = SystemQueryType.APP_NAME,
+ )
+
+ // Try retrieving the cached response - allows the cache to track the query type.
+ assertThat(cache.getCached(queryMessage)).isNull()
+ // Then send the query response - the response should be cached.
+ val responseMessage =
+ createSystemQueryResponseMessage(
+ queryId = queryId,
+ querySender = sender,
+ systemQueryPayload = "appName".toByteArray(),
+ )
+ cache.cache(responseMessage)
+
+ assertThat(cache.appName).isEqualTo("appName")
+ }
+
+ @Test
+ fun cache_cachedDeviceName() {
+ val queryId = 4
+ val sender = UUID.randomUUID()
+ val queryMessage =
+ createSystemQueryMessage(
+ queryId = queryId,
+ querySender = sender,
+ systemQueryType = SystemQueryType.DEVICE_NAME,
+ )
+
+ // Try retrieving the cached response - allows the cache to track the query type.
+ assertThat(cache.getCached(queryMessage)).isNull()
+ // Then send the query response - the response should be cached.
+ val responseMessage =
+ createSystemQueryResponseMessage(
+ queryId = queryId,
+ querySender = sender,
+ systemQueryPayload = "deviceName".toByteArray(),
+ )
+ cache.cache(responseMessage)
+
+ assertThat(cache.deviceName).isEqualTo("deviceName")
+ }
+
+ @Test
+ fun cache_cachedFeatureSupportStatus() {
+ val queryId = 4
+ val sender = UUID.randomUUID()
+ val queriedFeature = UUID.randomUUID()
+ val queryMessage =
+ createSystemQueryMessage(
+ queryId = queryId,
+ querySender = sender,
+ systemQueryType = SystemQueryType.IS_FEATURE_SUPPORTED,
+ systemQueryPayloads = listOf(queriedFeature.toString().toByteArray()),
+ )
+
+ // Try retrieving the cached response - allows the cache to track the query type.
+ assertThat(cache.getCached(queryMessage)).isNull()
+ // Then send the query response - the response should be cached.
+ val status =
+ FeatureSupportStatus.newBuilder().run {
+ featureId = queriedFeature.toString()
+ isSupported = true
+ build()
+ }
+ val featureSupportResponse =
+ FeatureSupportResponse.newBuilder().run {
+ addStatuses(status)
+ build()
+ }
+ val responseMessage =
+ createSystemQueryResponseMessage(
+ queryId = queryId,
+ querySender = sender,
+ systemQueryPayload = featureSupportResponse.toByteArray(),
+ )
+ cache.cache(responseMessage)
+
+ assertThat(queriedFeature in cache.supportedFeatures).isTrue()
+ }
+
+ @Test
+ fun cache_unrecognizedSenderAndId_notCached() {
+ val message =
+ createSystemQueryResponseMessage(
+ queryId = 4,
+ querySender = UUID.randomUUID(),
+ systemQueryPayload = "appName".toByteArray(),
+ )
+
+ cache.cache(message)
+
+ assertNothingIsCached()
+ }
+
+ @Test
+ fun clear_allCleared() {
+ cache.appName = "appName"
+
+ cache.clear()
+
+ assertNothingIsCached()
+ }
+
+ private fun assertNothingIsCached() {
+ assertThat(cache.appName).isNull()
+ assertThat(cache.deviceName).isNull()
+ assertThat(cache.supportedFeatures).isEmpty()
+ }
+
+ private fun createSystemQueryMessage(
+ queryId: Int,
+ querySender: UUID,
+ systemQueryType: SystemQueryType,
+ systemQueryPayloads: List<ByteArray> = emptyList(),
+ ): DeviceMessage {
+ val systemQuery =
+ SystemQuery.newBuilder().run {
+ type = systemQueryType
+ addAllPayloads(systemQueryPayloads.map { ByteString.copyFrom(it) })
+ build()
+ }
+ val query =
+ Query.newBuilder().run {
+ id = queryId
+ sender = ByteString.copyFrom(ByteUtils.uuidToBytes(querySender))
+ request = ByteString.copyFrom(systemQuery.toByteArray())
+ build()
+ }
+ val message =
+ DeviceMessage.createOutgoingMessage(
+ /* recipient= */ SYSTEM_FEATURE,
+ /* isMessageEncrypted= */ true,
+ /* operationType= */ OperationType.QUERY,
+ /* message= */ query.toByteArray(),
+ )
+ return message
+ }
+
+ private fun createSystemQueryResponseMessage(
+ queryId: Int,
+ querySender: UUID,
+ systemQueryPayload: ByteArray,
+ ): DeviceMessage {
+ val queryResponse =
+ QueryResponse.newBuilder().run {
+ this.queryId = queryId
+ success = true
+ response = ByteString.copyFrom(systemQueryPayload)
+ build()
+ }
+ val message =
+ DeviceMessage.createIncomingMessage(
+ /* recipient= */ querySender,
+ /* isMessageEncrypted= */ true,
+ /* operationType= */ OperationType.QUERY_RESPONSE,
+ /* message= */ queryResponse.toByteArray(),
+ /* originalMessageSize= */ 0,
+ )
+ return message
+ }
+
+ companion object {
+ private val SYSTEM_FEATURE: UUID = SYSTEM_FEATURE_ID.uuid
+ }
+}
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/FeatureCoordinatorTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/FeatureCoordinatorTest.kt
index de2b21c..0be386d 100644
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/FeatureCoordinatorTest.kt
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/FeatureCoordinatorTest.kt
@@ -2,8 +2,9 @@
import android.os.ParcelUuid
import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.google.android.companionprotos.DeviceMessageProto
import com.google.android.companionprotos.OperationProto.OperationType
+import com.google.android.companionprotos.message
+import com.google.android.connecteddevice.api.Connector.Companion.SYSTEM_FEATURE_ID
import com.google.android.connecteddevice.api.IAssociationCallback
import com.google.android.connecteddevice.api.IConnectionCallback
import com.google.android.connecteddevice.api.IDeviceAssociationCallback
@@ -21,6 +22,8 @@
import com.google.android.connecteddevice.model.ConnectedDevice
import com.google.android.connecteddevice.model.DeviceMessage
import com.google.android.connecteddevice.model.DeviceMessage.OperationType.CLIENT_MESSAGE
+import com.google.android.connecteddevice.model.DeviceMessage.OperationType.QUERY
+import com.google.android.connecteddevice.model.DeviceMessage.OperationType.QUERY_RESPONSE
import com.google.android.connecteddevice.model.Errors.DEVICE_ERROR_INSECURE_RECIPIENT_ID_DETECTED
import com.google.android.connecteddevice.storage.ConnectedDeviceStorage
import com.google.android.connecteddevice.util.ByteUtils
@@ -42,13 +45,18 @@
@RunWith(AndroidJUnit4::class)
class FeatureCoordinatorTest {
private val mockController: DeviceController = mock()
-
private val mockStorage: ConnectedDeviceStorage = mock()
-
private val mockLoggingManager: LoggingManager = mock()
+ private val mockSystemQueryCache: SystemQueryCache = mock()
private val coordinator =
- FeatureCoordinator(mockController, mockStorage, mockLoggingManager, directExecutor())
+ FeatureCoordinator(
+ mockController,
+ mockStorage,
+ mockSystemQueryCache,
+ mockLoggingManager,
+ directExecutor()
+ )
private val safeCoordinator = coordinator.safeFeatureCoordinator
@@ -59,21 +67,21 @@
UUID.randomUUID().toString(),
"driverDeviceName1",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val driverDevice2 =
ConnectedDevice(
UUID.randomUUID().toString(),
"driverDeviceName2",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val passengerDevice =
ConnectedDevice(
UUID.randomUUID().toString(),
"passengerDeviceName",
/* belongsToDriver= */ false,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
whenever(mockController.connectedDevices)
.thenReturn(listOf(driverDevice1, passengerDevice, driverDevice2))
@@ -90,21 +98,21 @@
UUID.randomUUID().toString(),
"passengerDevice1",
/* belongsToDriver= */ false,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val passengerDevice2 =
ConnectedDevice(
UUID.randomUUID().toString(),
"passengerDevice2",
/* belongsToDriver= */ false,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val driverDevice =
ConnectedDevice(
UUID.randomUUID().toString(),
"driverDevice",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
whenever(mockController.connectedDevices)
.thenReturn(listOf(passengerDevice1, driverDevice, passengerDevice2))
@@ -121,21 +129,21 @@
UUID.randomUUID().toString(),
"driverDeviceName1",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val driverDevice2 =
ConnectedDevice(
UUID.randomUUID().toString(),
"driverDeviceName2",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val passengerDevice =
ConnectedDevice(
UUID.randomUUID().toString(),
"passengerDeviceName",
/* belongsToDriver= */ false,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
whenever(mockController.connectedDevices)
.thenReturn(listOf(driverDevice1, passengerDevice, driverDevice2))
@@ -154,7 +162,7 @@
UUID.randomUUID().toString(),
"driverDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.onDeviceConnectedInternal(driverDevice)
@@ -170,9 +178,9 @@
val passengerDevice =
ConnectedDevice(
UUID.randomUUID().toString(),
- "driverDeviceName",
+ "passengerDeviceName",
/* belongsToDriver= */ false,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.onDeviceConnectedInternal(passengerDevice)
@@ -189,7 +197,7 @@
UUID.randomUUID().toString(),
"driverDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.onDeviceConnectedInternal(driverDevice)
@@ -204,9 +212,9 @@
val passengerDevice =
ConnectedDevice(
UUID.randomUUID().toString(),
- "driverDeviceName",
+ "passengerDeviceName",
/* belongsToDriver= */ false,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.onDeviceConnectedInternal(passengerDevice)
@@ -223,7 +231,7 @@
UUID.randomUUID().toString(),
"driverDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.onDeviceConnectedInternal(driverDevice)
@@ -238,9 +246,9 @@
val passengerDevice =
ConnectedDevice(
UUID.randomUUID().toString(),
- "driverDeviceName",
+ "passengerDeviceName",
/* belongsToDriver= */ false,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.onDeviceConnectedInternal(passengerDevice)
@@ -257,7 +265,7 @@
UUID.randomUUID().toString(),
"driverDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.onDeviceDisconnectedInternal(driverDevice)
@@ -272,9 +280,9 @@
val passengerDevice =
ConnectedDevice(
UUID.randomUUID().toString(),
- "driverDeviceName",
+ "passengerDeviceName",
/* belongsToDriver= */ false,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.onDeviceDisconnectedInternal(passengerDevice)
@@ -291,7 +299,7 @@
UUID.randomUUID().toString(),
"driverDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.onDeviceDisconnectedInternal(driverDevice)
@@ -306,9 +314,9 @@
val passengerDevice =
ConnectedDevice(
UUID.randomUUID().toString(),
- "driverDeviceName",
+ "passengerDeviceName",
/* belongsToDriver= */ false,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.onDeviceDisconnectedInternal(passengerDevice)
@@ -325,7 +333,7 @@
UUID.randomUUID().toString(),
"driverDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.onDeviceDisconnectedInternal(driverDevice)
@@ -340,9 +348,9 @@
val passengerDevice =
ConnectedDevice(
UUID.randomUUID().toString(),
- "driverDeviceName",
+ "passengerDeviceName",
/* belongsToDriver= */ false,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.onDeviceDisconnectedInternal(passengerDevice)
@@ -359,7 +367,7 @@
UUID.randomUUID().toString(),
"driverDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.unregisterConnectionCallback(mockConnectionCallback)
@@ -375,9 +383,9 @@
val passengerDevice =
ConnectedDevice(
UUID.randomUUID().toString(),
- "driverDeviceName",
+ "passengerDeviceName",
/* belongsToDriver= */ false,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.unregisterConnectionCallback(mockConnectionCallback)
@@ -395,7 +403,7 @@
UUID.randomUUID().toString(),
"driverDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.unregisterConnectionCallback(mockConnectionCallback)
@@ -413,14 +421,14 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val missedMessage =
DeviceMessage.createOutgoingMessage(
recipientId.uuid,
/* isMessageEncrypted= */ true,
CLIENT_MESSAGE,
- ByteUtils.randomBytes(10)
+ ByteUtils.randomBytes(10),
)
coordinator.onMessageReceivedInternal(connectedDevice, missedMessage)
@@ -439,7 +447,7 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.registerDeviceCallback(connectedDevice, recipientId, deviceCallback)
@@ -461,7 +469,7 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.registerDeviceCallback(connectedDevice, recipientId, deadDeviceCallback)
@@ -482,7 +490,7 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.registerDeviceCallback(connectedDevice, recipientId, deviceCallback)
coordinator.registerDeviceCallback(connectedDevice, recipientId, duplicateDeviceCallback)
@@ -494,6 +502,36 @@
}
@Test
+ fun registerDuplicateDeviceCallback_onDeviceDisconnected_clearBlockedRecipients() {
+ val mockConnectionCallback: IConnectionCallback = mockToBeAlive()
+ coordinator.registerDriverConnectionCallback(mockConnectionCallback)
+ val deviceCallback: IDeviceCallback = mockToBeAlive()
+ val duplicateDeviceCallback: IDeviceCallback = mockToBeAlive()
+ val callbackAfterReconnection: IDeviceCallback = mockToBeAlive()
+ val recipientId = ParcelUuid(UUID.randomUUID())
+ val connectedDevice =
+ ConnectedDevice(
+ UUID.randomUUID().toString(),
+ "testDeviceName",
+ /* belongsToDriver= */ true,
+ /* hasSecureChannel= */ true,
+ )
+
+ coordinator.registerDeviceCallback(connectedDevice, recipientId, deviceCallback)
+ coordinator.registerDeviceCallback(connectedDevice, recipientId, duplicateDeviceCallback)
+
+ verify(deviceCallback)
+ .onDeviceError(connectedDevice, DEVICE_ERROR_INSECURE_RECIPIENT_ID_DETECTED)
+ verify(duplicateDeviceCallback)
+ .onDeviceError(connectedDevice, DEVICE_ERROR_INSECURE_RECIPIENT_ID_DETECTED)
+
+ coordinator.onDeviceDisconnectedInternal(connectedDevice)
+ coordinator.registerDeviceCallback(connectedDevice, recipientId, callbackAfterReconnection)
+ verify(callbackAfterReconnection, never())
+ .onDeviceError(connectedDevice, DEVICE_ERROR_INSECURE_RECIPIENT_ID_DETECTED)
+ }
+
+ @Test
fun unregisterDeviceCallback_callbackNotInvokedAfterUnregistering() {
val deviceCallback: IDeviceCallback = mockToBeAlive()
val recipientId = ParcelUuid(UUID.randomUUID())
@@ -502,14 +540,14 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val message =
DeviceMessage.createOutgoingMessage(
recipientId.uuid,
/* isMessageEncrypted= */ true,
CLIENT_MESSAGE,
- ByteUtils.randomBytes(10)
+ ByteUtils.randomBytes(10),
)
coordinator.registerDeviceCallback(connectedDevice, recipientId, deviceCallback)
@@ -528,14 +566,14 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val message =
DeviceMessage.createOutgoingMessage(
recipientId.uuid,
/* isMessageEncrypted= */ true,
CLIENT_MESSAGE,
- ByteUtils.randomBytes(10)
+ ByteUtils.randomBytes(10),
)
coordinator.registerDeviceCallback(connectedDevice, recipientId, deviceCallback)
@@ -554,14 +592,14 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val message =
DeviceMessage.createOutgoingMessage(
otherRecipientId.uuid,
/* isMessageEncrypted= */ true,
CLIENT_MESSAGE,
- ByteUtils.randomBytes(10)
+ ByteUtils.randomBytes(10),
)
coordinator.registerDeviceCallback(connectedDevice, recipientId, deviceCallback)
@@ -580,14 +618,14 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val message =
DeviceMessage.createOutgoingMessage(
recipientId.uuid,
/* isMessageEncrypted= */ true,
CLIENT_MESSAGE,
- ByteUtils.randomBytes(10)
+ ByteUtils.randomBytes(10),
)
coordinator.registerDeviceCallback(connectedDevice, recipientId, deviceCallback)
coordinator.registerDeviceCallback(connectedDevice, recipientId, duplicateDeviceCallback)
@@ -608,7 +646,7 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.registerDeviceCallback(connectedDevice, recipientId, deviceCallback)
coordinator.registerDeviceCallback(connectedDevice, recipientId, duplicateDeviceCallback)
@@ -621,7 +659,7 @@
recipientId.uuid,
/* isMessageEncrypted= */ true,
CLIENT_MESSAGE,
- ByteUtils.randomBytes(10)
+ ByteUtils.randomBytes(10),
)
coordinator.onMessageReceivedInternal(connectedDevice, message)
@@ -636,33 +674,77 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val nullRecipientMessage =
DeviceMessage.createOutgoingMessage(
/* recipient= */ null,
/* isMessageEncrypted= */ true,
CLIENT_MESSAGE,
- ByteUtils.randomBytes(10)
+ ByteUtils.randomBytes(10),
)
coordinator.onMessageReceivedInternal(connectedDevice, nullRecipientMessage)
}
@Test
+ fun onMessageReceived_shouldCacheMessage_cachesMessage() {
+ val connectedDevice =
+ ConnectedDevice(
+ UUID.randomUUID().toString(),
+ "testDeviceName",
+ /* belongsToDriver= */ true,
+ /* hasSecureChannel= */ true,
+ )
+ val message =
+ DeviceMessage.createIncomingMessage(
+ /* recipient= */ UUID.randomUUID(),
+ /* isMessageEncrypted= */ false,
+ /* operationType= */ QUERY_RESPONSE,
+ /* message= */ ByteArray(0),
+ /* originalMessageSize= */ 0,
+ )
+ coordinator.onMessageReceivedInternal(connectedDevice, message, shouldCacheMessage = true)
+
+ verify(mockSystemQueryCache).maybeCacheResponse(connectedDevice, message)
+ }
+
+ @Test
+ fun onMessageReceived_shouldNotCacheMessage_skipsCache() {
+ val connectedDevice =
+ ConnectedDevice(
+ UUID.randomUUID().toString(),
+ "testDeviceName",
+ /* belongsToDriver= */ true,
+ /* hasSecureChannel= */ true,
+ )
+ val message =
+ DeviceMessage.createIncomingMessage(
+ /* recipient= */ UUID.randomUUID(),
+ /* isMessageEncrypted= */ false,
+ /* operationType= */ QUERY_RESPONSE,
+ /* message= */ ByteArray(0),
+ /* originalMessageSize= */ 0,
+ )
+ coordinator.onMessageReceivedInternal(connectedDevice, message, shouldCacheMessage = false)
+
+ verify(mockSystemQueryCache, never()).maybeCacheResponse(any(), any())
+ }
+
+ @Test
fun sendMessage_sendsMessageToController() {
val connectedDevice =
ConnectedDevice(
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val message =
DeviceMessage.createOutgoingMessage(
UUID.randomUUID(),
/* isMessageEncrypted= */ true,
CLIENT_MESSAGE,
- ByteUtils.randomBytes(10)
+ ByteUtils.randomBytes(10),
)
coordinator.sendMessage(connectedDevice, message)
@@ -671,6 +753,42 @@
}
@Test
+ fun sendMessage_cachedQueryResponse_skipsController() {
+ val connectedDevice =
+ ConnectedDevice(
+ UUID.randomUUID().toString(),
+ "testDeviceName",
+ /* belongsToDriver= */ true,
+ /* hasSecureChannel= */ true,
+ )
+ val sender = UUID.randomUUID()
+ val deviceCallback: IDeviceCallback = mockToBeAlive()
+ coordinator.registerDeviceCallback(connectedDevice, ParcelUuid(sender), deviceCallback)
+
+ val cachedResponse =
+ DeviceMessage.createIncomingMessage(
+ /* recipient= */ sender,
+ /* isMessageEncrypted= */ false,
+ /* operationType= */ QUERY_RESPONSE,
+ /* message= */ ByteArray(0),
+ /* originalMessageSize= */ 0,
+ )
+ whenever(mockSystemQueryCache.getCachedResponse(any(), any())).thenReturn(cachedResponse)
+
+ val message =
+ DeviceMessage.createOutgoingMessage(
+ SYSTEM_FEATURE_ID.uuid,
+ /* isMessageEncrypted= */ true,
+ QUERY,
+ ByteUtils.randomBytes(10),
+ )
+ coordinator.sendMessage(connectedDevice, message)
+
+ verify(mockController, never()).sendMessage(any(), any())
+ verify(deviceCallback).onMessageReceived(connectedDevice, cachedResponse)
+ }
+
+ @Test
fun startAssociation_startsAssociationWithCorrectlySizedName() {
val associationCallback: IAssociationCallback = mockToBeAlive()
@@ -707,7 +825,7 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.registerDeviceCallback(connectedDevice, recipientId, deviceCallback)
coordinator.registerDeviceCallback(connectedDevice, secondRecipientId, secondDeviceCallback)
@@ -729,14 +847,14 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val otherConnectedDevice =
ConnectedDevice(
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
coordinator.registerDeviceCallback(connectedDevice, recipientId, deviceCallback)
coordinator.registerDeviceCallback(otherConnectedDevice, otherRecipientId, otherDeviceCallback)
@@ -756,7 +874,7 @@
UUID.randomUUID().toString(),
"deviceAddress",
"deviceName",
- /* isConnectionEnabled= */ true
+ /* isConnectionEnabled= */ true,
)
coordinator.onAssociatedDeviceAddedInternal(associatedDevice)
@@ -773,7 +891,7 @@
UUID.randomUUID().toString(),
"deviceAddress",
"deviceName",
- /* isConnectionEnabled= */ true
+ /* isConnectionEnabled= */ true,
)
coordinator.onAssociatedDeviceRemovedInternal(associatedDevice)
@@ -790,7 +908,7 @@
UUID.randomUUID().toString(),
"deviceAddress",
"deviceName",
- /* isConnectionEnabled= */ true
+ /* isConnectionEnabled= */ true,
)
coordinator.onAssociatedDeviceUpdatedInternal(associatedDevice)
@@ -807,7 +925,7 @@
UUID.randomUUID().toString(),
"deviceAddress",
"deviceName",
- /* isConnectionEnabled= */ true
+ /* isConnectionEnabled= */ true,
)
coordinator.unregisterDeviceAssociationCallback(callback)
@@ -866,13 +984,13 @@
UUID.randomUUID().toString(),
/* deviceAddress= */ "",
/* deviceName= */ null,
- /* isConnectionEnabled= */ true
+ /* isConnectionEnabled= */ true,
),
AssociatedDevice(
UUID.randomUUID().toString(),
/* deviceAddress= */ "",
/* deviceName= */ null,
- /* isConnectionEnabled= */ false
+ /* isConnectionEnabled= */ false,
)
)
val listener: IOnAssociatedDevicesRetrievedListener = mockToBeAlive()
@@ -891,13 +1009,13 @@
UUID.randomUUID().toString(),
/* deviceAddress= */ "",
/* deviceName= */ null,
- /* isConnectionEnabled= */ true
+ /* isConnectionEnabled= */ true,
),
AssociatedDevice(
UUID.randomUUID().toString(),
/* deviceAddress= */ "",
/* deviceName= */ null,
- /* isConnectionEnabled= */ false
+ /* isConnectionEnabled= */ false,
)
)
val listener: IOnAssociatedDevicesRetrievedListener = mockToBeAlive()
@@ -916,13 +1034,13 @@
UUID.randomUUID().toString(),
/* deviceAddress= */ "",
/* deviceName= */ null,
- /* isConnectionEnabled= */ true
+ /* isConnectionEnabled= */ true,
),
AssociatedDevice(
UUID.randomUUID().toString(),
/* deviceAddress= */ "",
/* deviceName= */ null,
- /* isConnectionEnabled= */ false
+ /* isConnectionEnabled= */ false,
)
)
val listener: IOnAssociatedDevicesRetrievedListener = mockToBeAlive()
@@ -1015,21 +1133,21 @@
UUID.randomUUID().toString(),
"driverDeviceName1",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val driverDevice2 =
ConnectedDevice(
UUID.randomUUID().toString(),
"driverDeviceName2",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val passengerDevice =
ConnectedDevice(
UUID.randomUUID().toString(),
"passengerDeviceName",
/* belongsToDriver= */ false,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
whenever(mockController.connectedDevices)
.thenReturn(listOf(driverDevice1, passengerDevice, driverDevice2))
@@ -1082,17 +1200,15 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
- val message =
- DeviceMessageProto.Message.newBuilder()
- .setRecipient(ByteString.copyFrom(ByteUtils.uuidToBytes(recipientId.uuid)))
- .setIsPayloadEncrypted(true)
- .setOperation(
- OperationType.forNumber(/* CLIENT_MESSAGE */ 4) ?: OperationType.OPERATION_TYPE_UNKNOWN
- )
- .setPayload(ByteString.copyFrom(ByteUtils.randomBytes(10)))
- .build()
+ val message = message {
+ operation =
+ OperationType.forNumber(/* CLIENT_MESSAGE */ 4) ?: OperationType.OPERATION_TYPE_UNKNOWN
+ isPayloadEncrypted = true
+ recipient = ByteString.copyFrom(ByteUtils.uuidToBytes(recipientId.uuid))
+ payload = ByteString.copyFrom(ByteUtils.randomBytes(10))
+ }
val rawBytes = message.toByteArray()
@@ -1120,7 +1236,7 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
safeCoordinator.registerDeviceCallback(connectedDevice.deviceId, recipientId, deviceCallback)
@@ -1137,6 +1253,32 @@
}
@Test
+ fun safeFC_registerDeviceCallback_blocksRecipientIfRegisteredWithNonSafeDeviceCallback() {
+ val deviceCallback: IDeviceCallback = mockToBeAlive()
+ val duplicateDeviceCallback: ISafeDeviceCallback = mockToBeAlive()
+ val recipientId = ParcelUuid(UUID.randomUUID())
+ val connectedDevice =
+ ConnectedDevice(
+ UUID.randomUUID().toString(),
+ "testDeviceName",
+ /* belongsToDriver= */ true,
+ /* hasSecureChannel= */ true,
+ )
+
+ coordinator.registerDeviceCallback(connectedDevice, recipientId, deviceCallback)
+ safeCoordinator.registerDeviceCallback(
+ connectedDevice.deviceId,
+ recipientId,
+ duplicateDeviceCallback
+ )
+
+ verify(deviceCallback)
+ .onDeviceError(connectedDevice, DEVICE_ERROR_INSECURE_RECIPIENT_ID_DETECTED)
+ verify(duplicateDeviceCallback)
+ .onDeviceError(connectedDevice.deviceId, DEVICE_ERROR_INSECURE_RECIPIENT_ID_DETECTED)
+ }
+
+ @Test
fun safeFC_registerDeviceCallback_ignoreDeadPreviousRegistererIfAlreadyRegistered() {
val deadDeviceCallback: ISafeDeviceCallback = mockToBeDead()
val duplicateDeviceCallback: ISafeDeviceCallback = mockToBeAlive()
@@ -1146,7 +1288,7 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
safeCoordinator.registerDeviceCallback(
@@ -1175,7 +1317,7 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
safeCoordinator.registerDeviceCallback(connectedDevice.deviceId, recipientId, deviceCallback)
safeCoordinator.registerDeviceCallback(
@@ -1203,14 +1345,14 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val message =
DeviceMessage.createOutgoingMessage(
recipientId.uuid,
/* isMessageEncrypted= */ true,
CLIENT_MESSAGE,
- ByteUtils.randomBytes(10)
+ ByteUtils.randomBytes(10),
)
safeCoordinator.registerDeviceCallback(connectedDevice.deviceId, recipientId, deviceCallback)
@@ -1229,14 +1371,14 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val message =
DeviceMessage.createOutgoingMessage(
recipientId.uuid,
/* isMessageEncrypted= */ true,
CLIENT_MESSAGE,
- ByteUtils.randomBytes(10)
+ ByteUtils.randomBytes(10),
)
safeCoordinator.registerDeviceCallback(connectedDevice.deviceId, recipientId, deviceCallback)
@@ -1255,14 +1397,14 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val message =
DeviceMessage.createOutgoingMessage(
otherRecipientId.uuid,
/* isMessageEncrypted= */ true,
CLIENT_MESSAGE,
- ByteUtils.randomBytes(10)
+ ByteUtils.randomBytes(10),
)
safeCoordinator.registerDeviceCallback(connectedDevice.deviceId, recipientId, deviceCallback)
@@ -1281,14 +1423,14 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val message =
DeviceMessage.createOutgoingMessage(
recipientId.uuid,
/* isMessageEncrypted= */ true,
CLIENT_MESSAGE,
- ByteUtils.randomBytes(10)
+ ByteUtils.randomBytes(10),
)
safeCoordinator.registerDeviceCallback(connectedDevice.deviceId, recipientId, deviceCallback)
safeCoordinator.registerDeviceCallback(
@@ -1313,13 +1455,13 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
safeCoordinator.registerDeviceCallback(connectedDevice.deviceId, recipientId, deviceCallback)
safeCoordinator.registerDeviceCallback(
connectedDevice.deviceId,
recipientId,
- duplicateDeviceCallback
+ duplicateDeviceCallback,
)
// Clear the blocked recipient list.
@@ -1330,7 +1472,7 @@
recipientId.uuid,
/* isMessageEncrypted= */ true,
CLIENT_MESSAGE,
- ByteUtils.randomBytes(10)
+ ByteUtils.randomBytes(10),
)
coordinator.onMessageReceivedInternal(connectedDevice, message)
@@ -1345,36 +1487,70 @@
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
val nullRecipientMessage =
DeviceMessage.createOutgoingMessage(
/* recipient= */ null,
/* isMessageEncrypted= */ true,
CLIENT_MESSAGE,
- ByteUtils.randomBytes(10)
+ ByteUtils.randomBytes(10),
)
coordinator.onMessageReceivedInternal(connectedDevice, nullRecipientMessage)
}
@Test
- fun safeFC_sendMessage_sendsMessageToController() {
+ fun safeFC_sendMessage_succeedsOnRegisteredDevice() {
+ val deviceId = UUID.randomUUID().toString()
+ val connectedDevice =
+ ConnectedDevice(
+ deviceId,
+ "testDeviceName",
+ /* belongsToDriver= */ true,
+ /* hasSecureChannel= */ true,
+ )
+ val message = message {
+ operation =
+ OperationType.forNumber(/* CLIENT_MESSAGE */ 4) ?: OperationType.OPERATION_TYPE_UNKNOWN
+ isPayloadEncrypted = true
+ recipient = ByteString.copyFrom(ByteUtils.uuidToBytes(UUID.randomUUID()))
+ payload = ByteString.copyFrom(ByteUtils.randomBytes(10))
+ }
+
+ val rawBytes = message.toByteArray()
+
+ whenever(mockController.connectedDevices).thenReturn(listOf(connectedDevice))
+ whenever(mockController.sendMessage(any(), any())).thenReturn(true)
+ val messageSent = safeCoordinator.sendMessage(deviceId, rawBytes)
+
+ val deviceMessage =
+ DeviceMessage.createOutgoingMessage(
+ ByteUtils.bytesToUUID(message.recipient.toByteArray()),
+ message.isPayloadEncrypted,
+ DeviceMessage.OperationType.fromValue(message.operation.number),
+ message.payload.toByteArray(),
+ )
+
+ assertThat(messageSent).isTrue()
+ verify(mockController).sendMessage(UUID.fromString(deviceId), deviceMessage)
+ }
+
+ @Test
+ fun safeFC_sendMessage_failsOnUnregisteredDevice() {
val connectedDevice =
ConnectedDevice(
UUID.randomUUID().toString(),
"testDeviceName",
/* belongsToDriver= */ true,
- /* hasSecureChannel= */ true
+ /* hasSecureChannel= */ true,
)
- val message =
- DeviceMessageProto.Message.newBuilder()
- .setRecipient(ByteString.copyFrom(ByteUtils.uuidToBytes(UUID.randomUUID())))
- .setIsPayloadEncrypted(true)
- .setOperation(
- OperationType.forNumber(/* CLIENT_MESSAGE */ 4) ?: OperationType.OPERATION_TYPE_UNKNOWN
- )
- .setPayload(ByteString.copyFrom(ByteUtils.randomBytes(10)))
- .build()
+ val message = message {
+ operation =
+ OperationType.forNumber(/* CLIENT_MESSAGE */ 4) ?: OperationType.OPERATION_TYPE_UNKNOWN
+ isPayloadEncrypted = true
+ recipient = ByteString.copyFrom(ByteUtils.uuidToBytes(UUID.randomUUID()))
+ payload = ByteString.copyFrom(ByteUtils.randomBytes(10))
+ }
val rawBytes = message.toByteArray()
@@ -1385,11 +1561,12 @@
ByteUtils.bytesToUUID(message.recipient.toByteArray()),
message.isPayloadEncrypted,
DeviceMessage.OperationType.fromValue(message.operation.number),
- message.payload.toByteArray()
+ message.payload.toByteArray(),
)
- assertThat(messageSent).isFalse() // Recipient not registered so this will return false
- verify(mockController).sendMessage(UUID.fromString(connectedDevice.deviceId), deviceMessage)
+ assertThat(messageSent).isFalse()
+ verify(mockController, never())
+ .sendMessage(UUID.fromString(connectedDevice.deviceId), deviceMessage)
}
@Test
@@ -1430,13 +1607,13 @@
UUID.randomUUID().toString(),
/* deviceAddress= */ "",
/* deviceName= */ null,
- /* isConnectionEnabled= */ true
+ /* isConnectionEnabled= */ true,
),
AssociatedDevice(
UUID.randomUUID().toString(),
/* deviceAddress= */ "",
/* deviceName= */ null,
- /* isConnectionEnabled= */ false
+ /* isConnectionEnabled= */ false,
)
)
val listener: ISafeOnAssociatedDevicesRetrievedListener = mockToBeAlive()
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/MultiProtocolDeviceControllerTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/MultiProtocolDeviceControllerTest.kt
index 56df277..05a1b2a 100644
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/MultiProtocolDeviceControllerTest.kt
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/MultiProtocolDeviceControllerTest.kt
@@ -101,12 +101,13 @@
whenever(spyStorage.hashWithChallengeSecret(any(), any())).thenReturn(TEST_CHALLENGE)
deviceController =
MultiProtocolDeviceController(
+ context,
protocolDelegate,
spyStorage,
mockOobRunner,
testAssociationServiceUuid.uuid,
enablePassenger = false,
- callbackExecutor = directExecutor()
+ storageExecutor = directExecutor()
)
deviceController.registerCallback(mockCallback, directExecutor())
secureChannel =
@@ -143,12 +144,13 @@
}
MultiProtocolDeviceController(
+ context,
protocolDelegate,
transientErrorStorage,
mockOobRunner,
testAssociationServiceUuid.uuid,
enablePassenger = false,
- callbackExecutor = directExecutor()
+ storageExecutor = directExecutor()
)
.start()
@@ -178,12 +180,13 @@
whenever(spyStorage.allAssociatedDevices).thenReturn(listOf(driverDevice, passengerDevice))
deviceController =
MultiProtocolDeviceController(
+ context,
protocolDelegate,
spyStorage,
mockOobRunner,
testAssociationServiceUuid.uuid,
enablePassenger = true,
- callbackExecutor = directExecutor()
+ storageExecutor = directExecutor()
)
deviceController.start()
@@ -215,12 +218,13 @@
whenever(spyStorage.allAssociatedDevices).thenReturn(listOf(driverDevice, passengerDevice))
deviceController =
MultiProtocolDeviceController(
+ context,
protocolDelegate,
spyStorage,
mockOobRunner,
testAssociationServiceUuid.uuid,
enablePassenger = false,
- callbackExecutor = directExecutor()
+ storageExecutor = directExecutor()
)
deviceController.start()
@@ -350,12 +354,13 @@
)
deviceController =
MultiProtocolDeviceController(
+ context,
protocolDelegate,
spyStorage,
mockOobRunner,
testAssociationServiceUuid.uuid,
enablePassenger = false,
- callbackExecutor = directExecutor()
+ storageExecutor = directExecutor()
)
deviceController.registerCallback(mockCallback, directExecutor())
deviceController.start()
@@ -672,9 +677,32 @@
)
verify(spyStorage).saveChallengeSecret(deviceId.toString(), secret)
- verify(mockCallback).onDeviceConnected(any())
+ assertThat(deviceController.getConnectedDevice(deviceId)).isNotNull()
+ verify(mockAssociationCallback).onAssociationCompleted()
+ }
+
+ @Test
+ fun onAssociatedDeviceAdded_issuesDeviceConnectedCallback() {
+ val deviceId = UUID.randomUUID()
+ val deviceName = "TestDeviceName"
+ val associatedDevice =
+ AssociatedDevice(
+ deviceId.toString(),
+ "deviceAddress",
+ deviceName,
+ /* isConnectionEnabled= */ true
+ )
+ argumentCaptor<ConnectedDeviceStorage.AssociatedDeviceCallback>().apply {
+ verify(spyStorage).registerAssociatedDeviceCallback(capture())
+ firstValue.onAssociatedDeviceAdded(associatedDevice)
+ }
+ argumentCaptor<ConnectedDevice>().apply {
+ verify(mockCallback).onDeviceConnected(capture())
+ assertThat(firstValue.deviceId).isEqualTo(associatedDevice.deviceId)
+ assertThat(firstValue.hasSecureChannel()).isFalse()
+ }
verify(mockCallback).onSecureChannelEstablished(any())
- assertThat(deviceController.connectedDevices).isNotEmpty()
+ verify(spyStorage).getAllAssociatedDevices()
}
@Test
@@ -762,12 +790,13 @@
)
deviceController =
MultiProtocolDeviceController(
+ context,
protocolDelegate,
spyStorage,
mockOobRunner,
testAssociationServiceUuid.uuid,
enablePassenger = false,
- callbackExecutor = directExecutor()
+ storageExecutor = directExecutor()
)
deviceController.startAssociation(deviceName, mockAssociationCallback, testIdentifier.uuid)
@@ -806,12 +835,13 @@
)
deviceController =
MultiProtocolDeviceController(
+ context,
protocolDelegate,
spyStorage,
mockOobRunner,
testAssociationServiceUuid.uuid,
enablePassenger = true,
- callbackExecutor = directExecutor()
+ storageExecutor = directExecutor()
)
deviceController.startAssociation(deviceName, mockAssociationCallback, testIdentifier.uuid)
@@ -868,12 +898,13 @@
// Recreate controller after registering mock returns since they are used in the constructor.
deviceController =
MultiProtocolDeviceController(
+ context,
protocolDelegate,
spyStorage,
mockOobRunner,
testAssociationServiceUuid.uuid,
enablePassenger = false,
- callbackExecutor = directExecutor()
+ storageExecutor = directExecutor()
)
deviceController.registerCallback(mockCallback, directExecutor())
deviceController.start()
@@ -963,12 +994,13 @@
protocolDelegate.addProtocol(protocol2)
deviceController =
MultiProtocolDeviceController(
+ context,
protocolDelegate,
spyStorage,
mockOobRunner,
testAssociationServiceUuid.uuid,
enablePassenger = false,
- callbackExecutor = directExecutor()
+ storageExecutor = directExecutor()
)
.apply { registerCallback(mockCallback, directExecutor()) }
deviceController.initiateConnectionToDevice(deviceId.uuid)
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/SystemQueryCacheTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/SystemQueryCacheTest.kt
new file mode 100644
index 0000000..f242282
--- /dev/null
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/core/SystemQueryCacheTest.kt
@@ -0,0 +1,41 @@
+package com.google.android.connecteddevice.core
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.android.connecteddevice.model.ConnectedDevice
+import com.google.common.truth.Truth.assertThat
+import com.nhaarman.mockitokotlin2.mock
+import java.util.UUID
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SystemQueryCacheTest {
+
+ private lateinit var cache: SystemQueryCacheImpl
+
+ @Before
+ fun setUp() {
+ cache = SystemQueryCacheImpl()
+ }
+
+ @Test
+ fun maybeCacheResponse_createsCacheForNewDevice() {
+ val deviceId = UUID.randomUUID()
+ val device = createConnectedDevice(deviceId)
+
+ cache.maybeCacheResponse(device, mock())
+
+ assertThat(deviceId in cache.deviceCaches).isTrue()
+ }
+
+ companion object {
+ private fun createConnectedDevice(deviceId: UUID) =
+ ConnectedDevice(
+ deviceId.toString(),
+ /* deviceName= */ "",
+ /* belongsToDriver= */ true,
+ true,
+ )
+ }
+}
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/logging/LoggingFeatureTest.java b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/logging/LoggingFeatureTest.java
index 2b7191a..6395f18 100644
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/logging/LoggingFeatureTest.java
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/logging/LoggingFeatureTest.java
@@ -137,9 +137,9 @@
@NonNull
private static ConnectedDevice createConnectedDevice() {
return new ConnectedDevice(
- /* deviceId = */ "TEST_ID",
- /* deviceName = */ "TEST_NAME",
- /* belongsToActiveUser = */ true,
- /* hasSecureChannel = */ true);
+ /* deviceId= */ "TEST_ID",
+ /* deviceName= */ "TEST_NAME",
+ /* belongsToDriver= */ true,
+ /* hasSecureChannel= */ true);
}
}
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/system/SystemFeatureTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/system/SystemFeatureTest.kt
index 825f175..00a9766 100644
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/system/SystemFeatureTest.kt
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/system/SystemFeatureTest.kt
@@ -2,6 +2,7 @@
import android.bluetooth.BluetoothManager
import android.content.Context
+import android.os.Looper
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.android.companionprotos.SystemQuery
@@ -24,12 +25,15 @@
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.never
import com.nhaarman.mockitokotlin2.spy
+import com.nhaarman.mockitokotlin2.times
import com.nhaarman.mockitokotlin2.verify
import java.nio.charset.StandardCharsets
import java.util.UUID
+import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
+import org.robolectric.Shadows.shadowOf
@RunWith(AndroidJUnit4::class)
class SystemFeatureTest {
@@ -46,13 +50,17 @@
/* hasSecureChannel= */ true
)
+ private val queriedFeature1 = UUID.randomUUID()
+ private val queriedFeature2 = UUID.randomUUID()
+ private val onConnectionQueriedFeatures = listOf(queriedFeature1, queriedFeature2)
+
private lateinit var systemFeature: SystemFeature
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
context.getSystemService(BluetoothManager::class.java).adapter.name = TEST_DEVICE_NAME
- systemFeature = SystemFeature(context, mockStorage, fakeConnector)
+ systemFeature = SystemFeature(context, mockStorage, fakeConnector, onConnectionQueriedFeatures)
assertThat(fakeConnector.callback).isNotNull()
}
@@ -76,13 +84,26 @@
argumentCaptor<ByteArray>() {
verify(fakeConnector).sendQuerySecurely(eq(device), capture(), anyOrNull(), any())
- val systemQuery =
- SystemQuery.parseFrom(firstValue, ExtensionRegistryLite.getEmptyRegistry())
+ val systemQuery = SystemQuery.parseFrom(firstValue, ExtensionRegistryLite.getEmptyRegistry())
assertThat(systemQuery.type).isEqualTo(DEVICE_NAME)
}
}
@Test
+ fun onSecureChannelEstablished_sendsQueryForFeatureSupportStatus() {
+ runBlocking {
+ fakeConnector.callback?.onSecureChannelEstablished(device)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ argumentCaptor<List<UUID>>() {
+ verify(fakeConnector).queryFeatureSupportStatuses(eq(device), capture())
+ val queriedFeatures = firstValue
+ assertThat(queriedFeatures).isEqualTo(onConnectionQueriedFeatures)
+ }
+ }
+ }
+
+ @Test
fun deviceNameQueryResponse_successUpdatesDeviceName() {
fakeConnector.callback?.onSecureChannelEstablished(device)
@@ -112,7 +133,7 @@
argumentCaptor<Connector.QueryCallback>() {
verify(fakeConnector).sendQuerySecurely(eq(device), any(), anyOrNull(), capture())
- firstValue.onError(/* response= */ null)
+ firstValue.onError(ByteArray(0))
verify(mockStorage, never()).updateAssociatedDeviceName(any(), any())
}
}
@@ -123,8 +144,8 @@
argumentCaptor<Connector.QueryCallback>() {
verify(fakeConnector).sendQuerySecurely(eq(device), any(), anyOrNull(), capture())
- firstValue.onQueryFailedToSend(/* isTransient= */ false)
- firstValue.onQueryFailedToSend(/* isTransient= */ true)
+ firstValue.onQueryFailedToSend(isTransient = false)
+ firstValue.onQueryFailedToSend(isTransient = true)
verify(mockStorage, never()).updateAssociatedDeviceName(any(), any())
}
}
@@ -137,8 +158,7 @@
fakeConnector.callback?.onQueryReceived(device, queryId, query.toByteArray(), null)
argumentCaptor<ByteArray> {
- verify(fakeConnector)
- .respondToQuerySecurely(eq(device), eq(queryId), eq(true), capture())
+ verify(fakeConnector).respondToQuerySecurely(eq(device), eq(queryId), eq(true), capture())
assertThat(firstValue).isEqualTo(TEST_DEVICE_NAME.toByteArray(StandardCharsets.UTF_8))
}
}
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/trust/TrustedDeviceAgentServiceTest.kt b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/trust/TrustedDeviceAgentServiceTest.kt
index 12791f4..30c4243 100644
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/trust/TrustedDeviceAgentServiceTest.kt
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/trust/TrustedDeviceAgentServiceTest.kt
@@ -3,6 +3,9 @@
import android.app.ActivityManager
import android.app.KeyguardManager
import android.os.PowerManager
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import android.os.Build;
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.google.android.connecteddevice.trust.api.ITrustedDeviceAgentDelegate
@@ -18,6 +21,7 @@
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
+import org.robolectric.annotation.Config
import org.robolectric.RobolectricTestRunner
import org.robolectric.shadow.api.Shadow
import org.robolectric.shadows.ShadowKeyguardManager
@@ -25,6 +29,7 @@
@RunWith(RobolectricTestRunner::class)
+@Config(sdk=[Build.VERSION_CODES.S, Build.VERSION_CODES.R, Build.VERSION_CODES.Q])
class TrustedDeviceAgentServiceTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
@@ -60,7 +65,7 @@
}
@Test
- fun unlockUserWithToken_invokesCallbackAfterTokenReceived_DeviceAndUserAreLocked() {
+ fun unlockUserWithToken_invokesCallbackAfterTokenReceived_deviceAndUserAreLocked() {
lockUser()
sendToken()
@@ -70,7 +75,7 @@
}
@Test
- fun unlockUserWithToken_invokesCallbackAfterTokenReceived_UserIsUnlocked() {
+ fun unlockUserWithTokenBeforeAndroidT_invokesCallbackAfterTokenReceived_userIsUnlocked() {
service.isUserUnlocked = true
sendToken()
@@ -78,6 +83,16 @@
verify(mockTrustedDeviceManager).onUserUnlocked()
}
+ @Config(sdk=[Build.VERSION_CODES.TIRAMISU])
+ @Test
+ fun unlockUserWithTokenAfterAndroidT_doNotInvokesCallbackAfterTokenReceivedImmediately() {
+ service.isUserUnlocked = true
+
+ sendToken()
+
+ verify(mockTrustedDeviceManager, never()).onUserUnlocked()
+ }
+
@Test
fun onUserUnlock_doesNotInvokeCallbackIfTokenWasNotUsed() {
unlockUser()
diff --git a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/trust/TrustedDeviceViewModelTest.java b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/trust/TrustedDeviceViewModelTest.java
index e129a4d..d728a03 100644
--- a/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/trust/TrustedDeviceViewModelTest.java
+++ b/libs/connecteddevice/tests/unit/src/com/google/android/connecteddevice/trust/TrustedDeviceViewModelTest.java
@@ -26,6 +26,7 @@
import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -44,6 +45,7 @@
@Mock private ITrustedDeviceManager mockTrustedDeviceManager;
private KeyguardManager keyguardManager;
@Mock private Observer<EnrollmentState> mockEnrollmentStateObserver;
+ @Mock private Observer<List<TrustedDevice>> mockTrustedDevicesObserver;
private TrustedDeviceViewModel viewModel;
@@ -79,6 +81,22 @@
}
@Test
+ public void enrollTrustedDevice_beforeProcessEscrowToken_setEmptyTrustedDevices()
+ throws RemoteException {
+ List<TrustedDevice> emptyList = new ArrayList<>();
+ ArgumentCaptor<IOnTrustedDevicesRetrievedListener> listenerCaptor =
+ ArgumentCaptor.forClass(IOnTrustedDevicesRetrievedListener.class);
+ viewModel.enrollTrustedDevice(createAssociatedDevice());
+ // This first call is when the view model is initialized.
+ verify(mockTrustedDeviceManager, times(2))
+ .retrieveTrustedDevicesForActiveUser(listenerCaptor.capture());
+ listenerCaptor.getAllValues().get(1).onTrustedDevicesRetrieved(emptyList);
+ viewModel.getTrustedDevices().observeForever(mockTrustedDevicesObserver);
+ waitForLiveDataUpdate();
+ verify(mockTrustedDevicesObserver).onChanged(emptyList);
+ }
+
+ @Test
public void disableTrustedDevice() throws RemoteException {
TrustedDevice testDevice = createTrustedDevice();
viewModel.disableTrustedDevice(testDevice);
diff --git a/libs/encryptionrunner/android_build.gradle b/libs/encryptionrunner/android_build.gradle
new file mode 100644
index 0000000..bb84b61
--- /dev/null
+++ b/libs/encryptionrunner/android_build.gradle
@@ -0,0 +1,40 @@
+apply plugin: 'com.android.library'
+
+android {
+ compileSdkVersion rootProject.ext.sdkVersion
+
+ defaultConfig {
+ minSdkVersion rootProject.ext.minSdkVersion
+ targetSdkVersion rootProject.ext.sdkVersion
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ sourceSets {
+ main {
+ manifest.srcFile 'AndroidManifest.xml'
+ java.srcDirs = ['src']
+ }
+
+ androidTest {
+ java.srcDirs = ['tests/unit/src']
+ }
+ }
+}
+
+dependencies {
+ implementation files('ukey2.jar')
+
+ implementation libs.guava
+ implementation libs.androidx.annotation
+
+ androidTestImplementation libs.bundles.androidx.testing
+ androidTestImplementation libs.truth
+}