Creating Wearable Apps¶
SAP BTP SDK for Android version 7.0 provides wearable development support to assist developers in creating wearable apps for Android smartwatches.
Getting Started¶
Prerequisites¶
- Only the companion app is supported at this time.
- Only OAuth authentication is supported.
- Due to Android Wearable SDK limitations, apps in the work profile are not supported.
Add Dependencies¶
implementation 'com.sap.cloud.android:wearable-core:$sdk_version'
implementation 'com.sap.cloud.android:wearable-datatransfer:$sdk_version'
The wearable-core
artifact is for the wearable app. It contains the following features:
- Authentication support
- Network support
- 'Mobile Services' support
The wearable-datatransfer
artifact can be used by both the phone and wearable apps. This is used for transferring data between the two peers. Currently, 'data client' and 'message client' messages are supported. See Client comparison for more details.
Developing Wearable Apps¶
Like the mobile apps that consume 'SAP Mobile Services', wearable apps also require the following two steps before accessing the 'SAP Mobile Services':
- Activation, to get the application configurations.
- Authentication.
Unlike mobile phone apps, because of the small screen, it's not ideal to let the user input information on the watch to retrieve the AppConfig
and authenticate the user. So, these two steps require additional support from the mobile phone app.
Activation¶
To send the AppConfig
from the phone to the wearable, the phone app needs to call the following:
private val wearableMessageClient: WearableMessageClient by lazy {
WearableMessageClient(context)
}
...
val appConfig: AppConfig = ...
//Send `AppConfig` as message client data
wearableMessageClient.sendMessageClientData(appConfig.toString().toAppConfigMessage())
//Or, send `AppConfig` as data client data
wearableMessageClient.sendDataClientData(appConfig.toString().toAppConfigMessage())
The wearable app needs to listen to the data transfer events. This can be accomplished as follows:
class WearableApplication : Application() {
override fun onCreate() {
super.onCreate()
WearableServiceInitializer.start(
context = this,
bundleConfig = {
enableDataClient(false)
enableMessageClient(true)
},
services = listOf(
WearableDataTransferService(),
)
)
}
}
WearableDataTransferService
is in thewearable-core
artifact, so it needs to be added as the dependency of your module. It will be responsible for listening to the data transfer events.- By default,
WearableDataTransferService
will register both the data and message client listeners. If your project only wants to use one of them, you can enable/disable them accordingly inbundleConfig
. - For data client data, if you want to change the default path,
WearableDataTransferService
has an argument,dataClientPath
, that you can use to pass in your path. Be sure to use the same path in the phone app if you also want to send data from the wearable to the phone.
If you're using the compose-based Flows component for onboarding, onAppConfigRetrieved
of your FlowStateListener
might be a good place to send AppConfig
to the wearable. For example:
class DemoFlowStateListener(private val context: Context) : FlowStateListener() {
private val wearableMessageClient: WearableMessageClient by lazy {
WearableMessageClient(context)
}
override suspend fun onAppConfigRetrieved(appConfig: AppConfig) {
logger.debug("AppConfig ready: ${appConfig.serviceUrl}")
appConfig.primaryAuthenticationConfig?.also { auth ->
if (auth is OAuth) {
auth.config.getAllOAuthClients().firstOrNull { client ->
client.redirectURL.contains("https://wear.googleapis.com/3p_auth")
}?.also {
logger.debug("Sending app configuration to wearable...")
appConfig.toString().toAppConfigMessage().apply {
wearableMessageClient.sendMessageClientData(this)
}
}
}
}
}
...
}
The preceding code demonstrates the case that when onAppConfigRetrieved
is called, it will check to ensure the current AppConfig
has a special OAuth client, and then send it to the wearable app. The special OAuth client will be used to authenticate the user. This is described in another section of this document.
After receiving the AppConfig
from the phone, the wearable will save it into the local database so that next time the wearable app is restarted, it will be loaded from the database and can be used anywhere in the wearable app.
Authentication¶
Refer to Google's "Authentication on wearables" topic for information pertaining to wearable app authentication. wearable-core
supports two different ways to authenticate the user:
- Proof Key for Code Exchange(PKCE)
- Send OAuth token from the phone app.
Proof Key for Code Exchange(PKCE)¶
To use this authentication method:
- Your
AppConfig
needs to have an OAuth client with a redirect URL, such ashttps://wear.googleapis.com/3p_auth/<Your_app_id>
- Call the following API in your wearable app when needed:
Chip(
label = {
Text(stringResource(id = R.string.button_authenticate))
},
onClick = {
lifecycleScope.launch(IO) {
OAuth2Processor.startAuthentication()
}
},
modifier = Modifier
.padding(start = 12.dp, end = 12.dp)
.fillMaxWidth()
.wrapContentHeight(),
)
- After getting the authorization code from the phone,
wearable-core
will automatically retrieve OAuth access and refresh tokens and save them into the local database.
Send OAuth Token From Phone App¶
The authentication method includes the following procedures:
- Send remote request from the wearable to the phone.
Chip(
label = {
Text(stringResource(id = R.string.button_device_grant))
},
onClick = {
lifecycleScope.launch(IO) {
RemoteActivityUtil.sendRemoteRequest(
context = this@AuthenticationActivity,
uri = "watch://androidsdkqiangz.web.app/wearable"
)
}
},
modifier = Modifier
.padding(start = 12.dp, end = 12.dp)
.fillMaxWidth()
.wrapContentHeight(),
)
- Authenticate from the phone if necessary.
To receive the remote request from the wearable, the phone app needs an activity to respond to the request.
<activity
android:name=".ui.DeviceAuthorizationGrantActivity"
android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:host="androidsdkqiangz.web.app"
android:pathPrefix="/wearable"
android:scheme="watch" />
</intent-filter>
</activity>
DeviceAuthorizationGrantActivity
can perform the authentication if necessary to get the OAuth token and then send it to the wearable. If you're using the compose-based Flows component for onboarding, you can do the following:
FlowUtil.startFlow(
this,
flowContext = DemoApplication.getOnboardingFlowContext(this),
updateIntent = { intent ->
intent.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
)
FlowUtil.prepareCustomBundle(intent) {
forceLogoutWhenRestore(true)
}
}
) { resultCode, _ ->
...
}
The compose-based Flows component has some bundle options to control the onboarding/restore flows. For example, in the preceding code, forceLogoutWhenRestore
will notify the restore flow to perform a logout first to retrieve a brand new token during the restore flow.
- Send the token to the wearable
In your FlowStateListener
, you can put the logic to send the token to the wearable in onHostTokenRenewed
.
override suspend fun onHostTokenRenewed() {
UserSecureStoreDelegate.getInstance().getOAuthToken()?.also { token ->
logger.debug("Sending OAuth2 token to wearable...")
token.toString().toOAuth2TokenMessage().apply {
//send the token with message client.
wearableMessageClient.sendMessageClientData(this)
//or with data client.
wearableMessageClient.sendDataClientData(this)
}
}
}
- After receiving the token, the wearable app saves the token to the local database. This does not require any client code:
wearable-core
has a core service to handle several events automatically. When the token reaches the wearable, it will be saved automatically into the local database to be used for future API calls.
Make Network Requests¶
Network Request With OkHttpClient
¶
After the application configuration and the OAuth token are retrieved, the wearable app is able to make network requests with the following API:
object HttpClientProvider {
fun getOkHttpClient(
config: (OkHttpClient.Builder.() -> Unit) = {}
): OkHttpClient = ...
}
You can add your interceptors with the config
argument. For example:
CoroutineScope().launch(IO + CoroutineExceptionHandler { _, ex ->
logger.error("Error: ${ex.message}")
}) {
val url = ...
val request = okhttp3.Request.Builder().url(url).get()
.addHeader("Accept", "application/json").build()
val response = HttpClientProvider.getOkHttpClient {
connectTimeout(5000L, TimeUnit.SECONDS)
addInterceptor(MyOwnInterceptor())
}.newCall(request).execute()
response.use { ... }
}
UserService
¶
//UserService.kt
suspend fun retrieveUser(): ServiceResult<User> = ...
suspend fun logout(): ServiceResult<Boolean> = ...
SettingsExchangeService
¶
suspend inline fun <reified T : Any> retrievePolicy(
entityKeyPath: String? = null,
target: SettingsTarget = SettingsTarget.DEVICE
): ServiceResult<T> {
...
}
This service retrieves different kinds of client policies. For example:
//retrieve the feature flag
val result = retrievePolicy<FeatureFlag>()
//retrieve the client policy
val policies = SettingsExchangeService().retrievePolicy<ClientPolicies>()
Application States¶
wearable-core
has a state machine running when the app starts up, and there are services listening to different application states. For example, when authenticating the user using PKCE and the authorization code is retrieved, a core service will automatically make a network request to retrieve the OAuth access and refresh tokens.
abstract class WearableState(val isTransient: Boolean = false)
The states are categorized into two types, that is, whether or not the state can be accessed later after it happens. For example, the OAuth2AuthorizationCodeReceived
will not be saved for later use: it's fired and forgotten. For other states, such as AppConfigReady
and OAuth2TokenReady
, the wearable app can access them using:
fun <T : WearableState> getState(clazz: KClass<T>): T? = ...
The following is the list of states with isTransient
as false
:
AppConfigReady
OAuth2TokenReady
DeviceIdReady
WearOSVersionReady
ApplicationVersionReady
OAuth Token Expiration¶
When making a network request and the access token expires, wearable-core
will try to renew the access token with the refresh token. If this fails, the AuthenticationNeeded
state will be triggered, since wearable-core
does not know how to authenticate the user again. The client code needs to create a WearableService
and listen to this state. For example:
class AuthenticationNotificationService() : WearableService() {
override fun initialize(context: Context) {
super.initialize(context)
createNotificationChannel()
}
override suspend fun onStateChanged(state: WearableState) {
if (state is AuthenticationNeeded)
withContext(Main) { notifyNeedAuthenticationState() }
else
super.onStateChanged(state)
}
...
}
Then initialize this service with WearableServiceInitializer
. For example:
class WearableApplication : Application() {
override fun onCreate() {
super.onCreate()
WearableServiceInitializer.start(
context = this,
services = listOf(
AuthenticationNotificationService(
deviceGrantUri = "watch://androidsdkqiangz.web.app/wearable"
),
WearableDataTransferService(),
)
)
}
}
The deviceGrantUri
in the preceding code sample will be used by sending a remote activity request to the phone app, so the phone app needs to define an activity to handle this deep link. The preceding sample code will send out a notification when seeing the AuthenticationNeeded
state.
Data Transformation¶
From the above sections, you can see that transferring data between the phone and wearable is an essential feature. This is the responsibility of the wearable-datatransfer
artifact.
WearableMessageListener
Interface¶
interface WearableMessageListener {
/** Handles the given message.*/
suspend fun onMessage(message: WearableMessage)
}
Both the data client and message client data will be transformed as WearableMessage
, and given to the listeners to handle.
WearableMessage
¶
data class WearableMessage(val key: String, val value: String = "") { ... }
Currently, there are four predefined message types:
enum class WearableMessageType(val key: String) {
APP_CONFIG(key = "wearable_app_config"),
OAUTH2_TOKEN(key = "wearable_oauth2_token"),
PHONE_LOGGED_OUT(key = "wearable_phone_logged_out"),
REMOTE_ACTIVITY(key = "wearable_remote_activity")
}
WearableMessageClient
¶
This class is responsible for registering either data client or message client listeners and sending message to the paired devices.
class WearableMessageClient(
private val context: Context,
private val path: String = "/sap_data_transfer_wearable"
) {
fun registerMessageClientListener(vararg listeners: WearableMessageListener) { ... }
fun registerDataClientListener(vararg listeners: WearableMessageListener) { ... }
fun unregisterMessageClientListener() { ... }
fun unregisterDataClientListener() { ... }
suspend fun sendDataClientData(vararg message: WearableMessage): DataItem { ... }
suspend fun sendMessageClientData(message: WearableMessage, node: Node? = null) { ... }
}
The following example shows how the phone app handles the REMOTE_ACTIVITY
message from the wearable:
//Define a message listener
class RemoteActivityMessageListener(private val context: Context) : WearableMessageListener {
override suspend fun onMessage(message: WearableMessage) {
if (message.key == WearableMessageType.REMOTE_ACTIVITY.key) {
logger.debug("Received remote activity message, starting the activity: {}", message)
val intent = Intent(Intent.ACTION_VIEW).apply {
addCategory(Intent.CATEGORY_BROWSABLE)
data = Uri.parse(message.value)
}.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
}
...
}
//Register the listener with a service
class WearableMessageListenerService : MobileService() {
override fun init(application: Application, apiKey: String?) {
super.init(application, apiKey)
WearableMessageClient(application)
.registerMessageClientListener(RemoteActivityMessageListener(application))
}
}
//Initialize the service when app starts up.
class DemoApplication : Application() {
override fun onCreate() {
super.onCreate()
initServices()
PermissionRequestTracker.setPermissionRequestTrackingEnabled(this, true)
}
private fun initServices() {
val services = listOf(
loggingService, WearableMessageListenerService()
)
SDKInitializer.start(
application = this,
apiKey = "5f64ace3-0ba2-4ce3-92a9-08b49c3fe11c",
services = services.toTypedArray()
)
}
...
}
The wearable app sends the RemoteActivity
message to the phone app:
Chip(
onClick = {
MainScope().launch(IO + CoroutineExceptionHandler { _, ex ->
logger.error("Error: ${ex.message}")
}) {
WearableMessageClient(context).sendMessageClientData(
message = "watch://androidsdkqiangz.web.app/wearable".toRemoteActivityMessage()
)
}
},
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
label = { Text("Send Message") }
)
Enable Firebase Push¶
The wearable app can retrieve its own Firebase push token from the Firebase messaging server. After registering this token to SAP Business Technology Platform, the wearable app can receive notifications sent from the SAP mobile service cockpit.
Note
This information is not concerned with bridging push notifications from the phone to the wearable and how to present push messages on the wearable. For notification bridging, please refer to the Google documentation.
Add Dependency¶
To enable this feature for your wearable Android project, please add the following dependency in your `build.gradle'.
implementation 'com.sap.cloud.android:wearable-firebase-push:$sdk_version'
Implementation¶
You need to initialize a FirebasePushService
first. The signature is like this:
class FirebasePushService(
private val pushParameter: PushParameter = PushParameter(deviceModel = "Android"),
val remoteMessageHandler: RemoteMessageHandler? = null,
private val tokenRegisteredCallback: (suspend (FirebasePushService) -> Unit)? = null,
) : WearableService() { ... }
interface RemoteMessageHandler {
/** Handles the remote message. */
fun onRemoteMessage(message: RemoteMessage): Unit = Unit
}
Then, initialize this service with WearableServiceInitializer
. For example:
WearableServiceInitializer.start(
context = this,
bundleConfig = {
setLogoutWithPhone()
},
services = listOf(
AuthenticationNotificationService(
deviceGrantUri = "watch://androidsdkqiangz.web.app/wearable"
),
WearableDataTransferService(),
FirebasePushService(
remoteMessageHandler = DemoMessageHandler()
) { service ->
service.subscribePushTopic("Default")
}
)
)
FirebasePushService
has several APIs for the client apps:
suspend fun registerTokenToServer(token: String,): ServiceResult<Boolean> { ... }
suspend fun unregisterTokenFromServer(): ServiceResult<Boolean> { ... }
suspend fun updateMessageStatus(
notificationId: String,
status: PushMessageUpdateStatus = PushMessageUpdateStatus.Received
): ServiceResult<Boolean> { ... }
suspend fun retrievePushTopics(): ServiceResult<PushTopics> { ... }
suspend fun subscribePushTopic(topic: String): ServiceResult<Boolean> { ... }
suspend fun unsubscribePushTopic(topic: String): ServiceResult<Boolean> { ... }
Note
If your app uses FirebasePushService
to handle messages, the client code needs not call the updateMessageStatus
API because it's already handled inside SDK. PushMessageUpdateStatus.Received
state will be sent to SAP Business Technology Platform when message is received, PushMessageUpdateStatus.Consumed
will be sent to SAP Business Technology Platform after your RemoteMessageHandler
is executed.
Creating Wearable Apps for China¶
The SAP BTP SDK for Android provides support for creating Wear OS apps for China. Due to the Android statement that "Wear OS apps for China should continue to use APIs related to GoogleApiClient
"(see Notes), there are some differences required for China SDK APIs.
This section contains common changes that developers need to adopt for the China market.
Add China-Specific Dependencies¶
implementation 'com.sap.cloud.android:wearable-core-cn:$sdk_version'
implementation 'com.sap.cloud.android:wearable-datatransfer-cn:$sdk_version'
Currently the wearable-datatransfer-cn
artifact only supports GoogleApiClient
. See Notes for more details.
Developing Wearable Apps for China¶
API Differences¶
For cn.WearableMessageClient
, you can get the companion object directly using cn.WearableMessageClient.getInstance()
, which will be initialized during app startup by androidx.startup.Initializer
.
To send the data from the phone to the wearable and vice versa, use the following:
WearableMessageClient.getInstance().sendMessageClientData(
message = appConfig.toString().toAppConfigMessage()
)
The wearable app should listen to data transfer events with predefined keys, which include "WearableMessageType.APP_CONFIG", "WearableMessageType.OAUTH2_TOKEN", and "WearableMessageType.PHONE_LOGGED_OUT". This can be accomplished as follows:
class WearableChinaApplication : Application() {
override fun onCreate() {
super.onCreate()
WearableServiceInitializer.start(
context = this,
bundleConfig = {
setLogoutWithPhone()
},
services = listOf(
WearableDataTransferService(),
)
)
}
}
To support listening to data events with customized behaviors, you can add your own message listeners as follows:
WearableMessageClient.getInstance().addMessageListener(object: WearableMessageListener {
override suspend fun onMessage(message: WearableMessage) {
logger.debug("Message received: {}", message)
}
})