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
This commit is contained in:
Daniel Shokouhi 2021-10-20 19:34:29 -07:00 committed by GitHub
parent 5c9a1f5ed1
commit f7883bebc5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 376 additions and 1 deletions

View file

@ -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")

View file

@ -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<Node>? = null
private var allConnectedNodes: List<Node>? = 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)
}
}
}

View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="4dp"
app:layout_constraintTop_toTopOf="parent"
android:theme="@style/ThemeOverlay.HomeAssistant.ActionBar" />
<TextView
android:id="@+id/information_text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="100dp"
android:layout_marginStart="15dp"
android:autoLink="web"
android:layout_marginEnd="15dp"
style="@style/TextAppearance.HomeAssistant.Headline"
app:layout_constraintBottom_toTopOf="@id/remote_open_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="spread_inside" />
<Button
android:id="@+id/remote_open_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/install_app"
android:visibility="invisible"
android:layout_marginBottom="50dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/information_text_view" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/get_help"
android:title="@string/get_help"
android:icon="@drawable/ic_question_toolbar"
android:visible="false"
app:showAsAction="always"/>
</menu>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="android_wear_capabilities">
<!-- IMPORTANT NOTE: Should be different than capability in Wear res/values/wear.xml. -->
<item>verify_phone_app</item>
</string-array>
</resources>

View file

@ -3,6 +3,8 @@
xmlns:tools="http://schemas.android.com/tools"
package="io.homeassistant.companion.android">
<uses-sdk tools:overrideLibrary="androidx.wear.remote.interactions" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" />
@ -233,6 +235,11 @@
android:name=".onboarding.OnboardingActivity"
android:configChanges="orientation|screenSize|keyboardHidden" />
<activity
android:name=".settings.SettingsWearActivity"
android:parentActivityName=".settings.SettingsActivity"
android:configChanges="orientation|screenSize" />
<service android:name=".onboarding.WearOnboardingListener">
<intent-filter>
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />

View file

@ -234,6 +234,14 @@ class SettingsFragment : PreferenceFragmentCompat(), SettingsView {
true
}
}
val pm = requireContext().packageManager
val hasWearApp = pm.getLaunchIntentForPackage("com.google.android.wearable.app")
findPreference<PreferenceCategory>("wear_category")?.isVisible = hasWearApp != null
findPreference<Preference>("wear_settings")?.setOnPreferenceClickListener {
startActivity(SettingsWearActivity.newInstance(requireContext()))
return@setOnPreferenceClickListener true
}
}
findPreference<Preference>("changelog")?.let {

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/colorAccent"
android:pathData="M20,12c0,-2.54 -1.19,-4.81 -3.04,-6.27L16,0H8l-0.95,5.73C5.19,7.19 4,9.45 4,12s1.19,4.81 3.05,6.27L8,24h8l0.96,-5.73C18.81,16.81 20,14.54 20,12zM6,12c0,-3.31 2.69,-6 6,-6s6,2.69 6,6 -2.69,6 -6,6 -6,-2.69 -6,-6z"/>
</vector>

View file

@ -623,4 +623,14 @@ like to connect to:</string>
<string name="prioritize_internal_summary">Always try the internal URL first before the external URL. Enable this setting if you typically leave location off.</string>
<string name="autoplay_video">Autoplay Videos</string>
<string name="autoplay_video_summary">Autoplay Videos when lovelace dashboard is active. Enabling this setting may increase data usage unexpectedly, proceed with caution.</string>
<string name="message_checking">Checking Wear Devices with App</string>
<string name="message_missing_all">The Wear app is missing on your watch, click the button below to install the app.\n\nNote: Currently the Wear OS app requires you to be enrolled in the beta for the phone app. If the button does not work then please join the beta: https://play.google.com/apps/testing/io.homeassistant.companion.android</string>
<string name="message_some_installed">The Wear app is installed on some of your wear devices: (%1$s)\n\nClick the button below to install the app on the other devices.\n\nNote: Currently the Wear OS app requires you to be enrolled in the beta for the phone app. If the button does not work then please join the beta: https://play.google.com/apps/testing/io.homeassistant.companion.android</string>
<string name="message_all_installed">The Wear app is installed on all of your wear devices! \n\nStay tuned for more updates to this page.</string>
<string name="store_request_successful">Request to install app on wear device sent successfully</string>
<string name="store_request_unsuccessful">Play Store Request Failed. Wear device(s) may not support Play Store, that is, the Wear device may be version 1.0.</string>
<string name="install_app">Install App on Wear Device</string>
<string name="wear_os_category">Wear OS</string>
<string name="wear_os_settings_title">Wear OS Settings</string>
<string name="wear_os_settings_summary">Manage Wear OS App</string>
</resources>

View file

@ -57,6 +57,16 @@
app:isPreferenceVisible="false"
app:useSimpleSummaryProvider="true"/>
</PreferenceCategory>
<PreferenceCategory
android:key="wear_category"
app:isPreferenceVisible="false"
android:title="@string/wear_os_category">
<Preference
android:key="wear_settings"
android:title="@string/wear_os_settings_title"
android:icon="@drawable/ic_baseline_watch_24"
android:summary="@string/wear_os_settings_summary" />
</PreferenceCategory>
<PreferenceCategory
android:title="@string/other_settings">
<SwitchPreference

View file

@ -0,0 +1,22 @@
package io.homeassistant.companion.android.settings
import android.content.Context
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.CapabilityInfo
class SettingsWearActivity : AppCompatActivity(), CapabilityClient.OnCapabilityChangedListener {
override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) {
// No op
}
companion object {
private const val TAG = "SettingsWearAct"
fun newInstance(context: Context): Intent {
return Intent(context, SettingsWearActivity::class.java)
}
}
}

View file

@ -3,5 +3,6 @@
tools:keep="@array/android_wear_capabilities">
<string-array name="android_wear_capabilities">
<item>authentication_token</item>
<item>verify_wear_app</item>
</string-array>
</resources>