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()
+    }
+}