Skip to content

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 the wearable-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 in bundleConfig.
  • 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)

To use this authentication method:

  • Your AppConfig needs to have an OAuth client with a redirect URL, such as https://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)
    }
})

Last update: April 5, 2024