Initialising the Comapi App Messaging SDK
To initialise the SDK you need:
- A configured API space.
- An authentication provider that can generate a JWT matching the auth scheme configured for your API space.
Both are supplied via the configuration object. This is the only required setup; event listeners are optional.
Tip: Keep the API space ID out of source control. Store it inlocal.propertiesand expose it throughbuildConfigFieldin your module'sbuild.gradle/build.gradle.kts, then reference it asBuildConfig.COMAPI_API_SPACE_ID.
ComapiConfig config = new ComapiConfig()
.apiSpaceId(BuildConfig.COMAPI_API_SPACE_ID)
.authenticator(new ChallengeHandler());val config = ComapiConfig()
.apiSpaceId(BuildConfig.COMAPI_API_SPACE_ID)
.authenticator(ChallengeHandler())The authentication challenge handler
Your handler must extend ComapiAuthenticator (an abstract class). The SDK calls onAuthenticationChallenge whenever it needs a fresh JWT — typically at session start and when the existing token is close to expiry. Call ChallengeOptions.getNonce() to retrieve the nonce that must be included in the JWT, then pass the signed token to AuthClient.authenticateWithToken(...).
Don't block the caller. Treat the token fetch as asynchronous work. If the fetch fails, callauthenticateWithToken(null)so the SDK can surface the failure rather than hang waiting for a token. The SDK applies a 5-minute timeout on challenges by default.
import com.comapi.ComapiAuthenticator;
import com.comapi.internal.network.AuthClient;
import com.comapi.internal.network.ChallengeOptions;
public class ChallengeHandler extends ComapiAuthenticator {
@Override
public void onAuthenticationChallenge(AuthClient authClient,
ChallengeOptions options) {
// Fetch the token off the calling thread, then call:
// authClient.authenticateWithToken(/* token built from options.getNonce() */);
}
}import com.comapi.ComapiAuthenticator
import com.comapi.internal.network.AuthClient
import com.comapi.internal.network.ChallengeOptions
class ChallengeHandler(
private val tokenService: TokenService,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
) : ComapiAuthenticator() {
override fun onAuthenticationChallenge(
authClient: AuthClient,
options: ChallengeOptions
) {
scope.launch {
val token = runCatching { tokenService.fetchJwt(nonce = options.nonce) }
.getOrElse { t ->
Log.e("Comapi", "Token fetch failed", t)
null
}
authClient.authenticateWithToken(token)
}
}
}
Don't ship the shared secret in the app. Sign the JWT on your backend and have the app fetch the signed token over HTTPS. Embedding the shared secret in the client lets anyone who decompiles your APK impersonate your users.
Initialising the SDK
The SDK ships two surfaces:
- Callbacks —
Comapi→ComapiClient - Reactive —
RxComapi→RxComapiClient(returns an RxJava 1.xObservable)
For Kotlin code, the callback API can be wrapped in a suspend function so you can await the client without nesting callbacks (shown below).
Firebase is initialised automatically. The Firebase Android SDK ships aContentProvider(com.google.firebase.provider.FirebaseInitProvider) that runs before yourApplication.onCreate()and before anyandroidx.startupinitializer, soFirebaseAppis guaranteed to be ready by the time you initialise Comapi. You do not need to callFirebaseApp.initializeApp(context)yourself unless you're using a non-defaultFirebaseOptionsconfiguration.
Option A — App Startup (recommended)
App Startup (androidx.startup:startup-runtime) is the modern approach to SDK initialisation. It avoids bloating Application.onCreate(), gives you deterministic init order between your initializers, and integrates cleanly with Baseline Profiles.
import androidx.startup.Initializer;
public class ComapiInitializer implements Initializer<Boolean> {
@NonNull
@Override
public Boolean create(@NonNull Context context) {
Application app = (Application) context.getApplicationContext();
ComapiConfig config = new ComapiConfig()
.apiSpaceId(BuildConfig.COMAPI_API_SPACE_ID)
.authenticator(new ChallengeHandler())
.pushMessageListener(new PushHandler());
Comapi.initialiseShared(app, config, new Callback<ComapiClient>() {
@Override public void success(ComapiClient client) {
Log.d("Comapi", "SDK ready");
}
@Override public void error(Throwable t) {
Log.e("Comapi", "SDK init failed", t);
}
});
return Boolean.TRUE;
}
@NonNull
@Override
public List<Class<? extends Initializer<?>>> dependencies() {
// FirebaseApp is initialised by FirebaseInitProvider before any
// androidx.startup Initializer runs, so no explicit dependency is needed.
return Collections.emptyList();
}
}import androidx.startup.Initializer
class ComapiInitializer : Initializer<Boolean> {
override fun create(context: Context): Boolean {
val app = context.applicationContext as Application
val config = ComapiConfig()
.apiSpaceId(BuildConfig.COMAPI_API_SPACE_ID)
.authenticator(ChallengeHandler(TokenService.get(app)))
.pushMessageListener(PushHandler())
Comapi.initialiseShared(app, config, object : Callback<ComapiClient> {
override fun success(client: ComapiClient) {
Log.d("Comapi", "SDK ready")
}
override fun error(t: Throwable) {
Log.e("Comapi", "SDK init failed", t)
}
})
return true
}
// FirebaseApp is initialised by FirebaseInitProvider before any
// androidx.startup Initializer runs, so no explicit dependency is needed.
override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}Register the initializer in your manifest, replacing com.example.ComapiInitializer with your initializer's fully-qualified class name:
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="com.example.ComapiInitializer"
android:value="androidx.startup" />
</provider>Option B — Application.onCreate (classic)
If you prefer the traditional approach, init from your Application subclass.
public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
ComapiConfig config = new ComapiConfig()
.apiSpaceId(BuildConfig.COMAPI_API_SPACE_ID)
.authenticator(new ChallengeHandler())
.pushMessageListener(new PushHandler());
Comapi.initialiseShared(this, config, new Callback<ComapiClient>() {
@Override public void success(ComapiClient client) { /* ready */ }
@Override public void error(Throwable t) { /* handle */ }
});
}
}class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
val config = ComapiConfig()
.apiSpaceId(BuildConfig.COMAPI_API_SPACE_ID)
.authenticator(ChallengeHandler(TokenService.get(this)))
.pushMessageListener(PushHandler())
Comapi.initialiseShared(this, config, object : Callback<ComapiClient> {
override fun success(client: ComapiClient) {
// Use client to communicate with services
}
override fun error(t: Throwable) {
Log.e("Comapi", "SDK init failed", t)
}
})
}
}Option C — Coroutines wrapper around the callback API
For Kotlin codebases, you can wrap the callback init in a suspend function and await the client without nesting callbacks. Wrap it once and reuse:
suspend fun initialiseComapi(
app: Application,
config: ComapiConfig
): ComapiClient = suspendCancellableCoroutine { cont ->
Comapi.initialiseShared(app, config, object : Callback<ComapiClient> {
override fun success(client: ComapiClient) {
if (cont.isActive) cont.resume(client) {}
}
override fun error(t: Throwable) {
if (cont.isActive) cont.resumeWithException(t)
}
})
}
// Usage from Application:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
ProcessLifecycleOwner.get().lifecycleScope.launch {
try {
val client = initialiseComapi(this@MyApplication, buildConfig())
// do something with client
} catch (t: Throwable) {
Log.e("Comapi", "init failed", t)
}
}
}
}Option D — Reactive (Rx) initialisation
public class MyApplication extends Application {
private final CompositeSubscription subscriptions = new CompositeSubscription();
@Override
public void onCreate() {
super.onCreate();
ComapiConfig config = new ComapiConfig()
.apiSpaceId(BuildConfig.COMAPI_API_SPACE_ID)
.authenticator(new ChallengeHandler())
.pushMessageListener(new PushHandler());
subscriptions.add(
RxComapi.initialiseShared(this, config)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
client -> { /* use client */ },
t -> Log.e("Comapi", "init failed", t)
)
);
}
@Override
public void onTerminate() {
subscriptions.clear();
super.onTerminate();
}
}class MyApplication : Application() {
private val subscriptions = CompositeSubscription()
override fun onCreate() {
super.onCreate()
val config = ComapiConfig()
.apiSpaceId(BuildConfig.COMAPI_API_SPACE_ID)
.authenticator(ChallengeHandler(TokenService.get(this)))
.pushMessageListener(PushHandler())
subscriptions.add(
RxComapi.initialiseShared(this, config)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ client -> /* use client */ },
{ t -> Log.e("Comapi", "init failed", t) }
)
)
}
override fun onTerminate() {
subscriptions.clear()
super.onTerminate()
}
}Retrieving the singleton
Both initialiseShared calls store a singleton client instance.
AboutgetShared().Comapi.getShared()andRxComapi.getShared()return the non-null singleton onceinitialiseShared(...)has been called — the client is created synchronously insideinitialiseShared(...), before the async network init finishes. They only throwRuntimeExceptionif you call them beforeinitialiseShared(...)has run at all (e.g. from aContentProviderthat runs ahead ofApplication.onCreate()). With init inApplication.onCreate(),getShared()is safe to call from any later component, but the client may not yet be authenticated — for actions that need an authenticated session, route the client out of the initCallback/Observableor checkclient.getState() == GlobalState.SESSION_ACTIVE.
ComapiClient client = Comapi.getShared();
// or
RxComapiClient rxClient = RxComapi.getShared();val client: ComapiClient = Comapi.getShared()
// or
val rxClient: RxComapiClient = RxComapi.getShared()If you don't want the SDK to hold the singleton, use the non-singleton variants and store the client yourself — ideally via your DI container (Hilt, Koin, Dagger, or a simple service locator):
Comapi.initialise(app, config, callback);
// or
RxComapi.initialise(app, config); // returns Observable<RxComapiClient>Comapi.initialise(app, config, callback)
// or
RxComapi.initialise(app, config) // returns Observable<RxComapiClient>Add listeners
While the app is in the foreground the SDK holds an open socket and emits realtime events. ComapiConfig accepts four listener types, all of them optional:
pushMessageListener— receives the rawRemoteMessagefor FCM pushes delivered while the SDK is running. Required if you want to inspect or render foreground pushes yourself.messagingListener— receives socket events about messages and conversations (new messages, status changes, participants added/removed, typing, etc.). See Realtime events.profileListener— receivesProfileUpdateEvents when the current user's profile changes server-side.stateListener— receives session/socket lifecycle events (onSessionStart,onSocketConnected,onSocketDisconnected,onSocketStart).
You can also addListener(...) / removeListener(...) on the client after init.
ComapiConfig config = new ComapiConfig()
.apiSpaceId(BuildConfig.COMAPI_API_SPACE_ID)
.authenticator(new ChallengeHandler())
.pushMessageListener(new PushHandler())
.messagingListener(new MyMessagingListener())
.profileListener(new MyProfileListener())
.stateListener(new MyStateListener());val config = ComapiConfig()
.apiSpaceId(BuildConfig.COMAPI_API_SPACE_ID)
.authenticator(ChallengeHandler(TokenService.get(this)))
.pushMessageListener(PushHandler())
.messagingListener(MyMessagingListener())
.profileListener(MyProfileListener())
.stateListener(MyStateListener())Implementing PushMessageListener
onMessageReceived is invoked with a Firebase RemoteMessage. Use ComapiClient.parsePushMessage(message) to extract the Dotdigital deep-link URL and any custom data payload.
import com.comapi.internal.push.PushMessageListener;
import com.google.firebase.messaging.RemoteMessage;
public class PushHandler implements PushMessageListener {
@Override
public void onMessageReceived(RemoteMessage message) {
try {
PushDetails details = ComapiClient.parsePushMessage(message);
// url = details.getUrl(), data = details.getData()
} catch (JSONException e) {
Log.w("Comapi", "Failed to parse push payload", e);
}
}
}import com.comapi.internal.push.PushMessageListener
import com.google.firebase.messaging.RemoteMessage
class PushHandler : PushMessageListener {
override fun onMessageReceived(message: RemoteMessage) {
try {
val details = ComapiClient.parsePushMessage(message)
Log.d("Comapi", "url=${details.url}, data=${details.data}")
// Build and display your own foreground notification here if desired.
} catch (e: JSONException) {
Log.w("Comapi", "Failed to parse push payload", e)
}
}
}
Android 13+ notification permission. From API 33 onwards your app must request thePOST_NOTIFICATIONSruntime permission before any notification will display, including those Android renders automatically while the app is backgrounded. Request it from your launcher Activity using the Activity Result API. The SDK's manifest already declares the permission, but the user still has to grant it.
Lifecycle-aware delivery with Flow (Kotlin)
PushMessageListener is registered against ComapiConfig and lives as long as the SDK — it isn't lifecycle-aware. For Kotlin codebases, if you want UI components to react to incoming pushes only while they're visible, bridge the listener into a SharedFlow and collect with repeatOnLifecycle:
import com.comapi.internal.push.PushMessageListener
object PushEvents {
private val _messages = MutableSharedFlow<RemoteMessage>(extraBufferCapacity = 64)
val messages: SharedFlow<RemoteMessage> = _messages.asSharedFlow()
val listener = object : PushMessageListener {
override fun onMessageReceived(message: RemoteMessage) {
_messages.tryEmit(message)
}
}
}
// In a Fragment:
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
PushEvents.messages.collect { message -> renderInApp(message) }
}
}Advanced options
FCM
By default the SDK manages Firebase Cloud Messaging registration and tokens for you, and this is the recommended setup. To disable that behaviour:
config.fcmEnabled(false);config.fcmEnabled(false)
If you disable FCM management, you take over full responsibility for token lifecycle. You'll need to register your ownFirebaseMessagingService, fetch the token at startup viaFirebaseMessaging.getInstance().getToken(), and forward tokens to the SDK whenever they rotate. The exact SDK method for handing a token back to the SDK is not part of the publicly documented surface — checkComapiClient/Sessionin the SDK source for the current signature, or leave FCM enabled unless you have a specific reason to manage it yourself.
Logging
Levels are OFF, FATAL, ERROR, WARNING, INFO, DEBUG. Default is WARNING. Gate verbose levels on debug builds so they don't ship in release.
LogConfig lets you set the level independently for three sinks: file (the SDK's internal rolling log), console (Logcat), and network (HTTP request/response logging).
import com.comapi.internal.log.LogLevel;
import com.comapi.internal.log.LogConfig;
LogLevel logLevel = BuildConfig.DEBUG ? LogLevel.DEBUG : LogLevel.WARNING;
ComapiConfig config = new ComapiConfig().logConfig(
new LogConfig()
.setFileLevel(logLevel)
.setConsoleLevel(logLevel)
.setNetworkLevel(logLevel)
);import com.comapi.internal.log.LogLevel
import com.comapi.internal.log.LogConfig
val logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.WARNING
val config = ComapiConfig().logConfig(
LogConfig()
.setFileLevel(logLevel)
.setConsoleLevel(logLevel)
.setNetworkLevel(logLevel)
)Set a custom limit (in kilobytes) for the internal log files:
new ComapiConfig().logSizeLimitKilobytes(2048);ComapiConfig().logSizeLimitKilobytes(2_048)
Privacy note:DEBUGandINFOnetwork logs typically include request URLs, headers, and bodies. Never ship a release build at anything aboveWARNING.
Proxy
For debugging through a local proxy (Charles, mitmproxy, Proxyman):
new ComapiConfig().apiConfiguration(
new APIConfig().proxy("http://10.0.2.2:8888")); // emulator → host machineComapiConfig().apiConfiguration(
APIConfig().proxy("http://10.0.2.2:8888") // emulator → host machine
)APIConfig also exposes service(host) and socket(host) for overriding the REST and WebSocket endpoints when running against a non-production environment.
Debug builds only. Plaintext proxies must not be wired up in release. To intercept HTTPS on Android 7+ you also need a Network Security Config that trusts your proxy's CA, scoped to thedebugbuild type only.