The state update from AmbientAware
diff --git a/api/auth/data/com.google.android.horologist.auth.data.oauth.pkce/-p-k-c-e-o-auth-code-repository/fetch.html b/api/auth/data/com.google.android.horologist.auth.data.oauth.pkce/-p-k-c-e-o-auth-code-repository/fetch.html index 4cafdbbff5..9cf5fdf3de 100644 --- a/api/auth/data/com.google.android.horologist.auth.data.oauth.pkce/-p-k-c-e-o-auth-code-repository/fetch.html +++ b/api/auth/data/com.google.android.horologist.auth.data.oauth.pkce/-p-k-c-e-o-auth-code-repository/fetch.html @@ -63,7 +63,7 @@
Factory method for TokenBundleRepositoryImpl.
If multiple token bundles are available, specify the key of the specific token bundle wished to be retrieved. Otherwise the token bundle stored with the default key will be used.
Factory method for TokenBundleRepositoryImpl.
If multiple token bundles are available, specify the key of the specific token bundle wished to be retrieved. Otherwise the token bundle stored with the default key will be used.
Factory method for TokenBundleRepositoryImpl.
Factory method for TokenBundleRepositoryImpl.
An example of using AmbientAware: Provides the time, at the specified update frequency, whilst in interactive mode, or when ambient-generated updates occur (typically every 1 min).
An example of using AmbientAware: Provides the time, at the specified update frequency, whilst in interactive mode, or when ambient-generated updates occur (typically every 1 min).
A Navigation and Scroll aware Scaffold.
In addition to NavGraphBuilder.scrollable, 3 additional extensions are supported scalingLazyColumnComposable, scrollStateComposable and lazyListComposable.
These should be used to build the ScrollableState or FocusRequester as well as configure the behaviour of TimeText, PositionIndicator or Vignette.
A Navigation and Scroll aware Scaffold.
In addition to NavGraphBuilder.scrollable, 3 additional extensions are supported scalingLazyColumnComposable, scrollStateComposable and lazyListComposable.
These should be used to build the ScrollableState or FocusRequester as well as configure the behaviour of TimeText, PositionIndicator or Vignette.
The context items provided to a navigation composable.
The context items provided to a navigation composable.
The context items provided to a navigation composable.
The context items provided to a navigation composable.
Add non scrolling screen to the navigation graph. The NavBackStackEntry and NavScaffoldViewModel are passed into the content block so that the Scaffold may be customised, such as disabling TimeText.
Add non scrolling screen to the navigation graph. The NavBackStackEntry and NavScaffoldViewModel are passed into the content block so that the Scaffold may be customised, such as disabling TimeText.
Add a screen to the navigation graph featuring a Lazy list such as LazyColumn.
Add a screen to the navigation graph featuring a Lazy list such as LazyColumn.
Add a screen to the navigation graph featuring a ScalingLazyColumn.
Add a screen to the navigation graph featuring a ScalingLazyColumn.
Add a screen to the navigation graph featuring a Scrollable item.
Add a screen to the navigation graph featuring a Scrollable item.
A Navigation and Scroll aware Scaffold.
A Navigation and Scroll aware Scaffold.
Add a screen to the navigation graph featuring a Lazy list such as LazyColumn.
The scrollState must be taken from the ScaffoldContext.
Add a screen to the navigation graph featuring a Lazy list such as LazyColumn.
The scrollState must be taken from the ScaffoldContext.
Adds the LazyPagingItems and their content to the scope. The range from 0 (inclusive) to LazyPagingItems.itemCount (exclusive) always represents the full range of presentable items, because every event from PagingDataDiffer will trigger a recomposition.
Adds the LazyPagingItems and their content to the scope. The range from 0 (inclusive) to LazyPagingItems.itemCount (exclusive) always represents the full range of presentable items, because every event from PagingDataDiffer will trigger a recomposition.
Adds the LazyPagingItems and their content to the scope. The range from 0 (inclusive) to LazyPagingItems.itemCount (exclusive) always represents the full range of presentable items, because every event from PagingDataDiffer will trigger a recomposition.
Code from https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:paging/paging-compose/src/main/java/androidx/paging/compose/LazyPagingItems.kt
the items received from a Flow of PagingData.
a factory of stable and unique keys representing the item. Using the same key for multiple items in the list is not allowed. Type of the key should be saveable via Bundle on Android. If null is passed the position in the list will represent the key. When you specify the key the scroll position will be maintained based on the key, which means if you add/remove items before the current visible item the item with the given key will be kept as the first visible one.
the content displayed by a single item. In case the item is null
, the itemContent method should handle the logic of displaying a placeholder instead of the main content displayed by an item which is not null
.
Adds the LazyPagingItems and their content to the scope. The range from 0 (inclusive) to LazyPagingItems.itemCount (exclusive) always represents the full range of presentable items, because every event from PagingDataDiffer will trigger a recomposition.
Code from https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:paging/paging-compose/src/main/java/androidx/paging/compose/LazyPagingItems.kt
the items received from a Flow of PagingData.
a factory of stable and unique keys representing the item. Using the same key for multiple items in the list is not allowed. Type of the key should be saveable via Bundle on Android. If null is passed the position in the list will represent the key. When you specify the key the scroll position will be maintained based on the key, which means if you add/remove items before the current visible item the item with the given key will be kept as the first visible one.
the content displayed by a single item. In case the item is null
, the itemContent method should handle the logic of displaying a placeholder instead of the main content displayed by an item which is not null
.
Simple implementation of EventLogger for logging critical Media3 events.
Most logging behaviour is inherited from EventLogger.
Simple implementation of EventLogger for logging critical Media3 events.
Most logging behaviour is inherited from EventLogger.
Simple implementation of EventLogger for logging critical Media3 events.
Simple implementation of EventLogger for logging critical Media3 events.
Simple implementation of TransferListener and EventListener for networking activity.
Simple implementation of TransferListener and EventListener for networking activity.
wearAppHelper.markSetupComplete()
+
+And when the app is no longer considered in a fully setup state, use the following:
+wearAppHelper.markSetupNoLongerComplete()
+
Horologist is a group of libraries that aim to supplement Wear OS developers with features that are commonly required by developers but not yet available.
"},{"location":"#maintained-versions","title":"Maintained Versions","text":"The currently maintained branches of Horologist are.
Version Branch Description 0.4.x release-0.4.x Wear Compose 1.2.x (stable) and Media3, and generally stable APIs. 0.5.x main Wear Compose 1.3 alpha, Media3 and generally latest alphas of Androidx."},{"location":"#media","title":"\ud83c\udfb5 Media","text":"Horologist provides the Media Toolkit: a set of libraries to build Media apps on Wear OS and a sample app that you can run to see the toolkit in action.
The toolkit includes:
PlayerScreen
.horologist-media-ui
) that is agnostic to the Player implementation.horologist-media
) using Media3.High quality prebuilt composables, such as Time and Date pickers.
Layout related functionality such as a Navigation Aware Scaffold.
Opinionated implementation of the components of the Wear Material Compose library , based on the specifications of Wear Material Design Kit .
Domain model for Audio related functionality. Volume Control, Output switching. Subscribing to a Flow of changes in audio or output.
Libraries to help developers to build apps following the Sign-In guidelines for Wear OS .
auth-data
library.auth-data
libraryauth-data
library.Kotlin coroutines flavoured TileService.
horologist-tiles
"},{"location":"#why-the-name","title":"Why the name?","text":"The name mirrors the Accompanist name, and is also Watch related.
https://en.wiktionary.org/wiki/horologist
horologist (Noun) Someone who makes or repairs timepieces, watches or clocks.
"},{"location":"#contributions","title":"Contributions","text":"Please contribute! We will gladly review any pull requests submitted. Make sure to read the Contributing page to know what our expectations of contributions are.
"},{"location":"#license","title":"License","text":"Copyright 2023 The Android Open Source Project\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n https://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n
"},{"location":"audio-ui/","title":"Audio Settings UI library","text":""},{"location":"audio-ui/#volume-screen","title":"Volume Screen","text":"A volume screen, showing the current audio output (headphones, speakers) and allowing to change the button with a stepper or bezel.
VolumeScreen(focusRequester = focusRequester)\n
"},{"location":"audio-ui/#download","title":"Download","text":"repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-audio-ui:<version>\"\n}\n
"},{"location":"audio/","title":"Audio Settings Library","text":"Domain model for Volume and Audio Output.
val audioRepository = SystemAudioRepository.fromContext(application)\n\naudioRepository.increaseVolume()\n\nval volumeState: StateFlow<VolumeState> = audioRepository.volumeState\n\nval audioOutput: StateFlow<AudioOutput> = audioRepository.audioOutput\n\nval output = audioOutput.value\nif (output is AudioOutput.BluetoothHeadset) {\n println(output.name)\n}\n
"},{"location":"audio/#download","title":"Download","text":"repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-audio:<version>\"\n}\n
"},{"location":"auth-composables/","title":"Auth Composables","text":"This library contains a set of composables screens and components related to authentication.
The previews of the composables can be found in the debug
folder of the module source code.
This library is not dependent on any specific authentication implementation as per architecture overview.
"},{"location":"auth-composables/#screens","title":"Screens","text":"SignInPlaceholderScreen
SelectAccountScreen
CheckYourPhoneScreen
CreateAccountChip
GuestModeChip
OtherOptionsChip
SignInChip
This library contains implementation for Mobile apps for some of the authentication methods provided by the auth-data
library.
This library contains implementation for Wear apps for most of the authentication methods listed in the Authentication on wearables guide.
The repositories of this library are built mainly to support the components from auth-ui library, but can be used with your own UI components.
"},{"location":"auth-data/#token-sharing","title":"Token sharing","text":"This guide will walk you through on how to display a screen on your watch app so that users can select their Google account to sign-in to your app.
"},{"location":"auth-googlesignin-guide/#requirements","title":"Requirements","text":"Follow the setup instructions for integrating Google Sign-in into an Android app from this link.
"},{"location":"auth-googlesignin-guide/#getting-started","title":"Getting started","text":"Add dependencies
Add the following dependencies to your project\u2019s build.gradle:
dependencies {\n implementation \"com.google.android.horologist:horologist-auth-composables:<version>\"\n implementation \"com.google.android.horologist:horologist-auth-ui:<version>\"\n implementation \"com.google.android.horologist:horologist-compose-material:<version>\"\n}\n
Create an instance of GoogleSignInClient
Create an instance of GoogleSignInClient, according to your requirements, for example:
val googleSignInClient = GoogleSignIn.getClient(\n applicationContext,\n GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .build()\n)\n
Create a ViewModel
Create your implementation of GoogleSignInViewModel, passing the GoogleSignInClient
created:
class MyGoogleSignInViewModel(\n googleSignInClient: GoogleSignInClient,\n) : GoogleSignInViewModel(googleSignInClient)\n
Display the screen
Display the GoogleSignInScreen passing an instance of the GoogleSignInViewModel
created:
GoogleSignInScreen(\n onAuthCancelled = { /* code to navigate to another screen on this event */ },\n onAuthSucceed = { /* code to navigate to another screen on this event */ },\n viewModel = hiltViewModel<MyGoogleSignInViewModel>()\n)\n
This sample uses Hilt to retrieve an instance of the ViewModel, but you should use what suits your project best, see this link for more info.
"},{"location":"auth-googlesignin-guide/#retrieve-the-signed-in-account","title":"Retrieve the signed in account","text":"In order to have access an instance of the GoogleSignInAccount selected by the user, follow the steps:
Implement GoogleSignInEventListener
class GoogleSignInEventListenerImpl : GoogleSignInEventListener {\n override suspend fun onSignedIn(account: GoogleSignInAccount) {\n // your implementation using the account parameter\n }\n}\n
Pass the listener to the ViewModel
Pass an instance of GoogleSignInEventListener
to GoogleSignInViewModel
:
class MyGoogleSignInViewModel(\n googleSignInClient: GoogleSignInClient,\n googleSignInEventListener: GoogleSignInEventListener,\n) : GoogleSignInViewModel(googleSignInClient, googleSignInEventListener)\n
The purpose of the auth libraries is to:
The following libraries are provided:
auth-data
library.auth-data
library.auth-data
library.The following sample apps are also provided:
The auth libraries are separated by layers (UI and data), following the recommended app architecture. The reason for including an extra UI library (auth-composables
) is to provide flexibility to projects that would like to only use the UI components that are not dependent on auth-data
.
The usage of the auth libraries will vary according to the requirements of your project.
As per architecture overview, your project might not need to add all the auth libraries as dependency. If that\u2019s the case, refer to the documentation of each library required to your project for a guide on how to get started.
"},{"location":"auth-sample-apps/","title":"Auth sample apps","text":""},{"location":"auth-sample-apps/#wear-sample","title":"Wear sample","text":"The app showcases the implementation of the following authentication methods:
The app showcases the implementation of the following authentication methods:
This guide will walk you through on how to securely transfer authentication data from the phone app to the watch app using Horologist's Auth libraries.
"},{"location":"auth-tokenshare-guide/#requirements","title":"Requirements","text":"Horologist Auth library is built on top of Wearable Data Layer API, so your phone and watch apps must:
Add dependencies
Add the following dependencies to your project\u2019s build.gradle.
For the phone app project:
dependencies {\n implementation \"com.google.android.horologist:horologist-auth-data-phone:<version>\"\n implementation \"com.google.android.horologist:horologist-datalayer:<version>\"\n}\n
For the watch app project:
dependencies {\n implementation \"com.google.android.horologist:horologist-auth-data:<version>\"\n implementation \"com.google.android.horologist:horologist-datalayer:<version>\"\n}\n
Add capability to phone app project
On the phone app project, add a wear.xml
file in the res/values
folder with the following content:
<resources>\n <string-array name=\"android_wear_capabilities\">\n <item>horologist_phone</item>\n </string-array>\n</resources>\n
Create a WearDataLayerRegistry
In both projects, create an instance of WearDataLayerRegistry from the datalayer:
val registry = WearDataLayerRegistry.fromContext(\n application = // application context,\n coroutineScope = // a coroutine scope\n)\n
This class should be created as a singleton in your app.
Define the data to be transferred
Define which authentication data that should be transferred from the phone to the watch. It can be a data class with many properties, it can also be a protocol buffer. For this guide, we will pass a simple String
instance.
Create a Serializer
for the data
Create a DataStore Serializer
class for the class defined to be transferred from the phone to the watch (String
for this guide):
public object TokenSerializer : Serializer<String> {\n override val defaultValue: String = \"\"\n\n override suspend fun readFrom(input: InputStream): String =\n InputStreamReader(input).readText()\n\n override suspend fun writeTo(t: String, output: OutputStream) {\n withContext(Dispatchers.IO) {\n output.write(t.toByteArray())\n }\n }\n} \n
This class should preferable be placed in a shared module between the phone and watch projects, but could also be duplicated in both projects.
More information about this serialization in this blog post.
Create a TokenBundleRepository
on the phone project
Create an instance of TokenBundleRepository on the phone app project:
val tokenBundleRepository = TokenBundleRepositoryImpl(\n registry = registry,\n coroutineScope = // a coroutine scope,\n serializer = TokenSerializer\n) \n
Check if the repository is available (optional)
Before using the repository, you can check if it is available to be used on the current device with:
tokenBundleRepository.isAvailable()\n
If the repository is not available on the device, all the calls to it will fail silently.
See the requirements of Wearable Data Layer API.
Send authentication data
The authentication data can be sent from the phone calling update
:
tokenBundleRepository.update(\"token\")\n
Create a TokenBundleRepository
on the watch project
Create an instance of TokenBundleRepository on the watch app project:
val tokenBundleRepository = TokenBundleRepositoryImpl.create(\n registry = registry,\n serializer = TokenSerializer\n)\n
Receive authentication data
The authentication data can be listened from the watch via the flow
property:
tokenBundleRepository.flow\n
This library contains a set of composables screens and components related to authentication.
The previews of the composables can be found in the debug
folder of the module source code.
The composables of this module might depend on repository interfaces defined in auth-data library. The implementation of these repositories does not necessarily need to be from auth-data
, they can be your own implementation. Some of the composables might depend on an external library.
A screen to prompt users to sign in.
It helps achieve to the following best practices:
A screen for the Google Sign-In authentication method.
It uses different screens from auth-composables to display the full authentication flow.
It relies on the Google Sign-In for Android library for authentication and account selection. So an instance of GoogleSignInClient has to be provided to GoogleSignInViewModel
.
A screen for the OAuth (PKCE) authentication method.
It uses different screens from auth-composables to display the full authentication flow.
A implementation for the following repositories are required to be provided:
A screen for the OAuth (Device Grant) authentication method.
It uses different screens from auth-composables to display the full authentication flow.
A implementation for the following repositories are required to be provided:
repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-composables:<version>\"\n}\n
"},{"location":"compose-layout/","title":"Compose Layout library","text":""},{"location":"compose-layout/#scalinglazycolumn-responsive-layout","title":"ScalingLazyColumn responsive() layout.","text":"The responsive()
layout factory will ensure that your ScalingLazyColumn is positioned correctly on all screen sizes.
Pass in a boolean for the firstItemIsFullWidth
param to indicate whether the first item can fit just below TimeText, or must be shifted down further to avoid cutting off the edges.
The overloaded ScalingLazyColumn
composable with ScalingLazyColumnState
param, when combined with responsive()
will handle all the following:
val columnState =\n rememberColumnState(ScalingLazyColumnDefaults.responsive(firstItemIsFullWidth = false))\n\nScaffold(\n modifier = Modifier\n .fillMaxSize(),\n timeText = {\n TimeText(modifier = Modifier.scrollAway(columnState))\n }\n) {\n ScalingLazyColumn(\n modifier = Modifier.fillMaxSize(),\n columnState = columnState,\n ) {\n item {\n ListHeader {\n Text(\n text = \"Main\",\n modifier = Modifier.fillMaxWidth(0.6f),\n textAlign = TextAlign.Center\n )\n }\n }\n items(10) {\n Chip(\"Item $it\", onClick = {})\n }\n }\n}\n
"},{"location":"compose-layout/#navigation-scaffold","title":"Navigation Scaffold.","text":"Syncs the TimeText, PositionIndicator and Scaffold to the current navigation destination state. The TimeText will scroll out of the way of content automatically.
WearNavScaffold(\n startDestination = \"home\",\n navController = navController\n) {\n scalingLazyColumnComposable(\n \"home\",\n scrollStateBuilder = { ScalingLazyListState(initialCenterItemIndex = 0) }\n ) {\n MenuScreen(\n scrollState = it.scrollableState,\n focusRequester = it.viewModel.focusRequester\n )\n }\n\n scalingLazyColumnComposable(\n \"items\",\n scrollStateBuilder = { ScalingLazyListState() }\n ) {\n ScalingLazyColumn(\n modifier = Modifier\n .fillMaxSize()\n .scrollableColumn(it.viewModel.focusRequester, it.scrollableState),\n state = it.scrollableState\n ) {\n items(100) {\n Text(\"i = $it\")\n }\n }\n }\n\n scrollStateComposable(\n \"settings\",\n scrollStateBuilder = { ScrollState(0) }\n ) {\n Column(\n modifier = Modifier\n .fillMaxSize()\n .verticalScroll(state = it.scrollableState)\n .scrollableColumn(focusRequester = it.viewModel.focusRequester, scrollableState = it.scrollableState),\n horizontalAlignment = Alignment.CenterHorizontally\n ) {\n (1..100).forEach {\n Text(\"i = $it\")\n }\n }\n }\n}\n
"},{"location":"compose-layout/#box-inset-layout","title":"Box Inset Layout.","text":"Use as a break glass for simple layout to fit within a safe square.
Box(\n modifier = Modifier\n .fillMaxRectangle()\n) {\n // App Content here \n}\n
"},{"location":"compose-layout/#fade-away-modifier","title":"Fade Away Modifier","text":""},{"location":"compose-layout/#ambientaware-composable","title":"AmbientAware composable","text":"AmbientAware
allows your UI to react to ambient mode changes. For more information on how Ambient mode and Always-on work on Wear OS, see the developer guidance.
You should place this composable high up in your design, as it alters the behavior of the Activity.
@Composable\nfun WearApp() {\n AmbientAware { ambientStateUpdate ->\n // App Content here\n }\n}\n
If you need some screens to use always-on, and others not to, then you can use the additional argument supplied to AmbientAware
.
For example, in a workout app, it is desirable that the main workout screen uses always-on, but the workout summary at the end does not. See the ExerciseClient
guide and samples for more information on building a workout app.
@Composable\nfun WearApp() {\n // Hoist state here for your current screen logic\n\n AmbientAware(isAlwaysOnScreen = currentScreen.useAlwaysOn) { ambientStateUpdate ->\n // App Content here\n }\n}\n
"},{"location":"compose-layout/#download","title":"Download","text":"repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-compose-layout:<version>\"\n}\n
"},{"location":"compose-material/","title":"Compose Material","text":"A library providing opinionated implementation of the components of the Wear Material Compose library, based on the specifications of Wear Material Design Kit.
"},{"location":"compose-material/#motivation","title":"Motivation","text":"In order to display a Chip
component in a Wear OS app, using Wear Material Compose library, the code would look like this:
Chip(\n label = { Text(\"Primary label\") },\n onClick = { },\n secondaryLabel = { Text(\"Secondary label\") },\n icon = { Icon(imageVector = Icons.Default.Image, contentDescription = null) }\n)\n
In comparison, using Horologist's Compose Material library, the code would look simpler:
Chip(\n label = \"Primary label\",\n onClick = { },\n secondaryLabel = \"Secondary label\",\n icon = Icons.Default.Image\n)\n
As seen above, Horologist's Compose Material provides convenient ways of passing parameters to the components. Furthermore, for this particular component, it will also take care of:
The list is not exhaustive and it varies for each individual component.
"},{"location":"compose-material/#when-this-library-should-not-be-used","title":"When this library should not be used?","text":"If the specifications for the component needed in your app does not match the specifications of the components listed in Wear Material Design Kit, then Wear Material Compose library should be used instead.
"},{"location":"compose-tools/","title":"Compose Tools library","text":""},{"location":"compose-tools/#tile-previews","title":"Tile Previews.","text":"Android Studio Preview support for tiles, using the TilesRenderer inside and AndroidView. Uses either raw Tiles proto, or the TilesLayoutRenderer abstraction to define a predictable process for generating a Tile for a given state.
@WearPreviewDevices\n@WearPreviewFontSizes\n@Composable\nfun SampleTilePreview() {\n val context = LocalContext.current\n\n val tileState = remember { SampleTileRenderer.TileState(0) }\n\n val resourceState = remember {\n val image =\n BitmapFactory.decodeResource(context.resources, R.drawable.ic_uamp).toImageResource()\n SampleTileRenderer.ResourceState(image)\n }\n\n val renderer = remember {\n SampleTileRenderer(context)\n }\n\n TileLayoutPreview(\n tileState,\n resourceState,\n renderer\n )\n}\n
"},{"location":"compose-tools/#download","title":"Download","text":"repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-compose-tools:<version>\"\n}\n
"},{"location":"contributing/","title":"How to Contribute","text":"We'd love to accept your patches and contributions to this project. There are just a few small guidelines you need to follow.
If you find a common problem that you think would help other Wear developers please consider submitting a PR. Please avoid significant work before raising an issue https://github.com/google/horologist/issues with the label \"Feature Request\"
"},{"location":"contributing/#development","title":"Development","text":"The project should work immediately from a fresh checkout in Android Studio (Stable or newer) or Gradle (./gradlew).
When submitting a PR, please check API compatibility and lint rules first.
A good first step is
./gradlew spotlessApply spotlessCheck compileDebugSources compileReleaseSources metalavaGenerateSignature metalavaGenerateSignatureRelease lintDebug\n
Also make sure you have (Git LFS) installed.
If you change any code affecting screenshot tests, then run the following to update changed images on a Linux host. Alternatively uncomment the same property in gradle.properties.
./gradlew testDebug -P screenshot.record=repair\n
This can be automated For a PR against the same branch, the Github action should commit against PR if possible. For maintainers, this means creating the branch in the google/horologist repo. For contributors, once your PR fails, create a second PR in your fork, that will fix the issue.
"},{"location":"contributing/#contributor-license-agreement","title":"Contributor License Agreement","text":"Contributions to this project must be accompanied by a Contributor License Agreement. You (or your employer) retain the copyright to your contribution, this simply gives us permission to use and redistribute your contributions as part of the project. Head over to https://cla.developers.google.com/ to see your current agreements on file or to sign a new one.
You generally only need to submit a CLA once, so if you've already submitted one (even if it was for a different project), you probably don't need to do it again.
"},{"location":"contributing/#code-reviews","title":"Code reviews","text":"All submissions, including submissions by project members, require review. We use GitHub pull requests for this purpose. Consult GitHub Help for more information on using pull requests.
"},{"location":"contributing/#translation-and-localization","title":"Translation and localization","text":"This project uses a semi-automatic pipeline to translate strings. When new or updated localized strings are ready, a PR is generated (example: google/horologist#692). Only the files configured via localization.bzl are sent for translation.
If you see a problem with translated text, don't edit localized resource files (e.g. res/values-en/strings.xml
) manually, as they'll be overwritten. Instead, file an issue and use the l10n label. This will then be forwarded to the relevant teams.
There are a couple of reasons we may not accept an otherwise valuable contribution.
These libraries provides an easy means to detect and install your app across both watch and phone.
However, they are not intended to cover complex use cases, or complex interactions between watch and phone.
"},{"location":"datalayer-helpers-guide/#getting-started","title":"Getting started","text":"Include the necessary dependency:
dependencies {\n implementation \"com.google.android.horologist:horologist-datalayer-watch:<version>\"\n}\n
and
dependencies {\n implementation \"com.google.android.horologist:horologist-datalayer-phone:<version>\"\n}\n
For your watch and phone projects respectively.
Initialize the client, including passing a WearDataLayerRegistry
.
val appHelper = WearDataLayerAppHelper(context, wearDataLayerRegistry, scope)\n\n// or\nval appHelper = PhoneDataLayerAppHelper(context, wearDataLayerRegistry)\n
Connection and installation status
This is something that your app may do from time to time, or on start up.
val connectedNodes = appHelper.connectedNodes()\n
The resulting list might will contain entries such as:
AppHelperNodeStatus(\n id=7cd1c38a,\n displayName=Google Pixel Watch,\n appInstallationStatus=Installed(nodeType=WATCH),\n surfacesInfo=# SurfacesInfo@125fcbff\n complications {\n instance_id: 1234\n name: \"MyComplication\"\n timestamp {\n nanos: 738000000\n seconds: 1680015523\n }\n type: \"SHORT_TEXT\"\n }\n tiles {\n name: \"MyTile\"\n timestamp {\n nanos: 364000000\n seconds: 1680016845\n }\n }\n)\n
Responding to availability change
Once you've established the app on both devices, you may wish to respond to when the partner device connects or disconnects. For example, you may only want to show a \"launch workout\" button on the phone when the watch is connected.
val nodes by appHelper.connectedAndInstalledNodes\n .collectAsStateWithLifecycle()\n
Installing the app on the other device
Where the app isn't installed on the other device - be that phone or watch - then the library offers a one step option to launch installation:
appHelper.installOnNode(node.id)\n
Launching the app on the other device
If the app is installed on the other device, you can launch it remotely:
val result = appHelper.startRemoteOwnApp(node.id)\n
Launching a specific activity on the other device
In addition to launching your own app, you may wish to launch a different activity as part of the user journey:
val config = activityConfig { \n packageName = \"com.example.myapp\"\n classFullName = \"com.example.myapp.MyActivity\"\n}\nappHelper.startRemoteActivity(node.id, config)\n
Launching the companion app
In some cases, it can be useful to launch the companion app, either from the watch or the phone.
For example, if the connected device does not have your Tile installed, you may wish to offer the user the option to navigate to the companion app to install it:
if (node.surfacesInfo.tilesList.isEmpty() && askUserAttempts < MAX_ATTEMPTS) {\n // Show guidance to the user and then launch companion\n // to allow the to install the Tile.\n val result = appHelper.startCompanion(node.id)\n}\n
Tracking Tile installation (Wear-only)
To determine whether your Tile(s) are installed, add the following to your TileService
:
In onTileAddEvent
:
wearAppHelper.markTileAsInstalled(\"SummaryTile\")\n
In onTileRemoveEvent
:
wearAppHelper.markTileAsRemoved(\"SummaryTile\")\n
Tracking Complication installation (Wear-only)
To determine whether your Complication(s) are in-use, add the following to your ComplicationDataSourceService
:
In onComplicationActivated
:
wearAppHelper.markComplicationAsActivated(\"GoalsComplication\")\n
In onComplicationDeactivated
:
wearAppHelper.markComplicationAsDeactivated(\"GoalsComplication\")\n
Tracking the main activity has been launched at least once (Wear-only)
To determine if your main activity has been launched once, use:
kotlin wearAppHelper.markActivityLaunchedOnce()
DataStore documentation https://developer.android.com/topic/libraries/architecture/datastore
Direct DataLayer sample code https://github.com/android/wear-os-samples
"},{"location":"datalayer/#datalayer-approach","title":"DataLayer approach.","text":"The Horologist DataLayer libraries, provide common abstractions on top of the Wearable DataLayer. These are built upon a common assumption of Google Protobuf and gRPC, which allows sharing data definitions throughout your Wear and Mobile apps.
See this gradle build file for an example of configuring a build to use proto definitions.
syntax = \"proto3\";\n\nmessage CounterValue {\n int64 value = 1;\n .google.protobuf.Timestamp updated = 2;\n}\n\nmessage CounterDelta {\n int64 delta = 1;\n}\n\nservice CounterService {\n rpc getCounter(.google.protobuf.Empty) returns (CounterValue);\n rpc increment(CounterDelta) returns (CounterValue);\n}\n
"},{"location":"datalayer/#registering-serializers","title":"Registering Serializers.","text":"The WearDataLayerRegistry is an application singleton to register the Serializers.
object CounterValueSerializer : Serializer<CounterValue> {\n override val defaultValue: CounterValue\n get() = CounterValue.getDefaultInstance()\n\n override suspend fun readFrom(input: InputStream): CounterValue =\n try {\n CounterValue.parseFrom(input)\n } catch (exception: InvalidProtocolBufferException) {\n throw CorruptionException(\"Cannot read proto.\", exception)\n }\n\n override suspend fun writeTo(t: CounterValue, output: OutputStream) {\n t.writeTo(output)\n }\n}\n\nval registry = WearDataLayerRegistry.fromContext(\n application = sampleApplication,\n coroutineScope = coroutineScope,\n).apply {\n registerSerializer(CounterValueSerializer)\n}\n
"},{"location":"datalayer/#use-androidx-datastore","title":"Use Androidx DataStore","text":"This library provides a new implementation of Androidx DataStore, in addition to the local Proto and Preferences implementations. The implementation uses the Wearable DataClient with a single owner and multiple readers.
See DataStore.
"},{"location":"datalayer/#publishing-a-datastore","title":"Publishing a DataStore","text":"private val dataStore: DataStore<CounterValue> by lazy {\n registry.protoDataStore<CounterValue>(lifecycleScope)\n}\n
"},{"location":"datalayer/#reading-a-remote-datastore","title":"Reading a remote DataStore","text":"val counterFlow = registry.protoFlow<CounterValue>(TargetNodeId.PairedPhone)\n
"},{"location":"datalayer/#using-grpc","title":"Using gRPC","text":"This library implements the gRPC transport over the Wearable MessageClient using the RPC request feature.
"},{"location":"datalayer/#implementing-a-service","title":"Implementing a service","text":"class CounterService(val dataStore: DataStore<GrpcDemoProto.CounterValue>) :\n CounterServiceGrpcKt.CounterServiceCoroutineImplBase() {\n override suspend fun getCounter(request: Empty): GrpcDemoProto.CounterValue {\n return dataStore.data.first()\n }\n\n override suspend fun increment(request: GrpcDemoProto.CounterDelta): GrpcDemoProto.CounterValue {\n return dataStore.updateData {\n it.copy {\n this.value = this.value + request.delta\n this.updated = System.currentTimeMillis().toProtoTimestamp()\n }\n }\n }\n }\n\nclass WearCounterDataService : BaseGrpcDataService<CounterServiceGrpcKt.CounterServiceCoroutineImplBase>() {\n\n private val dataStore: DataStore<CounterValue> by lazy {\n registry.protoDataStore<CounterValue>(lifecycleScope)\n }\n\n override val registry: WearDataLayerRegistry by lazy {\n WearDataLayerRegistry.fromContext(\n application = applicationContext,\n coroutineScope = lifecycleScope,\n ).apply {\n registerSerializer(CounterValueSerializer)\n }\n }\n\n override fun buildService(): CounterServiceGrpcKt.CounterServiceCoroutineImplBase {\n return CounterService(dataStore)\n }\n}\n
"},{"location":"datalayer/#calling-a-remote-service","title":"Calling a remote service","text":"val client = registry.grpcClient(\n nodeId = TargetNodeId.PairedPhone,\n coroutineScope = sampleApplication.servicesCoroutineScope,\n) {\n CounterServiceGrpcKt.CounterServiceCoroutineStub(it)\n}\n\n// Call the increment method from the proto service definition\nval newValue: CounterValue =\n counterService.increment(counterDelta { delta = i.toLong() })\n
"},{"location":"datalayer/#download","title":"Download","text":"repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-datalayer:<version>\"\n}\n
"},{"location":"media-data/","title":"Media Data library","text":"This library contains the implementation of the repositories defined in the media domain library, using Media3 and an internal database as data sources.
It also exposes its data sources classes so they can be used by your custom repositories.
"},{"location":"media-data/#mediadownloadservice","title":"MediaDownloadService","text":"An implementation of Media3\u2019s DownloadService
that, in conjunction with auxiliary classes, will monitor the state and progress of media downloads, and update the information in the internal database.
Add your own implementation of the service, extending MediaDownloadService
;
Add your service implementation to your app\u2019s AndroidManifest.xml
:
<service android:name=\"MediaDownloadServiceImpl\"\n android:exported=\"false\">\n <intent-filter>\n <action android:name=\"com.google.android.exoplayer.downloadService.action.RESTART\"/>\n <category android:name=\"android.intent.category.DEFAULT\"/>\n </intent-filter>\n</service>\n
The DownloadManagerListener
is an implementation of listener for DownloadManager events which can also get notified of DownloadService creation and destruction events. This class persists information such as the id and the download status in the local database MediaDownloadLocalDataSource
.
override fun onDownloadChanged(\n downloadManager: DownloadManager,\n download: Download,\n finalException: Exception?\n ) {\n coroutineScope.launch {\n val mediaId = download.request.id\n val status = MediaDownloadEntityStatusMapper.map(download.state)\n\n if (status == MediaDownloadEntityStatus.Downloaded) {\n mediaDownloadLocalDataSource.setDownloaded(mediaId)\n } else {\n mediaDownloadLocalDataSource.updateStatus(mediaId, status)\n }\n }\n\n downloadProgressMonitor.start(downloadManager)\n }\n\noverride fun onDownloadRemoved(downloadManager: DownloadManager, download: Download) {\n coroutineScope.launch {\n val mediaId = download.request.id\n mediaDownloadLocalDataSource.delete(mediaId)\n }\n }\n
"},{"location":"media-data/#downloadprogressmonitor","title":"DownloadProgressMonitor","text":"The DownloadProgressMonitor
monitors the status of the download by polling the DownloadManager
and persists the progress in the local database MediaDownloadLocalDataSource
.
private fun update(downloadManager: DownloadManager) {\n coroutineScope.launch {\n val downloads = mediaDownloadLocalDataSource.getAllDownloading()\n\n if (downloads.isNotEmpty()) {\n for (it in downloads) {\n downloadManager.downloadIndex.getDownload(it.mediaId)?.let { download ->\n mediaDownloadLocalDataSource.updateProgress(\n mediaId = download.request.id,\n progress = download.percentDownloaded\n .coerceAtLeast(DOWNLOAD_PROGRESS_START),\n size = download.contentLength\n )\n }\n }\n } else {\n stop()\n }\n }\n\n if (running) {\n handler.removeCallbacksAndMessages(null)\n handler.postDelayed({ update(downloadManager) }, UPDATE_INTERVAL_MILLIS)\n }\n }\n
"},{"location":"media-data/#ui-implementation","title":"UI Implementation","text":"The PlaylistsDownloadScreen is composed by the MediaContent
and the ButtonContent
. The MediaContent
displays the content that is being downloaded or already downloaded. For content that is being downloaded, we display the download progress for each track in place of the artist name in the secondaryLabel
val secondaryLabel = when (downloadMediaUiModel) {\n is DownloadMediaUiModel.Downloading -> {\n when (downloadMediaUiModel.progress) {\n is DownloadMediaUiModel.Progress.Waiting -> stringResource(\n id = R.string.horologist_playlist_download_download_progress_waiting\n )\n\n is DownloadMediaUiModel.Progress.InProgress -> when (downloadMediaUiModel.size) {\n is DownloadMediaUiModel.Size.Known -> {\n val size = Formatter.formatShortFileSize(\n LocalContext.current,\n downloadMediaUiModel.size.sizeInBytes\n )\n stringResource(\n id = R.string.horologist_playlist_download_download_progress_known_size,\n downloadMediaUiModel.progress.progress,\n size\n )\n }\n\n DownloadMediaUiModel.Size.Unknown -> stringResource(\n id = R.string.horologist_playlist_download_download_progress_unknown_size,\n downloadMediaUiModel.progress.progress\n )\n }\n }\n }\n\n is DownloadMediaUiModel.Downloaded -> downloadMediaUiModel.artist\n is DownloadMediaUiModel.NotDownloaded -> downloadMediaUiModel.artist\n }\n
The ButtonContent
displays either a download chip or three buttons to delete, shuffle and play the content. While content is being downloaded, we show an animation on the first button on the left to track progress .
if (state.downloadMediaListState == PlaylistDownloadScreenState.Loaded.DownloadMediaListState.None) {\n if (state.downloadsProgress is DownloadsProgress.InProgress) {\n StandardChip(\n label = stringResource(id = R.string.horologist_playlist_download_button_cancel),\n onClick = { onCancelDownloadButtonClick(state.collectionModel) },\n modifier = Modifier.padding(bottom = 16.dp),\n icon = Icons.Default.Close\n )\n } else {\n StandardChip(\n label = stringResource(id = R.string.horologist_playlist_download_button_download),\n onClick = { onDownloadButtonClick(state.collectionModel) },\n modifier = Modifier.padding(bottom = 16.dp),\n icon = Icons.Default.Download\n )\n }\n } else {\n Row(\n modifier = Modifier\n .padding(bottom = 16.dp)\n .height(52.dp),\n verticalAlignment = CenterVertically,\n horizontalArrangement = Arrangement.spacedBy(6.dp, CenterHorizontally)\n ) {\n FirstButton(\n downloadMediaListState = state.downloadMediaListState,\n downloadsProgress = state.downloadsProgress,\n collectionModel = state.collectionModel,\n onDownloadButtonClick = onDownloadButtonClick,\n onCancelDownloadButtonClick = onCancelDownloadButtonClick,\n onDownloadCompletedButtonClick = onDownloadCompletedButtonClick,\n modifier = Modifier\n .weight(weight = 0.3F, fill = false)\n )\n\n StandardButton(\n imageVector = Icons.Default.Shuffle,\n contentDescription = stringResource(id = R.string.horologist_playlist_download_button_shuffle_content_description),\n onClick = { onShuffleButtonClick(state.collectionModel) },\n modifier = Modifier\n .weight(weight = 0.3F, fill = false)\n )\n\n StandardButton(\n imageVector = Icons.Filled.PlayArrow,\n contentDescription = stringResource(id = R.string.horologist_playlist_download_button_play_content_description),\n onClick = { onPlayButtonClick(state.collectionModel) },\n modifier = Modifier\n .weight(weight = 0.3F, fill = false)\n )\n }\n }\n
"},{"location":"media-data/#result","title":"Result","text":"Download screens:
"},{"location":"media-playerscreen/","title":"Stateful PlayerScreen guide","text":"
This is a guide on how to use the stateful PlayerScreen
with your own implementation of PlayerRepository
.
class PlayerRepositoryImpl : PlayerRepository {\n // implement required properties and functions\n}\n
In the sample implementation below, the repository listens to the events of Media3's Player
and update its property values accordingly (see onIsPlayingChanged
). Its operations are also called on the Player
(see setPlaybackSpeed
).
class PlayerRepositoryImpl(\n private val player: Player\n) : PlayerRepository {\n\n private val _currentState: MutableStateFlow<PlayerState> = MutableStateFlow(PlayerState.Idle)\n override val currentState: StateFlow<PlayerState> = _currentState\n\n private val listener = object : Player.Listener {\n\n override fun onIsPlayingChanged(isPlaying: Boolean) {\n _currentState.value = if (isPlaying) { \n PlayerState.Playing\n } else {\n PlayerState.Ready\n }\n }\n }\n\n init {\n player.add(listener)\n }\n\n fun setPlaybackSpeed(speed: Float) { \n player.setPlaybackSpeed(speed) \n }\n}\n
"},{"location":"media-playerscreen/#2-extend-playerviewmodel","title":"2. Extend PlayerViewModel","text":"Pass your implementation of PlayerRepository
as constructor parameter.
class MyCustomViewModel(\n playerRepository: PlayerRepositoryImpl\n): PlayerViewModel(playerRepository) {\n // add custom implementation\n}\n
"},{"location":"media-playerscreen/#3-add-playerscreen","title":"3. Add PlayerScreen","text":"Pass your PlayerViewModel
extension as value to the constructor parameter.
PlayerScreen(playerViewModel = myCustomViewModel)\n
"},{"location":"media-playerscreen/#class-diagram","title":"Class diagram","text":"The following diagram shows the interactions between the classes.
"},{"location":"media-sample/","title":"Media sample app","text":"The goal of this sample is to show how to implement an audio media app for Wear OS, using the Horologist media libraries, following the design principles described in Considerations for media apps .
The app supports listening to downloaded music. It loads a music catalog from a remote server and allows the user to browse the albums and songs. Tapping on a song will play it through connected speakers or headphones. Under the hood it uses Media3.
"},{"location":"media-sample/#features","title":"Features","text":"The app showcases the implementation of the following features:
This list is not exhaustive.
"},{"location":"media-sample/#audio","title":"Audio","text":"Music provided by the Free Music Archive.
Recordings provided by the Ambisonic Sound Library.
Horologist provides what it is called the \"Media Toolkit\": a set of libraries to build media apps on Wear OS and a sample app that you can run to see the toolkit in action.
The following modules in the Horologist project are part of the toolkit:
PlayerScreen
.media-ui
) that is agnostic to the Player
implementation.media
) using Media3.Player
on top of Media3 including functionalities such as avoiding playing music on the watch speaker.The Media Toolkit libraries are separated by layers (UI, domain and data) following the recommended app architecture .
The reason for including a domain layer is to provide flexibility to projects to use the UI library or the data library independently.
For example, if your project already contains an implementation for the player and you are only interested in using the media screens provided by the toolkit, then only the UI library needs to be added as a dependency. Thus, no extra dependencies ( e.g. Media3) will be added to your project.
On the other hand, if your project does not need any of the media screens or media UI components provided by the UI library, and you are only interested in the player implementation, then only the data library needs to be added as a dependency to your project.
"},{"location":"media-toolkit/#getting-started","title":"Getting started","text":"The usage of the toolkit will vary according to the requirements of your project.
As per architecture overview, your project might not need to add all the libraries of the toolkit as dependency. If that\u2019s the case, refer to the documentation of each library required to your project for a guide on how to get started.
For a walkthrough on how to build a very simple media application using some libraries of the toolkit, refer to this guide.
For good reference on how to use all the libraries available in the toolkit, refer to the code of the media-sample app.
"},{"location":"media-ui/","title":"Media UI library","text":"This library contains a set of composables for media player apps:
Individual controls like Play, Pause and Seek buttons; Components that might combine multiple controls, like PlayPauseButton
and MediaControlButtons
; Screens, like PlayerScreen
, BrowseScreen
and EntityScreen
.
The previews of the composables can be found in the debug
folder of the module source code.
This library is not dependent on any specific player implementation as per architecture overview.
"},{"location":"media-ui/#stateful-components","title":"Stateful components","text":"Most of the components available in this library contain an overloaded version of themselves which accept either a UI model (MediaUiModel
, PlaylistUiModel
) or PlayerUiState
or PlayerViewModel
as parameters. We call those versions \u201cstateful components\u201d, which is a different definition from the compose documentation .
While the stateless components provide full customization, the stateful components provide convenience (if the default implementation suits your project requirements), as can be seen in the example below.
Stateless PodcastControlButtons
usage:
PodcastControlButtons(\n onPlayButtonClick = { },\n onPauseButtonClick = { },\n playPauseButtonEnabled = true,\n playing = false,\n percent = 0f,\n onSeekBackButtonClick = { },\n seekBackButtonEnabled = true,\n onSeekForwardButtonClick = { },\n seekForwardButtonEnabled = true,\n)\n
Stateful PodcastControlButtons
usage:
PodcastControlButtons(\n playerViewModel = viewModel,\n playerUiState = playerUiState,\n)\n
Further examples on how to use these components can be found in the Stateful PlayerScreen guide.
"},{"location":"media/","title":"Media Domain library","text":"This library currently contains a set of models and repositories that are common to media apps. But it can be expanded in the future to also include common use cases, as per domain layer guide.
The data and domain layer guides seem to imply that the definitions of the repositories should belong to the data layer. This would make the domain library dependent on a specific data library. In this project, the repositories are defined ( not implemented) in the domain layer. This makes the domain layer independent of external layers, and any implementation of the repositories can be used: the ones provided by the toolkit, or custom implementations provided by your project.
The reason for having a domain library is described in the architecture overview.
"},{"location":"media3-backend/","title":"Wear Media3 Backend library","text":""},{"location":"media3-backend/#features","title":"Features","text":"The media3-backend module implements many of the suggested approaches at https://developer.android.com/training/wearables/overlays/audio. These enforce the best practices that will make you app work well for users on a range of Wear OS devices.
These build on top of the Media3 player featuring ExoPlayer which is optimised for Wear Playback, and is the standard playback engine for Wear OS media apps.
All functionality is demonstrated in the media-sample
app, and as such is not described here with extensive code samples.
Any extended playback such as music, podcasts, or radio should only use a connected bluetooth speaker. See https://developer.android.com/training/wearables/principles#bluetooh-headphones for principles applying to Media and Wear OS apps generally.
When not connected, the default Watch Speaker will be used and so this must be actively avoided.
Horologist provides the PlaybackRules
abstraction that allows you to intercept playback requests through your UI, a system media Tile, pressing a bluetooth headphone, or other services such as assistant.
public object Normal : PlaybackRules {\n /**\n * Can the given item be played with it's given state.\n */\n override suspend fun canPlayItem(mediaItem: MediaItem): Boolean = true\n\n /**\n * Can Media be played with the given audio target.\n */\n override fun canPlayWithOutput(audioOutput: AudioOutput): Boolean =\n audioOutput is AudioOutput.BluetoothHeadset\n}\n
The WearConfiguredPlayer
wraps the ExoPlayer to avoid starting playback and also pause immediately if the headset becomes disconnected. It will prompt the user to connect a headset at this point.
The AudioOutputSelector
and default implementation BluetoothSettingsOutputSelector
are used to prompt the user to connect a Bluetooth headset and then continue playback once connected.
public interface AudioOutputSelector {\n /**\n * Change from the current audio output, according to some sensible logic,\n * and return when either the user has selected a new audio output or returning null\n * if timed out.\n */\n public suspend fun selectNewOutput(currentAudioOutput: AudioOutput): AudioOutput?\n}\n
"},{"location":"media3-backend/#audio-offload","title":"Audio Offload","text":"In line with https://exoplayer.dev/battery-consumption.html#audio-playback, Audio Offload allows your app to playback audio while in the background without waking up. This dramatically improves the users battery life, as well as decreasing the occurrences of Audio Underruns.
The AudioOffloadManager
configures and controls Audio Offload, enabling sleeping while your app is in the background and disabling while in the foreground.
The media3-backend
module interacts with ExoPlayer
instance, but many events may be required for error handling, logging or metrics. Your can register your own Player.Listener
with the ExoPlayer
instance, but to receive generally useful events you can implement ErrorReporter
to receive events and report with Android Log
or write to a database.
Other things in the Horologist media libs will report events, and they all consistently use ErrorReporter
to allow you to understand all activity in your app.
public interface ErrorReporter {\n public fun logMessage(\n message: String,\n category: Category = Category.Unknown,\n level: Level = Level.Info\n )\n}\n
"},{"location":"media3-backend/#download","title":"Download","text":"repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-media3-backend:<version>\"\n}\n
"},{"location":"network-awareness/","title":"Network Awareness library","text":""},{"location":"network-awareness/#download","title":"Download","text":"repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-network-awareness:<version>\"\n}\n
"},{"location":"network-awareness/#problem-statement","title":"Problem Statement","text":"On Wear choice of network is critical to efficient applications. See https://developer.android.com/training/wearables/data/network-access for more information.
The default behaviour is roughly
This leads to some suboptimal decisions
This library allows defining rules based on the RequestType and NetworkType, currently integrated into OkHttp.
public interface NetworkingRules {\n /**\n * Is this request considered high bandwidth and should activate LTE or Wifi.\n */\n public fun isHighBandwidthRequest(requestType: RequestType): Boolean\n\n /**\n * Checks whether this request is allowed on the current network type.\n */\n public fun checkValidRequest(\n requestType: RequestType,\n currentNetworkInfo: NetworkInfo\n ): RequestCheck\n\n /**\n * Returns the preferred network for a request.\n *\n * Null means no suitable network.\n */\n public fun getPreferredNetwork(\n networks: Networks,\n requestType: RequestType\n ): NetworkStatus?\n}\n
It allow allows logging network usage and visibility into network status.
See media-sample for an example. Key classes to observe usage of
This guide will walk you through on how to build a very simple media player app for Wear OS, capable of playing a media which is hosted on the internet.
This guide assumes that you are familiar with:
Create a new project from Android Studio by choosing \"Basic Wear App Without Associated Tiles\" from \"Wear OS\" templates. Add dependency on media-ui
to your project\u2019s build.gradle
:
implementation \"com.google.android.horologist:horologist-media-ui:$horologist_version\"\n
"},{"location":"simple-media-app-guide/#2-add-playerscreen","title":"2 - Add PlayerScreen
","text":"Add the following code to your Activity
\u2019s onCreate
function:
setContent {\n PlayerScreen(\n mediaDisplay = {\n TextMediaDisplay(\n title = \"Song name\",\n subtitle = \"Artist name\"\n )\n },\n controlButtons = {\n PodcastControlButtons(\n onPlayButtonClick = { },\n onPauseButtonClick = { },\n playPauseButtonEnabled = true,\n playing = false,\n onSeekBackButtonClick = { },\n seekBackButtonEnabled = true,\n onSeekForwardButtonClick = { },\n seekForwardButtonEnabled = true,\n )\n },\n buttons = { }\n )\n}\n
This code is displaying PlayerScreen
on the app. PlayerScreen
is a full screen composable that contains slots parameters to pass the contents to be displayed for media display, control buttons and more.
In this sample, we are using the UI components TextMediaDisplay
and PodcastControlButtons
, provided by the UI library, as values to parameters of PlayerScreen
.
Run the app and you should see the following screen:
None of the controls are working, as they were not implemented yet.
"},{"location":"simple-media-app-guide/#make-the-screen-functional","title":"Make the screen functional","text":""},{"location":"simple-media-app-guide/#1-add-dependencies","title":"1 - Add dependencies","text":"Add the following dependencies to your project\u2019s build.gradle:
implementation \"com.google.android.horologist:horologist-media-data:$horologist_version\"\nimplementation \"com.google.android.horologist:horologist-audio-ui:$horologist_version\"\nimplementation(\"androidx.media3:media3-exoplayer:$media3_version\")\n
"},{"location":"simple-media-app-guide/#2-add-viewmodel","title":"2 - Add ViewModel
","text":"Add a ViewModel
extending PlayerViewModel
, providing an instance of PlayerRepositoryImpl
:
class MyViewModel(\n player: Player,\n playerRepository: PlayerRepositoryImpl = PlayerRepositoryImpl()\n) : PlayerViewModel(playerRepository) {}\n
"},{"location":"simple-media-app-guide/#3-add-init-block","title":"3 - Add init block","text":"Add the following init block to the ViewModel
to connect the Player
to the PlayerRepository
, set a media and update the position of the player every second:
init {\n viewModelScope.launch {\n playerRepository.connect(player) {}\n\n playerRepository.setMedia(\n Media(\n id = \"wake_up_02\",\n uri = \"https://storage.googleapis.com/uamp/The_Kyoto_Connection_-_Wake_Up/02_-_Geisha.mp3\",\n title = \"Geisha\",\n artist = \"The Kyoto Connection\"\n )\n )\n }\n}\n
"},{"location":"simple-media-app-guide/#4-create-an-instance-of-the-viewmodel","title":"4 - Create an instance of the ViewModel
","text":"Change your Activity
\u2019s onCreate
function to:
@SuppressLint(\"UnsafeOptInUsageError\")\nval player = ExoPlayer.Builder(this)\n .setSeekForwardIncrementMs(5000L)\n .setSeekBackIncrementMs(5000L)\n .build()\n// ViewModels should NOT be created here like this\nval viewModel = MyViewModel(player)\nval volumeViewModel = createVolumeViewModel()\n\nsetContent {\n PlayerScreen(\n playerViewModel = viewModel,\n volumeViewModel = volumeViewModel,\n mediaDisplay = { playerUiState: PlayerUiState ->\n DefaultMediaInfoDisplay(playerUiState)\n },\n controlButtons = { playerUIController: PlayerUiController,\n playerUiState: PlayerUiState ->\n PodcastControlButtons(\n playerController = playerUIController,\n playerUiState = playerUiState\n )\n },\n buttons = { }\n )\n}\n
Add createVolumeViewModel
function to create a VolumeViewModel:
fun createVolumeViewModel(): VolumeViewModel {\n val audioRepository = SystemAudioRepository.fromContext(application)\n val vibrator: Vibrator = application.getSystemService(Vibrator::class.java)\n return VolumeViewModel(audioRepository, audioRepository, onCleared = {\n audioRepository.close()\n }, vibrator)\n}\n
We are creating an instance of ExoPlayer
, passing it to the ViewModel
.
Then for the PlayerScreen
slots we are using:
DefaultMediaDisplay
component, which accepts a MediaUiModel
instance as parameter;PodcastControlButtons
, which accepts instances of PlayerViewModel
and PlayerUiState
as parameters to hook the controls with the ViewModel
;Run the app again and this time, play with the screen controls as the app should be able to play, pause, and seek the media now:
"},{"location":"tiles/","title":"Tiles Library","text":""},{"location":"tiles/#suspendingtileservice","title":"SuspendingTileService","text":"Provides a SuspendingTileService, which also acts as a LifecycleService.
class ExampleTileService : SuspendingTileService() {\n override suspend fun tileRequest(requestParams: RequestBuilders.TileRequest): Tile {\n return Tile.Builder()\n // create your tile here\n .build()\n }\n\n override suspend fun resourcesRequest(\n requestParams: RequestBuilders.ResourcesRequest\n ): ResourceBuilders.Resources = ResourceBuilders.Resources.Builder().setVersion(\"1\").build()\n}\n
"},{"location":"tiles/#coil-image-helpers","title":"Coil Image Helpers","text":"Provides a suspending method to load an image from the network, convert to an RGB_565 bitmap, and encode as a Tiles InlineImageResource.
val imageResource = imageLoader.loadImageResource(applicationContext, \n \"https://media.githubusercontent.com/media/google/horologist/main/docs/media-ui/playerscreen.png\") {\n // Show a local error image if missing\n error(R.drawable.missingImage)\n}\n
"},{"location":"tiles/#download","title":"Download","text":"repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-tiles:<version>\"\n}\n
"},{"location":"updating-old/","title":"Updating & releasing Horologist","text":"This guide is currently not in use. See updating.md instead.
This doc is mostly for maintainers.
"},{"location":"updating-old/#new-features-bugfixes","title":"New features & bugfixes","text":"All new features should be uploaded as PRs against the main
branch.
Once merged into main
, they will be automatically merged into the snapshot
branch.
We publish snapshot versions of Horologist, which depend on a SNAPSHOT
versions of Jetpack Compose. These are built from the snapshot
branch.
As mentioned above, updating to a new Compose snapshot is done by submitting a new PR against the snapshot
branch:
git checkout snapshot && git pull\n# Create branch for PR\ngit checkout -b update_snapshot\n
Now edit the project to depend on the new Compose SNAPSHOT version:
Edit /gradle/libs.versions.toml
:
Under [versions]
:
composesnapshot
property to be the snapshot numbercompose
property is correctMake sure the project builds and test pass:
./gradlew check\n
Now git commit
the changes and push to GitHub.
Finally create a PR (with the base branch as snapshot
) and send for review.
Once the next Jetpack Compose version is out, we're ready to push a new release:
"},{"location":"updating-old/#1-merge-snapshot-into-main","title":"#1: Mergesnapshot
into main
","text":"First we merge the snapshot
branch into main
:
git checkout snapshot && git pull\ngit checkout main && git pull\n\n# Create branch for PR\ngit checkout -b main_snapshot_merge\n\n# Merge in the snapshot branch\ngit merge snapshot\n
"},{"location":"updating-old/#2-update-dependencies","title":"#2: Update dependencies","text":"Edit /gradle/libs.versions.toml
:
Under [versions]
:
composesnapshot
property to a single character (usually -
). This disables the snapshot repository.compose
property to match the new release (i.e. 1.0.0-beta06
)Make sure the project builds and test pass:
./gradlew check\n
Commit the changes.
"},{"location":"updating-old/#3-bump-the-version-number","title":"#3: Bump the version number","text":"Edit gradle.properties:
VERSION_NAME
property and remove the -SNAPSHOT
suffix.Commit the changes, using the commit message containing the new version name.
"},{"location":"updating-old/#4-push-to-github","title":"#4: Push to GitHub","text":"Push the branch to GitHub and create a PR against the main
branch, and send for review. Once approved and merged, it will be automatically deployed to Maven Central.
Once the above PR has been approved and merged, we need to create the GitHub release:
At this point the release is published. This will trigger the docs action to run, which will auto-deploy a new version of the website.
"},{"location":"updating-old/#6-prepare-the-next-development-version","title":"#6: Prepare the next development version","text":"The current release is now finished, but we need to update the version for the next development version:
Edit gradle.properties:
VERSION_NAME
property, by increasing the version number, and adding the -SNAPSHOT
suffix.0.3.0
. Update to 0.3.1-SNAPSHOT
git commit
and push to main
.
Finally, merge all of these changes back to snapshot
:
git checkout snapshot && git pull\ngit merge main\ngit push\n
"},{"location":"updating/","title":"Updating & releasing Horologist","text":"This doc is mostly for maintainers.
Ensure your Sonatype JIRA credentials are set in your environment variables.
export ORG_GRADLE_PROJECT_mavenCentralUsername=username\nexport ORG_GRADLE_PROJECT_mavenCentralPassword=password\n
Decrypt the signing key to release a public build.
release/signing-setup.sh '<Horologist AES key>'\ngradlew clean publish --no-parallel --stacktrace\nrelease/signing-cleanup.sh\n
The deployment then needs to be manually released via the Nexus Repository Manager. See Releasing Deployment from OSSRH.
"},{"location":"updating/#snapshot-release","title":"Snapshot release","text":"For a snapshot release, the signing key is not used. Ensure VERSION_NAME
in gradle.properties has the -SNAPSHOT
suffix or specify the version via -PVERSION_NAME=...
.
gradlew -PVERSION_NAME=0.0.1-SNAPSHOT clean publish --no-parallel --stacktrace\n
"},{"location":"using-snapshot-version/","title":"Using a Snapshot Version of the Library","text":"If you would like to depend on the cutting edge version of the Horologist library, you can use the snapshot versions that are published to Sonatype OSSRH's snapshot repository. These are updated on every commit to main
.
To do so:
repositories {\n // ...\n maven { url 'https://oss.sonatype.org/content/repositories/snapshots' }\n}\n\ndependencies {\n // Check the latest SNAPSHOT version from the link above\n classpath 'com.google.android.horologist:horologist-tiles:XXX-SNAPSHOT'\n}\n
You might see a number of different versioned snapshots. If we use an example:
0.3.0-SNAPSHOT
is a build from the main
branch, and depends on the latest tagged Jetpack Compose release (i.e. alpha03).0.3.0.compose-6574163-SNAPSHOT
is a build from the snapshot
branch. This depends on the SNAPSHOT build of Jetpack Compose from build 6574163
. You should only use these if you are using Jetpack Compose snapshot versions (see below).If you're using SNAPSHOT
versions of the androidx.compose
libraries, you might run into issues with the current stable Horologist release forcing an older version of those libraries.
We publish snapshot versions of Horologist which depend on recent Jetpack Compose SNAPSHOT repositories. To find a recent build, look through the snapshot repository for any versions in the scheme x.x.x.compose-YYYY-SNAPSHOT
(for example: 0.3.0.compose-6574163-SNAPSHOT
). The YYYY
in the scheme is the snapshot build being used from AndroidX (from the example: build 6574163
). You can then use it like so:
repositories {\n // ...\n maven { url 'https://oss.sonatype.org/content/repositories/snapshots' }\n}\n\ndependencies {\n // Check the latest SNAPSHOT version from the link above\n classpath 'com.google.android.horologist:horologist-tiles:XXXX.compose-YYYYY-SNAPSHOT'\n}\n
These builds are updated regularly, but there's no guarantee that we will create one for a given snapshot number.
Note: you might also see versions in the scheme x.x.x.ui-YYYY-SNAPSHOT
. These are the same, just using an older suffix.
Horologist is a group of libraries that aim to supplement Wear OS developers with features that are commonly required by developers but not yet available.
"},{"location":"#maintained-versions","title":"Maintained Versions","text":"The currently maintained branches of Horologist are.
Version Branch Description 0.4.x release-0.4.x Wear Compose 1.2.x (stable) and Media3, and generally stable APIs. 0.5.x main Wear Compose 1.3 alpha, Media3 and generally latest alphas of Androidx."},{"location":"#media","title":"\ud83c\udfb5 Media","text":"Horologist provides the Media Toolkit: a set of libraries to build Media apps on Wear OS and a sample app that you can run to see the toolkit in action.
The toolkit includes:
PlayerScreen
.horologist-media-ui
) that is agnostic to the Player implementation.horologist-media
) using Media3.High quality prebuilt composables, such as Time and Date pickers.
Layout related functionality such as a Navigation Aware Scaffold.
Opinionated implementation of the components of the Wear Material Compose library , based on the specifications of Wear Material Design Kit .
Domain model for Audio related functionality. Volume Control, Output switching. Subscribing to a Flow of changes in audio or output.
Libraries to help developers to build apps following the Sign-In guidelines for Wear OS .
auth-data
library.auth-data
libraryauth-data
library.Kotlin coroutines flavoured TileService.
horologist-tiles
"},{"location":"#why-the-name","title":"Why the name?","text":"The name mirrors the Accompanist name, and is also Watch related.
https://en.wiktionary.org/wiki/horologist
horologist (Noun) Someone who makes or repairs timepieces, watches or clocks.
"},{"location":"#contributions","title":"Contributions","text":"Please contribute! We will gladly review any pull requests submitted. Make sure to read the Contributing page to know what our expectations of contributions are.
"},{"location":"#license","title":"License","text":"Copyright 2023 The Android Open Source Project\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n https://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n
"},{"location":"audio-ui/","title":"Audio Settings UI library","text":""},{"location":"audio-ui/#volume-screen","title":"Volume Screen","text":"A volume screen, showing the current audio output (headphones, speakers) and allowing to change the button with a stepper or bezel.
VolumeScreen(focusRequester = focusRequester)\n
"},{"location":"audio-ui/#download","title":"Download","text":"repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-audio-ui:<version>\"\n}\n
"},{"location":"audio/","title":"Audio Settings Library","text":"Domain model for Volume and Audio Output.
val audioRepository = SystemAudioRepository.fromContext(application)\n\naudioRepository.increaseVolume()\n\nval volumeState: StateFlow<VolumeState> = audioRepository.volumeState\n\nval audioOutput: StateFlow<AudioOutput> = audioRepository.audioOutput\n\nval output = audioOutput.value\nif (output is AudioOutput.BluetoothHeadset) {\n println(output.name)\n}\n
"},{"location":"audio/#download","title":"Download","text":"repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-audio:<version>\"\n}\n
"},{"location":"auth-composables/","title":"Auth Composables","text":"This library contains a set of composables screens and components related to authentication.
The previews of the composables can be found in the debug
folder of the module source code.
This library is not dependent on any specific authentication implementation as per architecture overview.
"},{"location":"auth-composables/#screens","title":"Screens","text":"SignInPlaceholderScreen
SelectAccountScreen
CheckYourPhoneScreen
CreateAccountChip
GuestModeChip
OtherOptionsChip
SignInChip
This library contains implementation for Mobile apps for some of the authentication methods provided by the auth-data
library.
This library contains implementation for Wear apps for most of the authentication methods listed in the Authentication on wearables guide.
The repositories of this library are built mainly to support the components from auth-ui library, but can be used with your own UI components.
"},{"location":"auth-data/#token-sharing","title":"Token sharing","text":"This guide will walk you through on how to display a screen on your watch app so that users can select their Google account to sign-in to your app.
"},{"location":"auth-googlesignin-guide/#requirements","title":"Requirements","text":"Follow the setup instructions for integrating Google Sign-in into an Android app from this link.
"},{"location":"auth-googlesignin-guide/#getting-started","title":"Getting started","text":"Add dependencies
Add the following dependencies to your project\u2019s build.gradle:
dependencies {\n implementation \"com.google.android.horologist:horologist-auth-composables:<version>\"\n implementation \"com.google.android.horologist:horologist-auth-ui:<version>\"\n implementation \"com.google.android.horologist:horologist-compose-material:<version>\"\n}\n
Create an instance of GoogleSignInClient
Create an instance of GoogleSignInClient, according to your requirements, for example:
val googleSignInClient = GoogleSignIn.getClient(\n applicationContext,\n GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)\n .requestEmail()\n .build()\n)\n
Create a ViewModel
Create your implementation of GoogleSignInViewModel, passing the GoogleSignInClient
created:
class MyGoogleSignInViewModel(\n googleSignInClient: GoogleSignInClient,\n) : GoogleSignInViewModel(googleSignInClient)\n
Display the screen
Display the GoogleSignInScreen passing an instance of the GoogleSignInViewModel
created:
GoogleSignInScreen(\n onAuthCancelled = { /* code to navigate to another screen on this event */ },\n onAuthSucceed = { /* code to navigate to another screen on this event */ },\n viewModel = hiltViewModel<MyGoogleSignInViewModel>()\n)\n
This sample uses Hilt to retrieve an instance of the ViewModel, but you should use what suits your project best, see this link for more info.
"},{"location":"auth-googlesignin-guide/#retrieve-the-signed-in-account","title":"Retrieve the signed in account","text":"In order to have access an instance of the GoogleSignInAccount selected by the user, follow the steps:
Implement GoogleSignInEventListener
class GoogleSignInEventListenerImpl : GoogleSignInEventListener {\n override suspend fun onSignedIn(account: GoogleSignInAccount) {\n // your implementation using the account parameter\n }\n}\n
Pass the listener to the ViewModel
Pass an instance of GoogleSignInEventListener
to GoogleSignInViewModel
:
class MyGoogleSignInViewModel(\n googleSignInClient: GoogleSignInClient,\n googleSignInEventListener: GoogleSignInEventListener,\n) : GoogleSignInViewModel(googleSignInClient, googleSignInEventListener)\n
The purpose of the auth libraries is to:
The following libraries are provided:
auth-data
library.auth-data
library.auth-data
library.The following sample apps are also provided:
The auth libraries are separated by layers (UI and data), following the recommended app architecture. The reason for including an extra UI library (auth-composables
) is to provide flexibility to projects that would like to only use the UI components that are not dependent on auth-data
.
The usage of the auth libraries will vary according to the requirements of your project.
As per architecture overview, your project might not need to add all the auth libraries as dependency. If that\u2019s the case, refer to the documentation of each library required to your project for a guide on how to get started.
"},{"location":"auth-sample-apps/","title":"Auth sample apps","text":""},{"location":"auth-sample-apps/#wear-sample","title":"Wear sample","text":"The app showcases the implementation of the following authentication methods:
The app showcases the implementation of the following authentication methods:
This guide will walk you through on how to securely transfer authentication data from the phone app to the watch app using Horologist's Auth libraries.
"},{"location":"auth-tokenshare-guide/#requirements","title":"Requirements","text":"Horologist Auth library is built on top of Wearable Data Layer API, so your phone and watch apps must:
Add dependencies
Add the following dependencies to your project\u2019s build.gradle.
For the phone app project:
dependencies {\n implementation \"com.google.android.horologist:horologist-auth-data-phone:<version>\"\n implementation \"com.google.android.horologist:horologist-datalayer:<version>\"\n}\n
For the watch app project:
dependencies {\n implementation \"com.google.android.horologist:horologist-auth-data:<version>\"\n implementation \"com.google.android.horologist:horologist-datalayer:<version>\"\n}\n
Add capability to phone app project
On the phone app project, add a wear.xml
file in the res/values
folder with the following content:
<resources>\n <string-array name=\"android_wear_capabilities\">\n <item>horologist_phone</item>\n </string-array>\n</resources>\n
Create a WearDataLayerRegistry
In both projects, create an instance of WearDataLayerRegistry from the datalayer:
val registry = WearDataLayerRegistry.fromContext(\n application = // application context,\n coroutineScope = // a coroutine scope\n)\n
This class should be created as a singleton in your app.
Define the data to be transferred
Define which authentication data that should be transferred from the phone to the watch. It can be a data class with many properties, it can also be a protocol buffer. For this guide, we will pass a simple String
instance.
Create a Serializer
for the data
Create a DataStore Serializer
class for the class defined to be transferred from the phone to the watch (String
for this guide):
public object TokenSerializer : Serializer<String> {\n override val defaultValue: String = \"\"\n\n override suspend fun readFrom(input: InputStream): String =\n InputStreamReader(input).readText()\n\n override suspend fun writeTo(t: String, output: OutputStream) {\n withContext(Dispatchers.IO) {\n output.write(t.toByteArray())\n }\n }\n} \n
This class should preferable be placed in a shared module between the phone and watch projects, but could also be duplicated in both projects.
More information about this serialization in this blog post.
Create a TokenBundleRepository
on the phone project
Create an instance of TokenBundleRepository on the phone app project:
val tokenBundleRepository = TokenBundleRepositoryImpl(\n registry = registry,\n coroutineScope = // a coroutine scope,\n serializer = TokenSerializer\n) \n
Check if the repository is available (optional)
Before using the repository, you can check if it is available to be used on the current device with:
tokenBundleRepository.isAvailable()\n
If the repository is not available on the device, all the calls to it will fail silently.
See the requirements of Wearable Data Layer API.
Send authentication data
The authentication data can be sent from the phone calling update
:
tokenBundleRepository.update(\"token\")\n
Create a TokenBundleRepository
on the watch project
Create an instance of TokenBundleRepository on the watch app project:
val tokenBundleRepository = TokenBundleRepositoryImpl.create(\n registry = registry,\n serializer = TokenSerializer\n)\n
Receive authentication data
The authentication data can be listened from the watch via the flow
property:
tokenBundleRepository.flow\n
This library contains a set of composables screens and components related to authentication.
The previews of the composables can be found in the debug
folder of the module source code.
The composables of this module might depend on repository interfaces defined in auth-data library. The implementation of these repositories does not necessarily need to be from auth-data
, they can be your own implementation. Some of the composables might depend on an external library.
A screen to prompt users to sign in.
It helps achieve to the following best practices:
A screen for the Google Sign-In authentication method.
It uses different screens from auth-composables to display the full authentication flow.
It relies on the Google Sign-In for Android library for authentication and account selection. So an instance of GoogleSignInClient has to be provided to GoogleSignInViewModel
.
A screen for the OAuth (PKCE) authentication method.
It uses different screens from auth-composables to display the full authentication flow.
A implementation for the following repositories are required to be provided:
A screen for the OAuth (Device Grant) authentication method.
It uses different screens from auth-composables to display the full authentication flow.
A implementation for the following repositories are required to be provided:
repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-composables:<version>\"\n}\n
"},{"location":"compose-layout/","title":"Compose Layout library","text":""},{"location":"compose-layout/#scalinglazycolumn-responsive-layout","title":"ScalingLazyColumn responsive() layout.","text":"The responsive()
layout factory will ensure that your ScalingLazyColumn is positioned correctly on all screen sizes.
Pass in a boolean for the firstItemIsFullWidth
param to indicate whether the first item can fit just below TimeText, or must be shifted down further to avoid cutting off the edges.
The overloaded ScalingLazyColumn
composable with ScalingLazyColumnState
param, when combined with responsive()
will handle all the following:
val columnState =\n rememberColumnState(ScalingLazyColumnDefaults.responsive(firstItemIsFullWidth = false))\n\nScaffold(\n modifier = Modifier\n .fillMaxSize(),\n timeText = {\n TimeText(modifier = Modifier.scrollAway(columnState))\n }\n) {\n ScalingLazyColumn(\n modifier = Modifier.fillMaxSize(),\n columnState = columnState,\n ) {\n item {\n ListHeader {\n Text(\n text = \"Main\",\n modifier = Modifier.fillMaxWidth(0.6f),\n textAlign = TextAlign.Center\n )\n }\n }\n items(10) {\n Chip(\"Item $it\", onClick = {})\n }\n }\n}\n
"},{"location":"compose-layout/#navigation-scaffold","title":"Navigation Scaffold.","text":"Syncs the TimeText, PositionIndicator and Scaffold to the current navigation destination state. The TimeText will scroll out of the way of content automatically.
WearNavScaffold(\n startDestination = \"home\",\n navController = navController\n) {\n scalingLazyColumnComposable(\n \"home\",\n scrollStateBuilder = { ScalingLazyListState(initialCenterItemIndex = 0) }\n ) {\n MenuScreen(\n scrollState = it.scrollableState,\n focusRequester = it.viewModel.focusRequester\n )\n }\n\n scalingLazyColumnComposable(\n \"items\",\n scrollStateBuilder = { ScalingLazyListState() }\n ) {\n ScalingLazyColumn(\n modifier = Modifier\n .fillMaxSize()\n .scrollableColumn(it.viewModel.focusRequester, it.scrollableState),\n state = it.scrollableState\n ) {\n items(100) {\n Text(\"i = $it\")\n }\n }\n }\n\n scrollStateComposable(\n \"settings\",\n scrollStateBuilder = { ScrollState(0) }\n ) {\n Column(\n modifier = Modifier\n .fillMaxSize()\n .verticalScroll(state = it.scrollableState)\n .scrollableColumn(focusRequester = it.viewModel.focusRequester, scrollableState = it.scrollableState),\n horizontalAlignment = Alignment.CenterHorizontally\n ) {\n (1..100).forEach {\n Text(\"i = $it\")\n }\n }\n }\n}\n
"},{"location":"compose-layout/#box-inset-layout","title":"Box Inset Layout.","text":"Use as a break glass for simple layout to fit within a safe square.
Box(\n modifier = Modifier\n .fillMaxRectangle()\n) {\n // App Content here \n}\n
"},{"location":"compose-layout/#fade-away-modifier","title":"Fade Away Modifier","text":""},{"location":"compose-layout/#ambientaware-composable","title":"AmbientAware composable","text":"AmbientAware
allows your UI to react to ambient mode changes. For more information on how Ambient mode and Always-on work on Wear OS, see the developer guidance.
You should place this composable high up in your design, as it alters the behavior of the Activity.
@Composable\nfun WearApp() {\n AmbientAware { ambientStateUpdate ->\n // App Content here\n }\n}\n
If you need some screens to use always-on, and others not to, then you can use the additional argument supplied to AmbientAware
.
For example, in a workout app, it is desirable that the main workout screen uses always-on, but the workout summary at the end does not. See the ExerciseClient
guide and samples for more information on building a workout app.
@Composable\nfun WearApp() {\n // Hoist state here for your current screen logic\n\n AmbientAware(isAlwaysOnScreen = currentScreen.useAlwaysOn) { ambientStateUpdate ->\n // App Content here\n }\n}\n
"},{"location":"compose-layout/#download","title":"Download","text":"repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-compose-layout:<version>\"\n}\n
"},{"location":"compose-material/","title":"Compose Material","text":"A library providing opinionated implementation of the components of the Wear Material Compose library, based on the specifications of Wear Material Design Kit.
"},{"location":"compose-material/#motivation","title":"Motivation","text":"In order to display a Chip
component in a Wear OS app, using Wear Material Compose library, the code would look like this:
Chip(\n label = { Text(\"Primary label\") },\n onClick = { },\n secondaryLabel = { Text(\"Secondary label\") },\n icon = { Icon(imageVector = Icons.Default.Image, contentDescription = null) }\n)\n
In comparison, using Horologist's Compose Material library, the code would look simpler:
Chip(\n label = \"Primary label\",\n onClick = { },\n secondaryLabel = \"Secondary label\",\n icon = Icons.Default.Image\n)\n
As seen above, Horologist's Compose Material provides convenient ways of passing parameters to the components. Furthermore, for this particular component, it will also take care of:
The list is not exhaustive and it varies for each individual component.
"},{"location":"compose-material/#when-this-library-should-not-be-used","title":"When this library should not be used?","text":"If the specifications for the component needed in your app does not match the specifications of the components listed in Wear Material Design Kit, then Wear Material Compose library should be used instead.
"},{"location":"compose-tools/","title":"Compose Tools library","text":""},{"location":"compose-tools/#tile-previews","title":"Tile Previews.","text":"Android Studio Preview support for tiles, using the TilesRenderer inside and AndroidView. Uses either raw Tiles proto, or the TilesLayoutRenderer abstraction to define a predictable process for generating a Tile for a given state.
@WearPreviewDevices\n@WearPreviewFontSizes\n@Composable\nfun SampleTilePreview() {\n val context = LocalContext.current\n\n val tileState = remember { SampleTileRenderer.TileState(0) }\n\n val resourceState = remember {\n val image =\n BitmapFactory.decodeResource(context.resources, R.drawable.ic_uamp).toImageResource()\n SampleTileRenderer.ResourceState(image)\n }\n\n val renderer = remember {\n SampleTileRenderer(context)\n }\n\n TileLayoutPreview(\n tileState,\n resourceState,\n renderer\n )\n}\n
"},{"location":"compose-tools/#download","title":"Download","text":"repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-compose-tools:<version>\"\n}\n
"},{"location":"contributing/","title":"How to Contribute","text":"We'd love to accept your patches and contributions to this project. There are just a few small guidelines you need to follow.
If you find a common problem that you think would help other Wear developers please consider submitting a PR. Please avoid significant work before raising an issue https://github.com/google/horologist/issues with the label \"Feature Request\"
"},{"location":"contributing/#development","title":"Development","text":"The project should work immediately from a fresh checkout in Android Studio (Stable or newer) or Gradle (./gradlew).
When submitting a PR, please check API compatibility and lint rules first.
A good first step is
./gradlew spotlessApply spotlessCheck compileDebugSources compileReleaseSources metalavaGenerateSignature metalavaGenerateSignatureRelease lintDebug\n
Also make sure you have (Git LFS) installed.
If you change any code affecting screenshot tests, then run the following to update changed images on a Linux host. Alternatively uncomment the same property in gradle.properties.
./gradlew testDebug -P screenshot.record=repair\n
This can be automated For a PR against the same branch, the Github action should commit against PR if possible. For maintainers, this means creating the branch in the google/horologist repo. For contributors, once your PR fails, create a second PR in your fork, that will fix the issue.
"},{"location":"contributing/#contributor-license-agreement","title":"Contributor License Agreement","text":"Contributions to this project must be accompanied by a Contributor License Agreement. You (or your employer) retain the copyright to your contribution, this simply gives us permission to use and redistribute your contributions as part of the project. Head over to https://cla.developers.google.com/ to see your current agreements on file or to sign a new one.
You generally only need to submit a CLA once, so if you've already submitted one (even if it was for a different project), you probably don't need to do it again.
"},{"location":"contributing/#code-reviews","title":"Code reviews","text":"All submissions, including submissions by project members, require review. We use GitHub pull requests for this purpose. Consult GitHub Help for more information on using pull requests.
"},{"location":"contributing/#translation-and-localization","title":"Translation and localization","text":"This project uses a semi-automatic pipeline to translate strings. When new or updated localized strings are ready, a PR is generated (example: google/horologist#692). Only the files configured via localization.bzl are sent for translation.
If you see a problem with translated text, don't edit localized resource files (e.g. res/values-en/strings.xml
) manually, as they'll be overwritten. Instead, file an issue and use the l10n label. This will then be forwarded to the relevant teams.
There are a couple of reasons we may not accept an otherwise valuable contribution.
These libraries provides an easy means to detect and install your app across both watch and phone.
However, they are not intended to cover complex use cases, or complex interactions between watch and phone.
"},{"location":"datalayer-helpers-guide/#getting-started","title":"Getting started","text":"Include the necessary dependency:
dependencies {\n implementation \"com.google.android.horologist:horologist-datalayer-watch:<version>\"\n}\n
and
dependencies {\n implementation \"com.google.android.horologist:horologist-datalayer-phone:<version>\"\n}\n
For your watch and phone projects respectively.
Initialize the client, including passing a WearDataLayerRegistry
.
val appHelper = WearDataLayerAppHelper(context, wearDataLayerRegistry, scope)\n\n// or\nval appHelper = PhoneDataLayerAppHelper(context, wearDataLayerRegistry)\n
Connection and installation status
This is something that your app may do from time to time, or on start up.
val connectedNodes = appHelper.connectedNodes()\n
The resulting list might will contain entries such as:
AppHelperNodeStatus(\n id=7cd1c38a,\n displayName=Google Pixel Watch,\n appInstallationStatus=Installed(nodeType=WATCH),\n surfacesInfo=# SurfacesInfo@125fcbff\n complications {\n instance_id: 1234\n name: \"MyComplication\"\n timestamp {\n nanos: 738000000\n seconds: 1680015523\n }\n type: \"SHORT_TEXT\"\n }\n tiles {\n name: \"MyTile\"\n timestamp {\n nanos: 364000000\n seconds: 1680016845\n }\n }\n)\n
Responding to availability change
Once you've established the app on both devices, you may wish to respond to when the partner device connects or disconnects. For example, you may only want to show a \"launch workout\" button on the phone when the watch is connected.
val nodes by appHelper.connectedAndInstalledNodes\n .collectAsStateWithLifecycle()\n
Installing the app on the other device
Where the app isn't installed on the other device - be that phone or watch - then the library offers a one step option to launch installation:
appHelper.installOnNode(node.id)\n
Launching the app on the other device
If the app is installed on the other device, you can launch it remotely:
val result = appHelper.startRemoteOwnApp(node.id)\n
Launching a specific activity on the other device
In addition to launching your own app, you may wish to launch a different activity as part of the user journey:
val config = activityConfig { \n packageName = \"com.example.myapp\"\n classFullName = \"com.example.myapp.MyActivity\"\n}\nappHelper.startRemoteActivity(node.id, config)\n
Launching the companion app
In some cases, it can be useful to launch the companion app, either from the watch or the phone.
For example, if the connected device does not have your Tile installed, you may wish to offer the user the option to navigate to the companion app to install it:
if (node.surfacesInfo.tilesList.isEmpty() && askUserAttempts < MAX_ATTEMPTS) {\n // Show guidance to the user and then launch companion\n // to allow the to install the Tile.\n val result = appHelper.startCompanion(node.id)\n}\n
Tracking Tile installation (Wear-only)
To determine whether your Tile(s) are installed, add the following to your TileService
:
In onTileAddEvent
:
wearAppHelper.markTileAsInstalled(\"SummaryTile\")\n
In onTileRemoveEvent
:
wearAppHelper.markTileAsRemoved(\"SummaryTile\")\n
Tracking Complication installation (Wear-only)
To determine whether your Complication(s) are in-use, add the following to your ComplicationDataSourceService
:
In onComplicationActivated
:
wearAppHelper.markComplicationAsActivated(\"GoalsComplication\")\n
In onComplicationDeactivated
:
wearAppHelper.markComplicationAsDeactivated(\"GoalsComplication\")\n
Tracking the main activity has been launched at least once (Wear-only)
To determine if your main activity has been launched once, use:
kotlin wearAppHelper.markActivityLaunchedOnce()
Tracking the app has been set up (Wear-only)
To mark that the user has completed in the app the necessary setup steps such that it is ready for use, use the following:
wearAppHelper.markSetupComplete()\n
And when the app is no longer considered in a fully setup state, use the following:
wearAppHelper.markSetupNoLongerComplete()\n
DataStore documentation https://developer.android.com/topic/libraries/architecture/datastore
Direct DataLayer sample code https://github.com/android/wear-os-samples
"},{"location":"datalayer/#datalayer-approach","title":"DataLayer approach.","text":"The Horologist DataLayer libraries, provide common abstractions on top of the Wearable DataLayer. These are built upon a common assumption of Google Protobuf and gRPC, which allows sharing data definitions throughout your Wear and Mobile apps.
See this gradle build file for an example of configuring a build to use proto definitions.
syntax = \"proto3\";\n\nmessage CounterValue {\n int64 value = 1;\n .google.protobuf.Timestamp updated = 2;\n}\n\nmessage CounterDelta {\n int64 delta = 1;\n}\n\nservice CounterService {\n rpc getCounter(.google.protobuf.Empty) returns (CounterValue);\n rpc increment(CounterDelta) returns (CounterValue);\n}\n
"},{"location":"datalayer/#registering-serializers","title":"Registering Serializers.","text":"The WearDataLayerRegistry is an application singleton to register the Serializers.
object CounterValueSerializer : Serializer<CounterValue> {\n override val defaultValue: CounterValue\n get() = CounterValue.getDefaultInstance()\n\n override suspend fun readFrom(input: InputStream): CounterValue =\n try {\n CounterValue.parseFrom(input)\n } catch (exception: InvalidProtocolBufferException) {\n throw CorruptionException(\"Cannot read proto.\", exception)\n }\n\n override suspend fun writeTo(t: CounterValue, output: OutputStream) {\n t.writeTo(output)\n }\n}\n\nval registry = WearDataLayerRegistry.fromContext(\n application = sampleApplication,\n coroutineScope = coroutineScope,\n).apply {\n registerSerializer(CounterValueSerializer)\n}\n
"},{"location":"datalayer/#use-androidx-datastore","title":"Use Androidx DataStore","text":"This library provides a new implementation of Androidx DataStore, in addition to the local Proto and Preferences implementations. The implementation uses the Wearable DataClient with a single owner and multiple readers.
See DataStore.
"},{"location":"datalayer/#publishing-a-datastore","title":"Publishing a DataStore","text":"private val dataStore: DataStore<CounterValue> by lazy {\n registry.protoDataStore<CounterValue>(lifecycleScope)\n}\n
"},{"location":"datalayer/#reading-a-remote-datastore","title":"Reading a remote DataStore","text":"val counterFlow = registry.protoFlow<CounterValue>(TargetNodeId.PairedPhone)\n
"},{"location":"datalayer/#using-grpc","title":"Using gRPC","text":"This library implements the gRPC transport over the Wearable MessageClient using the RPC request feature.
"},{"location":"datalayer/#implementing-a-service","title":"Implementing a service","text":"class CounterService(val dataStore: DataStore<GrpcDemoProto.CounterValue>) :\n CounterServiceGrpcKt.CounterServiceCoroutineImplBase() {\n override suspend fun getCounter(request: Empty): GrpcDemoProto.CounterValue {\n return dataStore.data.first()\n }\n\n override suspend fun increment(request: GrpcDemoProto.CounterDelta): GrpcDemoProto.CounterValue {\n return dataStore.updateData {\n it.copy {\n this.value = this.value + request.delta\n this.updated = System.currentTimeMillis().toProtoTimestamp()\n }\n }\n }\n }\n\nclass WearCounterDataService : BaseGrpcDataService<CounterServiceGrpcKt.CounterServiceCoroutineImplBase>() {\n\n private val dataStore: DataStore<CounterValue> by lazy {\n registry.protoDataStore<CounterValue>(lifecycleScope)\n }\n\n override val registry: WearDataLayerRegistry by lazy {\n WearDataLayerRegistry.fromContext(\n application = applicationContext,\n coroutineScope = lifecycleScope,\n ).apply {\n registerSerializer(CounterValueSerializer)\n }\n }\n\n override fun buildService(): CounterServiceGrpcKt.CounterServiceCoroutineImplBase {\n return CounterService(dataStore)\n }\n}\n
"},{"location":"datalayer/#calling-a-remote-service","title":"Calling a remote service","text":"val client = registry.grpcClient(\n nodeId = TargetNodeId.PairedPhone,\n coroutineScope = sampleApplication.servicesCoroutineScope,\n) {\n CounterServiceGrpcKt.CounterServiceCoroutineStub(it)\n}\n\n// Call the increment method from the proto service definition\nval newValue: CounterValue =\n counterService.increment(counterDelta { delta = i.toLong() })\n
"},{"location":"datalayer/#download","title":"Download","text":"repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-datalayer:<version>\"\n}\n
"},{"location":"media-data/","title":"Media Data library","text":"This library contains the implementation of the repositories defined in the media domain library, using Media3 and an internal database as data sources.
It also exposes its data sources classes so they can be used by your custom repositories.
"},{"location":"media-data/#mediadownloadservice","title":"MediaDownloadService","text":"An implementation of Media3\u2019s DownloadService
that, in conjunction with auxiliary classes, will monitor the state and progress of media downloads, and update the information in the internal database.
Add your own implementation of the service, extending MediaDownloadService
;
Add your service implementation to your app\u2019s AndroidManifest.xml
:
<service android:name=\"MediaDownloadServiceImpl\"\n android:exported=\"false\">\n <intent-filter>\n <action android:name=\"com.google.android.exoplayer.downloadService.action.RESTART\"/>\n <category android:name=\"android.intent.category.DEFAULT\"/>\n </intent-filter>\n</service>\n
The DownloadManagerListener
is an implementation of listener for DownloadManager events which can also get notified of DownloadService creation and destruction events. This class persists information such as the id and the download status in the local database MediaDownloadLocalDataSource
.
override fun onDownloadChanged(\n downloadManager: DownloadManager,\n download: Download,\n finalException: Exception?\n ) {\n coroutineScope.launch {\n val mediaId = download.request.id\n val status = MediaDownloadEntityStatusMapper.map(download.state)\n\n if (status == MediaDownloadEntityStatus.Downloaded) {\n mediaDownloadLocalDataSource.setDownloaded(mediaId)\n } else {\n mediaDownloadLocalDataSource.updateStatus(mediaId, status)\n }\n }\n\n downloadProgressMonitor.start(downloadManager)\n }\n\noverride fun onDownloadRemoved(downloadManager: DownloadManager, download: Download) {\n coroutineScope.launch {\n val mediaId = download.request.id\n mediaDownloadLocalDataSource.delete(mediaId)\n }\n }\n
"},{"location":"media-data/#downloadprogressmonitor","title":"DownloadProgressMonitor","text":"The DownloadProgressMonitor
monitors the status of the download by polling the DownloadManager
and persists the progress in the local database MediaDownloadLocalDataSource
.
private fun update(downloadManager: DownloadManager) {\n coroutineScope.launch {\n val downloads = mediaDownloadLocalDataSource.getAllDownloading()\n\n if (downloads.isNotEmpty()) {\n for (it in downloads) {\n downloadManager.downloadIndex.getDownload(it.mediaId)?.let { download ->\n mediaDownloadLocalDataSource.updateProgress(\n mediaId = download.request.id,\n progress = download.percentDownloaded\n .coerceAtLeast(DOWNLOAD_PROGRESS_START),\n size = download.contentLength\n )\n }\n }\n } else {\n stop()\n }\n }\n\n if (running) {\n handler.removeCallbacksAndMessages(null)\n handler.postDelayed({ update(downloadManager) }, UPDATE_INTERVAL_MILLIS)\n }\n }\n
"},{"location":"media-data/#ui-implementation","title":"UI Implementation","text":"The PlaylistsDownloadScreen is composed by the MediaContent
and the ButtonContent
. The MediaContent
displays the content that is being downloaded or already downloaded. For content that is being downloaded, we display the download progress for each track in place of the artist name in the secondaryLabel
val secondaryLabel = when (downloadMediaUiModel) {\n is DownloadMediaUiModel.Downloading -> {\n when (downloadMediaUiModel.progress) {\n is DownloadMediaUiModel.Progress.Waiting -> stringResource(\n id = R.string.horologist_playlist_download_download_progress_waiting\n )\n\n is DownloadMediaUiModel.Progress.InProgress -> when (downloadMediaUiModel.size) {\n is DownloadMediaUiModel.Size.Known -> {\n val size = Formatter.formatShortFileSize(\n LocalContext.current,\n downloadMediaUiModel.size.sizeInBytes\n )\n stringResource(\n id = R.string.horologist_playlist_download_download_progress_known_size,\n downloadMediaUiModel.progress.progress,\n size\n )\n }\n\n DownloadMediaUiModel.Size.Unknown -> stringResource(\n id = R.string.horologist_playlist_download_download_progress_unknown_size,\n downloadMediaUiModel.progress.progress\n )\n }\n }\n }\n\n is DownloadMediaUiModel.Downloaded -> downloadMediaUiModel.artist\n is DownloadMediaUiModel.NotDownloaded -> downloadMediaUiModel.artist\n }\n
The ButtonContent
displays either a download chip or three buttons to delete, shuffle and play the content. While content is being downloaded, we show an animation on the first button on the left to track progress .
if (state.downloadMediaListState == PlaylistDownloadScreenState.Loaded.DownloadMediaListState.None) {\n if (state.downloadsProgress is DownloadsProgress.InProgress) {\n StandardChip(\n label = stringResource(id = R.string.horologist_playlist_download_button_cancel),\n onClick = { onCancelDownloadButtonClick(state.collectionModel) },\n modifier = Modifier.padding(bottom = 16.dp),\n icon = Icons.Default.Close\n )\n } else {\n StandardChip(\n label = stringResource(id = R.string.horologist_playlist_download_button_download),\n onClick = { onDownloadButtonClick(state.collectionModel) },\n modifier = Modifier.padding(bottom = 16.dp),\n icon = Icons.Default.Download\n )\n }\n } else {\n Row(\n modifier = Modifier\n .padding(bottom = 16.dp)\n .height(52.dp),\n verticalAlignment = CenterVertically,\n horizontalArrangement = Arrangement.spacedBy(6.dp, CenterHorizontally)\n ) {\n FirstButton(\n downloadMediaListState = state.downloadMediaListState,\n downloadsProgress = state.downloadsProgress,\n collectionModel = state.collectionModel,\n onDownloadButtonClick = onDownloadButtonClick,\n onCancelDownloadButtonClick = onCancelDownloadButtonClick,\n onDownloadCompletedButtonClick = onDownloadCompletedButtonClick,\n modifier = Modifier\n .weight(weight = 0.3F, fill = false)\n )\n\n StandardButton(\n imageVector = Icons.Default.Shuffle,\n contentDescription = stringResource(id = R.string.horologist_playlist_download_button_shuffle_content_description),\n onClick = { onShuffleButtonClick(state.collectionModel) },\n modifier = Modifier\n .weight(weight = 0.3F, fill = false)\n )\n\n StandardButton(\n imageVector = Icons.Filled.PlayArrow,\n contentDescription = stringResource(id = R.string.horologist_playlist_download_button_play_content_description),\n onClick = { onPlayButtonClick(state.collectionModel) },\n modifier = Modifier\n .weight(weight = 0.3F, fill = false)\n )\n }\n }\n
"},{"location":"media-data/#result","title":"Result","text":"Download screens:
"},{"location":"media-playerscreen/","title":"Stateful PlayerScreen guide","text":"
This is a guide on how to use the stateful PlayerScreen
with your own implementation of PlayerRepository
.
class PlayerRepositoryImpl : PlayerRepository {\n // implement required properties and functions\n}\n
In the sample implementation below, the repository listens to the events of Media3's Player
and update its property values accordingly (see onIsPlayingChanged
). Its operations are also called on the Player
(see setPlaybackSpeed
).
class PlayerRepositoryImpl(\n private val player: Player\n) : PlayerRepository {\n\n private val _currentState: MutableStateFlow<PlayerState> = MutableStateFlow(PlayerState.Idle)\n override val currentState: StateFlow<PlayerState> = _currentState\n\n private val listener = object : Player.Listener {\n\n override fun onIsPlayingChanged(isPlaying: Boolean) {\n _currentState.value = if (isPlaying) { \n PlayerState.Playing\n } else {\n PlayerState.Ready\n }\n }\n }\n\n init {\n player.add(listener)\n }\n\n fun setPlaybackSpeed(speed: Float) { \n player.setPlaybackSpeed(speed) \n }\n}\n
"},{"location":"media-playerscreen/#2-extend-playerviewmodel","title":"2. Extend PlayerViewModel","text":"Pass your implementation of PlayerRepository
as constructor parameter.
class MyCustomViewModel(\n playerRepository: PlayerRepositoryImpl\n): PlayerViewModel(playerRepository) {\n // add custom implementation\n}\n
"},{"location":"media-playerscreen/#3-add-playerscreen","title":"3. Add PlayerScreen","text":"Pass your PlayerViewModel
extension as value to the constructor parameter.
PlayerScreen(playerViewModel = myCustomViewModel)\n
"},{"location":"media-playerscreen/#class-diagram","title":"Class diagram","text":"The following diagram shows the interactions between the classes.
"},{"location":"media-sample/","title":"Media sample app","text":"The goal of this sample is to show how to implement an audio media app for Wear OS, using the Horologist media libraries, following the design principles described in Considerations for media apps .
The app supports listening to downloaded music. It loads a music catalog from a remote server and allows the user to browse the albums and songs. Tapping on a song will play it through connected speakers or headphones. Under the hood it uses Media3.
"},{"location":"media-sample/#features","title":"Features","text":"The app showcases the implementation of the following features:
This list is not exhaustive.
"},{"location":"media-sample/#audio","title":"Audio","text":"Music provided by the Free Music Archive.
Recordings provided by the Ambisonic Sound Library.
Horologist provides what it is called the \"Media Toolkit\": a set of libraries to build media apps on Wear OS and a sample app that you can run to see the toolkit in action.
The following modules in the Horologist project are part of the toolkit:
PlayerScreen
.media-ui
) that is agnostic to the Player
implementation.media
) using Media3.Player
on top of Media3 including functionalities such as avoiding playing music on the watch speaker.The Media Toolkit libraries are separated by layers (UI, domain and data) following the recommended app architecture .
The reason for including a domain layer is to provide flexibility to projects to use the UI library or the data library independently.
For example, if your project already contains an implementation for the player and you are only interested in using the media screens provided by the toolkit, then only the UI library needs to be added as a dependency. Thus, no extra dependencies ( e.g. Media3) will be added to your project.
On the other hand, if your project does not need any of the media screens or media UI components provided by the UI library, and you are only interested in the player implementation, then only the data library needs to be added as a dependency to your project.
"},{"location":"media-toolkit/#getting-started","title":"Getting started","text":"The usage of the toolkit will vary according to the requirements of your project.
As per architecture overview, your project might not need to add all the libraries of the toolkit as dependency. If that\u2019s the case, refer to the documentation of each library required to your project for a guide on how to get started.
For a walkthrough on how to build a very simple media application using some libraries of the toolkit, refer to this guide.
For good reference on how to use all the libraries available in the toolkit, refer to the code of the media-sample app.
"},{"location":"media-ui/","title":"Media UI library","text":"This library contains a set of composables for media player apps:
Individual controls like Play, Pause and Seek buttons; Components that might combine multiple controls, like PlayPauseButton
and MediaControlButtons
; Screens, like PlayerScreen
, BrowseScreen
and EntityScreen
.
The previews of the composables can be found in the debug
folder of the module source code.
This library is not dependent on any specific player implementation as per architecture overview.
"},{"location":"media-ui/#stateful-components","title":"Stateful components","text":"Most of the components available in this library contain an overloaded version of themselves which accept either a UI model (MediaUiModel
, PlaylistUiModel
) or PlayerUiState
or PlayerViewModel
as parameters. We call those versions \u201cstateful components\u201d, which is a different definition from the compose documentation .
While the stateless components provide full customization, the stateful components provide convenience (if the default implementation suits your project requirements), as can be seen in the example below.
Stateless PodcastControlButtons
usage:
PodcastControlButtons(\n onPlayButtonClick = { },\n onPauseButtonClick = { },\n playPauseButtonEnabled = true,\n playing = false,\n percent = 0f,\n onSeekBackButtonClick = { },\n seekBackButtonEnabled = true,\n onSeekForwardButtonClick = { },\n seekForwardButtonEnabled = true,\n)\n
Stateful PodcastControlButtons
usage:
PodcastControlButtons(\n playerViewModel = viewModel,\n playerUiState = playerUiState,\n)\n
Further examples on how to use these components can be found in the Stateful PlayerScreen guide.
"},{"location":"media/","title":"Media Domain library","text":"This library currently contains a set of models and repositories that are common to media apps. But it can be expanded in the future to also include common use cases, as per domain layer guide.
The data and domain layer guides seem to imply that the definitions of the repositories should belong to the data layer. This would make the domain library dependent on a specific data library. In this project, the repositories are defined ( not implemented) in the domain layer. This makes the domain layer independent of external layers, and any implementation of the repositories can be used: the ones provided by the toolkit, or custom implementations provided by your project.
The reason for having a domain library is described in the architecture overview.
"},{"location":"media3-backend/","title":"Wear Media3 Backend library","text":""},{"location":"media3-backend/#features","title":"Features","text":"The media3-backend module implements many of the suggested approaches at https://developer.android.com/training/wearables/overlays/audio. These enforce the best practices that will make you app work well for users on a range of Wear OS devices.
These build on top of the Media3 player featuring ExoPlayer which is optimised for Wear Playback, and is the standard playback engine for Wear OS media apps.
All functionality is demonstrated in the media-sample
app, and as such is not described here with extensive code samples.
Any extended playback such as music, podcasts, or radio should only use a connected bluetooth speaker. See https://developer.android.com/training/wearables/principles#bluetooh-headphones for principles applying to Media and Wear OS apps generally.
When not connected, the default Watch Speaker will be used and so this must be actively avoided.
Horologist provides the PlaybackRules
abstraction that allows you to intercept playback requests through your UI, a system media Tile, pressing a bluetooth headphone, or other services such as assistant.
public object Normal : PlaybackRules {\n /**\n * Can the given item be played with it's given state.\n */\n override suspend fun canPlayItem(mediaItem: MediaItem): Boolean = true\n\n /**\n * Can Media be played with the given audio target.\n */\n override fun canPlayWithOutput(audioOutput: AudioOutput): Boolean =\n audioOutput is AudioOutput.BluetoothHeadset\n}\n
The WearConfiguredPlayer
wraps the ExoPlayer to avoid starting playback and also pause immediately if the headset becomes disconnected. It will prompt the user to connect a headset at this point.
The AudioOutputSelector
and default implementation BluetoothSettingsOutputSelector
are used to prompt the user to connect a Bluetooth headset and then continue playback once connected.
public interface AudioOutputSelector {\n /**\n * Change from the current audio output, according to some sensible logic,\n * and return when either the user has selected a new audio output or returning null\n * if timed out.\n */\n public suspend fun selectNewOutput(currentAudioOutput: AudioOutput): AudioOutput?\n}\n
"},{"location":"media3-backend/#audio-offload","title":"Audio Offload","text":"In line with https://exoplayer.dev/battery-consumption.html#audio-playback, Audio Offload allows your app to playback audio while in the background without waking up. This dramatically improves the users battery life, as well as decreasing the occurrences of Audio Underruns.
The AudioOffloadManager
configures and controls Audio Offload, enabling sleeping while your app is in the background and disabling while in the foreground.
The media3-backend
module interacts with ExoPlayer
instance, but many events may be required for error handling, logging or metrics. Your can register your own Player.Listener
with the ExoPlayer
instance, but to receive generally useful events you can implement ErrorReporter
to receive events and report with Android Log
or write to a database.
Other things in the Horologist media libs will report events, and they all consistently use ErrorReporter
to allow you to understand all activity in your app.
public interface ErrorReporter {\n public fun logMessage(\n message: String,\n category: Category = Category.Unknown,\n level: Level = Level.Info\n )\n}\n
"},{"location":"media3-backend/#download","title":"Download","text":"repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-media3-backend:<version>\"\n}\n
"},{"location":"network-awareness/","title":"Network Awareness library","text":""},{"location":"network-awareness/#download","title":"Download","text":"repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-network-awareness:<version>\"\n}\n
"},{"location":"network-awareness/#problem-statement","title":"Problem Statement","text":"On Wear choice of network is critical to efficient applications. See https://developer.android.com/training/wearables/data/network-access for more information.
The default behaviour is roughly
This leads to some suboptimal decisions
This library allows defining rules based on the RequestType and NetworkType, currently integrated into OkHttp.
public interface NetworkingRules {\n /**\n * Is this request considered high bandwidth and should activate LTE or Wifi.\n */\n public fun isHighBandwidthRequest(requestType: RequestType): Boolean\n\n /**\n * Checks whether this request is allowed on the current network type.\n */\n public fun checkValidRequest(\n requestType: RequestType,\n currentNetworkInfo: NetworkInfo\n ): RequestCheck\n\n /**\n * Returns the preferred network for a request.\n *\n * Null means no suitable network.\n */\n public fun getPreferredNetwork(\n networks: Networks,\n requestType: RequestType\n ): NetworkStatus?\n}\n
It allow allows logging network usage and visibility into network status.
See media-sample for an example. Key classes to observe usage of
This guide will walk you through on how to build a very simple media player app for Wear OS, capable of playing a media which is hosted on the internet.
This guide assumes that you are familiar with:
Create a new project from Android Studio by choosing \"Basic Wear App Without Associated Tiles\" from \"Wear OS\" templates. Add dependency on media-ui
to your project\u2019s build.gradle
:
implementation \"com.google.android.horologist:horologist-media-ui:$horologist_version\"\n
"},{"location":"simple-media-app-guide/#2-add-playerscreen","title":"2 - Add PlayerScreen
","text":"Add the following code to your Activity
\u2019s onCreate
function:
setContent {\n PlayerScreen(\n mediaDisplay = {\n TextMediaDisplay(\n title = \"Song name\",\n subtitle = \"Artist name\"\n )\n },\n controlButtons = {\n PodcastControlButtons(\n onPlayButtonClick = { },\n onPauseButtonClick = { },\n playPauseButtonEnabled = true,\n playing = false,\n onSeekBackButtonClick = { },\n seekBackButtonEnabled = true,\n onSeekForwardButtonClick = { },\n seekForwardButtonEnabled = true,\n )\n },\n buttons = { }\n )\n}\n
This code is displaying PlayerScreen
on the app. PlayerScreen
is a full screen composable that contains slots parameters to pass the contents to be displayed for media display, control buttons and more.
In this sample, we are using the UI components TextMediaDisplay
and PodcastControlButtons
, provided by the UI library, as values to parameters of PlayerScreen
.
Run the app and you should see the following screen:
None of the controls are working, as they were not implemented yet.
"},{"location":"simple-media-app-guide/#make-the-screen-functional","title":"Make the screen functional","text":""},{"location":"simple-media-app-guide/#1-add-dependencies","title":"1 - Add dependencies","text":"Add the following dependencies to your project\u2019s build.gradle:
implementation \"com.google.android.horologist:horologist-media-data:$horologist_version\"\nimplementation \"com.google.android.horologist:horologist-audio-ui:$horologist_version\"\nimplementation(\"androidx.media3:media3-exoplayer:$media3_version\")\n
"},{"location":"simple-media-app-guide/#2-add-viewmodel","title":"2 - Add ViewModel
","text":"Add a ViewModel
extending PlayerViewModel
, providing an instance of PlayerRepositoryImpl
:
class MyViewModel(\n player: Player,\n playerRepository: PlayerRepositoryImpl = PlayerRepositoryImpl()\n) : PlayerViewModel(playerRepository) {}\n
"},{"location":"simple-media-app-guide/#3-add-init-block","title":"3 - Add init block","text":"Add the following init block to the ViewModel
to connect the Player
to the PlayerRepository
, set a media and update the position of the player every second:
init {\n viewModelScope.launch {\n playerRepository.connect(player) {}\n\n playerRepository.setMedia(\n Media(\n id = \"wake_up_02\",\n uri = \"https://storage.googleapis.com/uamp/The_Kyoto_Connection_-_Wake_Up/02_-_Geisha.mp3\",\n title = \"Geisha\",\n artist = \"The Kyoto Connection\"\n )\n )\n }\n}\n
"},{"location":"simple-media-app-guide/#4-create-an-instance-of-the-viewmodel","title":"4 - Create an instance of the ViewModel
","text":"Change your Activity
\u2019s onCreate
function to:
@SuppressLint(\"UnsafeOptInUsageError\")\nval player = ExoPlayer.Builder(this)\n .setSeekForwardIncrementMs(5000L)\n .setSeekBackIncrementMs(5000L)\n .build()\n// ViewModels should NOT be created here like this\nval viewModel = MyViewModel(player)\nval volumeViewModel = createVolumeViewModel()\n\nsetContent {\n PlayerScreen(\n playerViewModel = viewModel,\n volumeViewModel = volumeViewModel,\n mediaDisplay = { playerUiState: PlayerUiState ->\n DefaultMediaInfoDisplay(playerUiState)\n },\n controlButtons = { playerUIController: PlayerUiController,\n playerUiState: PlayerUiState ->\n PodcastControlButtons(\n playerController = playerUIController,\n playerUiState = playerUiState\n )\n },\n buttons = { }\n )\n}\n
Add createVolumeViewModel
function to create a VolumeViewModel:
fun createVolumeViewModel(): VolumeViewModel {\n val audioRepository = SystemAudioRepository.fromContext(application)\n val vibrator: Vibrator = application.getSystemService(Vibrator::class.java)\n return VolumeViewModel(audioRepository, audioRepository, onCleared = {\n audioRepository.close()\n }, vibrator)\n}\n
We are creating an instance of ExoPlayer
, passing it to the ViewModel
.
Then for the PlayerScreen
slots we are using:
DefaultMediaDisplay
component, which accepts a MediaUiModel
instance as parameter;PodcastControlButtons
, which accepts instances of PlayerViewModel
and PlayerUiState
as parameters to hook the controls with the ViewModel
;Run the app again and this time, play with the screen controls as the app should be able to play, pause, and seek the media now:
"},{"location":"tiles/","title":"Tiles Library","text":""},{"location":"tiles/#suspendingtileservice","title":"SuspendingTileService","text":"Provides a SuspendingTileService, which also acts as a LifecycleService.
class ExampleTileService : SuspendingTileService() {\n override suspend fun tileRequest(requestParams: RequestBuilders.TileRequest): Tile {\n return Tile.Builder()\n // create your tile here\n .build()\n }\n\n override suspend fun resourcesRequest(\n requestParams: RequestBuilders.ResourcesRequest\n ): ResourceBuilders.Resources = ResourceBuilders.Resources.Builder().setVersion(\"1\").build()\n}\n
"},{"location":"tiles/#coil-image-helpers","title":"Coil Image Helpers","text":"Provides a suspending method to load an image from the network, convert to an RGB_565 bitmap, and encode as a Tiles InlineImageResource.
val imageResource = imageLoader.loadImageResource(applicationContext, \n \"https://media.githubusercontent.com/media/google/horologist/main/docs/media-ui/playerscreen.png\") {\n // Show a local error image if missing\n error(R.drawable.missingImage)\n}\n
"},{"location":"tiles/#download","title":"Download","text":"repositories {\n mavenCentral()\n}\n\ndependencies {\n implementation \"com.google.android.horologist:horologist-tiles:<version>\"\n}\n
"},{"location":"updating-old/","title":"Updating & releasing Horologist","text":"This guide is currently not in use. See updating.md instead.
This doc is mostly for maintainers.
"},{"location":"updating-old/#new-features-bugfixes","title":"New features & bugfixes","text":"All new features should be uploaded as PRs against the main
branch.
Once merged into main
, they will be automatically merged into the snapshot
branch.
We publish snapshot versions of Horologist, which depend on a SNAPSHOT
versions of Jetpack Compose. These are built from the snapshot
branch.
As mentioned above, updating to a new Compose snapshot is done by submitting a new PR against the snapshot
branch:
git checkout snapshot && git pull\n# Create branch for PR\ngit checkout -b update_snapshot\n
Now edit the project to depend on the new Compose SNAPSHOT version:
Edit /gradle/libs.versions.toml
:
Under [versions]
:
composesnapshot
property to be the snapshot numbercompose
property is correctMake sure the project builds and test pass:
./gradlew check\n
Now git commit
the changes and push to GitHub.
Finally create a PR (with the base branch as snapshot
) and send for review.
Once the next Jetpack Compose version is out, we're ready to push a new release:
"},{"location":"updating-old/#1-merge-snapshot-into-main","title":"#1: Mergesnapshot
into main
","text":"First we merge the snapshot
branch into main
:
git checkout snapshot && git pull\ngit checkout main && git pull\n\n# Create branch for PR\ngit checkout -b main_snapshot_merge\n\n# Merge in the snapshot branch\ngit merge snapshot\n
"},{"location":"updating-old/#2-update-dependencies","title":"#2: Update dependencies","text":"Edit /gradle/libs.versions.toml
:
Under [versions]
:
composesnapshot
property to a single character (usually -
). This disables the snapshot repository.compose
property to match the new release (i.e. 1.0.0-beta06
)Make sure the project builds and test pass:
./gradlew check\n
Commit the changes.
"},{"location":"updating-old/#3-bump-the-version-number","title":"#3: Bump the version number","text":"Edit gradle.properties:
VERSION_NAME
property and remove the -SNAPSHOT
suffix.Commit the changes, using the commit message containing the new version name.
"},{"location":"updating-old/#4-push-to-github","title":"#4: Push to GitHub","text":"Push the branch to GitHub and create a PR against the main
branch, and send for review. Once approved and merged, it will be automatically deployed to Maven Central.
Once the above PR has been approved and merged, we need to create the GitHub release:
At this point the release is published. This will trigger the docs action to run, which will auto-deploy a new version of the website.
"},{"location":"updating-old/#6-prepare-the-next-development-version","title":"#6: Prepare the next development version","text":"The current release is now finished, but we need to update the version for the next development version:
Edit gradle.properties:
VERSION_NAME
property, by increasing the version number, and adding the -SNAPSHOT
suffix.0.3.0
. Update to 0.3.1-SNAPSHOT
git commit
and push to main
.
Finally, merge all of these changes back to snapshot
:
git checkout snapshot && git pull\ngit merge main\ngit push\n
"},{"location":"updating/","title":"Updating & releasing Horologist","text":"This doc is mostly for maintainers.
Ensure your Sonatype JIRA credentials are set in your environment variables.
export ORG_GRADLE_PROJECT_mavenCentralUsername=username\nexport ORG_GRADLE_PROJECT_mavenCentralPassword=password\n
Decrypt the signing key to release a public build.
release/signing-setup.sh '<Horologist AES key>'\ngradlew clean publish --no-parallel --stacktrace\nrelease/signing-cleanup.sh\n
The deployment then needs to be manually released via the Nexus Repository Manager. See Releasing Deployment from OSSRH.
"},{"location":"updating/#snapshot-release","title":"Snapshot release","text":"For a snapshot release, the signing key is not used. Ensure VERSION_NAME
in gradle.properties has the -SNAPSHOT
suffix or specify the version via -PVERSION_NAME=...
.
gradlew -PVERSION_NAME=0.0.1-SNAPSHOT clean publish --no-parallel --stacktrace\n
"},{"location":"using-snapshot-version/","title":"Using a Snapshot Version of the Library","text":"If you would like to depend on the cutting edge version of the Horologist library, you can use the snapshot versions that are published to Sonatype OSSRH's snapshot repository. These are updated on every commit to main
.
To do so:
repositories {\n // ...\n maven { url 'https://oss.sonatype.org/content/repositories/snapshots' }\n}\n\ndependencies {\n // Check the latest SNAPSHOT version from the link above\n classpath 'com.google.android.horologist:horologist-tiles:XXX-SNAPSHOT'\n}\n
You might see a number of different versioned snapshots. If we use an example:
0.3.0-SNAPSHOT
is a build from the main
branch, and depends on the latest tagged Jetpack Compose release (i.e. alpha03).0.3.0.compose-6574163-SNAPSHOT
is a build from the snapshot
branch. This depends on the SNAPSHOT build of Jetpack Compose from build 6574163
. You should only use these if you are using Jetpack Compose snapshot versions (see below).If you're using SNAPSHOT
versions of the androidx.compose
libraries, you might run into issues with the current stable Horologist release forcing an older version of those libraries.
We publish snapshot versions of Horologist which depend on recent Jetpack Compose SNAPSHOT repositories. To find a recent build, look through the snapshot repository for any versions in the scheme x.x.x.compose-YYYY-SNAPSHOT
(for example: 0.3.0.compose-6574163-SNAPSHOT
). The YYYY
in the scheme is the snapshot build being used from AndroidX (from the example: build 6574163
). You can then use it like so:
repositories {\n // ...\n maven { url 'https://oss.sonatype.org/content/repositories/snapshots' }\n}\n\ndependencies {\n // Check the latest SNAPSHOT version from the link above\n classpath 'com.google.android.horologist:horologist-tiles:XXXX.compose-YYYYY-SNAPSHOT'\n}\n
These builds are updated regularly, but there's no guarantee that we will create one for a given snapshot number.
Note: you might also see versions in the scheme x.x.x.ui-YYYY-SNAPSHOT
. These are the same, just using an older suffix.