Allow Phone to Onboard Wear Device (#2002)

* Initial data flowing.

* Functional login from phone.

* Cleanup data transfer between devices.

* Fix minimal build.

* Address review comments.

* Defaults to actual device when logging in now.
Location hidden when logging into wear device.
This commit is contained in:
Justin Bassett 2021-12-10 21:27:56 -05:00 committed by GitHub
parent 81975c77f3
commit 8c5149ddcb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 943 additions and 870 deletions

View file

@ -1,174 +0,0 @@
package io.homeassistant.companion.android.settings
import android.app.Application
import android.net.Uri
import android.util.Log
import android.widget.Toast
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.DataClient
import com.google.android.gms.wearable.DataEvent
import com.google.android.gms.wearable.DataEventBuffer
import com.google.android.gms.wearable.DataMap
import com.google.android.gms.wearable.DataMapItem
import com.google.android.gms.wearable.PutDataMapRequest
import com.google.android.gms.wearable.Wearable
import dagger.hilt.android.lifecycle.HiltViewModel
import io.homeassistant.companion.android.HomeAssistantApplication
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import kotlinx.coroutines.launch
import org.burnoutcrew.reorderable.ItemPosition
import org.burnoutcrew.reorderable.move
import org.json.JSONArray
import javax.inject.Inject
import io.homeassistant.companion.android.common.R as commonR
@HiltViewModel
class SettingsWearViewModel @Inject constructor(
private val integrationUseCase: IntegrationRepository,
application: Application
) :
AndroidViewModel(application),
DataClient.OnDataChangedListener {
private val application = getApplication<HomeAssistantApplication>()
companion object {
private const val TAG = "SettingsWearViewModel"
private const val CAPABILITY_WEAR_FAVORITES = "send_home_favorites"
}
var entities = mutableStateMapOf<String, Entity<*>>()
private set
var favoriteEntityIds = mutableStateListOf<String>()
fun init() {
loadEntities()
}
private fun loadEntities() {
viewModelScope.launch {
integrationUseCase.getEntities().forEach {
entities[it.entityId] = it
}
}
}
private fun saveHomeFavorites(data: String) {
val jsonString = JSONArray(data)
Log.d(TAG, "saveHomeFavorites: $jsonString")
favoriteEntityIds.clear()
for (favorite in 0 until jsonString.length()) {
favoriteEntityIds.add(
jsonString.getString(favorite)
)
}
}
fun onEntitySelected(checked: Boolean, entityId: String) {
if (checked)
favoriteEntityIds.add(entityId)
else
favoriteEntityIds.remove(entityId)
sendHomeFavorites(favoriteEntityIds.toList())
}
fun onMove(fromItem: ItemPosition, toItem: ItemPosition) {
favoriteEntityIds.move(favoriteEntityIds.indexOfFirst { it == fromItem.key }, favoriteEntityIds.indexOfFirst { it == toItem.key })
}
fun canDragOver(position: ItemPosition) = favoriteEntityIds.any { it == position.key }
fun sendHomeFavorites(favoritesList: List<String>) = viewModelScope.launch {
Log.d(TAG, "sendHomeFavorites")
val putDataRequest = PutDataMapRequest.create("/save_home_favorites").run {
dataMap.putString("favorites", favoritesList.toString())
setUrgent()
asPutDataRequest()
}
Wearable.getDataClient(application).putDataItem(putDataRequest).apply {
addOnSuccessListener { Log.d(TAG, "Successfully sent favorites to wear") }
addOnFailureListener { e ->
Log.e(TAG, "Failed to send favorites to wear", e)
Toast.makeText(application, application.getString(commonR.string.failure_send_favorites_wear), Toast.LENGTH_SHORT).show()
}
}
}
fun findExistingFavorites() {
Log.d(TAG, "Finding existing favorites")
viewModelScope.launch {
Wearable.getDataClient(application).getDataItems(Uri.parse("wear://*/home_favorites"))
.addOnSuccessListener { dataItemBuffer ->
Log.d(TAG, "Found existing favorites: ${dataItemBuffer.count}")
dataItemBuffer.forEach {
val data = getFavorites(DataMapItem.fromDataItem(it).dataMap)
saveHomeFavorites(data)
}
dataItemBuffer.release()
}
}
}
private fun getFavorites(map: DataMap): String {
return map.getString("favorites", "")
}
fun requestFavorites() {
Log.d(TAG, "Requesting favorites")
viewModelScope.launch {
Wearable.getCapabilityClient(application)
.getCapability(CAPABILITY_WEAR_FAVORITES, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener {
it.nodes.forEach { node ->
Log.d(TAG, "Requesting favorite data")
Wearable.getMessageClient(application).sendMessage(
node.id,
"/send_home_favorites",
ByteArray(0)
).apply {
addOnSuccessListener {
Log.d(
TAG,
"Request to favorites sent successfully"
)
}
addOnFailureListener { e ->
Log.e(TAG, "Failed to get favorites", e)
}
}
}
}
}
}
fun startWearListening() {
Wearable.getDataClient(application).addListener(this)
}
fun stopWearListening() {
Wearable.getDataClient(application).removeListener(this)
}
override fun onDataChanged(dataEvents: DataEventBuffer) {
Log.d(TAG, "onDataChanged ${dataEvents.count}")
dataEvents.forEach { event ->
if (event.type == DataEvent.TYPE_CHANGED) {
event.dataItem.also { item ->
if (item.uri.path?.compareTo("/home_favorites") == 0) {
val data = getFavorites(DataMapItem.fromDataItem(item).dataMap)
saveHomeFavorites(data)
}
}
}
}
dataEvents.release()
}
}

View file

@ -1,55 +0,0 @@
package io.homeassistant.companion.android.settings.views
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.google.android.gms.wearable.Node
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.settings.SettingsWearViewModel
import javax.inject.Inject
@AndroidEntryPoint
class SettingsWearMainView : AppCompatActivity() {
private val settingsWearViewModel by viewModels<SettingsWearViewModel>()
@Inject
lateinit var integrationUseCase: IntegrationRepository
companion object {
private const val TAG = "SettingsWearDevice"
private var currentNodes = setOf<Node>()
const val LANDING = "Landing"
const val FAVORITES = "Favorites"
fun newInstance(context: Context, wearNodes: Set<Node>): Intent {
currentNodes = wearNodes
return Intent(context, SettingsWearMainView::class.java)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
LoadSettingsHomeView(settingsWearViewModel, currentNodes.first().displayName)
}
settingsWearViewModel.init()
}
override fun onResume() {
super.onResume()
settingsWearViewModel.startWearListening()
settingsWearViewModel.findExistingFavorites()
settingsWearViewModel.requestFavorites()
}
override fun onPause() {
super.onPause()
settingsWearViewModel.stopWearListening()
}
}

View file

@ -1,242 +1,242 @@
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 io.homeassistant.companion.android.settings.views.SettingsWearMainView
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
import io.homeassistant.companion.android.common.R as commonR
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(binding.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(commonR.string.message_checking)
binding.remoteOpenButton.isInvisible = true
}
allConnectedNodes.isEmpty() -> {
Log.d(TAG, "No devices")
binding.informationTextView.text = getString(commonR.string.message_checking)
binding.remoteOpenButton.isInvisible = true
}
wearNodesWithApp.isEmpty() -> {
Log.d(TAG, "Missing on all devices")
binding.informationTextView.text = getString(commonR.string.message_missing_all)
binding.remoteOpenButton.isVisible = true
}
wearNodesWithApp.size < allConnectedNodes.size -> {
Log.d(TAG, "Installed on some devices")
startActivity(SettingsWearMainView.newInstance(applicationContext, wearNodesWithApp))
finish()
}
else -> {
Log.d(TAG, "Installed on all devices")
startActivity(SettingsWearMainView.newInstance(applicationContext, wearNodesWithApp))
finish()
}
}
}
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(commonR.string.store_request_successful),
Toast.LENGTH_SHORT
).show()
} catch (cancellationException: CancellationException) {
// Request was cancelled normally
} catch (throwable: Throwable) {
Toast.makeText(
this@SettingsWearActivity,
getString(commonR.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)
}
}
}
package io.homeassistant.companion.android.settings.wear
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 io.homeassistant.companion.android.settings.wear.views.SettingsWearMainView
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
import io.homeassistant.companion.android.common.R as commonR
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(binding.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(commonR.string.message_checking)
binding.remoteOpenButton.isInvisible = true
}
allConnectedNodes.isEmpty() -> {
Log.d(TAG, "No devices")
binding.informationTextView.text = getString(commonR.string.message_checking)
binding.remoteOpenButton.isInvisible = true
}
wearNodesWithApp.isEmpty() -> {
Log.d(TAG, "Missing on all devices")
binding.informationTextView.text = getString(commonR.string.message_missing_all)
binding.remoteOpenButton.isVisible = true
}
wearNodesWithApp.size < allConnectedNodes.size -> {
Log.d(TAG, "Installed on some devices")
startActivity(SettingsWearMainView.newInstance(applicationContext, wearNodesWithApp))
finish()
}
else -> {
Log.d(TAG, "Installed on all devices")
startActivity(SettingsWearMainView.newInstance(applicationContext, wearNodesWithApp))
finish()
}
}
}
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(commonR.string.store_request_successful),
Toast.LENGTH_SHORT
).show()
} catch (cancellationException: CancellationException) {
// Request was cancelled normally
} catch (throwable: Throwable) {
Toast.makeText(
this@SettingsWearActivity,
getString(commonR.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,177 @@
package io.homeassistant.companion.android.settings.wear
import android.app.Application
import android.util.Log
import android.widget.Toast
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.DataClient
import com.google.android.gms.wearable.DataEvent
import com.google.android.gms.wearable.DataEventBuffer
import com.google.android.gms.wearable.DataMap
import com.google.android.gms.wearable.DataMapItem
import com.google.android.gms.wearable.PutDataMapRequest
import com.google.android.gms.wearable.Wearable
import dagger.hilt.android.lifecycle.HiltViewModel
import io.homeassistant.companion.android.HomeAssistantApplication
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import kotlinx.coroutines.launch
import org.burnoutcrew.reorderable.ItemPosition
import org.burnoutcrew.reorderable.move
import javax.inject.Inject
import io.homeassistant.companion.android.common.R as commonR
@HiltViewModel
class SettingsWearViewModel @Inject constructor(
private val integrationUseCase: IntegrationRepository,
application: Application
) :
AndroidViewModel(application),
DataClient.OnDataChangedListener {
companion object {
private const val TAG = "SettingsWearViewModel"
private const val CAPABILITY_WEAR_SENDS_CONFIG = "sends_config"
private const val KEY_UPDATE_TIME = "UpdateTime"
private const val KEY_IS_AUTHENTICATED = "isAuthenticated"
private const val KEY_FAVORITES = "favorites"
}
private val objectMapper = jacksonObjectMapper()
var hasData = mutableStateOf(false)
private set
var isAuthenticated = mutableStateOf(false)
private set
var entities = mutableStateMapOf<String, Entity<*>>()
private set
var favoriteEntityIds = mutableStateListOf<String>()
private set
init {
Wearable.getDataClient(application).addListener(this)
Wearable.getCapabilityClient(application)
.getCapability(CAPABILITY_WEAR_SENDS_CONFIG, CapabilityClient.FILTER_REACHABLE)
.addOnSuccessListener {
it.nodes.forEach { node ->
Log.d(TAG, "Requesting config from node ${node.id}")
Wearable.getMessageClient(application).sendMessage(
node.id,
"/requestConfig",
ByteArray(0)
).apply {
addOnSuccessListener {
Log.d(TAG, "Request for config sent successfully")
}
addOnFailureListener { e ->
Log.e(TAG, "Failed to request config", e)
}
}
}
}
viewModelScope.launch {
integrationUseCase.getEntities().forEach {
entities[it.entityId] = it
}
}
}
override fun onCleared() {
Wearable.getDataClient(getApplication<HomeAssistantApplication>()).removeListener(this)
}
fun onEntitySelected(checked: Boolean, entityId: String) {
if (checked)
favoriteEntityIds.add(entityId)
else
favoriteEntityIds.remove(entityId)
sendHomeFavorites(favoriteEntityIds.toList())
}
fun onMove(fromItem: ItemPosition, toItem: ItemPosition) {
favoriteEntityIds.move(
favoriteEntityIds.indexOfFirst { it == fromItem.key },
favoriteEntityIds.indexOfFirst { it == toItem.key }
)
}
fun canDragOver(position: ItemPosition) = favoriteEntityIds.any { it == position.key }
fun sendHomeFavorites(favoritesList: List<String>) = viewModelScope.launch {
val application = getApplication<HomeAssistantApplication>()
val putDataRequest = PutDataMapRequest.create("/updateFavorites").run {
dataMap.putLong(KEY_UPDATE_TIME, System.nanoTime())
dataMap.putString(KEY_FAVORITES, objectMapper.writeValueAsString(favoritesList))
setUrgent()
asPutDataRequest()
}
Wearable.getDataClient(application).putDataItem(putDataRequest).apply {
addOnSuccessListener { Log.d(TAG, "Successfully sent favorites to wear") }
addOnFailureListener { e ->
Log.e(TAG, "Failed to send favorites to wear", e)
Toast.makeText(
application,
application.getString(commonR.string.failure_send_favorites_wear),
Toast.LENGTH_SHORT
).show()
}
}
}
fun sendAuthToWear(
url: String,
authCode: String,
deviceName: String,
deviceTrackingEnabled: Boolean
) {
val putDataRequest = PutDataMapRequest.create("/authenticate").run {
dataMap.putString("URL", url)
dataMap.putString("AuthCode", authCode)
dataMap.putString("DeviceName", deviceName)
dataMap.putBoolean("LocationTracking", deviceTrackingEnabled)
setUrgent()
asPutDataRequest()
}
Wearable.getDataClient(getApplication<HomeAssistantApplication>()).putDataItem(putDataRequest).apply {
addOnSuccessListener { Log.d(TAG, "Successfully sent favorites to wear") }
addOnFailureListener { e -> Log.e(TAG, "Failed to send favorites to wear", e) }
}
}
override fun onDataChanged(dataEvents: DataEventBuffer) {
Log.d(TAG, "onDataChanged ${dataEvents.count}")
dataEvents.forEach { event ->
if (event.type == DataEvent.TYPE_CHANGED) {
event.dataItem.also { item ->
when (item.uri.path) {
"/config" -> {
onLoadConfigFromWear(DataMapItem.fromDataItem(item).dataMap)
}
}
}
}
}
dataEvents.release()
}
private fun onLoadConfigFromWear(data: DataMap) {
isAuthenticated.value = data.getBoolean(KEY_IS_AUTHENTICATED, false)
val favoriteEntityIdList: List<String> =
objectMapper.readValue(data.getString(KEY_FAVORITES, "[]"))
favoriteEntityIds.clear()
favoriteEntityIdList.forEach { entityId ->
favoriteEntityIds.add(entityId)
}
hasData.value = true
}
}

View file

@ -1,151 +1,151 @@
package io.homeassistant.companion.android.settings.views
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Checkbox
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.HelpOutline
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.mikepenz.iconics.IconicsDrawable
import io.homeassistant.companion.android.settings.SettingsWearViewModel
import org.burnoutcrew.reorderable.detectReorderAfterLongPress
import org.burnoutcrew.reorderable.draggedItem
import org.burnoutcrew.reorderable.rememberReorderState
import org.burnoutcrew.reorderable.reorderable
import io.homeassistant.companion.android.common.R as commonR
const val WEAR_DOCS_LINK = "https://companion.home-assistant.io/docs/wear-os/wear-os"
val supportedDomains = listOf(
"input_boolean", "light", "lock", "switch", "script", "scene"
)
@Composable
fun LoadWearFavoritesSettings(
settingsWearViewModel: SettingsWearViewModel
) {
val context = LocalContext.current
val reorderState = rememberReorderState()
val validEntities = settingsWearViewModel.entities.filter { it.key.split(".")[0] in supportedDomains }.values.sortedBy { it.entityId }.toList()
val favoriteEntities = settingsWearViewModel.favoriteEntityIds
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(commonR.string.wear_favorite_entities)) },
actions = {
IconButton(onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(WEAR_DOCS_LINK))
context.startActivity(intent)
}) {
Icon(
Icons.Filled.HelpOutline,
contentDescription = stringResource(id = commonR.string.help)
)
}
}
)
}
) {
LazyColumn(
state = reorderState.listState,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.padding(top = 10.dp, start = 5.dp, end = 10.dp)
.then(
Modifier.reorderable(
reorderState,
{ from, to -> settingsWearViewModel.onMove(from, to) },
canDragOver = { settingsWearViewModel.canDragOver(it) },
onDragEnd = { _, _ ->
settingsWearViewModel.sendHomeFavorites(settingsWearViewModel.favoriteEntityIds.toList())
}
)
)
) {
item {
Text(
text = stringResource(commonR.string.wear_set_favorites),
fontWeight = FontWeight.Bold
)
}
items(favoriteEntities.size, { favoriteEntities[it] }) { index ->
Row(
modifier = Modifier
.padding(12.dp)
.clickable {
settingsWearViewModel.onEntitySelected(
false,
favoriteEntities[index]
)
}
.draggedItem(
reorderState.offsetByKey(favoriteEntities[index]),
Orientation.Vertical
)
.detectReorderAfterLongPress(reorderState)
) {
val iconBitmap = IconicsDrawable(LocalContext.current, "cmd-drag_vertical").toBitmap().asImageBitmap()
Icon(iconBitmap, "", modifier = Modifier.padding(top = 13.dp))
Checkbox(
checked = favoriteEntities.contains(favoriteEntities[index]),
onCheckedChange = {
settingsWearViewModel.onEntitySelected(it, favoriteEntities[index])
},
modifier = Modifier.padding(end = 5.dp)
)
Text(
text = favoriteEntities[index].replace("[", "").replace("]", ""),
modifier = Modifier.padding(top = 10.dp)
)
}
}
item {
Divider()
}
if (!validEntities.isNullOrEmpty()) {
items(validEntities.size) { index ->
val item = validEntities[index]
if (!favoriteEntities.contains(item.entityId)) {
Row(
modifier = Modifier
.padding(15.dp)
.clickable {
settingsWearViewModel.onEntitySelected(true, item.entityId)
}
) {
Checkbox(
checked = false,
onCheckedChange = {
settingsWearViewModel.onEntitySelected(it, item.entityId)
},
modifier = Modifier.padding(end = 5.dp)
)
Text(
text = item.entityId,
modifier = Modifier.padding(top = 10.dp)
)
}
}
}
}
}
}
}
package io.homeassistant.companion.android.settings.wear.views
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Checkbox
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.HelpOutline
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.mikepenz.iconics.IconicsDrawable
import io.homeassistant.companion.android.settings.wear.SettingsWearViewModel
import org.burnoutcrew.reorderable.detectReorderAfterLongPress
import org.burnoutcrew.reorderable.draggedItem
import org.burnoutcrew.reorderable.rememberReorderState
import org.burnoutcrew.reorderable.reorderable
import io.homeassistant.companion.android.common.R as commonR
const val WEAR_DOCS_LINK = "https://companion.home-assistant.io/docs/wear-os/wear-os"
val supportedDomains = listOf(
"input_boolean", "light", "lock", "switch", "script", "scene"
)
@Composable
fun LoadWearFavoritesSettings(
settingsWearViewModel: SettingsWearViewModel
) {
val context = LocalContext.current
val reorderState = rememberReorderState()
val validEntities = settingsWearViewModel.entities.filter { it.key.split(".")[0] in supportedDomains }.values.sortedBy { it.entityId }.toList()
val favoriteEntities = settingsWearViewModel.favoriteEntityIds
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(commonR.string.wear_favorite_entities)) },
actions = {
IconButton(onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(WEAR_DOCS_LINK))
context.startActivity(intent)
}) {
Icon(
Icons.Filled.HelpOutline,
contentDescription = stringResource(id = commonR.string.help)
)
}
}
)
}
) {
LazyColumn(
state = reorderState.listState,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.padding(top = 10.dp, start = 5.dp, end = 10.dp)
.then(
Modifier.reorderable(
reorderState,
{ from, to -> settingsWearViewModel.onMove(from, to) },
canDragOver = { settingsWearViewModel.canDragOver(it) },
onDragEnd = { _, _ ->
settingsWearViewModel.sendHomeFavorites(settingsWearViewModel.favoriteEntityIds.toList())
}
)
)
) {
item {
Text(
text = stringResource(commonR.string.wear_set_favorites),
fontWeight = FontWeight.Bold
)
}
items(favoriteEntities.size, { favoriteEntities[it] }) { index ->
Row(
modifier = Modifier
.padding(12.dp)
.clickable {
settingsWearViewModel.onEntitySelected(
false,
favoriteEntities[index]
)
}
.draggedItem(
reorderState.offsetByKey(favoriteEntities[index]),
Orientation.Vertical
)
.detectReorderAfterLongPress(reorderState)
) {
val iconBitmap = IconicsDrawable(LocalContext.current, "cmd-drag_vertical").toBitmap().asImageBitmap()
Icon(iconBitmap, "", modifier = Modifier.padding(top = 13.dp))
Checkbox(
checked = favoriteEntities.contains(favoriteEntities[index]),
onCheckedChange = {
settingsWearViewModel.onEntitySelected(it, favoriteEntities[index])
},
modifier = Modifier.padding(end = 5.dp)
)
Text(
text = favoriteEntities[index].replace("[", "").replace("]", ""),
modifier = Modifier.padding(top = 10.dp)
)
}
}
item {
Divider()
}
if (!validEntities.isNullOrEmpty()) {
items(validEntities.size) { index ->
val item = validEntities[index]
if (!favoriteEntities.contains(item.entityId)) {
Row(
modifier = Modifier
.padding(15.dp)
.clickable {
settingsWearViewModel.onEntitySelected(true, item.entityId)
}
) {
Checkbox(
checked = false,
onCheckedChange = {
settingsWearViewModel.onEntitySelected(it, item.entityId)
},
modifier = Modifier.padding(end = 5.dp)
)
Text(
text = item.entityId,
modifier = Modifier.padding(top = 10.dp)
)
}
}
}
}
}
}
}

View file

@ -1,30 +1,35 @@
package io.homeassistant.companion.android.settings.views
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.google.android.material.composethemeadapter.MdcTheme
import io.homeassistant.companion.android.settings.SettingsWearViewModel
@Composable
fun LoadSettingsHomeView(
settingsWearViewModel: SettingsWearViewModel,
deviceName: String
) {
MdcTheme {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = SettingsWearMainView.LANDING) {
composable(SettingsWearMainView.FAVORITES) {
LoadWearFavoritesSettings(
settingsWearViewModel
)
}
composable(SettingsWearMainView.LANDING) {
SettingWearLandingView(deviceName) {
navController.navigate(SettingsWearMainView.FAVORITES)
}
}
}
}
}
package io.homeassistant.companion.android.settings.wear.views
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.google.android.material.composethemeadapter.MdcTheme
import io.homeassistant.companion.android.settings.wear.SettingsWearViewModel
@Composable
fun LoadSettingsHomeView(
settingsWearViewModel: SettingsWearViewModel,
deviceName: String,
loginWearOs: () -> Unit
) {
MdcTheme {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = SettingsWearMainView.LANDING) {
composable(SettingsWearMainView.FAVORITES) {
LoadWearFavoritesSettings(
settingsWearViewModel
)
}
composable(SettingsWearMainView.LANDING) {
SettingWearLandingView(
deviceName = deviceName,
hasData = settingsWearViewModel.hasData.value,
isAuthed = settingsWearViewModel.isAuthenticated.value,
navigateFavorites = { navController.navigate(SettingsWearMainView.FAVORITES) },
loginWearOs = loginWearOs
)
}
}
}
}

View file

@ -1,78 +1,106 @@
package io.homeassistant.companion.android.settings.views
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.HelpOutline
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.homeassistant.companion.android.util.wearDeviceName
import io.homeassistant.companion.android.common.R as commonR
@Composable
fun SettingWearLandingView(
deviceName: String,
navigateFavorites: () -> Unit
) {
val context = LocalContext.current
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(commonR.string.wear_settings)) },
actions = {
IconButton(onClick = {
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(WEAR_DOCS_LINK)
)
context.startActivity(intent)
}) {
Icon(
Icons.Filled.HelpOutline,
contentDescription = stringResource(id = commonR.string.help)
)
}
}
)
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, top = 10.dp, end = 20.dp)
) {
Text(
text = stringResource(id = commonR.string.manage_favorites_device, deviceName),
textAlign = TextAlign.Center
)
Button(
onClick = navigateFavorites,
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp, end = 10.dp)
) {
Text(text = stringResource(commonR.string.set_favorites_on_device))
}
}
}
}
@Preview
@Composable
private fun PreviewSettingWearLandingView() {
SettingWearLandingView(wearDeviceName) {}
}
package io.homeassistant.companion.android.settings.wear.views
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.HelpOutline
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.homeassistant.companion.android.util.wearDeviceName
import io.homeassistant.companion.android.common.R as commonR
@Composable
fun SettingWearLandingView(
deviceName: String,
hasData: Boolean,
isAuthed: Boolean,
navigateFavorites: () -> Unit,
loginWearOs: () -> Unit
) {
val context = LocalContext.current
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(commonR.string.wear_settings)) },
actions = {
IconButton(onClick = {
val intent = Intent(
Intent.ACTION_VIEW,
Uri.parse(WEAR_DOCS_LINK)
)
context.startActivity(intent)
}) {
Icon(
Icons.Filled.HelpOutline,
contentDescription = stringResource(id = commonR.string.help)
)
}
}
)
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, top = 10.dp, end = 20.dp)
) {
Text(
text = stringResource(id = commonR.string.manage_wear_device, deviceName),
textAlign = TextAlign.Center
)
when {
!hasData -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
}
isAuthed -> {
Button(
onClick = navigateFavorites,
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp, end = 10.dp)
) {
Text(stringResource(commonR.string.set_favorites_on_device))
}
}
else -> {
Button(
onClick = loginWearOs,
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp, end = 10.dp)
) {
Text(stringResource(commonR.string.login_wear_os_device))
}
}
}
}
}
}
@Preview
@Composable
private fun PreviewSettingWearLandingView() {
SettingWearLandingView(
deviceName = wearDeviceName,
hasData = true,
isAuthed = true,
navigateFavorites = {},
loginWearOs = {}
)
}

View file

@ -0,0 +1,68 @@
package io.homeassistant.companion.android.settings.wear.views
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import com.google.android.gms.wearable.Node
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.onboarding.OnboardingActivity
import io.homeassistant.companion.android.settings.wear.SettingsWearViewModel
import javax.inject.Inject
@AndroidEntryPoint
class SettingsWearMainView : AppCompatActivity() {
private val settingsWearViewModel by viewModels<SettingsWearViewModel>()
@Inject
lateinit var integrationUseCase: IntegrationRepository
private val registerActivityResult =
registerForActivityResult(
ActivityResultContracts.StartActivityForResult(),
this::onOnboardingComplete
)
companion object {
private const val TAG = "SettingsWearDevice"
private var currentNodes = setOf<Node>()
const val LANDING = "Landing"
const val FAVORITES = "Favorites"
fun newInstance(context: Context, wearNodes: Set<Node>): Intent {
currentNodes = wearNodes
return Intent(context, SettingsWearMainView::class.java)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
LoadSettingsHomeView(
settingsWearViewModel,
currentNodes.first().displayName,
this::loginWearOs
)
}
}
private fun loginWearOs() {
registerActivityResult.launch(OnboardingActivity.newInstance(this, currentNodes.first().displayName, false))
}
private fun onOnboardingComplete(result: ActivityResult) {
val intent = result.data!!
val url = intent.getStringExtra("URL").toString()
val authCode = intent.getStringExtra("AuthCode").toString()
val deviceName = intent.getStringExtra("DeviceName").toString()
val deviceTrackingEnabled = intent.getBooleanExtra("LocationTracking", false)
settingsWearViewModel.sendAuthToWear(url, authCode, deviceName, deviceTrackingEnabled)
}
}

View file

@ -257,12 +257,12 @@
android:configChanges="orientation|screenSize|keyboardHidden" />
<activity
android:name=".settings.SettingsWearActivity"
android:name=".settings.wear.SettingsWearActivity"
android:parentActivityName=".settings.SettingsActivity"
android:configChanges="orientation|screenSize" />
<activity
android:name=".settings.views.SettingsWearMainView"
android:name=".settings.wear.views.SettingsWearMainView"
android:parentActivityName=".settings.SettingsActivity"
android:configChanges="orientation|screenSize" />

View file

@ -2,8 +2,10 @@ package io.homeassistant.companion.android.onboarding
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.R
@ -15,15 +17,24 @@ class OnboardingActivity : AppCompatActivity() {
companion object {
private const val AUTHENTICATION_FRAGMENT = "authentication_fragment"
private const val TAG = "OnboardingActivity"
private const val EXTRA_DEFAULT_DEVICE_NAME = "extra_default_device_name"
private const val EXTRA_LOCATION_TRACKING_POSSIBLE = "location_tracking_possible"
fun newInstance(context: Context): Intent {
return Intent(context, OnboardingActivity::class.java)
fun newInstance(context: Context, defaultDeviceName: String = Build.MODEL, locationTrackingPossible: Boolean = true): Intent {
return Intent(context, OnboardingActivity::class.java).apply {
putExtra(EXTRA_DEFAULT_DEVICE_NAME, defaultDeviceName)
putExtra(EXTRA_LOCATION_TRACKING_POSSIBLE, locationTrackingPossible)
}
}
}
private val viewModel by viewModels<OnboardingViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_onboarding)
viewModel.deviceName.value = intent.getStringExtra(EXTRA_DEFAULT_DEVICE_NAME) ?: Build.MODEL
viewModel.locationTrackingPossible.value = intent.getBooleanExtra(EXTRA_LOCATION_TRACKING_POSSIBLE, false)
supportFragmentManager
.beginTransaction()

View file

@ -2,7 +2,6 @@ package io.homeassistant.companion.android.onboarding
import android.app.Application
import android.net.nsd.NsdManager
import android.os.Build
import android.webkit.URLUtil
import android.widget.Toast
import androidx.compose.runtime.mutableStateListOf
@ -38,7 +37,8 @@ class OnboardingViewModel @Inject constructor(
val manualUrl = mutableStateOf("")
val manualContinueEnabled = mutableStateOf(false)
val authCode = mutableStateOf("")
val deviceName = mutableStateOf(Build.MODEL)
val deviceName = mutableStateOf("")
val locationTrackingPossible = mutableStateOf(false)
val locationTrackingEnabled = mutableStateOf(false)
fun onManualUrlUpdated(url: String) {

View file

@ -61,23 +61,25 @@ fun MobileAppIntegrationView(
}
)
)
Row {
if (onboardingViewModel.locationTrackingPossible.value) {
Row {
Text(
text = stringResource(commonR.string.enable_location_tracking),
modifier = Modifier
.align(Alignment.CenterVertically)
.weight(1f)
)
Switch(
checked = onboardingViewModel.locationTrackingEnabled.value,
onCheckedChange = onLocationTrackingChanged,
colors = SwitchDefaults.colors(uncheckedThumbColor = MaterialTheme.colors.secondary)
)
}
Text(
text = stringResource(commonR.string.enable_location_tracking),
modifier = Modifier
.align(Alignment.CenterVertically)
.weight(1f)
)
Switch(
checked = onboardingViewModel.locationTrackingEnabled.value,
onCheckedChange = onLocationTrackingChanged,
colors = SwitchDefaults.colors(uncheckedThumbColor = MaterialTheme.colors.secondary)
text = stringResource(id = commonR.string.enable_location_tracking_description),
fontWeight = FontWeight.Light
)
}
Text(
text = stringResource(id = commonR.string.enable_location_tracking_description),
fontWeight = FontWeight.Light
)
TextButton(onClick = openPrivacyPolicy) {
Text(stringResource(id = commonR.string.privacy_url))
}

View file

@ -35,6 +35,7 @@ import io.homeassistant.companion.android.settings.qs.ManageTilesFragment
import io.homeassistant.companion.android.settings.shortcuts.ManageShortcutsSettingsFragment
import io.homeassistant.companion.android.settings.ssid.SsidDialogFragment
import io.homeassistant.companion.android.settings.ssid.SsidPreference
import io.homeassistant.companion.android.settings.wear.SettingsWearActivity
import io.homeassistant.companion.android.settings.widgets.ManageWidgetsSettingsFragment
import io.homeassistant.companion.android.util.DisabledLocationHandler
import io.homeassistant.companion.android.util.LocationPermissionInfoHandler

View file

@ -1,22 +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)
}
}
}
package io.homeassistant.companion.android.settings.wear
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

@ -1,22 +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.DataClient
import com.google.android.gms.wearable.DataEventBuffer
class SettingsWearMainView : AppCompatActivity(), DataClient.OnDataChangedListener {
companion object {
private const val TAG = "SettingsWearMainView"
fun newInstance(context: Context): Intent {
return Intent(context, SettingsWearMainView::class.java)
}
}
override fun onDataChanged(dataEvents: DataEventBuffer) {
// No op
}
}
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.DataClient
import com.google.android.gms.wearable.DataEventBuffer
class SettingsWearMainView : AppCompatActivity(), DataClient.OnDataChangedListener {
companion object {
private const val TAG = "SettingsWearMainView"
fun newInstance(context: Context): Intent {
return Intent(context, SettingsWearMainView::class.java)
}
}
override fun onDataChanged(dataEvents: DataEventBuffer) {
// No op
}
}

View file

@ -244,6 +244,7 @@
<string name="log">Log</string>
<string name="logbook">Logbook</string>
<string name="login">Login</string>
<string name="login_wear_os_device">Login Wear OS Device</string>
<string name="logout">Logout</string>
<string name="lovelace_view_dashboard">Lovelace View or Dashboard</string>
<string name="lovelace">Lovelace</string>
@ -251,7 +252,6 @@
<string name="manage_all_notifications">Manage All Notifications</string>
<string name="manage_all_sensors_summary">This device has %1$d available sensors to utilize.</string>
<string name="manage_all_sensors">Manage All Sensors</string>
<string name="manage_favorites_device">Manage Favorites on device %1$s</string>
<string name="manage_shortcuts_summary">Manage launcher shortcuts</string>
<string name="manage_shortcuts">Manage Shortcuts</string>
<string name="manage_ssids_input_exists">This SSID already exists.</string>
@ -260,6 +260,7 @@
<string name="manage_this_notification">Manage This Notification</string>
<string name="manage_tiles_summary">Setup and manage Quick Setting tiles here. They will not function until you set them up here.</string>
<string name="manage_tiles">Manage Tiles</string>
<string name="manage_wear_device">Manage device %1$s</string>
<string name="manage_widgets_summary">Edit your widgets, adding/deleting can only be done from the home screen</string>
<string name="manage_widgets">Manage Widgets</string>
<string name="manual_desc">Enter the URL of your Home Assistant server. Make sure the URL includes the protocol and port. For example:\n\nhttp://homeassistant.local:8123 or \nhttps://example.duckdns.org.</string>

View file

@ -99,6 +99,7 @@ dependencies {
implementation("com.google.dagger:hilt-android:2.40.5")
kapt("com.google.dagger:hilt-android-compiler:2.40.5")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.0")
implementation("com.squareup.okhttp3:okhttp:4.9.3")
implementation("com.mikepenz:iconics-core:5.3.3")

View file

@ -83,9 +83,11 @@
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<action android:name="com.google.android.gms.wearable.DATA_CHANGED" />
<data android:scheme="wear" android:host="*"
android:path="/send_home_favorites" />
android:path="/authenticate" />
<data android:scheme="wear" android:host="*"
android:path="/save_home_favorites" />
android:path="/requestConfig" />
<data android:scheme="wear" android:host="*"
android:path="/updateFavorites" />
</intent-filter>
</service>
</application>

View file

@ -1,7 +1,9 @@
package io.homeassistant.companion.android.phone
import android.net.Uri
import android.content.Intent
import android.util.Log
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.google.android.gms.wearable.DataClient
import com.google.android.gms.wearable.DataEvent
import com.google.android.gms.wearable.DataEventBuffer
@ -12,38 +14,65 @@ import com.google.android.gms.wearable.PutDataMapRequest
import com.google.android.gms.wearable.Wearable
import com.google.android.gms.wearable.WearableListenerService
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
import io.homeassistant.companion.android.common.data.integration.DeviceRegistration
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.common.data.url.UrlRepository
import io.homeassistant.companion.android.database.AppDatabase
import io.homeassistant.companion.android.database.wear.Favorites
import io.homeassistant.companion.android.home.HomeActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.json.JSONArray
import javax.inject.Inject
@AndroidEntryPoint
class PhoneSettingsListener : WearableListenerService(), DataClient.OnDataChangedListener {
@Inject
lateinit var authenticationRepository: AuthenticationRepository
@Inject
lateinit var urlRepository: UrlRepository
@Inject
lateinit var integrationUseCase: IntegrationRepository
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
private val objectMapper = jacksonObjectMapper()
companion object {
private const val TAG = "PhoneSettingsListener"
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "We have favorite message listener")
private const val KEY_UPDATE_TIME = "UpdateTime"
private const val KEY_IS_AUTHENTICATED = "isAuthenticated"
private const val KEY_FAVORITES = "favorites"
}
override fun onMessageReceived(event: MessageEvent) {
Log.d(TAG, "Message received: $event")
if (event.path == "/send_home_favorites") {
val nodeId = event.sourceNodeId
sendHomeFavorites(nodeId)
if (event.path == "/requestConfig") {
sendPhoneData()
}
}
private fun sendPhoneData() = mainScope.launch {
val currentFavorites =
AppDatabase.getInstance(applicationContext).favoritesDao().getAll() ?: listOf()
val putDataRequest = PutDataMapRequest.create("/config").run {
dataMap.putLong(KEY_UPDATE_TIME, System.nanoTime())
dataMap.putBoolean(KEY_IS_AUTHENTICATED, integrationUseCase.isRegistered())
dataMap.putString(KEY_FAVORITES, objectMapper.writeValueAsString(currentFavorites.map { it.id }))
setUrgent()
asPutDataRequest()
}
Wearable.getDataClient(this@PhoneSettingsListener).putDataItem(putDataRequest).apply {
addOnSuccessListener { Log.d(TAG, "Successfully sent /config to device") }
addOnFailureListener { e -> Log.e(TAG, "Failed to send /config to device", e) }
}
}
@ -52,71 +81,49 @@ class PhoneSettingsListener : WearableListenerService(), DataClient.OnDataChange
dataEvents.forEach { event ->
if (event.type == DataEvent.TYPE_CHANGED) {
event.dataItem.also { item ->
if (item.uri.path?.compareTo("/save_home_favorites") == 0) {
val data = getHomeFavorites(DataMapItem.fromDataItem(item).dataMap)
Log.d(TAG, "onDataChanged: Received home favorites: $data")
saveFavorites()
}
}
}
}
}
private fun getHomeFavorites(map: DataMap): String {
map.apply {
return getString("favorites", "")
}
}
private fun sendHomeFavorites(nodeId: String) = mainScope.launch {
Log.d(TAG, "sendHomeFavorites to: $nodeId")
val currentFavorites = AppDatabase.getInstance(applicationContext).favoritesDao().getAll()
val list = emptyList<String>().toMutableList()
for (favorite in currentFavorites!!) {
list += listOf(favorite.id)
}
val jsonArray = JSONArray(list.toString())
val jsonString = List(jsonArray.length()) {
jsonArray.getString(it)
}.map { it }
Log.d(TAG, "new list: $jsonString")
val putDataRequest = PutDataMapRequest.create("/home_favorites").run {
dataMap.putString("favorites", jsonString.toString())
setUrgent()
asPutDataRequest()
}
Wearable.getDataClient(this@PhoneSettingsListener).putDataItem(putDataRequest).apply {
addOnSuccessListener { Log.d(TAG, "Successfully sent favorites to device") }
addOnFailureListener { e ->
Log.e(TAG, "Failed to send favorites to device", e)
}
}
}
private fun saveFavorites() {
Log.d(TAG, "Finding existing favorites")
val favoritesDao = AppDatabase.getInstance(applicationContext).favoritesDao()
mainScope.launch {
Wearable.getDataClient(applicationContext).getDataItems(Uri.parse("wear://*/save_home_favorites"))
.addOnSuccessListener {
Log.d(TAG, "Found existing favorites: ${it.count}")
it.forEach { dataItem ->
val data = getHomeFavorites(DataMapItem.fromDataItem(dataItem).dataMap).removeSurrounding("[", "]").split(", ").toList()
Log.d(
TAG,
"Favorites: $data"
)
favoritesDao.deleteAll()
if (data.isNotEmpty()) {
data.forEachIndexed { index, s ->
favoritesDao.add(Favorites(s, index))
}
when (item.uri.path) {
"/authenticate" -> {
login(DataMapItem.fromDataItem(item).dataMap)
}
"/updateFavorites" -> {
saveFavorites(DataMapItem.fromDataItem(item).dataMap)
}
}
it.release()
}
}
}
dataEvents.release()
}
private fun login(dataMap: DataMap) = mainScope.launch {
val url = dataMap.getString("URL")
val authCode = dataMap.getString("AuthCode")
val deviceName = dataMap.getString("DeviceName")
val deviceTrackingEnabled = dataMap.getString("LocationTracking")
urlRepository.saveUrl(url)
authenticationRepository.registerAuthorizationCode(authCode)
integrationUseCase.registerDevice(
DeviceRegistration(
"${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
deviceName
)
)
sendPhoneData()
val intent = HomeActivity.newInstance(applicationContext)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
}
private fun saveFavorites(dataMap: DataMap) {
val favoritesIds: List<String> =
objectMapper.readValue(dataMap.getString(KEY_FAVORITES, "[]"))
val favoritesDao = AppDatabase.getInstance(applicationContext).favoritesDao()
favoritesDao.deleteAll()
favoritesIds.forEachIndexed { index, entityId ->
favoritesDao.add(Favorites(entityId, index))
}
}
}

View file

@ -4,7 +4,6 @@
<string-array name="android_wear_capabilities">
<item>authentication_token</item>
<item>verify_wear_app</item>
<item>send_home_favorites</item>
<item>save_home_favorites</item>
<item>sends_config</item>
</string-array>
</resources>