Initialise Andorid SDK

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 in local.properties and expose it through buildConfigField in your module's build.gradle / build.gradle.kts, then reference it as BuildConfig.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, call authenticateWithToken(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:

  • CallbacksComapiComapiClient
  • ReactiveRxComapiRxComapiClient (returns an RxJava 1.x Observable)

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 a ContentProvider (com.google.firebase.provider.FirebaseInitProvider) that runs before your Application.onCreate() and before any androidx.startup initializer, so FirebaseApp is guaranteed to be ready by the time you initialise Comapi. You do not need to call FirebaseApp.initializeApp(context) yourself unless you're using a non-default FirebaseOptions configuration.

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.

📦

About getShared(). Comapi.getShared() and RxComapi.getShared() return the non-null singleton once initialiseShared(...) has been called — the client is created synchronously inside initialiseShared(...), before the async network init finishes. They only throw RuntimeException if you call them before initialiseShared(...) has run at all (e.g. from a ContentProvider that runs ahead of Application.onCreate()). With init in Application.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 init Callback/Observable or check client.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 raw RemoteMessage for 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 — receives ProfileUpdateEvents 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 the POST_NOTIFICATIONS runtime 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 own FirebaseMessagingService, fetch the token at startup via FirebaseMessaging.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 — check ComapiClient / Session in 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: DEBUG and INFO network logs typically include request URLs, headers, and bodies. Never ship a release build at anything above WARNING.

Proxy

For debugging through a local proxy (Charles, mitmproxy, Proxyman):

new ComapiConfig().apiConfiguration(
    new APIConfig().proxy("http://10.0.2.2:8888")); // emulator → host machine
ComapiConfig().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 the debug build type only.