Skip to content

Widget Extensions

SAP BTP SDK for Android version 4.0 now supports a simplified means of developing app widgets.

The UI component of app widget development needs to be handled in the client code. SAP BTP SDK for Android provides the AppExtensionService service to facilitate building a read-only okHttpClient that causes the client code to call APIs that access the resources at the server side.

When the client code tries to access the server resources using API calls, the user's secure store must be opened so that the user credentials can be retrieved to construct the okHttpClient. But this is not ideal for app widgets, which usually require that information be retrieved from the server without the host app running.

To address this constraint, the following support has been added:

  • OAuth applications can now exchange a read-only OAuth token for app widget development.
  • A new AppExtensionService service exchanges the read-only token automatically when the user signs in.
  • The new buildReadonlyOkHttpClient API facilitates the building of the okHttpClient.

Note: To create a widget extension for an app built using the SAP BTP SDK for Android, you must follow the standard procedure for App widgets.

AppExtensionService

The important AppExtensionService functions are:

class AppExtensionService @JvmOverloads constructor(
    private var appConfig: AppConfig? = null,
    private val clientFilter: ((List<AbstractOAuthClient>) -> OAuthClient)? = null,
    private val serviceReadyListener: ((ready: Boolean) -> Unit)? = null
) : MobileService() {
    ...
    fun getAppConfig(): AppConfig? = ...
    suspend fun updateAppConfig(appConfig: AppConfig, replace: Boolean = false) { ... }
    fun buildReadonlyOkHttpClient(): OkHttpClient? { ... }
}
  • appConfig (optional): If the flows component is used for onboarding, the client code does not need to pass this in when initializing the service. Otherwise, the client code needs this argument in the constructor, or can call the updateAppConfig API later.
  • clientFilter: Selects the OAuth clients in AppConfig to be used for exchanging the read-only token. If not provided, the first client in the list will be used. Using the flows component, the client code can also specify which OAuth client to use for authentication for the host app. AppExtensionService can use the same filter to use the same OAuth client to exchange the read-only token. It can also have its own filter to use a different OAuth client than the host app.
  • serviceReadyListener: Notifies the client code with the status change of this service. Because the access token and the refresh token of the read-only OAuth token will be expired (as well as in the host app), the ready status of this service may change during the lifecycle of the app. In this case, the buildReadonlyOkHttpClient API can return null, so you need to ensure that the client code handles such cases carefully.

To initialize this service, you can use the following code in onCreate of your Application:

    val services = mutableListOf<MobileService>()
    services.add(AppExtensionService(
        serviceReadyListener = { ready ->
            if (ready) {
                logger.debug("AppExtensionService ready.")
                sendBroadcast(Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE))
            }
        }
    ))
    SDKInitializer.start(
        this,
        services = services.toTypedArray()
    )

To use the read-only token to make an API call:

override fun onDataSetChanged() {
    SDKInitializer.getService(AppExtensionService::class)?.also { service ->
        service.buildReadonlyOkHttpClient()?.also { okHttpClient ->
            service.getAppConfig()?.also { appConfig ->
                runBlocking {
                    val rows =
                        ODataService(appConfig = appConfig, okHttpClient = okHttpClient).read(0,3)
                    customers.clear()
                    customers.addAll(rows)
                }
            }
        } ?: logger.debug("AppExtensionService not ready yet.")
    } ?: logger.debug("No AppExtensionService initialized with SDKInitializer.")
}

Integration with Flows

Suppose you are developing an app widget that displays a list of customers. Upon clicking one of the customers in the widget, we want to navigate to the customer detail screen in the host app. If the flows component is used for onboarding, we will handle the following two cases:

  • The host app is not started when clicking the customer.
  • The host app is running in the background and the passcode is needed before navigating to the customer detail screen.

Both cases will need the restore flow to run before navigating to the customer detail screen, and the sign-in screen should also be brought up explicitly within the customer click event handler to prevent the 'timeout unlock' flow from executing automatically.

The new startRestoreFlowExplicitly API handles these cases by starting the restore flow explicitly and disabling the 'timeout unlock' temporarily:

fun startRestoreFlowExplicitly(
    activity: Activity,
    flowContext: FlowContext,
    flowActivityResultCallback: FlowActivityResultCallback
) { ... }

The client code can do the following upon clicking the customer in the widget:

    //Customer detail activity onResume
    override fun onResume() {
        super.onResume()
        val customerId = intent.getStringExtra(EXTRA_CUSTOMER_ID)
        logger.debug("Customer id: $customerId")
        val flowContext = FlowContext(
            appConfig = AppConfig.Builder().applicationId("app_id").build(),
            multipleUserMode = false,
            flowStateListener = MyFlowStateListener(application = application),
            flowActionHandler = MyFlowActionHandler(),
            flowOptions = FlowOptions(
                appTheme = R.style.AppTheme,
                activationOption = ActivationOption.QR_ONLY,
                excludeEula = false
            )
        )
        Flow.startRestoreFlowExplicitly(this, flowContext) { _, resultCode, _ ->
            if (resultCode == Activity.RESULT_OK) {
                binding.customerId.text = customerId
                customerId?.also { queryCustomer(it) }
            } else {
                finish()
            }
        }
    }

WidgetService

WidgetService (com.sap.cloud.mobile.foundation.ext.WidgetService) is the recommended service for app widget development, replacing AppExtensionService. Unlike AppExtensionService, which exchanges a separate scope-restricted token, WidgetService reuses the host app's OAuth token directly — removing the need for a separate token exchange and simplifying the widget lifecycle.

Registration

Register WidgetService in Application.onCreate() alongside your other services:

SDKInitializer.start(
    this,
    services = arrayOf(WidgetService())
)

Note: If your app uses the flows component and previously registered AppExtensionService for token exchange with FlowStateListener or HostTokenRenewService, keep that registration in addition to WidgetService. The two services operate independently.

Building an HTTP Client

buildHttpClient is a suspend function that returns a configured OkHttpClient, or null if AppConfig is not ready or no user is currently set:

suspend fun buildHttpClient(
    clientFilter: ((List<AbstractOAuthClient>) -> AbstractOAuthClient)? = null,
    configure: (OkHttpClient.Builder.() -> Unit)? = null
): OkHttpClient?
  • clientFilter: Selects which OAuth client from AppConfig to use. If null, the first client in the list is used. Provide this parameter when your app registers multiple OAuth clients, and the widget should authenticate with a specific one.
  • configure: An optional OkHttpClient.Builder customization block applied before the authentication interceptor. Use it to add logging or tracing interceptors.

A null return value indicates that AppConfig is not ready or no user is currently set. If the token does not exist or has expired, the function still returns an OkHttpClient, but requests made with it will throw an IOException. Your widget code should handle both cases by checking for null before making requests, and catching IOException to handle token absence or expiration.

The following example shows typical usage inside a CoroutineWorker:

override suspend fun doWork(): Result {
    val widgetService = SDKInitializer.getService(WidgetService::class)
    val client = widgetService?.buildHttpClient(
        clientFilter = { clients ->
            clients.firstOrNull { client ->
                val uri = client.redirectURL.toUri()
                uri.scheme == "myapp" && uri.host == "example.com"
            } ?: clients.first()
        }
    )
    if (client == null) {
        // AppConfig not ready or no user set — update widget to show sign-in prompt
        return Result.success()
    }
    // Use client for API calls
    return Result.success()
}

Keeping Widgets Updated

WidgetService automatically stores and updates the token with each ApplicationState.HostTokenRenewed event. To ensure widget data remains up-to-date after re-authentication or a user switch, observe these events in Application.onCreate() and trigger a background refresh:

private fun observeAuthStateForWidgetRefresh() {
    ApplicationStates.addStateListener { state ->
        when (state) {
            is ApplicationState.HostTokenRenewed,
            is ApplicationState.OnUserSwitch -> {
                PolicyFetchWorker.enqueueImmediate(applicationContext)
            }
            else -> Unit
        }
    }
}

Security

WidgetService protects stored tokens using two independent layers of encryption:

  1. Database encryption: The token is stored in a SQLCipher backed secure store, keyed by an Android Keystore managed AES key.
  2. Application-layer encryption: Before being written to the database, the token JSON is AES-256-GCM encrypted with a composite key derived by XOR-combining the Keystore key with a 32-byte random nonce stored in EncryptedSharedPreferences. All key material is zeroed from memory immediately after each encrypt or decrypt operation.

This two-factor key derivation ensures that obtaining the Keystore key alone — for example, via a heap dump between token operations — is insufficient to decrypt the stored token. An attacker would also need read access to the file system for the EncryptedSharedPreferences file.

Additional security measures include:

  • Read-only enforcement: The authentication interceptor allows only GET, HEAD, and OPTIONS requests. Any other HTTP method returns a 405 Method Not Allowed response without reaching the network.
  • Automatic token eviction: If the token endpoint returns 400 or 401 during a refresh attempt, the stored token is removed immediately rather than left as a stale entry.
  • Store alias obfuscation: Store file names and Keystore alias strings are XOR-obfuscated in the compiled artifact so they do not appear in plain text during static analysis.

Migrating From AppExtensionService

WidgetService is the replacement for AppExtensionService for widget HTTP client building. The key differences are:

AppExtensionService WidgetService
Constructor AppConfig, clientFilter, serviceReadyListener No arguments
Token model Exchanges a scope-restricted read-only token Uses the host app's OAuth token directly
HTTP client method buildReadonlyOkHttpClient(): OkHttpClient? buildHttpClient(clientFilter?, configure?): OkHttpClient?
clientFilter return type OAuthClient AbstractOAuthClient (widened)
Ready notification serviceReadyListener((Boolean) -> Unit) callback Check buildHttpClient return value for null
Cookie jar Isolated (read-only) Shared with the host app (WebkitCookieJar)
AppConfig source Passed in constructor Resolved automatically from SettingsProvider, with an on-disk fallback

Before:

// Application.onCreate
val services = mutableListOf<MobileService>()
services.add(AppExtensionService(
    serviceReadyListener = { ready ->
        if (ready) sendBroadcast(Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE))
    }
))
SDKInitializer.start(this, services = services.toTypedArray())
// Widget data provider
SDKInitializer.getService(AppExtensionService::class)?.also { service ->
    service.buildReadonlyOkHttpClient()?.also { okHttpClient ->
        service.getAppConfig()?.also { appConfig ->
            runBlocking { /* API call */ }
        }
    } ?: logger.debug("AppExtensionService not ready yet.")
}

After:

// Application.onCreate
SDKInitializer.start(this, services = arrayOf(WidgetService()))

ApplicationStates.addStateListener { state ->
    when (state) {
        is ApplicationState.HostTokenRenewed,
        is ApplicationState.OnUserSwitch -> {
            sendBroadcast(Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE))
        }
        else -> Unit
    }
}
// Widget data provider (inside a suspend function or CoroutineWorker)
val client = SDKInitializer.getService(WidgetService::class)?.buildHttpClient()
if (client == null) {
    // AppConfig not ready or no user set — show sign-in prompt in the widget
    return
}
// Use client for API calls

No new onboarding is required when migrating. The old AppExtensionService store remains on-disk but becomes inactive. WidgetService populates its own encrypted store on the first HostTokenRenewed event after the app upgrade.


Last update: August 18, 2026