How to install WearOS companion app along with the Android phone app automatically
Asked Answered
D

2

7

I've released my Android and WearOS apps as separate APKs using the Google Play Console Multi-APK delivery method.

Both apps are discoverable when browsing from the respective device - Android app on phone and WearOS app on the watch. Additionally, when the phone app is installed on the Android device, I can see on my WearOS device that I can install the companion app on the "Apps on your phone" screen in Play Store app.

The official Google's WearOS documentation states the following:

On devices running Wear 2.0, when a user installs a phone app that has an associated watch app (either an embedded APK or an APK uploaded via the Play Console), the user gets a watch notification about the available watch app. Tapping the notification opens the watch Play Store, giving the user the option to install the watch app.

However, nothing happens when the Android app is installed on the phone. Moreover, user is not aware that the app has the WearOS companion app since it's not visible on the phone Play Store app or the website. The same goes for the watch - when user discovers my app from their WearOS device and installs it, the phone counterpart is not installed nor the user is notified of it.

The WearOS app is not standalone so it requires phone app to function. It has the same package name and is signed with the same key. All the notifications are allowed on the watch and on the WearOS app on the phone.

So, is there a way to automate the WearOS app installation or at least let user know that they can install it? Thanks!

Dirt answered 18/9, 2020 at 5:51 Comment(0)
A
13

Since Wear OS 2.0, there's no way to fully automate this. Google went all-in on the idea of "standalone" Wear apps, and left us developers of integrated apps mostly out in the cold.

As far as I know, the only way to get both apps installed since Wear 2.0 is a process flow like the following:

  1. When the user runs your phone app, use the Capability API to check if your app is installed on a paired watch.
  2. If it's not installed, show an appropriate UI to the user telling them about the problem.
  3. And from that UI, give them an action to open the Play Store on the watch to your app's listing, using RemoteIntent.startRemoteActivity().

And you need to do something similar in your watch app, in case the user installs and runs that first.

This process is documented (with some code samples) here: https://developer.android.com/training/wearables/apps/standalone-apps#detecting-your-app

Antitoxic answered 18/9, 2020 at 16:49 Comment(3)
Thank you. This custom solution is what I'm going to implement. I just wonder why Google put it into under "Standalone Wear apps" section whereas it applies to non-standalone apps mostly since they need the counterpart app to work.Dirt
I agree, placing it on that page makes it hard to find! If my answer has helped you, would you mind accepting?Antitoxic
I am still having problems with this and will likely post another thread on it. Internal testing seems to complicate things for me as well. Currently I am getting a FATAL EXCEPTION - NullPointerException after I call the RemoteIntent.Mucin
R
1

You can use these GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener methods but they are deprecated

here is the example

class WearOsActivity : AppCompatActivity(), CapabilityClient.OnCapabilityChangedListener,
GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener {


private lateinit var signInAccount: GoogleSignInAccount
private lateinit var mGoogleApiClient: GoogleApiClient
private lateinit var capabilityClient: CapabilityClient
private lateinit var nodeClient: NodeClient
private lateinit var remoteActivityHelper: RemoteActivityHelper

private var wearNodesWithApp: Set<Node>? = null
private var allConnectedNodes: List<Node>? = null

val remoteOpenButton by lazy { findViewById<Button>(R.id.connect_watch) }
val informationTextView by lazy { findViewById<TextView>(R.id.informationTextView) }
private val fitnessOptions =
    FitnessOptions.builder().addDataType(DataType.TYPE_STEP_COUNT_DELTA).build()

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_wear_os)

    capabilityClient = Wearable.getCapabilityClient(this)
    nodeClient = Wearable.getNodeClient(this)
    remoteActivityHelper = RemoteActivityHelper(this)

    mGoogleApiClient = GoogleApiClient.Builder(this)
        .addApi(Fitness.HISTORY_API)
        .addApi(Fitness.RECORDING_API)
        .addScope(Scope(Scopes.PROFILE))
        .build()
    mGoogleApiClient.connect()


    if (!GoogleSignIn.hasPermissions(
            GoogleSignIn.getLastSignedInAccount(this),
            fitnessOptions
        )
    ) {
        signInAccount = GoogleSignIn.getAccountForExtension(this, fitnessOptions)
        GoogleSignIn.requestPermissions(
            this, // or FragmentActivity
            0,
            signInAccount,
            fitnessOptions
        )
    } else {
        // If permissions are already granted, directly call the function to get total calories
        getTotalCalories()

    }


    val fitnessOptions = FitnessOptions.builder()
        .addDataType(DataType.TYPE_CALORIES_EXPENDED, FitnessOptions.ACCESS_READ)
        .build()

    if (!GoogleSignIn.hasPermissions(
            GoogleSignIn.getLastSignedInAccount(this),
            fitnessOptions
        )
    ) {
        val signInAccount = GoogleSignIn.getAccountForExtension(this, fitnessOptions)
        GoogleSignIn.requestPermissions(
            this, // or FragmentActivity
            0,
            signInAccount,
            fitnessOptions
        )
    } else {
        // If permissions are already granted, directly call the function to get total calories
        getTotalCalories()
    }


    remoteOpenButton.setOnClickListener {
        openPlayStoreOnWearDevicesWithoutApp()
    }
    updateUI()

    lifecycleScope.launch {
        lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
            launch {
                // Initial request for devices with our capability, aka, our Wear app installed.
                findWearDevicesWithApp()
            }
            launch {
                // Initial request for all Wear devices connected (with or without our capability).
                // Additional Note: Because there isn't a listener for ALL Nodes added/removed from network
                // that isn't deprecated, we simply update the full list when the Google API Client is
                // connected and when capability changes come through in the onCapabilityChanged() method.
                findAllWearDevices()
            }
        }
    }
}

override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) {
    Log.d(TAG, "onCapabilityChanged(): $capabilityInfo")
    wearNodesWithApp = capabilityInfo.nodes

    lifecycleScope.launch {
        // Because we have an updated list of devices with/without our app, we need to also update
        // our list of active Wear devices.
        findAllWearDevices()
    }
}


override fun onPause() {
    Log.d(TAG, "onPause()")
    super.onPause()
    capabilityClient.removeListener(this, CAPABILITY_WEAR_APP)
}

override fun onResume() {
    Log.d(TAG, "onResume()")
    super.onResume()
    capabilityClient.addListener(this, CAPABILITY_WEAR_APP)
}

private fun getTotalCalories() {
    val endTime = LocalDateTime.now().atZone(ZoneId.systemDefault())
    val startTime = endTime.minusDays(1)
    Log.i(TAG, "Range Start: $startTime")
    Log.i(TAG, "Range End: $endTime")

    val readStepsRequest =
        DataReadRequest.Builder()
            // The data request can specify multiple data types to return,
            // effectively combining multiple data queries into one call.
            // This example demonstrates aggregating only one data type.
            .aggregate(DataType.AGGREGATE_STEP_COUNT_DELTA)
            // Analogous to a "Group By" in SQL, defines how data should be
            // aggregated.
            // bucketByTime allows for a time span, whereas bucketBySession allows
            // bucketing by <a href="/fit/android/using-sessions">sessions</a>.
            .bucketByTime(1, TimeUnit.DAYS)
            .setTimeRange(startTime.toEpochSecond(), endTime.toEpochSecond(), TimeUnit.SECONDS)
            .build()

    val readCalRequest = DataReadRequest.Builder()
        .aggregate(DataType.AGGREGATE_CALORIES_EXPENDED)
        .bucketByActivityType(1, TimeUnit.SECONDS)
        .setTimeRange(startTime.toEpochSecond(), endTime.toEpochSecond(), TimeUnit.SECONDS)
        .build()

    Fitness.getHistoryClient(this, GoogleSignIn.getAccountForExtension(this, fitnessOptions))
        .readData(readCalRequest)
        .addOnSuccessListener { response ->
            // The aggregate query puts datasets into buckets, so flatten into a
            // single list of datasets
            for (dataSet in response.buckets.flatMap { it.dataSets }) {
                dumpDataSet(dataSet)
            }
        }
        .addOnFailureListener { e ->
            Log.w(TAG, "There was an error reading data from Google Fit", e)
        }

}

private fun dumpDataSet(dataSet: DataSet) {
    Log.i(TAG, "Data returned for Data type: ${dataSet.dataType.name}")
    for (dp in dataSet.dataPoints) {
        Log.i(TAG, "Data point:")
        Log.i(TAG, "\tType: ${dp.dataType.name}")
        Log.i(TAG, "\tStart: ${dp.getStartTimeString()}")
        Log.i(TAG, "\tEnd: ${dp.getEndTimeString()}")
        for (field in dp.dataType.fields) {
            Log.i(TAG, "\tField: ${field.name.toString()} Value: ${dp.getValue(field)}")
        }
    }
}

private fun DataPoint.getStartTimeString() =
    Instant.ofEpochSecond(this.getStartTime(TimeUnit.SECONDS))
        .atZone(ZoneId.systemDefault())
        .toLocalDateTime().toString()

private fun DataPoint.getEndTimeString() =
    Instant.ofEpochSecond(this.getEndTime(TimeUnit.SECONDS))
        .atZone(ZoneId.systemDefault())
        .toLocalDateTime().toString()


private suspend fun findWearDevicesWithApp() {
    Log.d(TAG, "findWearDevicesWithApp()")

    try {
        val capabilityInfo = capabilityClient
            .getCapability(CAPABILITY_WEAR_APP, CapabilityClient.FILTER_ALL)
            .await()

        withContext(Dispatchers.Main) {
            Log.d(TAG, "Capability request succeeded.")
            wearNodesWithApp = capabilityInfo.nodes
            Log.d(TAG, "Capable Nodes: $wearNodesWithApp")
            updateUI()
        }
    } catch (cancellationException: CancellationException) {
        // Request was cancelled normally
        throw cancellationException
    } catch (throwable: Throwable) {
        Log.d(TAG, "Capability request failed to return any results.")
    }
}

private suspend fun findAllWearDevices() {
    Log.d(TAG, "findAllWearDevices()")

    try {
        val connectedNodes = nodeClient.connectedNodes.await()

        withContext(Dispatchers.Main) {
            allConnectedNodes = connectedNodes
            updateUI()
        }
    } catch (cancellationException: CancellationException) {
        // Request was cancelled normally
    } catch (throwable: Throwable) {
        Log.d(TAG, "Node request failed to return any results.")
    }
}

private fun updateUI() {
    Log.d(TAG, "updateUI()")

    val wearNodesWithApp = wearNodesWithApp
    val allConnectedNodes = allConnectedNodes

    when {
        wearNodesWithApp == null || allConnectedNodes == null -> {
            Log.d(TAG, "Waiting on Results for both connected nodes and nodes with app")
            informationTextView.text = getString(R.string.message_checking)
            remoteOpenButton.alpha = 0.5f
            remoteOpenButton.isEnabled = false
        }
        allConnectedNodes.isEmpty() -> {
            Log.d(TAG, "No devices")
            informationTextView.text = getString(R.string.message_checking)
            remoteOpenButton.alpha = 0.5f
            remoteOpenButton.isEnabled = false
        }
        wearNodesWithApp.isEmpty() -> {
            Log.d(TAG, "Missing on all devices")
            informationTextView.text = getString(R.string.message_missing_all)
            remoteOpenButton.alpha = 1f
            remoteOpenButton.isEnabled = true
        }
        wearNodesWithApp.size < allConnectedNodes.size -> {
            // TODO: Add your code to communicate with the wear app(s) via Wear APIs
            //       (MessageClient, DataClient, etc.)
            Log.d(TAG, "Installed on some devices")
            informationTextView.text =
                getString(R.string.message_some_installed, wearNodesWithApp.toString())
            remoteOpenButton.alpha = 1f
            remoteOpenButton.isEnabled = true
        }
        else -> {
            // TODO: Add your code to communicate with the wear app(s) via Wear APIs
            //       (MessageClient, DataClient, etc.)
            Log.d(TAG, "Installed on all devices")
            informationTextView.text =
                getString(R.string.message_all_installed, wearNodesWithApp.toString())
            remoteOpenButton.alpha = 0.5f
            remoteOpenButton.isEnabled = false
        }
    }
}

private fun openPlayStoreOnWearDevicesWithoutApp() {
    Log.d(TAG, "openPlayStoreOnWearDevicesWithoutApp()")

    val wearNodesWithApp = wearNodesWithApp ?: return
    val allConnectedNodes = allConnectedNodes ?: return

    // Determine the list of nodes (wear devices) that don't have the app installed yet.
    val nodesWithoutApp = allConnectedNodes - wearNodesWithApp

    Log.d(TAG, "Number of nodes without app: " + nodesWithoutApp.size)
    val intent = Intent(Intent.ACTION_VIEW)
        .addCategory(Intent.CATEGORY_BROWSABLE)
        .setData(Uri.parse(PLAY_STORE_APP_URI))

    // In parallel, start remote activity requests for all wear devices that don't have the app installed yet.
    nodesWithoutApp.forEach { node ->
        lifecycleScope.launch {
            try {
                remoteActivityHelper
                    .startRemoteActivity(
                        targetIntent = intent,
                        targetNodeId = node.id
                    )
                    .await()

                Toast.makeText(
                    this@WearOsActivity,
                    "The App is Successfully Installed on your wearOs",
                    Toast.LENGTH_SHORT
                ).show()
            } catch (cancellationException: CancellationException) {
                // Request was cancelled normally
            } catch (throwable: Throwable) {
                Toast.makeText(
                    this@WearOsActivity,
                    "Install request failed",
                    Toast.LENGTH_LONG
                ).show()
            }
        }
    }
}


companion object {
    private const val TAG = "MainMobileActivity"

    // Name of capability listed in Wear app's wear.xml.
    // IMPORTANT NOTE: This should be named differently than your Phone app's capability.
    private const val CAPABILITY_WEAR_APP = "verify_remote_example_wear_app"

    // Links to Wear app (Play Store).
    // TODO: Replace with your links/packages.
    private const val PLAY_STORE_APP_URI =
        "market://details?id=com.yewapp"
}

override fun onConnected(p0: Bundle?) {
    TODO("Not yet implemented")
}

override fun onConnectionSuspended(p0: Int) {
    TODO("Not yet implemented")
}

override fun onConnectionFailed(p0: ConnectionResult) {
    TODO("Not yet implemented")
}

}

Reiterant answered 13/4, 2023 at 6:13 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.