Migration of RemoteIntent from WSL to AndroidX
Migrating class RemoteIntent from Wearable Support
Library to AndroidX wear:wear-remote-interactions.
Bug: 174757053
Test: Unit tests in androidx.wear.remote.interactions.RemoteIntentHelperTest and manually running sample app
Relnote: "RemoteIntent class from
Wearable Support Library to AndroidX."
Change-Id: I1d7e07052da1e0f942e61b044de08f97b8c51279
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 5887db6..cfc5420 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -130,6 +130,7 @@
playCore = { module = "com.google.android.play:core", version = "1.9.1" }
playServicesBase = { module = "com.google.android.gms:play-services-base", version = "17.0.0" }
playServicesBasement = { module = "com.google.android.gms:play-services-basement", version = "17.0.0" }
+playServicesWearable = { module = "com.google.android.gms:play-services-wearable", version = "17.1.0" }
protobuf = { module = "com.google.protobuf:protobuf-java", version = "3.4.0" }
protobufCompiler = { module = "com.google.protobuf:protoc", version = "3.10.0" }
protobufGradlePlugin = { module = "com.google.protobuf:protobuf-gradle-plugin", version = "0.8.16" }
diff --git a/wear/wear-remote-interactions/api/current.txt b/wear/wear-remote-interactions/api/current.txt
index 367672c..7c87d3b 100644
--- a/wear/wear-remote-interactions/api/current.txt
+++ b/wear/wear-remote-interactions/api/current.txt
@@ -13,6 +13,32 @@
method public int getPlayStoreAvailabilityOnPhone(android.content.Context context);
}
+ public final class RemoteIntentHelper {
+ ctor public RemoteIntentHelper(android.content.Context context, optional java.util.concurrent.Executor executor);
+ method public static android.content.IntentFilter createActionRemoteIntentFilter();
+ method public static android.content.Intent? getRemoteIntentExtraIntent(android.content.Intent intent);
+ method public static String? getRemoteIntentNodeId(android.content.Intent intent);
+ method public static boolean hasActionRemoteIntent(android.content.IntentFilter intentFilter);
+ method public static boolean isActionRemoteIntent(android.content.Intent intent);
+ method public com.google.common.util.concurrent.ListenableFuture<java.lang.Void> startRemoteActivity(android.content.Intent intent, optional String? nodeId);
+ method public com.google.common.util.concurrent.ListenableFuture<java.lang.Void> startRemoteActivity(android.content.Intent intent);
+ field public static final androidx.wear.remote.interactions.RemoteIntentHelper.Companion Companion;
+ field public static final int RESULT_FAILED = 1; // 0x1
+ field public static final int RESULT_OK = 0; // 0x0
+ }
+
+ public static final class RemoteIntentHelper.Companion {
+ method public android.content.IntentFilter createActionRemoteIntentFilter();
+ method public android.content.Intent? getRemoteIntentExtraIntent(android.content.Intent intent);
+ method public String? getRemoteIntentNodeId(android.content.Intent intent);
+ method public boolean hasActionRemoteIntent(android.content.IntentFilter intentFilter);
+ method public boolean isActionRemoteIntent(android.content.Intent intent);
+ }
+
+ public static final class RemoteIntentHelper.RemoteIntentException extends java.lang.Exception {
+ ctor public RemoteIntentHelper.RemoteIntentException(String message);
+ }
+
public final class WatchFaceConfigIntentHelper {
method public static String? getPeerIdExtra(android.content.Intent watchFaceIntent);
method public static android.content.ComponentName? getWatchFaceComponentExtra(android.content.Intent watchFaceIntent);
diff --git a/wear/wear-remote-interactions/api/public_plus_experimental_current.txt b/wear/wear-remote-interactions/api/public_plus_experimental_current.txt
index 367672c..7c87d3b 100644
--- a/wear/wear-remote-interactions/api/public_plus_experimental_current.txt
+++ b/wear/wear-remote-interactions/api/public_plus_experimental_current.txt
@@ -13,6 +13,32 @@
method public int getPlayStoreAvailabilityOnPhone(android.content.Context context);
}
+ public final class RemoteIntentHelper {
+ ctor public RemoteIntentHelper(android.content.Context context, optional java.util.concurrent.Executor executor);
+ method public static android.content.IntentFilter createActionRemoteIntentFilter();
+ method public static android.content.Intent? getRemoteIntentExtraIntent(android.content.Intent intent);
+ method public static String? getRemoteIntentNodeId(android.content.Intent intent);
+ method public static boolean hasActionRemoteIntent(android.content.IntentFilter intentFilter);
+ method public static boolean isActionRemoteIntent(android.content.Intent intent);
+ method public com.google.common.util.concurrent.ListenableFuture<java.lang.Void> startRemoteActivity(android.content.Intent intent, optional String? nodeId);
+ method public com.google.common.util.concurrent.ListenableFuture<java.lang.Void> startRemoteActivity(android.content.Intent intent);
+ field public static final androidx.wear.remote.interactions.RemoteIntentHelper.Companion Companion;
+ field public static final int RESULT_FAILED = 1; // 0x1
+ field public static final int RESULT_OK = 0; // 0x0
+ }
+
+ public static final class RemoteIntentHelper.Companion {
+ method public android.content.IntentFilter createActionRemoteIntentFilter();
+ method public android.content.Intent? getRemoteIntentExtraIntent(android.content.Intent intent);
+ method public String? getRemoteIntentNodeId(android.content.Intent intent);
+ method public boolean hasActionRemoteIntent(android.content.IntentFilter intentFilter);
+ method public boolean isActionRemoteIntent(android.content.Intent intent);
+ }
+
+ public static final class RemoteIntentHelper.RemoteIntentException extends java.lang.Exception {
+ ctor public RemoteIntentHelper.RemoteIntentException(String message);
+ }
+
public final class WatchFaceConfigIntentHelper {
method public static String? getPeerIdExtra(android.content.Intent watchFaceIntent);
method public static android.content.ComponentName? getWatchFaceComponentExtra(android.content.Intent watchFaceIntent);
diff --git a/wear/wear-remote-interactions/api/restricted_current.txt b/wear/wear-remote-interactions/api/restricted_current.txt
index 367672c..7c87d3b 100644
--- a/wear/wear-remote-interactions/api/restricted_current.txt
+++ b/wear/wear-remote-interactions/api/restricted_current.txt
@@ -13,6 +13,32 @@
method public int getPlayStoreAvailabilityOnPhone(android.content.Context context);
}
+ public final class RemoteIntentHelper {
+ ctor public RemoteIntentHelper(android.content.Context context, optional java.util.concurrent.Executor executor);
+ method public static android.content.IntentFilter createActionRemoteIntentFilter();
+ method public static android.content.Intent? getRemoteIntentExtraIntent(android.content.Intent intent);
+ method public static String? getRemoteIntentNodeId(android.content.Intent intent);
+ method public static boolean hasActionRemoteIntent(android.content.IntentFilter intentFilter);
+ method public static boolean isActionRemoteIntent(android.content.Intent intent);
+ method public com.google.common.util.concurrent.ListenableFuture<java.lang.Void> startRemoteActivity(android.content.Intent intent, optional String? nodeId);
+ method public com.google.common.util.concurrent.ListenableFuture<java.lang.Void> startRemoteActivity(android.content.Intent intent);
+ field public static final androidx.wear.remote.interactions.RemoteIntentHelper.Companion Companion;
+ field public static final int RESULT_FAILED = 1; // 0x1
+ field public static final int RESULT_OK = 0; // 0x0
+ }
+
+ public static final class RemoteIntentHelper.Companion {
+ method public android.content.IntentFilter createActionRemoteIntentFilter();
+ method public android.content.Intent? getRemoteIntentExtraIntent(android.content.Intent intent);
+ method public String? getRemoteIntentNodeId(android.content.Intent intent);
+ method public boolean hasActionRemoteIntent(android.content.IntentFilter intentFilter);
+ method public boolean isActionRemoteIntent(android.content.Intent intent);
+ }
+
+ public static final class RemoteIntentHelper.RemoteIntentException extends java.lang.Exception {
+ ctor public RemoteIntentHelper.RemoteIntentException(String message);
+ }
+
public final class WatchFaceConfigIntentHelper {
method public static String? getPeerIdExtra(android.content.Intent watchFaceIntent);
method public static android.content.ComponentName? getWatchFaceComponentExtra(android.content.Intent watchFaceIntent);
diff --git a/wear/wear-remote-interactions/build.gradle b/wear/wear-remote-interactions/build.gradle
index 3eb34f4..e8ac65a 100644
--- a/wear/wear-remote-interactions/build.gradle
+++ b/wear/wear-remote-interactions/build.gradle
@@ -27,12 +27,18 @@
dependencies {
api("androidx.annotation:annotation:1.1.0")
api(libs.kotlinStdlib)
+ api(libs.guavaListenableFuture)
+ api(libs.kotlinCoroutinesGuava)
+ implementation("androidx.concurrent:concurrent-futures:1.0.0")
+
androidTestImplementation(libs.testExtJunit)
androidTestImplementation(libs.testCore)
androidTestImplementation(libs.testRunner)
androidTestImplementation(libs.testRules)
androidTestImplementation 'junit:junit:4.12'
- testImplementation(libs.testExtJunit)
+
+ // Needed for Assert.assertThrows
+ testImplementation("junit:junit:4.13")
testImplementation(libs.testCore)
testImplementation(libs.testRules)
testImplementation(libs.testRunner)
@@ -40,7 +46,9 @@
testImplementation(libs.mockitoCore)
testImplementation(libs.mockitoKotlin)
+ implementation("androidx.annotation:annotation:1.2.0")
implementation(libs.playServicesBasement)
+ implementation(libs.playServicesWearable, { exclude group: "androidx.core"})
}
android {
diff --git a/wear/wear-remote-interactions/src/main/java/androidx/wear/remote/interactions/PlayStoreAvailability.kt b/wear/wear-remote-interactions/src/main/java/androidx/wear/remote/interactions/PlayStoreAvailability.kt
index 9fb132f..8b05980 100644
--- a/wear/wear-remote-interactions/src/main/java/androidx/wear/remote/interactions/PlayStoreAvailability.kt
+++ b/wear/wear-remote-interactions/src/main/java/androidx/wear/remote/interactions/PlayStoreAvailability.kt
@@ -21,6 +21,7 @@
import android.provider.Settings
import androidx.annotation.IntDef
import androidx.annotation.RequiresApi
+import androidx.wear.remote.interactions.RemoteInteractionsUtil.isCurrentDeviceAWatch
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailabilityLight
@@ -58,8 +59,6 @@
// versions from R.
private const val SETTINGS_PLAY_STORE_AVAILABILITY = "phone_play_store_availability"
- internal const val SYSTEM_FEATURE_WATCH: String = "android.hardware.type.watch"
-
/**
* Returns whether the Play Store is available on the Phone. If
* [PLAY_STORE_ERROR_UNKNOWN] is returned, the caller should try again later. This
@@ -71,11 +70,7 @@
@JvmStatic
@PlayStoreStatus
public fun getPlayStoreAvailabilityOnPhone(context: Context): Int {
- val isCurrentDeviceAWatch = context.packageManager.hasSystemFeature(
- SYSTEM_FEATURE_WATCH
- )
-
- if (!isCurrentDeviceAWatch) {
+ if (!isCurrentDeviceAWatch(context)) {
val isPlayServiceAvailable =
GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(context)
return if (isPlayServiceAvailable == ConnectionResult.SUCCESS) PLAY_STORE_AVAILABLE
diff --git a/wear/wear-remote-interactions/src/main/java/androidx/wear/remote/interactions/RemoteIntentHelper.kt b/wear/wear-remote-interactions/src/main/java/androidx/wear/remote/interactions/RemoteIntentHelper.kt
new file mode 100644
index 0000000..f9b5195
--- /dev/null
+++ b/wear/wear-remote-interactions/src/main/java/androidx/wear/remote/interactions/RemoteIntentHelper.kt
@@ -0,0 +1,323 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.wear.remote.interactions
+
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Bundle
+import android.os.Parcel
+import android.os.ResultReceiver
+import androidx.annotation.IntDef
+import androidx.annotation.RestrictTo
+import androidx.annotation.VisibleForTesting
+import androidx.concurrent.futures.CallbackToFutureAdapter
+import androidx.wear.remote.interactions.RemoteInteractionsUtil.isCurrentDeviceAWatch
+import com.google.android.gms.wearable.NodeClient
+import com.google.android.gms.wearable.Wearable
+import com.google.common.util.concurrent.ListenableFuture
+import java.util.concurrent.Executor
+import java.util.concurrent.Executors
+
+/**
+ * Support for opening android intents on other devices.
+ *
+ *
+ * The following example opens play store for the given app on another device:
+ *
+ * ```
+ * RemoteIntentHelper.startRemoteActivity(
+ * context, nodeId,
+ * new Intent(Intent.ACTION_VIEW).setData(
+ * Uri.parse("http://play.google.com/store/apps/details?id=com.example.myapp")
+ * ),
+ * null
+ * )
+ * ```
+ *
+ * @param context The [Context] of the application for sending the intent.
+ * @param executor [Executor] used for getting data to be passed in remote intent. If not
+ * specified, default will be `Executors.newSingleThreadExecutor()`.
+ */
+public class RemoteIntentHelper(
+ private val context: Context,
+ private val executor: Executor = Executors.newSingleThreadExecutor()
+) {
+ public companion object {
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal const val ACTION_REMOTE_INTENT: String =
+ "com.google.android.wearable.intent.action.REMOTE_INTENT"
+
+ private const val EXTRA_INTENT: String = "com.google.android.wearable.intent.extra.INTENT"
+
+ private const val EXTRA_NODE_ID: String = "com.google.android.wearable.intent.extra.NODE_ID"
+
+ private const val EXTRA_RESULT_RECEIVER: String =
+ "com.google.android.wearable.intent.extra.RESULT_RECEIVER"
+
+ /**
+ * Result code passed to [ResultReceiver.send] when a remote intent was sent successfully.
+ */
+ public const val RESULT_OK: Int = 0
+
+ /** Result code passed to [ResultReceiver.send] when a remote intent failed to send. */
+ public const val RESULT_FAILED: Int = 1
+
+ internal const val DEFAULT_PACKAGE = "com.google.android.wearable.app"
+
+ /**
+ * Creates [android.content.IntentFilter] with action specifying remote intent.
+ */
+ @JvmStatic
+ public fun createActionRemoteIntentFilter(): IntentFilter =
+ IntentFilter(ACTION_REMOTE_INTENT)
+
+ /**
+ * Checks whether action of the given [android.content.Intent] specifies the remote intent.
+ */
+ @JvmStatic
+ public fun isActionRemoteIntent(intent: Intent): Boolean =
+ ACTION_REMOTE_INTENT == intent.action
+
+ /**
+ * Checks whether the given [android.content.IntentFilter] has action that specifies the
+ * remote intent.
+ */
+ @JvmStatic
+ public fun hasActionRemoteIntent(intentFilter: IntentFilter): Boolean =
+ intentFilter.hasAction(ACTION_REMOTE_INTENT)
+
+ /**
+ * Returns the [android.content.Intent] extra specifying remote intent.
+ *
+ * @param intent The intent holding configuration.
+ * @return The remote intent, or null if none was set.
+ */
+ @JvmStatic
+ public fun getRemoteIntentExtraIntent(intent: Intent): Intent? =
+ intent.getParcelableExtra(EXTRA_INTENT)
+
+ /**
+ * Returns the [String] extra specifying node ID of remote intent.
+ *
+ * @param intent The intent holding configuration.
+ * @return The node id, or null if none was set.
+ */
+ @JvmStatic
+ public fun getRemoteIntentNodeId(intent: Intent): String? =
+ intent.getStringExtra(EXTRA_NODE_ID)
+
+ /**
+ * Returns the [android.os.ResultReceiver] extra of remote intent.
+ *
+ * @param intent The intent holding configuration.
+ * @return The result receiver, or null if none was set.
+ */
+ @JvmStatic
+ internal fun getRemoteIntentResultReceiver(intent: Intent): ResultReceiver? =
+ intent.getParcelableExtra(EXTRA_RESULT_RECEIVER)
+
+ /** Re-package a result receiver as a vanilla version for cross-process sending */
+ @JvmStatic
+ internal fun getResultReceiverForSending(receiver: ResultReceiver): ResultReceiver {
+ val parcel = Parcel.obtain()
+ receiver.writeToParcel(parcel, 0)
+ parcel.setDataPosition(0)
+ val receiverForSending = ResultReceiver.CREATOR.createFromParcel(parcel)
+ parcel.recycle()
+ return receiverForSending
+ }
+ }
+
+ /**
+ * Used for testing only, so we can set mock NodeClient.
+ */
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal var nodeClient: NodeClient = Wearable.getNodeClient(context)
+
+ /**
+ * Start an activity on another device. This api currently supports sending intents with
+ * action set to [android.content.Intent.ACTION_VIEW], a data uri populated using
+ * [android.content.Intent.setData], and with the category
+ * [android.content.Intent.CATEGORY_BROWSABLE] present. If the current device is a watch,
+ * the activity will start on the companion phone device. Otherwise, the activity will
+ * start on all connected watch devices.
+ *
+ * @param intent The intent to open on the remote device. Action must be set to
+ * [android.content.Intent.ACTION_VIEW], a data uri must be populated
+ * using [android.content.Intent.setData], and the category
+ * [android.content.Intent.CATEGORY_BROWSABLE] must be present.
+ * @param nodeId Wear OS node id for the device where the activity should be
+ * started. If null, and the current device is a watch, the
+ * activity will start on the companion phone device. Otherwise,
+ * the activity will start on all connected watch devices.
+ * @return The [ListenableFuture] which resolves if starting activity was successful or
+ * throws [Exception] if any errors happens. If there's a problem with starting remote
+ * activity, [RemoteIntentException] will be thrown.
+ */
+ @JvmOverloads
+ public fun startRemoteActivity(
+ intent: Intent,
+ nodeId: String? = null,
+ ): ListenableFuture<Void> {
+ return CallbackToFutureAdapter.getFuture {
+ require(Intent.ACTION_VIEW == intent.action) {
+ "Only ${Intent.ACTION_VIEW} action is currently supported for starting a" +
+ " remote activity"
+ }
+ requireNotNull(intent.data) { "Data Uri is required when starting a remote activity" }
+ require(intent.categories?.contains(Intent.CATEGORY_BROWSABLE) == true) {
+ "The category ${Intent.CATEGORY_BROWSABLE} must be present on the intent"
+ }
+
+ startCreatingIntentForRemoteActivity(
+ intent, nodeId, it, nodeClient,
+ object : Callback {
+ override fun intentCreated(intent: Intent) {
+ context.sendBroadcast(intent)
+ }
+
+ override fun onFailure(exception: Exception) {
+ it.setException(exception)
+ }
+ }
+ )
+ }
+ }
+
+ private fun startCreatingIntentForRemoteActivity(
+ intent: Intent,
+ nodeId: String?,
+ completer: CallbackToFutureAdapter.Completer<Void>,
+ nodeClient: NodeClient,
+ callback: Callback
+ ) {
+ if (isCurrentDeviceAWatch(context)) {
+ callback.intentCreated(
+ createIntent(
+ intent,
+ RemoteIntentResultReceiver(completer, numNodes = 1),
+ nodeId,
+ DEFAULT_PACKAGE
+ )
+ )
+ return
+ }
+
+ if (nodeId != null) {
+ nodeClient.getCompanionPackageForNode(nodeId)
+ .addOnCompleteListener(
+ executor,
+ { taskPackageName ->
+ val packageName = taskPackageName.result ?: DEFAULT_PACKAGE
+ callback.intentCreated(
+ createIntent(
+ intent,
+ RemoteIntentResultReceiver(completer, numNodes = 1),
+ nodeId,
+ packageName
+ )
+ )
+ }
+ ).addOnFailureListener(executor, { callback.onFailure(it) })
+ return
+ }
+
+ nodeClient.connectedNodes.addOnCompleteListener(
+ executor,
+ { taskConnectedNodes ->
+ val connectedNodes = taskConnectedNodes.result
+ val resultReceiver = RemoteIntentResultReceiver(completer, connectedNodes.size)
+ for (node in connectedNodes) {
+ nodeClient.getCompanionPackageForNode(node.id).addOnCompleteListener(
+ executor,
+ { taskPackageName ->
+ val packageName = taskPackageName.result ?: DEFAULT_PACKAGE
+ callback.intentCreated(
+ createIntent(intent, resultReceiver, node.id, packageName)
+ )
+ }
+ ).addOnFailureListener(executor, { callback.onFailure(it) })
+ }
+ }
+ ).addOnFailureListener(executor, { callback.onFailure(it) })
+ }
+
+ /**
+ * Creates [android.content.Intent] with action specifying remote intent. If any of
+ * additional extras are specified, they will be added to it. If specified, [ResultReceiver]
+ * will be re-packed to be parcelable. If specified, packageName will be set.
+ */
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal fun createIntent(
+ extraIntent: Intent?,
+ resultReceiver: ResultReceiver?,
+ nodeId: String?,
+ packageName: String? = null
+ ): Intent {
+ val remoteIntent = Intent(ACTION_REMOTE_INTENT)
+ // Put the extra when non-null value is passed in
+ extraIntent?.let { remoteIntent.putExtra(EXTRA_INTENT, extraIntent) }
+ resultReceiver?.let {
+ remoteIntent.putExtra(
+ EXTRA_RESULT_RECEIVER,
+ getResultReceiverForSending(resultReceiver)
+ )
+ }
+ nodeId?.let { remoteIntent.putExtra(EXTRA_NODE_ID, nodeId) }
+ packageName?.let { remoteIntent.setPackage(packageName) }
+ return remoteIntent
+ }
+
+ /**
+ * Result code passed to [ResultReceiver.send] for the status of remote intent.
+ *
+ * @hide
+ */
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ @IntDef(RESULT_OK, RESULT_FAILED)
+ @Retention(AnnotationRetention.SOURCE)
+ public annotation class SendResult
+
+ public class RemoteIntentException(message: String) : Exception(message)
+
+ private interface Callback {
+ fun intentCreated(intent: Intent)
+ fun onFailure(exception: Exception)
+ }
+
+ private class RemoteIntentResultReceiver(
+ private val completer: CallbackToFutureAdapter.Completer<Void>,
+ private var numNodes: Int
+ ) : ResultReceiver(null) {
+ private var numFailedResults: Int = 0
+
+ override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
+ numNodes--
+ if (resultCode != RESULT_OK) numFailedResults++
+ // Don't send result if not all nodes have finished.
+ if (numNodes > 0) return
+
+ if (numFailedResults == 0) {
+ completer.set(null)
+ } else {
+ completer.setException(
+ RemoteIntentException("There was an error while starting remote activity.")
+ )
+ }
+ }
+ }
+}
diff --git a/wear/wear-remote-interactions/src/main/java/androidx/wear/remote/interactions/RemoteInteractionsUtil.kt b/wear/wear-remote-interactions/src/main/java/androidx/wear/remote/interactions/RemoteInteractionsUtil.kt
new file mode 100644
index 0000000..d79b16d
--- /dev/null
+++ b/wear/wear-remote-interactions/src/main/java/androidx/wear/remote/interactions/RemoteInteractionsUtil.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 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 androidx.wear.remote.interactions
+
+import android.content.Context
+import android.os.Build
+import androidx.annotation.DoNotInline
+import androidx.annotation.RequiresApi
+
+internal object RemoteInteractionsUtil {
+ internal const val SYSTEM_FEATURE_WATCH: String = "android.hardware.type.watch"
+
+ internal fun isCurrentDeviceAWatch(context: Context) =
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Api24Impl.hasSystemFeature(context)
+
+ @RequiresApi(Build.VERSION_CODES.N)
+ private object Api24Impl {
+ @JvmStatic
+ @DoNotInline
+ fun hasSystemFeature(context: Context) =
+ context.packageManager.hasSystemFeature(SYSTEM_FEATURE_WATCH)
+ }
+}
diff --git a/wear/wear-remote-interactions/src/test/java/androidx/wear/remote/interactions/PlayStoreAvailabilityTest.kt b/wear/wear-remote-interactions/src/test/java/androidx/wear/remote/interactions/PlayStoreAvailabilityTest.kt
index 2560004..36e7580 100644
--- a/wear/wear-remote-interactions/src/test/java/androidx/wear/remote/interactions/PlayStoreAvailabilityTest.kt
+++ b/wear/wear-remote-interactions/src/test/java/androidx/wear/remote/interactions/PlayStoreAvailabilityTest.kt
@@ -54,7 +54,7 @@
val context: Context = ApplicationProvider.getApplicationContext()
contentResolver = context.contentResolver
shadowPackageManager = Shadows.shadowOf(context.packageManager)
- shadowPackageManager?.setSystemFeature(PlayStoreAvailability.SYSTEM_FEATURE_WATCH, true)
+ shadowPackageManager?.setSystemFeature(RemoteInteractionsUtil.SYSTEM_FEATURE_WATCH, true)
}
@Test
diff --git a/wear/wear-remote-interactions/src/test/java/androidx/wear/remote/interactions/RemoteIntentHelperTest.kt b/wear/wear-remote-interactions/src/test/java/androidx/wear/remote/interactions/RemoteIntentHelperTest.kt
new file mode 100644
index 0000000..e4dca7b
--- /dev/null
+++ b/wear/wear-remote-interactions/src/test/java/androidx/wear/remote/interactions/RemoteIntentHelperTest.kt
@@ -0,0 +1,367 @@
+/*
+ * Copyright 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 androidx.wear.remote.interactions
+
+import android.app.Application
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.Uri
+import android.os.Looper
+import android.os.ResultReceiver
+import androidx.test.core.app.ApplicationProvider
+import androidx.wear.remote.interactions.RemoteIntentHelper.Companion.ACTION_REMOTE_INTENT
+import androidx.wear.remote.interactions.RemoteIntentHelper.Companion.DEFAULT_PACKAGE
+import androidx.wear.remote.interactions.RemoteIntentHelper.Companion.RESULT_FAILED
+import androidx.wear.remote.interactions.RemoteIntentHelper.Companion.RESULT_OK
+import androidx.wear.remote.interactions.RemoteIntentHelper.Companion.createActionRemoteIntentFilter
+import androidx.wear.remote.interactions.RemoteIntentHelper.Companion.getRemoteIntentExtraIntent
+import androidx.wear.remote.interactions.RemoteIntentHelper.Companion.getRemoteIntentNodeId
+import androidx.wear.remote.interactions.RemoteIntentHelper.Companion.getRemoteIntentResultReceiver
+import androidx.wear.remote.interactions.RemoteIntentHelper.Companion.hasActionRemoteIntent
+import androidx.wear.remote.interactions.RemoteIntentHelper.Companion.isActionRemoteIntent
+import com.google.android.gms.tasks.Tasks
+import com.google.android.gms.wearable.Node
+import com.google.android.gms.wearable.NodeClient
+import com.nhaarman.mockitokotlin2.mock
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.Implements
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.Executor
+
+@RunWith(WearRemoteInteractionsTestRunner::class)
+@Config(shadows = [RemoteIntentHelperTest.ActualResultReceiver::class])
+class RemoteIntentHelperTest {
+ @Implements(ResultReceiver::class)
+ class ActualResultReceiver {
+ // Robolectric stubs out ResultReceiver. The stubbed version just calls onReceiveResult
+ // from send(). Problem is, the ResultReceiver in the BroadcastReceiver below has already
+ // been parceled (and back), so it doesn't have an implementation of onReceiveResult; it
+ // instead wants to call the original version over the embedded IBinder.
+ //
+ // To fix, this class replaces that shadow with a version that just falls back to the
+ // proper Android implementation.
+ }
+
+ class TestBroadcastReceiver(private val result: Int) : BroadcastReceiver() {
+ companion object {
+ // If this is set, result receiver will send [RESULT_OK] and {RESULT_FAILED]
+ // alternatively.
+ const val DIFFERENT_RESULT = -1
+ }
+
+ private var altResult = RESULT_OK
+
+ override fun onReceive(context: Context?, intent: Intent?) {
+ val resultReceiver = intent?.let {
+ getRemoteIntentResultReceiver(it)
+ }
+ if (result == DIFFERENT_RESULT) {
+ altResult = (altResult + 1) % 2
+ resultReceiver?.send(result, null)
+ } else {
+ resultReceiver?.send(result, null)
+ }
+ }
+ }
+
+ private val testPackageName = "package.name"
+ private val testPackageName2 = "package.name2"
+ private val testNodeId = "Test Node ID"
+ private val testNodeId2 = "Test Node ID2"
+ private val testUri = Uri.parse("market://details?id=com.google.android.wearable.app")
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private val testExtraIntent = Intent(Intent.ACTION_VIEW)
+ .addCategory(Intent.CATEGORY_BROWSABLE)
+ .setData(testUri)
+ private lateinit var remoteIntentHelper: RemoteIntentHelper
+
+ @Mock private var mockNodeClient: NodeClient = mock()
+ @Mock private val mockTestNode: Node = mock()
+ @Mock private val mockTestNode2: Node = mock()
+
+ @Before
+ fun setUp() {
+ remoteIntentHelper = RemoteIntentHelper(context, SyncExecutor())
+ remoteIntentHelper.nodeClient = mockNodeClient
+ }
+
+ private fun setSystemFeatureWatch(isWatch: Boolean) {
+ val shadowPackageManager = shadowOf(context.packageManager)
+ shadowPackageManager!!.setSystemFeature(
+ RemoteInteractionsUtil.SYSTEM_FEATURE_WATCH, isWatch
+ )
+ }
+
+ private fun nodeClientReturnFakePackageName(nodeId: String, packageName: String?) {
+ Mockito.`when`(mockNodeClient.getCompanionPackageForNode(nodeId))
+ .thenReturn(Tasks.forResult(packageName))
+ }
+
+ private fun nodeClientReturnFakeConnectedNodes() {
+ Mockito.`when`(mockTestNode.id).thenReturn(testNodeId)
+ Mockito.`when`(mockTestNode2.id).thenReturn(testNodeId2)
+ Mockito.`when`(mockNodeClient.connectedNodes)
+ .thenReturn(Tasks.forResult(listOf(mockTestNode, mockTestNode2)))
+ }
+
+ @Test
+ fun testStartRemoteActivity_notActionViewIntent() {
+ assertThrows(
+ ExecutionException::class.java
+ ) { remoteIntentHelper.startRemoteActivity(Intent(), testNodeId).get() }
+ }
+
+ @Test
+ fun testStartRemoteActivity_dataNull() {
+ assertThrows(
+ ExecutionException::class.java
+ ) { remoteIntentHelper.startRemoteActivity(Intent(Intent.ACTION_VIEW), testNodeId).get() }
+ }
+
+ @Test
+ fun testStartRemoteActivity_notCategoryBrowsable() {
+ assertThrows(
+ ExecutionException::class.java
+ ) {
+ remoteIntentHelper.startRemoteActivity(
+ Intent(Intent.ACTION_VIEW).setData(Uri.EMPTY), testNodeId
+ ).get()
+ }
+ }
+
+ @Test
+ fun testStartRemoteActivity_watch() {
+ setSystemFeatureWatch(true)
+ val receiver = TestBroadcastReceiver(RESULT_OK)
+ context.registerReceiver(receiver, IntentFilter(ACTION_REMOTE_INTENT))
+
+ try {
+ val future = remoteIntentHelper.startRemoteActivity(testExtraIntent, testNodeId)
+ shadowOf(Looper.getMainLooper()).idle()
+ assertTrue(future.isDone)
+ future.get()
+ } catch (e: Exception) {
+ fail("startRemoteActivity.get() shouldn't throw exception in this case.")
+ } finally {
+ context.unregisterReceiver(receiver)
+ }
+
+ val broadcastIntents =
+ shadowOf(ApplicationProvider.getApplicationContext() as Application)
+ .broadcastIntents
+ assertEquals(1, broadcastIntents.size)
+ val intent = broadcastIntents[0]
+ assertEquals(testExtraIntent, getRemoteIntentExtraIntent(intent))
+ assertEquals(testNodeId, getRemoteIntentNodeId(intent))
+ assertEquals(DEFAULT_PACKAGE, intent.`package`)
+ }
+
+ @Test
+ fun testStartRemoteActivity_watchFailed() {
+ setSystemFeatureWatch(true)
+ val receiver = TestBroadcastReceiver(RESULT_FAILED)
+ context.registerReceiver(receiver, IntentFilter(ACTION_REMOTE_INTENT))
+
+ try {
+ val future = remoteIntentHelper.startRemoteActivity(testExtraIntent, testNodeId)
+ shadowOf(Looper.getMainLooper()).idle()
+ assertTrue(future.isDone)
+ assertThrows(ExecutionException::class.java) { future.get() }
+ } finally {
+ context.unregisterReceiver(receiver)
+ }
+ }
+
+ @Test
+ fun testStartRemoteActivity_phoneWithPackageName() {
+ setSystemFeatureWatch(false)
+ nodeClientReturnFakePackageName(testNodeId, testPackageName)
+ val receiver = TestBroadcastReceiver(RESULT_OK)
+ context.registerReceiver(receiver, IntentFilter(ACTION_REMOTE_INTENT))
+
+ try {
+ val future = remoteIntentHelper.startRemoteActivity(testExtraIntent, testNodeId)
+ shadowOf(Looper.getMainLooper()).idle()
+ assertTrue(future.isDone)
+ future.get()
+ } catch (e: Exception) {
+ fail("startRemoteActivity.get() shouldn't throw exception in this case.")
+ } finally {
+ context.unregisterReceiver(receiver)
+ }
+
+ val broadcastIntents =
+ shadowOf(ApplicationProvider.getApplicationContext() as Application)
+ .broadcastIntents
+ assertEquals(1, broadcastIntents.size)
+ assertRemoteIntentEqual(testExtraIntent, testNodeId, testPackageName, broadcastIntents[0])
+ }
+
+ @Test
+ fun testStartRemoteActivity_phoneWithoutPackageName() {
+ setSystemFeatureWatch(false)
+ nodeClientReturnFakePackageName(testNodeId, null)
+ val receiver = TestBroadcastReceiver(RESULT_OK)
+ context.registerReceiver(receiver, IntentFilter(ACTION_REMOTE_INTENT))
+
+ try {
+ val future = remoteIntentHelper.startRemoteActivity(testExtraIntent, testNodeId)
+ shadowOf(Looper.getMainLooper()).idle()
+ assertTrue(future.isDone)
+ future.get()
+ } catch (e: Exception) {
+ fail("startRemoteActivity.get() shouldn't throw exception in this case.")
+ } finally {
+ context.unregisterReceiver(receiver)
+ }
+
+ val broadcastIntents =
+ shadowOf(ApplicationProvider.getApplicationContext() as Application)
+ .broadcastIntents
+ assertEquals(1, broadcastIntents.size)
+ val intent = broadcastIntents[0]
+ assertEquals(testExtraIntent, getRemoteIntentExtraIntent(intent))
+ assertEquals(testNodeId, getRemoteIntentNodeId(intent))
+ assertEquals(DEFAULT_PACKAGE, intent.`package`)
+ }
+
+ @Test
+ fun testStartRemoteActivity_phoneWithoutNodeId_allOk() {
+ setSystemFeatureWatch(false)
+ nodeClientReturnFakeConnectedNodes()
+ nodeClientReturnFakePackageName(testNodeId, testPackageName)
+ nodeClientReturnFakePackageName(testNodeId2, testPackageName2)
+ val receiver = TestBroadcastReceiver(RESULT_OK)
+ context.registerReceiver(receiver, IntentFilter(ACTION_REMOTE_INTENT))
+
+ try {
+ val future = remoteIntentHelper.startRemoteActivity(testExtraIntent, nodeId = null)
+ shadowOf(Looper.getMainLooper()).idle()
+ assertTrue(future.isDone)
+ future.get()
+ } catch (e: Exception) {
+ fail("startRemoteActivity.get() shouldn't throw exception in this case.")
+ } finally {
+ context.unregisterReceiver(receiver)
+ }
+
+ shadowOf(Looper.getMainLooper()).idle()
+ val broadcastIntents =
+ shadowOf(ApplicationProvider.getApplicationContext() as Application)
+ .broadcastIntents
+ assertEquals(2, broadcastIntents.size)
+
+ assertRemoteIntentEqual(testExtraIntent, testNodeId, testPackageName, broadcastIntents[0])
+ assertRemoteIntentEqual(testExtraIntent, testNodeId2, testPackageName2, broadcastIntents[1])
+ }
+
+ @Test
+ fun testStartRemoteActivity_phoneWithoutNodeId_oneOkOneFail() {
+ setSystemFeatureWatch(false)
+ nodeClientReturnFakeConnectedNodes()
+ nodeClientReturnFakePackageName(testNodeId, testPackageName)
+ nodeClientReturnFakePackageName(testNodeId2, testPackageName2)
+ val receiver = TestBroadcastReceiver(TestBroadcastReceiver.DIFFERENT_RESULT)
+ context.registerReceiver(receiver, IntentFilter(ACTION_REMOTE_INTENT))
+
+ assertThrows(ExecutionException::class.java) {
+ val future = remoteIntentHelper.startRemoteActivity(testExtraIntent, nodeId = null)
+ shadowOf(Looper.getMainLooper()).idle()
+ assertTrue(future.isDone)
+ future.get()
+ }
+ context.unregisterReceiver(receiver)
+
+ shadowOf(Looper.getMainLooper()).idle()
+ val broadcastIntents =
+ shadowOf(ApplicationProvider.getApplicationContext() as Application)
+ .broadcastIntents
+ assertEquals(2, broadcastIntents.size)
+ assertRemoteIntentEqual(testExtraIntent, testNodeId, testPackageName, broadcastIntents[0])
+ assertRemoteIntentEqual(testExtraIntent, testNodeId2, testPackageName2, broadcastIntents[1])
+ }
+
+ @Test
+ fun testStartRemoteActivity_phoneWithoutNodeId_allFail() {
+ setSystemFeatureWatch(false)
+ nodeClientReturnFakeConnectedNodes()
+ nodeClientReturnFakePackageName(testNodeId, testPackageName)
+ nodeClientReturnFakePackageName(testNodeId2, testPackageName2)
+ val receiver = TestBroadcastReceiver(RESULT_FAILED)
+ context.registerReceiver(receiver, IntentFilter(ACTION_REMOTE_INTENT))
+
+ assertThrows(ExecutionException::class.java) {
+ val future = remoteIntentHelper.startRemoteActivity(testExtraIntent, nodeId = null)
+ shadowOf(Looper.getMainLooper()).idle()
+ assertTrue(future.isDone)
+ future.get()
+ }
+ context.unregisterReceiver(receiver)
+
+ shadowOf(Looper.getMainLooper()).idle()
+ val broadcastIntents =
+ shadowOf(ApplicationProvider.getApplicationContext() as Application)
+ .broadcastIntents
+ assertEquals(2, broadcastIntents.size)
+ assertRemoteIntentEqual(testExtraIntent, testNodeId, testPackageName, broadcastIntents[0])
+ assertRemoteIntentEqual(testExtraIntent, testNodeId2, testPackageName2, broadcastIntents[1])
+ }
+
+ private fun assertRemoteIntentEqual(
+ expectedExtraIntent: Intent,
+ expectedNodeId: String,
+ expectedPackageName: String,
+ actualIntent: Intent
+ ) {
+ assertEquals(expectedExtraIntent, getRemoteIntentExtraIntent(actualIntent))
+ assertEquals(expectedNodeId, getRemoteIntentNodeId(actualIntent))
+ assertEquals(expectedPackageName, actualIntent.`package`)
+ }
+
+ @Test
+ fun testActionRemoteIntentWithExtras() {
+ val intent = remoteIntentHelper.createIntent(testExtraIntent, null, testNodeId)
+
+ assertTrue(isActionRemoteIntent(intent))
+ assertEquals(testExtraIntent, getRemoteIntentExtraIntent(intent))
+ assertEquals(testNodeId, getRemoteIntentNodeId(intent))
+ }
+
+ @Test
+ fun testHasActionRemoteIntent() {
+ val intentFilter = createActionRemoteIntentFilter()
+ assertTrue(hasActionRemoteIntent(intentFilter))
+ }
+}
+
+private class SyncExecutor : Executor {
+ override fun execute(command: Runnable?) {
+ command?.run()
+ }
+}