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
Add the module
Octolit provides the
qeembedmodule 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")) }Permissions
The library's manifest already asks for
INTERNET,CAMERA(optional) andUSE_BIOMETRIC, and registers aFileProvider(authority<applicationId>.qeembed.files) for camera photos. Nothing to add for the basics; see KYC upload for the camera.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-BiometricPromptneeds it.
#Configure
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,
)| Field | Default | Meaning |
|---|---|---|
baseUrl | required | The trader address of your brand, https://live.your-brand.com. |
screen | "terminal" | First screen on a cold start: terminal, wallet, history, account, positions. |
account | null | Trading account id to select. |
language | null | UI language (en, de, es, pt, ar, …). null = the module detects the device language. |
theme | QEEmbedTheme() | Colours, shape and font - see the theme fields below. |
hide | listOf("header", "nav", "branding") | Parts of the module's chrome to hide; your app has its own navigation. |
depositByHost | true | The Deposit button sends deposit_requested instead of opening the platform cashier. |
linksByHost | true | Outside links go to onOpenExternal / the system browser instead of opening inside the WebView. |
lockEnabled | true | Lock screen + biometric re-unlock through the host. |
lockAfterSeconds | 300 | Seconds in the background after which the module locks itself. |
proModeAllowed | null | false = simple terminal only; null / true = each user keeps their own Pro mode choice. |
showLoginForm | false | true = 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):
| Field | Default | Meaning |
|---|---|---|
mode | null | QEEmbedTheme.Mode.LIGHT or DARK. |
primary | null | "#RRGGBB" accent colour. |
background | null | "#RRGGBB" page background. |
paper | null | "#RRGGBB" card background (default = background). |
text | null | "#RRGGBB" main text colour; checked for 4.5:1 contrast. |
radius | null | Corner radius in px, coerced to 0 … 32. |
fontFamily | null | Font family name. |
fontUrl | null | https 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.
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 callsonLogin. onSessionExpired/onAuthRequired: fetch a new token and calllogin(token).logout()clears the store and signs the module out. Call it when the user signs out of your app.
#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.
// 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.
| Callback | Event | Use |
|---|---|---|
onReady(event) | ready | The module shows its first screen - hide your splash. event.string("version"), event.bool("authenticated"). |
onState(event) | state | Answer to init / getState / resume: authenticated, locked, gate, path. |
onLogin(session, event) | login | Signed in; the SDK already saved session in the store. event has client_id, account, email. |
onLogout() | logout | Signed out; the store is cleared. |
onSessionExpired() | session_expired | The server refused the session; the store is cleared - fetch a new token. |
onAuthRequired() | auth_required | A screen needs a signed-in user and there is none - fetch a token. |
onNavigation(path) | navigation | The module moved; also in trading.currentPath. |
onTradeOpened(event) | trade_opened | event.data has position { id, symbol, side, lots, price } and request. |
onTradeClosed(event) | trade_closed | event.data has position_id, partial, lots, position, bulk?, reason?. |
onDepositRequested(account, clientId) | deposit_requested | Open your cashier. |
onOpenExternal(url: Uri): Boolean | open_external | Return true when you opened it; false (default) = the SDK opens the system browser. |
onUnlockRequested(reason): Boolean | unlock_requested | Return true to run your own prompt (then call unlock(ok)); false (default) = BiometricPrompt. |
onUnlocked() | unlocked | The lock screen is gone. |
onThemeApplied(event) | theme_applied | What the module applied: mode, primary, bg, paper, text, radius, font. |
onPushRegistered(ok, reason) | push_registered | Answer to registerPushToken. |
onError(code, event) | error | embed_disabled, bad_command, command_failed, unlock_denied; event has command and message. |
onAny(event) | every event | Raw 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.
| Method | Command | Effect |
|---|---|---|
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) | login | A one-time token while the module is already showing. |
logout() | logout | Signs the module out and clears the store. |
setTheme(theme: QEEmbedTheme?) | setTheme | Any subset; null clears back to the brand theme. |
setLanguage(lang) | setLanguage | Switch the UI language. |
navigate(screen) / navigateToPath(path) | navigate | Move to a screen or a module path. |
lock() / unlock(ok) | lock / unlock | Show the lock screen; answer an unlock request. |
resume() / pause() | resume | Call from onResume / onPause; resume re-checks the lock timer. |
getState() | getState | Answered by onState. |
registerPushToken(token, provider = "fcm") | pushTokenRegistered | Hand over the FCM token; queued until signed in; platform "android". |
handleDeepLink(uri: Uri?): Boolean | navigate | Maps 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) | any | The raw bridge: Init, Login, RestoreSession, Logout, SetTheme, SetLanguage, Navigate, PushTokenRegistered, Lock, Unlock, Resume, GetState. |
#Theming and language
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 layoutText 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.
#Navigation, deep links, back button
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)inonCreateand inonNewIntent(aftersetIntent), 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 returnsfalsethe module is at its start and your own navigation takes over. - The WebView navigates only to the host of
baseUrl; other URLs go toonOpenExternalor 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.
// 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.
// 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 sendsdeposit_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 toonOpenExternal(url); returntruewhen 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:
No session
Load with no session: the neutral sign-in page shows and
ready { authenticated: false }arrives.Token sign-in
start(token)with a token from the demo Partner API:loginarrives, the terminal shows, the token is no longer in the URL. Kill the app, start again without a token: signed in.Theme
Dark / light, your primary colour, radius, font →
theme_applied. Screenshots on a small phone (390 dp wide).Hand-overs
Deposit button →
deposit_requested; an outside link →open_external/ system browser.Lock
Background 5 min (or
lock()) → lock screen → BiometricPrompt →unlocked.Push
Token →
push_registered { ok: true }; a test push withqe_screenopens that screen.KYC
A photo from the camera and one from the gallery upload.
Deep links
From a cold start and from a running app (
onNewIntent), app link and custom scheme.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:
setupandstart()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 exampleconfigChanges) 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
QEEmbedAndroidexposes one method,postMessage, and accepts only the module's JSON; commands are plain JSON passed towindow.QEEmbed.handle. - No certificate pinning by design - the platform's certificates rotate automatically.