SDK

Android SDK

A Gradle library module, minSdk 24. io.qeembed.QEEmbedView is a FrameLayout around a WebView with the trading module in embed mode; it speaks the bridge through a @JavascriptInterface and exposes every command as a method. No third-party dependencies beyond AndroidX.

#Install

  1. Add the module

    Octolit provides the qeembed module on request. Put it next to your app module and include it:

    Gradle
    // settings.gradle.kts
    include(":qeembed")
    
    // app/build.gradle.kts
    dependencies {
        implementation(project(":qeembed"))
    }
  2. Permissions

    The library's manifest already asks for INTERNET, CAMERA (optional) and USE_BIOMETRIC, and registers a FileProvider (authority <applicationId>.qeembed.files) for camera photos. Nothing to add for the basics; see KYC upload for the camera.

  3. The view

    Layout
    <!-- res/layout/activity_main.xml -->
    <io.qeembed.QEEmbedView
        android:id="@+id/trading"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    Your activity must be a FragmentActivity / AppCompatActivity - BiometricPrompt needs it.

#Configure

QEEmbedConfig
import io.qeembed.QEEmbedConfig
import io.qeembed.QEEmbedTheme

val config = QEEmbedConfig(
    baseUrl = "https://live.your-brand.com",
    screen = "terminal",
    language = Locale.getDefault().language,
    theme = QEEmbedTheme(mode = QEEmbedTheme.Mode.DARK, primary = "#ff7a1a", background = "#101418", paper = "#171c22", radius = 14),
    depositByHost = true,   // the Deposit button opens YOUR cashier (deposit_requested)
    linksByHost = true,     // outside links open in the system browser (open_external)
    lockEnabled = true,     // BiometricPrompt after 5 min in the background
    lockAfterSeconds = 300,
)
FieldDefaultMeaning
baseUrlrequiredThe trader address of your brand, https://live.your-brand.com.
screen"terminal"First screen on a cold start: terminal, wallet, history, account, positions.
accountnullTrading account id to select.
languagenullUI language (en, de, es, pt, ar, …). null = the module detects the device language.
themeQEEmbedTheme()Colours, shape and font - see the theme fields below.
hidelistOf("header", "nav", "branding")Parts of the module's chrome to hide; your app has its own navigation.
depositByHosttrueThe Deposit button sends deposit_requested instead of opening the platform cashier.
linksByHosttrueOutside links go to onOpenExternal / the system browser instead of opening inside the WebView.
lockEnabledtrueLock screen + biometric re-unlock through the host.
lockAfterSeconds300Seconds in the background after which the module locks itself.
proModeAllowednullfalse = simple terminal only; null / true = each user keeps their own Pro mode choice.
showLoginFormfalsetrue = keep the module's own sign-in form when there is no session (default: a neutral page, you sign in by SSO).

QEEmbedTheme fields (all optional):

FieldDefaultMeaning
modenullQEEmbedTheme.Mode.LIGHT or DARK.
primarynull"#RRGGBB" accent colour.
backgroundnull"#RRGGBB" page background.
papernull"#RRGGBB" card background (default = background).
textnull"#RRGGBB" main text colour; checked for 4.5:1 contrast.
radiusnullCorner radius in px, coerced to 0 … 32.
fontFamilynullFont family name.
fontUrlnullhttps URL of a stylesheet that loads the font (other schemes are dropped).

The view injects the options as window.__QE_EMBED__ on page start and also puts them on the first URL (config.startUrl(loginToken)) so a reload keeps embed mode. Algorithmic darkening of the WebView is off - your theme decides the colours.

#Sign-in (SSO)

Your app signs the user in with your backend; your backend asks the Partner API for a one-time trading login token (POST /api/external/v1/users/{external_id}/sso, see the SSO guide); the token goes to the module. The activity below is the whole host side, lifecycle included.

MainActivity.kt
class MainActivity : AppCompatActivity() {
    private lateinit var trading: QEEmbedView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        trading = findViewById(R.id.trading)

        trading.setup(this, config)                       // once, in onCreate (FragmentActivity / AppCompatActivity)
        trading.unlockTitle = "Unlock your trading account"
        trading.listener = object : QEEmbedListener() {
            override fun onReady(event: QEEmbedEvent) { hideSplash() }
            override fun onLogin(session: String, event: QEEmbedEvent) { signedIn = true }
            override fun onLogout() { signedIn = false }
            override fun onSessionExpired() { signedIn = false; signIn() }     // a fresh one-time token
            override fun onAuthRequired() { signIn() }
            override fun onDepositRequested(account: String?, clientId: String?) { openCashier(account) }
            override fun onError(code: String, event: QEEmbedEvent) { Log.w("trading", "module error $code") }
            // onUnlockRequested not overridden -> BiometricPrompt (fingerprint / face / PIN) answers by itself
        }

        // back button: the module first, then your own navigation
        onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                if (!trading.goBackIfPossible()) { isEnabled = false; onBackPressedDispatcher.onBackPressed() }
            }
        })

        if (trading.sessionStore.loadSession() != null) trading.start() else signIn()
        trading.handleDeepLink(intent?.data)
    }

    /** 1. your app signs the user in with YOUR backend, 2. your backend asks the Partner API for a one-time
     *  trading login token for that user, 3. the token goes to the module. Never put the Partner API key in the app. */
    private fun signIn() {
        YourBackend.fetchTradingLoginToken { token ->
            if (trading.isReady) trading.login(token) else trading.start(token)
        }
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)
        trading.handleDeepLink(intent.data)
    }

    override fun onResume() { super.onResume(); trading.resume() }
    override fun onPause() { super.onPause(); trading.pause() }
    override fun onDestroy() { trading.destroy(); super.onDestroy() }
}
  • start(token) on the first load, login(token) while the module is already showing (trading.isReady).
  • The module answers login { session, client_id, account }; the SDK saves the session in the Keystore-encrypted store and calls onLogin.
  • onSessionExpired / onAuthRequired: fetch a new token and call login(token).
  • logout() clears the store and signs the module out. Call it when the user signs out of your app.
Never
put the Partner API key in the app, log the token or the session, or reuse a one-time token. Fetch the token on a background thread; call the view on the main thread.

#Session restore

On the next app start call start() with no token: the kept session is sent as restoreSession and the user is signed in without a round trip through your backend. trading.sessionStore.loadSession() tells you whether there is one.

Session stores
// default: EncryptedSharedPreferences (AES256-GCM, Keystore key), file "qeembed_session"
trading.setup(this, config)                                            // QEEmbedKeystoreSessionStore(this)

// tests: nothing survives a restart
trading.setup(this, config, sessionStore = QEEmbedMemorySessionStore())

// your own store
class MyStore : QEEmbedSessionStore {
    override fun loadSession(): String? = …
    override fun saveSession(session: String) { … }
    override fun clearSession() { … }
}

#Events

trading.listener is an open class QEEmbedListener; override the callbacks you need. Every callback runs on the main thread.

CallbackEventUse
onReady(event)readyThe module shows its first screen - hide your splash. event.string("version"), event.bool("authenticated").
onState(event)stateAnswer to init / getState / resume: authenticated, locked, gate, path.
onLogin(session, event)loginSigned in; the SDK already saved session in the store. event has client_id, account, email.
onLogout()logoutSigned out; the store is cleared.
onSessionExpired()session_expiredThe server refused the session; the store is cleared - fetch a new token.
onAuthRequired()auth_requiredA screen needs a signed-in user and there is none - fetch a token.
onNavigation(path)navigationThe module moved; also in trading.currentPath.
onTradeOpened(event)trade_openedevent.data has position { id, symbol, side, lots, price } and request.
onTradeClosed(event)trade_closedevent.data has position_id, partial, lots, position, bulk?, reason?.
onDepositRequested(account, clientId)deposit_requestedOpen your cashier.
onOpenExternal(url: Uri): Booleanopen_externalReturn true when you opened it; false (default) = the SDK opens the system browser.
onUnlockRequested(reason): Booleanunlock_requestedReturn true to run your own prompt (then call unlock(ok)); false (default) = BiometricPrompt.
onUnlocked()unlockedThe lock screen is gone.
onThemeApplied(event)theme_appliedWhat the module applied: mode, primary, bg, paper, text, radius, font.
onPushRegistered(ok, reason)push_registeredAnswer to registerPushToken.
onError(code, event)errorembed_disabled, bad_command, command_failed, unlock_denied; event has command and message.
onAny(event)every eventRaw access: event.type, event.rawType, event.data (JSONObject), event.timestampMs.

QEEmbedEvent carries type (QEEmbedEventType), rawType, data (a JSONObject) and helpers string(key), int(key), bool(key). Read-only state on the view: isReady, isAuthenticated, isLocked, currentPath.

#Commands

Every bridge command is a method. Commands sent before ready are queued and flushed in order.

MethodCommandEffect
setup(activity, config, sessionStore = QEEmbedKeystoreSessionStore(activity))-Once in onCreate. The activity must be a FragmentActivity / AppCompatActivity (BiometricPrompt).
start(loginToken: String? = null)load + init (+ login / restoreSession)Loads the module. With a token: signs in. Without: restores the kept session when there is one.
login(token, account)loginA one-time token while the module is already showing.
logout()logoutSigns the module out and clears the store.
setTheme(theme: QEEmbedTheme?)setThemeAny subset; null clears back to the brand theme.
setLanguage(lang)setLanguageSwitch the UI language.
navigate(screen) / navigateToPath(path)navigateMove to a screen or a module path.
lock() / unlock(ok)lock / unlockShow the lock screen; answer an unlock request.
resume() / pause()resumeCall from onResume / onPause; resume re-checks the lock timer.
getState()getStateAnswered by onState.
registerPushToken(token, provider = "fcm")pushTokenRegisteredHand over the FCM token; queued until signed in; platform "android".
handleDeepLink(uri: Uri?): BooleannavigateMaps an app link / scheme URI; false = not for the module.
goBackIfPossible(): Boolean-WebView history back; false = at the start, do your own navigation.
destroy()-From onDestroy; releases the WebView.
send(command: QEEmbedCommand)anyThe raw bridge: Init, Login, RestoreSession, Logout, SetTheme, SetLanguage, Navigate, PushTokenRegistered, Lock, Unlock, Resume, GetState.

#Theming and language

At runtime
trading.setTheme(QEEmbedTheme(mode = QEEmbedTheme.Mode.LIGHT, primary = "#2563eb"))   // any subset; answered with theme_applied
trading.setTheme(null)                                                                  // back to the brand's own theme
trading.setLanguage("de")                                                               // RTL languages flip the layout

Text colours you pass are checked for 4.5:1 readability; an unreadable value is replaced by the module's own and onThemeApplied tells you what was applied. The OS dark-mode switch does not override your theme.

navigate + QEEmbedDeepLink
trading.navigate("wallet")                       // terminal | wallet | history | account | positions
trading.navigateToPath("/dashboard/positions")   // a module path
trading.listener = object : QEEmbedListener() {
    override fun onNavigation(path: String) { syncTabBar(path) }
}

// Deep links: QEEmbedDeepLink maps three shapes to navigate
//   https://app.your-brand.com/trade/positions    (app link on your host, prefix "/trade")
//   yourapp://trade/wallet                         (custom scheme, host "trade")
//   https://live.your-brand.com/dashboard/wallet   (the module's own address)
QEEmbedDeepLink.linkPrefix = "/trade"
QEEmbedDeepLink.schemeHost = "trade"
val handled: Boolean = trading.handleDeepLink(uri)   // false = not for the module, route it yourself
  • Call handleDeepLink(intent?.data) in onCreate and in onNewIntent (after setIntent), as the activity above does.
  • Only module paths are accepted (QEEmbedDeepLink.safePath): no scheme, no host, so a link can never move the WebView to another site.
  • Back button: goBackIfPossible() first; when it returns false the module is at its start and your own navigation takes over.
  • The WebView navigates only to the host of baseUrl; other URLs go to onOpenExternal or the system browser.

#Biometric lock

With lockEnabled the module locks after lockAfterSeconds in the background (and on lock()) and sends unlock_requested { reason }. The SDK shows BiometricPrompt with BIOMETRIC_WEAK or DEVICE_CREDENTIAL (fingerprint, face, PIN) and answers unlock itself. QEEmbedBiometrics.isAvailable(activity) tells you whether the device can.

Lock
// default: the SDK shows BiometricPrompt (BIOMETRIC_WEAK or DEVICE_CREDENTIAL) and answers unlock itself
trading.unlockTitle = "Unlock your trading account"

// your own prompt instead: return true, then call unlock(ok)
trading.listener = object : QEEmbedListener() {
    override fun onUnlockRequested(reason: String): Boolean {     // user | host | resume | timeout
        MyAuth.confirm { ok -> trading.unlock(ok) }
        return true
    }
    override fun onUnlocked() { Log.i("trading", "unlocked") }
}
trading.lock()                                                     // lock now (for example on your own "Lock" button)

Call resume() from onResume: the WebView resumes and the module re-checks the lock timer, answering state plus unlock_requested when locked. unlock(false) answers error unlock_denied and the lock screen stays.

#Push notifications

You register with FCM as today and hand the token over once. The SDK queues it until the user is signed in, sends pushTokenRegistered { token, platform: "android", provider: "fcm" } and the module answers push_registered { ok }. Trade, margin and deposit pushes for that user then go to that device.

FCM hand-off
// The SDK has no Firebase dependency: you own the registration and hand the token over.
// In onCreate, after trading.setup(...):
FirebaseMessaging.getInstance().token.addOnSuccessListener { trading.registerPushToken(it) }   // provider "fcm"

class FcmService : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        // keep it; the activity passes it on with trading.registerPushToken(token) once the module is signed in
        getSharedPreferences("push", MODE_PRIVATE).edit().putString("fcm_token", token).apply()
    }

    override fun onMessageReceived(message: RemoteMessage) {
        // data payload: qe_path=/dashboard/positions or qe_screen=positions
        val target = QEEmbedPush.target(message.data)
        val uri = when {
            target?.path != null -> Uri.parse("https://live.your-brand.com${target.path}")
            target?.screen != null -> Uri.parse("yourapp://trade/${target.screen}")
            else -> null
        }
        val open = Intent(this, MainActivity::class.java).apply {
            data = uri
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
        }
        val pending = PendingIntent.getActivity(this, 0, open, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
        // ... build and show the notification with pending as its content intent
    }
}

A push data payload may carry a target as qe_path or qe_screen; QEEmbedPush.target(data) returns null for pushes that are not for the module. Turn the target into a deep-link URI on the notification's intent and handleDeepLink does the rest when the activity opens.

#KYC upload

The module's KYC page uses a normal file input. The SDK's onShowFileChooser opens a document picker and adds the camera when your app holds the CAMERA permission (photos go through the library's FileProvider). Ask for the permission before the user reaches KYC, or leave it out: the picker still offers gallery and files.

#Deposits and outside links

  • depositByHost = true (default): the Deposit button only sends deposit_requested { account, client_id } → onDepositRequested. Open your cashier; balances refresh by themselves. false = the platform cashier inside the module.
  • linksByHost = true (default): every link to another site goes to onOpenExternal(url); return true when you handled it, otherwise the SDK opens the system browser. The WebView never leaves the module's origin.

#Test plan

Run on the demo brand, on a real phone or an emulator, before your store build:

  1. No session

    Load with no session: the neutral sign-in page shows and ready { authenticated: false } arrives.

  2. Token sign-in

    start(token) with a token from the demo Partner API: login arrives, the terminal shows, the token is no longer in the URL. Kill the app, start again without a token: signed in.

  3. Theme

    Dark / light, your primary colour, radius, font → theme_applied. Screenshots on a small phone (390 dp wide).

  4. Hand-overs

    Deposit button → deposit_requested; an outside link → open_external / system browser.

  5. Lock

    Background 5 min (or lock()) → lock screen → BiometricPrompt → unlocked.

  6. Push

    Token → push_registered { ok: true }; a test push with qe_screen opens that screen.

  7. KYC

    A photo from the camera and one from the gallery upload.

  8. Deep links

    From a cold start and from a running app (onNewIntent), app link and custom scheme.

  9. Rough conditions

    Rotate, switch the OS dark mode (the module keeps your theme), airplane mode on and off.

#Performance on low-end phones

  • The module is tuned for a throttled mobile profile (4× CPU slowdown, slow 4G).
  • Keep the WebView warm: setup and start() early in the activity that hosts the tab, show the tab later.
  • Do not recreate the view per screen; use navigate. One module instance per app. Handle rotation yourself (for example configChanges) rather than letting the activity recreate the WebView.

#Security notes

  • The session is stored in EncryptedSharedPreferences (AES256-GCM, Keystore key) - it never leaves the device.
  • The WebView loads only the module's origin; other URLs go to the system browser.
  • The one-time token is removed from the URL after the exchange and never stored.
  • The bridge object QEEmbedAndroid exposes one method, postMessage, and accepts only the module's JSON; commands are plain JSON passed to window.QEEmbed.handle.
  • No certificate pinning by design - the platform's certificates rotate automatically.