From f7883bebc59cf34ddd0044da8b7fbcbf85be467c Mon Sep 17 00:00:00 2001 From: Daniel Shokouhi Date: Wed, 20 Oct 2021 19:34:29 -0700 Subject: [PATCH] Add Wear OS Settings section (#1796) * Add Wear OS Settings section * Fix minimal build * Fix PR build and hide category by default * Mention Wear OS app is only offered as a beta in case user is unable to install * Review comments --- app/build.gradle.kts | 5 +- .../android/settings/SettingsWearActivity.kt | 246 ++++++++++++++++++ .../res/layout/activity_settings_wear.xml | 42 +++ .../res/menu/menu_activity_settings_wear.xml | 10 + app/src/full/res/values/wear.xml | 7 + app/src/main/AndroidManifest.xml | 7 + .../android/settings/SettingsFragment.kt | 8 + .../res/drawable/ic_baseline_watch_24.xml | 9 + app/src/main/res/values/strings.xml | 10 + app/src/main/res/xml/preferences.xml | 10 + .../android/settings/SettingsWearActivity.kt | 22 ++ wear/src/main/res/values/wear.xml | 1 + 12 files changed, 376 insertions(+), 1 deletion(-) create mode 100755 app/src/full/java/io/homeassistant/companion/android/settings/SettingsWearActivity.kt create mode 100755 app/src/full/res/layout/activity_settings_wear.xml create mode 100755 app/src/full/res/menu/menu_activity_settings_wear.xml create mode 100755 app/src/full/res/values/wear.xml create mode 100755 app/src/main/res/drawable/ic_baseline_watch_24.xml create mode 100755 app/src/minimal/java/io/homeassistant/companion/android/settings/SettingsWearActivity.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ec429965e72..0a7380088d5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -132,7 +132,7 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.5.31") implementation("org.jetbrains.kotlin:kotlin-reflect:1.5.31") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2") implementation("com.google.dagger:dagger:2.39.1") kapt("com.google.dagger:dagger-compiler:2.39.1") @@ -146,6 +146,8 @@ dependencies { implementation("androidx.navigation:navigation-ui-ktx:2.3.5") implementation("com.google.android.material:material:1.4.0") + implementation("androidx.wear:wear-remote-interactions:1.0.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.0-rc01") implementation("com.google.android.gms:play-services-wearable:17.1.0") implementation("androidx.room:room-runtime:2.3.0") @@ -161,6 +163,7 @@ dependencies { "fullImplementation"("com.google.firebase:firebase-iid:21.1.0") "fullImplementation"("com.google.firebase:firebase-messaging:22.0.0") "fullImplementation"("io.sentry:sentry-android:5.2.3") + "fullImplementation"("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.5.2") implementation("androidx.work:work-runtime-ktx:2.6.0") implementation("androidx.biometric:biometric:1.1.0") diff --git a/app/src/full/java/io/homeassistant/companion/android/settings/SettingsWearActivity.kt b/app/src/full/java/io/homeassistant/companion/android/settings/SettingsWearActivity.kt new file mode 100755 index 00000000000..42bd5523a72 --- /dev/null +++ b/app/src/full/java/io/homeassistant/companion/android/settings/SettingsWearActivity.kt @@ -0,0 +1,246 @@ +package io.homeassistant.companion.android.settings + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.view.Menu +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.wear.remote.interactions.RemoteActivityHelper +import com.google.android.gms.wearable.CapabilityClient +import com.google.android.gms.wearable.CapabilityInfo +import com.google.android.gms.wearable.Node +import com.google.android.gms.wearable.NodeClient +import com.google.android.gms.wearable.Wearable +import io.homeassistant.companion.android.R +import io.homeassistant.companion.android.databinding.ActivitySettingsWearBinding +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.guava.await +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext + +class SettingsWearActivity : AppCompatActivity(), CapabilityClient.OnCapabilityChangedListener { + + private lateinit var binding: ActivitySettingsWearBinding + + private lateinit var capabilityClient: CapabilityClient + private lateinit var nodeClient: NodeClient + private lateinit var remoteActivityHelper: RemoteActivityHelper + + private var wearNodesWithApp: Set? = null + private var allConnectedNodes: List? = null + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.menu_activity_settings_wear, menu) + return true + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + menu?.findItem(R.id.get_help)?.let { + it.isVisible = true + it.intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://companion.home-assistant.io/docs/wear-os/wear-os")) + } + return true + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivitySettingsWearBinding.inflate(layoutInflater) + setContentView(binding.root) + + setSupportActionBar(findViewById(R.id.toolbar)) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + capabilityClient = Wearable.getCapabilityClient(this) + nodeClient = Wearable.getNodeClient(this) + remoteActivityHelper = RemoteActivityHelper(this) + + binding.remoteOpenButton.setOnClickListener { + openPlayStoreOnWearDevicesWithoutApp() + } + + // Perform the initial update of the UI + 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 onPause() { + super.onPause() + capabilityClient.removeListener(this, CAPABILITY_WEAR_APP) + } + + override fun onResume() { + super.onResume() + capabilityClient.addListener(this, CAPABILITY_WEAR_APP) + } + + /* + * Updates UI when capabilities change (install/uninstall wear app). + */ + override fun onCapabilityChanged(capabilityInfo: 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() + } + } + + private suspend fun findWearDevicesWithApp() { + + try { + val capabilityInfo = capabilityClient + .getCapability(CAPABILITY_WEAR_APP, CapabilityClient.FILTER_ALL) + .await() + + withContext(Dispatchers.Main) { + 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() { + + 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() { + + 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") + binding.informationTextView.text = getString(R.string.message_checking) + binding.remoteOpenButton.isInvisible = true + } + allConnectedNodes.isEmpty() -> { + Log.d(TAG, "No devices") + binding.informationTextView.text = getString(R.string.message_checking) + binding.remoteOpenButton.isInvisible = true + } + wearNodesWithApp.isEmpty() -> { + Log.d(TAG, "Missing on all devices") + binding.informationTextView.text = getString(R.string.message_missing_all) + binding.remoteOpenButton.isVisible = 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") + binding.informationTextView.text = + getString(R.string.message_some_installed, wearNodesWithApp.toString()) + binding.remoteOpenButton.isVisible = 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") + binding.informationTextView.text = + getString(R.string.message_all_installed) + binding.remoteOpenButton.isInvisible = true + } + } + } + + private fun 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@SettingsWearActivity, + getString(R.string.store_request_successful), + Toast.LENGTH_SHORT + ).show() + } catch (cancellationException: CancellationException) { + // Request was cancelled normally + } catch (throwable: Throwable) { + Toast.makeText( + this@SettingsWearActivity, + getString(R.string.store_request_unsuccessful), + Toast.LENGTH_LONG + ).show() + } + } + } + } + + companion object { + private const val TAG = "SettingsWearAct" + + // 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_wear_app" + + private const val PLAY_STORE_APP_URI = + "market://details?id=io.homeassistant.companion.android" + + fun newInstance(context: Context): Intent { + return Intent(context, SettingsWearActivity::class.java) + } + } +} diff --git a/app/src/full/res/layout/activity_settings_wear.xml b/app/src/full/res/layout/activity_settings_wear.xml new file mode 100755 index 00000000000..3b816246587 --- /dev/null +++ b/app/src/full/res/layout/activity_settings_wear.xml @@ -0,0 +1,42 @@ + + + + + + + +