Reputation: 946
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!
Upvotes: 7
Views: 5430
Reputation: 11
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")
}
}
Upvotes: 1
Reputation: 6635
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:
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
Upvotes: 13