diff --git a/DataLayer/Application/src/main/java/com/example/android/wearable/datalayer/MainActivity.kt b/DataLayer/Application/src/main/java/com/example/android/wearable/datalayer/MainActivity.kt index 5b52dda10..585bdd6ae 100644 --- a/DataLayer/Application/src/main/java/com/example/android/wearable/datalayer/MainActivity.kt +++ b/DataLayer/Application/src/main/java/com/example/android/wearable/datalayer/MainActivity.kt @@ -26,9 +26,18 @@ import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.gms.common.api.AvailabilityException +import com.google.android.gms.common.api.GoogleApi import com.google.android.gms.wearable.Asset import com.google.android.gms.wearable.CapabilityClient import com.google.android.gms.wearable.DataClient @@ -85,32 +94,45 @@ class MainActivity : ComponentActivity() { var count = 0 lifecycleScope.launch { - lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { - // Set the initial trigger such that the first count will happen in one second. - var lastTriggerTime = Instant.now() - (countInterval - Duration.ofSeconds(1)) - while (isActive) { - // Figure out how much time we still have to wait until our next desired trigger - // point. This could be less than the count interval if sending the count took - // some time. - delay( - Duration.between(Instant.now(), lastTriggerTime + countInterval).toMillis() - ) - // Update when we are triggering sending the count - lastTriggerTime = Instant.now() - sendCount(count) - - // Increment the count to send next time - count++ + if (isAvailable(capabilityClient)) { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + // Set the initial trigger such that the first count will happen in one second. + var lastTriggerTime = Instant.now() - (countInterval - Duration.ofSeconds(1)) + while (isActive) { + // Figure out how much time we still have to wait until our next desired trigger + // point. This could be less than the count interval if sending the count took + // some time. + delay( + Duration.between(Instant.now(), lastTriggerTime + countInterval) + .toMillis() + ) + // Update when we are triggering sending the count + lastTriggerTime = Instant.now() + sendCount(count) + + // Increment the count to send next time + // This count is local to the specific instance of this activity and may reset + // when a new instance is recreated. For a more complex example where the counter + // is stored in a DataStore and modeled as a proto, see thre Horologist DataLayer sample in + // https://google.github.io/horologist/datalayer/ + count++ + } } } } setContent { MaterialTheme { + val coroutineScope = rememberCoroutineScope() + var apiAvailable by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + apiAvailable = isAvailable(capabilityClient) + } MainApp( events = clientDataViewModel.events, image = clientDataViewModel.image, isCameraSupported = isCameraSupported, + apiAvailable = apiAvailable, onTakePhotoClick = ::takePhoto, onSendPhotoClick = ::sendPhoto, onStartWearableActivityClick = ::startWearableActivity @@ -163,6 +185,11 @@ class MainActivity : ComponentActivity() { } } + // This method starts the Wearable app on the connected Wear device. + // Alternative to this implementation, Horologist offers a DataHelper API which allows to + // start the main activity or a different activity of your choice from the Wearable app + // see https://google.github.io/horologist/datalayer-helpers-guide/#launching-a-specific-activity-on-the-other-device + // for details private fun startWearableActivity() { lifecycleScope.launch { try { @@ -172,6 +199,9 @@ class MainActivity : ComponentActivity() { .nodes // Send a message to all nodes in parallel + // If you need an acknowledge for the start activity use case, you can alternatively use + // [MessageClient.sendRequest](https://developers.google.com/android/reference/com/google/android/gms/wearable/MessageClient#sendRequest(java.lang.String,%20java.lang.String,%20byte[])) + // See an implementation in Horologist DataHelper https://github.com/google/horologist/blob/release-0.5.x/datalayer/core/src/main/java/com/google/android/horologist/data/apphelper/DataLayerAppHelper.kt#L210 nodes.map { node -> async { messageClient.sendMessage(node.id, START_ACTIVITY_PATH, byteArrayOf()) @@ -245,6 +275,26 @@ class MainActivity : ComponentActivity() { } } + // This function checks that the Wearable API is available on the mobile device. + // If you are using the Horologist DataHelpers, this method is available in + // https://google.github.io/horologist/datalayer-helpers-guide/#check-api-availability + + private suspend fun isAvailable(api: GoogleApi<*>): Boolean { + return try { + GoogleApiAvailability.getInstance() + .checkApiAvailability(api) + .await() + + true + } catch (e: AvailabilityException) { + Log.d( + TAG, + "${api.javaClass.simpleName} API is not available in this device." + ) + false + } + } + companion object { private const val TAG = "MainActivity" diff --git a/DataLayer/Application/src/main/java/com/example/android/wearable/datalayer/MainApp.kt b/DataLayer/Application/src/main/java/com/example/android/wearable/datalayer/MainApp.kt index 6b9ef2aa2..fe16389cc 100644 --- a/DataLayer/Application/src/main/java/com/example/android/wearable/datalayer/MainApp.kt +++ b/DataLayer/Application/src/main/java/com/example/android/wearable/datalayer/MainApp.kt @@ -30,13 +30,13 @@ import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.res.painterResource 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 @@ -49,11 +49,22 @@ fun MainApp( events: List, image: Bitmap?, isCameraSupported: Boolean, + apiAvailable: Boolean, onTakePhotoClick: () -> Unit, onSendPhotoClick: () -> Unit, onStartWearableActivityClick: () -> Unit ) { LazyColumn(contentPadding = PaddingValues(16.dp)) { + if (!apiAvailable) { + item { + Text( + text = stringResource(R.string.wearable_api_unavailable), + color = Color.Red, + textAlign = TextAlign.Center + ) + } + } + item { Row( verticalAlignment = Alignment.CenterVertically @@ -151,6 +162,7 @@ fun MainAppPreview() { isCameraSupported = true, onTakePhotoClick = {}, onSendPhotoClick = {}, - onStartWearableActivityClick = {} + onStartWearableActivityClick = {}, + apiAvailable = true ) } diff --git a/DataLayer/Application/src/main/res/values/strings.xml b/DataLayer/Application/src/main/res/values/strings.xml index 8ecf8344d..25ea067d0 100644 --- a/DataLayer/Application/src/main/res/values/strings.xml +++ b/DataLayer/Application/src/main/res/values/strings.xml @@ -25,4 +25,5 @@ Unknown DataItem type Message from watch Capability changed + The Wearable API is not available on this device diff --git a/DataLayer/README.md b/DataLayer/README.md index dbead14e3..77447c09f 100644 --- a/DataLayer/README.md +++ b/DataLayer/README.md @@ -2,26 +2,91 @@ Android DataLayer Sample ======================== -This sample demonstrates how to work with a WearableListenerService, -to produce and consume DataEvents and effectively work with the DataLayer. +This sample demonstrates how to work with a WearableListenerService using: -Introduction ------------- +- [DataClient][2] to exchange data events +- [MessageClient][3] to send messages +- [CapabilityClient][4] to find nodes with specific capabilities -This sample demonstrates how to make a handheld and an Wear device communicate -using the [DataClient][2]. -It does this by sending a picture between connected devices. -An Activity is being used for both the connected devices which implement their parts of -the required interfaces using Jetpack Compose. +Introduction +------------ -It showcases how to use an [WearableListenerService][1] to consume DataEvents -as well as implementations for various required listeners when using the [DataClient][2], -[MessageClient][3]. +This sample showcases how a phone and a Wear OS app can exchange data. It implements 3 use cases: + +1. Send a data asset from the phone to the watch +In the sample you can take a photo on the phone and send it to the paired watch. The photo is sent +as a [DataAsset][6] by using [DataClient][2]. +``` +val request = PutDataMapRequest.create(IMAGE_PATH).apply { + dataMap.putAsset(IMAGE_KEY, imageAsset) + dataMap.putLong(TIME_KEY, Instant.now().epochSecond) + } + .asPutDataRequest() + .setUrgent() + + val result = dataClient.putDataItem(request).await() +``` +This use case is successful if the watch is connected and has the Wear app +installed which is implemented by using [CapabilityClient][4]. +2. Send data from the phone to the watch and ackownledge via a message +The phone app increments a counter and send it over a period of 5 seconds as a [DataItem] by using [DataClient][2]. +The Wear app receives the [DataItem][5] by implementing a [WearableListenerService][1] and acknowledge +by sending a [Message] via [MessageClient][3] +``` +messageClient.sendMessage( + nodeId, + DATA_ITEM_RECEIVED_PATH, + payload +) +.await() +``` +3. Launch the Wear app from the phone app +The phone app checks if there is a connected node with a specific capability that identifies the +correspondent Wear app. The capability is declared in the wear.xml file: +``` + + + wear +``` +Then the phone app sends a message to the Wear app by specifying the node id of the device and +the path of the activity. +``` +nodes.map { node -> + async { + messageClient.sendMessage(node.id, START_ACTIVITY_PATH, byteArrayOf()) + .await() + } +}.awaitAll() +``` +The Wearable app is listening to events by implementing a [WearableListenerService][1] an upon receiving +the message starts the Activity. + +This samples is useful to learn about how to use the Wearable API clients and WearableListenerService. +Alternatively Horologist provides some API which facilitates some use cases such as like: + +- [Installing][7] the Wear app on another connected device by opening the Playstore app on the phone +- [Starting][8] the Wear app on another connected device +- [Finishing sign-in][9] the Wear app on another connected device +- [Installing a Wear Tile][10] from the phone by redirecting to the Tile settings editor screen (supported only on +some devices) +- [Persisting data on the DataStore and model as a proto][11] +- Check if the [Wearable API is supported][12] on mobile +- Find [connected nodes and understand if the app is already installed][13] [1]: https://developers.google.com/android/reference/com/google/android/gms/wearable/WearableListenerService [2]: https://developers.google.com/android/reference/com/google/android/gms/wearable/DataClient [3]: https://developers.google.com/android/reference/com/google/android/gms/wearable/MessageClient +[4]: https://developers.google.com/android/reference/com/google/android/gms/wearable/CapabilityClient +[5]: https://developers.google.com/android/reference/com/google/android/gms/wearable/DataItem +[6]: https://developers.google.com/android/reference/com/google/android/gms/wearable/DataAsset +[7]: https://google.github.io/horologist/datalayer-phone-ui/#install-app +[8]: https://google.github.io/horologist/datalayer-phone-ui/#reengage-prompt +[9]: https://google.github.io/horologist/datalayer-phone-ui/#signin-prompt +[10]: https://google.github.io/horologist/datalayer-phone-ui/#install-tile-prompt +[11]: https://google.github.io/horologist/datalayer/ +[12]: https://google.github.io/horologist/datalayer-helpers-guide/#check-api-availability +[13]: https://google.github.io/horologist/datalayer-helpers-guide/#connection-and-installation-status Pre-requisites -------------- @@ -31,7 +96,7 @@ Pre-requisites Screenshots ------------- -Screenshot Screenshot +Screenshot Screenshot Getting Started --------------- diff --git a/DataLayer/Wearable/src/main/AndroidManifest.xml b/DataLayer/Wearable/src/main/AndroidManifest.xml index af60081b0..0219f7bfa 100644 --- a/DataLayer/Wearable/src/main/AndroidManifest.xml +++ b/DataLayer/Wearable/src/main/AndroidManifest.xml @@ -20,6 +20,7 @@ () + private val mainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - MainApp( - events = clientDataViewModel.events, - image = clientDataViewModel.image, - onQueryOtherDevicesClicked = ::onQueryOtherDevicesClicked, - onQueryMobileCameraClicked = ::onQueryMobileCameraClicked - ) + WearApp() } } - private fun onQueryOtherDevicesClicked() { - lifecycleScope.launch { - try { - val nodes = getCapabilitiesForReachableNodes() - .filterValues { MOBILE_CAPABILITY in it || WEAR_CAPABILITY in it }.keys - displayNodes(nodes) - } catch (cancellationException: CancellationException) { - throw cancellationException - } catch (exception: Exception) { - Log.d(TAG, "Querying nodes failed: $exception") - } - } - } - - private fun onQueryMobileCameraClicked() { - lifecycleScope.launch { - try { - val nodes = getCapabilitiesForReachableNodes() - .filterValues { MOBILE_CAPABILITY in it && CAMERA_CAPABILITY in it }.keys - displayNodes(nodes) - } catch (cancellationException: CancellationException) { - throw cancellationException - } catch (exception: Exception) { - Log.d(TAG, "Querying nodes failed: $exception") - } - } - } - - /** - * Collects the capabilities for all nodes that are reachable using the [CapabilityClient]. - * - * [CapabilityClient.getAllCapabilities] returns this information as a [Map] from capabilities - * to nodes, while this function inverts the map so we have a map of [Node]s to capabilities. - * - * This form is easier to work with when trying to operate upon all [Node]s. - */ - private suspend fun getCapabilitiesForReachableNodes(): Map> = - capabilityClient.getAllCapabilities(CapabilityClient.FILTER_REACHABLE) - .await() - // Pair the list of all reachable nodes with their capabilities - .flatMap { (capability, capabilityInfo) -> - capabilityInfo.nodes.map { it to capability } - } - // Group the pairs by the nodes - .groupBy( - keySelector = { it.first }, - valueTransform = { it.second } - ) - // Transform the capability list for each node into a set - .mapValues { it.value.toSet() } - - private fun displayNodes(nodes: Set) { - val message = if (nodes.isEmpty()) { - getString(R.string.no_device) - } else { - getString(R.string.connected_nodes, nodes.joinToString(", ") { it.displayName }) - } - - Toast.makeText(this, message, Toast.LENGTH_SHORT).show() - } - override fun onResume() { super.onResume() - dataClient.addListener(clientDataViewModel) - messageClient.addListener(clientDataViewModel) + dataClient.addListener(mainViewModel) + messageClient.addListener(mainViewModel) capabilityClient.addListener( - clientDataViewModel, + mainViewModel, Uri.parse("wear://"), CapabilityClient.FILTER_REACHABLE ) @@ -125,16 +52,16 @@ class MainActivity : ComponentActivity() { override fun onPause() { super.onPause() - dataClient.removeListener(clientDataViewModel) - messageClient.removeListener(clientDataViewModel) - capabilityClient.removeListener(clientDataViewModel) + dataClient.removeListener(mainViewModel) + messageClient.removeListener(mainViewModel) + capabilityClient.removeListener(mainViewModel) } companion object { private const val TAG = "MainActivity" - private const val CAMERA_CAPABILITY = "camera" - private const val WEAR_CAPABILITY = "wear" - private const val MOBILE_CAPABILITY = "mobile" + const val CAMERA_CAPABILITY = "camera" + const val WEAR_CAPABILITY = "wear" + const val MOBILE_CAPABILITY = "mobile" } } diff --git a/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/MainApp.kt b/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/MainApp.kt deleted file mode 100644 index 7876f12b8..000000000 --- a/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/MainApp.kt +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.example.android.wearable.datalayer - -import android.graphics.Bitmap -import android.graphics.Color -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.wear.compose.foundation.lazy.items -import androidx.wear.compose.material.Card -import androidx.wear.compose.material.MaterialTheme -import androidx.wear.compose.material.Text -import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices -import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales -import com.google.android.horologist.annotations.ExperimentalHorologistApi -import com.google.android.horologist.compose.layout.AppScaffold -import com.google.android.horologist.compose.layout.ScalingLazyColumn -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults -import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.ItemType -import com.google.android.horologist.compose.layout.ScreenScaffold -import com.google.android.horologist.compose.layout.rememberResponsiveColumnState -import com.google.android.horologist.compose.material.Chip - -@OptIn(ExperimentalHorologistApi::class) -@Composable -fun MainApp( - events: List, - image: Bitmap?, - onQueryOtherDevicesClicked: () -> Unit, - onQueryMobileCameraClicked: () -> Unit -) { - AppScaffold { - val columnState = rememberResponsiveColumnState( - contentPadding = ScalingLazyColumnDefaults.padding( - first = ItemType.Chip, - last = ItemType.Text - ) - ) - - ScreenScaffold(scrollState = columnState) { - /* - * The Horologist [ScalingLazyColumn] takes care of the horizontal and vertical - * padding for the list, so there is no need to specify it. - */ - ScalingLazyColumn( - columnState = columnState, - modifier = Modifier - .fillMaxSize() - ) { - item { - Chip( - label = stringResource(id = R.string.query_other_devices), - onClick = onQueryOtherDevicesClicked - ) - } - item { - Chip( - label = stringResource(id = R.string.query_mobile_camera), - onClick = onQueryMobileCameraClicked - ) - } - - item { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .padding(32.dp) - ) { - if (image == null) { - Image( - painterResource(id = R.drawable.photo_placeholder), - contentDescription = stringResource( - id = R.string.photo_placeholder - ), - modifier = Modifier.fillMaxSize() - ) - } else { - Image( - image.asImageBitmap(), - contentDescription = stringResource( - id = R.string.captured_photo - ), - modifier = Modifier.fillMaxSize() - ) - } - } - } - - if (events.isEmpty()) { - item { - Text( - stringResource(id = R.string.waiting), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - } - } else { - items(events) { event -> - Card( - onClick = {}, - enabled = false - ) { - Column { - Text( - stringResource(id = event.title), - style = MaterialTheme.typography.title3 - ) - Text( - event.text, - style = MaterialTheme.typography.body2 - ) - } - } - } - } - } - } - } -} - -@WearPreviewDevices -@WearPreviewFontScales -@Composable -fun MainAppPreviewEvents() { - MainApp( - events = listOf( - Event( - title = R.string.data_item_changed, - text = "Event 1" - ), - Event( - title = R.string.data_item_deleted, - text = "Event 2" - ), - Event( - title = R.string.data_item_unknown, - text = "Event 3" - ), - Event( - title = R.string.message, - text = "Event 4" - ), - Event( - title = R.string.data_item_changed, - text = "Event 5" - ), - Event( - title = R.string.data_item_deleted, - text = "Event 6" - ) - ), - image = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888).apply { - eraseColor(Color.WHITE) - }, - onQueryOtherDevicesClicked = {}, - onQueryMobileCameraClicked = {} - ) -} - -@WearPreviewDevices -@WearPreviewFontScales -@Composable -fun MainAppPreviewEmpty() { - MainApp( - events = emptyList(), - image = null, - onQueryOtherDevicesClicked = {}, - onQueryMobileCameraClicked = {} - ) -} diff --git a/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/ClientDataViewModel.kt b/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/MainViewModel.kt similarity index 53% rename from DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/ClientDataViewModel.kt rename to DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/MainViewModel.kt index e93bd6875..eb935910e 100644 --- a/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/ClientDataViewModel.kt +++ b/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/MainViewModel.kt @@ -20,12 +20,12 @@ import android.app.Application import android.graphics.Bitmap import android.graphics.BitmapFactory import androidx.annotation.StringRes -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory import com.google.android.gms.wearable.Asset import com.google.android.gms.wearable.CapabilityClient import com.google.android.gms.wearable.CapabilityInfo @@ -38,11 +38,16 @@ import com.google.android.gms.wearable.MessageEvent import com.google.android.gms.wearable.Wearable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext -class ClientDataViewModel( +class MainViewModel( application: Application ) : AndroidViewModel(application), @@ -50,76 +55,85 @@ class ClientDataViewModel( MessageClient.OnMessageReceivedListener, CapabilityClient.OnCapabilityChangedListener { - private val _events = mutableStateListOf() - /** - * The list of events from the clients. + * The list ef events. */ - val events: List = _events + private val events = MutableSharedFlow>() /** * The currently received image (if any), available to display. */ - var image by mutableStateOf(null) - private set + private val image = MutableSharedFlow() private var loadPhotoJob: Job = Job().apply { complete() } @SuppressLint("VisibleForTests") override fun onDataChanged(dataEvents: DataEventBuffer) { // Add all events to the event log - _events.addAll( - dataEvents.map { dataEvent -> + runBlocking { + events.emit(dataEvents.map { dataEvent -> val title = when (dataEvent.type) { DataEvent.TYPE_CHANGED -> R.string.data_item_changed DataEvent.TYPE_DELETED -> R.string.data_item_deleted else -> R.string.data_item_unknown } - Event( title = title, text = dataEvent.dataItem.toString() + ) + } - ) - - // Do additional work for specific events - dataEvents.forEach { dataEvent -> - when (dataEvent.type) { - DataEvent.TYPE_CHANGED -> { - when (dataEvent.dataItem.uri.path) { - DataLayerListenerService.IMAGE_PATH -> { - loadPhotoJob.cancel() - loadPhotoJob = viewModelScope.launch { - image = loadBitmap( - DataMapItem.fromDataItem(dataEvent.dataItem) - .dataMap - .getAsset(DataLayerListenerService.IMAGE_KEY) - ) + + ) + } + // Do additional work for specific events + dataEvents.forEach { dataEvent -> + when (dataEvent.type) { + DataEvent.TYPE_CHANGED -> { + when (dataEvent.dataItem.uri.path) { + DataLayerListenerService.IMAGE_PATH -> { + loadPhotoJob.cancel() + loadPhotoJob = viewModelScope.launch { + loadBitmap( + DataMapItem.fromDataItem(dataEvent.dataItem) + .dataMap + .getAsset(DataLayerListenerService.IMAGE_KEY) + )?.let { + image.emit( + it + ) + } + } } } } } - } } } override fun onMessageReceived(messageEvent: MessageEvent) { - _events.add( - Event( - title = R.string.message, - text = messageEvent.toString() + runBlocking { + events.emit( + listOf( Event( + title = R.string.message, + text = messageEvent.toString() + ) ) - ) + ) + } } override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) { - _events.add( - Event( - title = R.string.capability_changed, - text = capabilityInfo.toString() + runBlocking { + events.emit( + listOf(Event( + title = R.string.capability_changed, + text = capabilityInfo.toString() + ) + ) ) - ) + } } private suspend fun loadBitmap(asset: Asset?): Bitmap? { @@ -132,6 +146,36 @@ class ClientDataViewModel( } } } + + companion object { + private const val TAG = "MainViewModel" + + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { + val application = this[APPLICATION_KEY]!! + MainViewModel( + application + ) + } + } + } + val state = + combine( + events, + image + ) { events, image -> + UIState(events, image) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = UIState() + ) + + data class UIState( + val events: List = listOf(), + val image: Bitmap? = null + ) } /** diff --git a/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/NodesScreen.kt b/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/NodesScreen.kt new file mode 100644 index 000000000..2fffd6d77 --- /dev/null +++ b/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/NodesScreen.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.android.wearable.datalayer + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.wear.compose.material.Text +import com.google.android.gms.wearable.Node +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.compose.layout.ScalingLazyColumn +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.ItemType +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.google.android.horologist.compose.material.Chip +import com.google.android.horologist.compose.material.ResponsiveListHeader + +@Composable +@OptIn(ExperimentalHorologistApi::class) +fun NodesScreen( + nodes: Set, + modifier: Modifier = Modifier +) { + val columnState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ItemType.Text, + last = ItemType.Chip + ) + ) + ScreenScaffold(scrollState = columnState) { + ScalingLazyColumn( + columnState = columnState, + modifier = modifier + ) { + item { + ResponsiveListHeader { + Text("Nodes") + } + } + items(nodes.size) { index -> + Chip( + label = nodes.elementAt(index).displayName, + onClick = { }, + secondaryLabel = nodes.elementAt(index).id + ) + } + } + } +} diff --git a/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/NodesViewModel.kt b/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/NodesViewModel.kt new file mode 100644 index 000000000..4696837a0 --- /dev/null +++ b/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/NodesViewModel.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.android.wearable.datalayer + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.example.android.wearable.datalayer.MainActivity.Companion.CAMERA_CAPABILITY +import com.example.android.wearable.datalayer.MainActivity.Companion.MOBILE_CAPABILITY +import com.example.android.wearable.datalayer.MainActivity.Companion.WEAR_CAPABILITY +import com.google.android.gms.wearable.CapabilityClient +import com.google.android.gms.wearable.Node +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.tasks.await + +class NodesViewModel( + private val capabilityClient: CapabilityClient +) : ViewModel() { + + private val nodes: SharedFlow> = flow { + emit( + getCapabilitiesForReachableNodes() + .filterValues { MOBILE_CAPABILITY in it || WEAR_CAPABILITY in it }.keys + ) + }.shareIn( + viewModelScope, + started = SharingStarted.Eagerly, + replay = 1 + ) + + private val cameraNodes: SharedFlow> = flow { + emit( + getCapabilitiesForReachableNodes() + .filterValues { MOBILE_CAPABILITY in it && CAMERA_CAPABILITY in it }.keys + ) + }.shareIn( + viewModelScope, + started = SharingStarted.Eagerly, + replay = 1 + ) + + /** + * Collects the capabilities for all nodes that are reachable using the [CapabilityClient]. + * + * [CapabilityClient.getAllCapabilities] returns this information as a [Map] from capabilities + * to nodes, while this function inverts the map so we have a map of [Node]s to capabilities. + * + * This form is easier to work with when trying to operate upon all [Node]s. + */ + private suspend fun getCapabilitiesForReachableNodes(): Map> = + capabilityClient.getAllCapabilities(CapabilityClient.FILTER_REACHABLE) + .await() + // Pair the list of all reachable nodes with their capabilities + .flatMap { (capability, capabilityInfo) -> + capabilityInfo.nodes.map { it to capability } + } + // Group the pairs by the nodes + .groupBy( + keySelector = { it.first }, + valueTransform = { it.second } + ) + // Transform the capability list for each node into a set + .mapValues { it.value.toSet() } + + val state = + combine( + nodes, + cameraNodes + ) { nodes, cameraNodes -> + UIState(nodes, cameraNodes) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = UIState() + ) + + data class UIState( + val nodes: Set = setOf(), + val cameraNodes: Set = setOf() + ) + + companion object { + private const val TAG = "NodesViewModel" + + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { + val application = this[APPLICATION_KEY]!! + + val capabilityClient = (application as SampleApplication).capabilityClient + NodesViewModel( + capabilityClient + ) + } + } + } +} diff --git a/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/SampleApplication.kt b/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/SampleApplication.kt new file mode 100644 index 000000000..ea2e19d56 --- /dev/null +++ b/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/SampleApplication.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.android.wearable.datalayer; + +import android.app.Application +import com.google.android.gms.wearable.Wearable + +class SampleApplication : Application() { + + val capabilityClient by lazy { Wearable.getCapabilityClient(this) } + +} diff --git a/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/WearApp.kt b/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/WearApp.kt new file mode 100644 index 000000000..3d1a5d490 --- /dev/null +++ b/DataLayer/Wearable/src/main/java/com/example/android/wearable/datalayer/WearApp.kt @@ -0,0 +1,234 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.android.wearable.datalayer + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.material.Card +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import androidx.wear.compose.navigation.SwipeDismissableNavHost +import androidx.wear.compose.navigation.composable +import androidx.wear.compose.navigation.rememberSwipeDismissableNavController +import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices +import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.compose.layout.AppScaffold +import com.google.android.horologist.compose.layout.ScalingLazyColumn +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults +import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults.ItemType +import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.google.android.horologist.compose.material.Chip + +@Composable +fun WearApp() { + AppScaffold { + val navController = rememberSwipeDismissableNavController() + SwipeDismissableNavHost(navController = navController, startDestination = "main") { + composable("main") { + MainScreen( + onShowNodesList = { navController.navigate("nodeslist") }, + onShowCameraNodesList = { navController.navigate("cameraNodeslist") }, + ) + } + composable("nodeslist") { + ConnectedNodesScreen() + } + composable("cameraNodeslist") { + CameraNodesScreen() + } + } + } +} + +@Composable +fun MainScreen( + onShowNodesList: () -> Unit, + onShowCameraNodesList: () -> Unit, + mainViewModel: MainViewModel = viewModel(factory = MainViewModel.Factory), +) { + val state by mainViewModel.state.collectAsStateWithLifecycle() + + MainScreen( + state.image, + state.events, + onShowNodesList, + onShowCameraNodesList, + ) +} +@OptIn(ExperimentalHorologistApi::class) +@Composable +fun MainScreen(image: Bitmap?, + events: List, + onShowNodesList: () -> Unit, + onShowCameraNodesList: () -> Unit,){ + + val columnState = rememberResponsiveColumnState( + contentPadding = ScalingLazyColumnDefaults.padding( + first = ItemType.Chip, + last = ItemType.Text + ) + ) + + ScreenScaffold(scrollState = columnState) { + /* + * The Horologist [ScalingLazyColumn] takes care of the horizontal and vertical + * padding for the list, so there is no need to specify it. + */ + ScalingLazyColumn( + columnState = columnState, + modifier = Modifier + ) { + item { + Chip( + label = stringResource(id = R.string.query_other_devices), + onClick = onShowNodesList + ) + } + item { + Chip( + label = stringResource(id = R.string.query_mobile_camera), + onClick = onShowCameraNodesList + ) + } + + item { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .padding(32.dp) + ) { + if (image == null) { + Image( + painterResource(id = R.drawable.photo_placeholder), + contentDescription = stringResource( + id = R.string.photo_placeholder + ), + modifier = Modifier.fillMaxSize() + ) + } else { + Image( + image.asImageBitmap(), + contentDescription = stringResource( + id = R.string.captured_photo + ), + modifier = Modifier.fillMaxSize() + ) + } + } + } + + if (events.isEmpty()) { + item { + Text( + stringResource(id = R.string.waiting), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } else { + items(events) { event -> + Card( + onClick = {}, + enabled = false + ) { + Column { + Text( + stringResource(id = event.title), + style = MaterialTheme.typography.title3 + ) + Text( + event.text, + style = MaterialTheme.typography.body2 + ) + } + } + } + } + } + } +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun MainScreenPreviewEvents() { + MainScreen( + events = listOf( + Event( + title = R.string.data_item_changed, + text = "Event 1" + ), + Event( + title = R.string.data_item_deleted, + text = "Event 2" + ), + Event( + title = R.string.data_item_unknown, + text = "Event 3" + ), + Event( + title = R.string.message, + text = "Event 4" + ), + Event( + title = R.string.data_item_changed, + text = "Event 5" + ), + Event( + title = R.string.data_item_deleted, + text = "Event 6" + ) + ), + image = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888).apply { + eraseColor(Color.WHITE) + }, + onShowCameraNodesList = {}, + onShowNodesList = {} + ) +} + +@WearPreviewDevices +@WearPreviewFontScales +@Composable +fun MainScreenPreviewEmpty() { + MainScreen( + events = emptyList(), + image = null, + onShowCameraNodesList = {}, + onShowNodesList = {} + ) +} diff --git a/DataLayer/gradle/libs.versions.toml b/DataLayer/gradle/libs.versions.toml index 3a360e40f..7f37b12b6 100644 --- a/DataLayer/gradle/libs.versions.toml +++ b/DataLayer/gradle/libs.versions.toml @@ -2,7 +2,8 @@ android-gradle-plugin = "8.4.1" androidx-activity = "1.9.0" androidx-compose-bom = "2024.05.00" -androidx-lifecycle = "2.8.1" +# do not bump to 2.8.0 until bumping to beta version of Compose +androidx-lifecycle = "2.7.0" androidx-wear-compose = "1.3.1" compose-compiler = "1.5.14" ktlint = "0.50.0" diff --git a/DataLayer/screenshots/phone_image.png b/DataLayer/screenshots/phone_image.png index e045b6543..3172828e7 100644 Binary files a/DataLayer/screenshots/phone_image.png and b/DataLayer/screenshots/phone_image.png differ diff --git a/DataLayer/screenshots/wearable_background_image.png b/DataLayer/screenshots/wearable_background_image.png deleted file mode 100644 index 78261808d..000000000 Binary files a/DataLayer/screenshots/wearable_background_image.png and /dev/null differ diff --git a/DataLayer/screenshots/wearable_image b/DataLayer/screenshots/wearable_image new file mode 100644 index 000000000..f4ffc2f46 Binary files /dev/null and b/DataLayer/screenshots/wearable_image differ