Android v1.0.1.x
Specifications
| Spec | Value |
|---|---|
| SDK version | 1.0.1.x |
| Min SDK | 23 (Android 6.0). The collect-only build: 21 (Android 5.0) |
| Compile SDK | 35 |
| Kotlin | 1.9.0 (version the SDK is built with) |
| AGP | 8.11.1 (version the SDK is built with) |
| Java target | 1.8 |
| Language | Kotlin |
| Coroutines | Required |
| Serialization | kotlinx.serialization |
| DI | Koin (isolated context) |
What's new in 1.0.1.x
| Area | Change |
|---|---|
| Configuration | TSBehavioralConfig now requires bootstrapKeyId and bootstrapPublicKey. New optional challengeTtlMillis and (now documented) maxBufferAgeMillis |
| Configuration | initialize() validates the config and throws the new TSSessionError.InvalidConfiguration |
| User identity | New setCusUserId(), getRegistrationStatus(), reportRegistrationFailed() and clearUser(): the SDK stores the cus_user_id, which user the device is registered for, and a 10-minute cooldown after a rejected registration |
| User identity | markDeviceRegistered() takes an optional cusUserId parameter |
| Kill switch | New getEnabledFlag() / setEnabledFlag() to cache your app's on/off switch |
| Session lifecycle | Behavior change: startCollect() is idempotent — it no longer finalizes a running session and opens a new one |
| Session lifecycle | New switchJourney() closes the running session and opens a new one in one call |
| Session lifecycle | The SDK replaces the server challenge before it expires (challenge rotation). A long journey produces several challenge ids |
| Session lifecycle | All session calls are serialized; the last reported screen and keyboard registrations made before startCollect() are carried into the session |
| Device risk signals | New call, screen-capture, accessibility, overlay-touch and remote-access signals in the collected metadata. The SDK manifest merges new permissions and <queries> into your app |
| Builds | The SDK ships as a standard build (this page) or a collect-only build without networking. See Build variants |
Migrating from 1.0.0.x
1. Add the bootstrap key to the configuration. Both values are issued by TrustVision.
Before (1.0.0.x):
val config = TSBehavioralConfig(
appId = "your-app-id",
baseUrl = "https://your-base-url/api",
metadataConfig = MetaDataCollectionConfig(intervalMillis = 2000L),
)
After (1.0.1.x):
val config = TSBehavioralConfig(
appId = "your-app-id",
baseUrl = "https://your-base-url/api",
bootstrapKeyId = "key-id-issued-by-trustvision",
bootstrapPublicKey = BOOTSTRAP_PUBLIC_KEY, // compiled into your app, see below
metadataConfig = MetaDataCollectionConfig(intervalMillis = 2000L),
)
2. Start a new journey explicitly. startCollect() used to discard a running session and start over. It now returns without doing anything while a session is collecting.
Before (1.0.0.x):
// Implicitly ended the previous session and opened a new one
TSBehavioralSDK.startCollect(context)
After (1.0.1.x):
// Close the running session (if any) and open a new one in one call
val previous: TSFinalizeResult? = TSBehavioralSDK.switchJourney(context)
// ...or, equivalently, in two calls
TSBehavioralSDK.finalizeCollect()
TSBehavioralSDK.startCollect(context)
3. Stop re-sending the screen after startCollect(). The SDK now remembers the last screen reported through updateScreenTransition() — also one reported before the session started — and replays it into every new session. Calling updateScreenTransition() again right after startCollect() records the screen twice.
Before (1.0.0.x):
TSBehavioralSDK.startCollect(context)
TSBehavioralSDK.updateScreenTransition("main_screen")
After (1.0.1.x):
TSBehavioralSDK.updateScreenTransition("main_screen") // from onResume, before or after startCollect
TSBehavioralSDK.startCollect(context)
4. Optional: let the SDK track the user identity. Your existing initInfo.isRegistered flow keeps working unchanged. To drop your own cus_user_id storage and retry logic, switch to getRegistrationStatus() — see Device registration. If your app already stored a cus_user_id, hand it over once with setCusUserId(storedId) before the first markDeviceRegistered() so the backend profile is kept. Each device then registers once more.
Installation
1. Add the SDK to your project
Get the library file behaviors.zip, extract and put all files into a repo folder in your Android project root. <version> is the SDK version of your delivery (1.0.1.x):
your-project/
repo/
com/
trustingsocial/
behaviors/
<version>/
behaviors-<version>.aar
behaviors-<version>.pom
behaviors-<version>.module
2. Configure repositories
Add the local Maven repository in your project-level settings.gradle.kts:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven {
url = uri("${rootProject.projectDir}/repo")
}
}
}
3. Add dependencies
In your app-level build.gradle.kts, replace <version> with the version of your delivery:
dependencies {
implementation("com.trustingsocial:behaviors:<version>")
}
4. ProGuard / R8
The SDK ships consumer ProGuard rules automatically. No additional configuration needed.
5. Manifest merge
The SDK manifest merges these entries into your app:
| Entry | Why |
|---|---|
ACCESS_NETWORK_STATE | Network connectivity state |
DETECT_SCREEN_RECORDING (Android 15+), DETECT_SCREEN_CAPTURE (Android 14+) | Screen-recording and screenshot detection. Install-time permissions, no runtime prompt |
<queries> with an android.accessibilityservice.AccessibilityService intent, plus a list of remote-access app packages | Package visibility (Android 11+) for the accessibility and remote-access signals |
See Device risk signals for what your app is responsible for.
Build variants
The SDK is delivered in one of two builds under the same Maven coordinate. TrustVision selects the build for your delivery.
| Build | Contains | Min SDK |
|---|---|---|
| Standard (networked) | TSBehavioralSDK: E2EE upload, server challenges, device registration. Everything on this page | 23 |
| Collect-only (offline) | TSMetadataCollector only: device metadata is handed to your app, which uploads it itself. No network stack, no Koin | 21 |
The collect-only build does not contain TSBehavioralSDK, the touch / keyboard / screen-transition / sensor collectors or the configuration types TSBehavioralConfig, SensorConfig, KeyboardConfig, TouchConfig and RetryConfig. Its API:
object TSMetadataCollector {
fun start(
context: Context,
config: MetaDataCollectionConfig,
appId: String,
excludeTypes: Set<MetaDataType> = emptySet(),
logger: SdkLogger = LogcatSdkLogger(),
onRecord: (Map<String, Any>) -> Unit,
)
fun stop()
}
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
context | Context | Yes | — | Any context; the application context is used internally. Pass the current Activity when a screen is already showing |
config | MetaDataCollectionConfig | Yes | — | Collection settings. intervalMillis controls the cadence |
appId | String | Yes | — | Your app's package name (application ID) |
excludeTypes | Set<MetaDataType> | No | emptySet() | Metadata groups to skip on each interval tick |
logger | SdkLogger | No | LogcatSdkLogger() | Logger for SDK messages |
onRecord | (Map<String, Any>) -> Unit | Yes | — | Called on each collection tick with the collected metadata as a flat map |
Calling start() again stops the running collection first. stop() is a no-op when nothing is running.
TSMetadataCollector.start(
context = this, // the current Activity
config = MetaDataCollectionConfig(intervalMillis = 5_000L),
appId = "your-app-id",
) { record ->
// forward `record` to your own upload / event-log pipeline
}
// ...
TSMetadataCollector.stop()
The rest of this page describes the standard build.
Configuration
import com.ts.behaviorsdk.external.TSBehavioralConfig
import com.ts.behaviorsdk.external.KeyboardConfig
import com.ts.behaviorsdk.external.TouchConfig
import com.ts.behaviorsdk.external.RetryConfig
import com.ts.behaviorsdk.devicemetadata.MetaDataCollectionConfig
import com.ts.behaviorsdk.sensor.SensorConfig
import com.ts.behaviorsdk.data.SensorType
val config = TSBehavioralConfig(
appId = "your-app-id", // Your app's package name / application ID
baseUrl = "https://your-base-url/api", // Provided by TrustVision
bootstrapKeyId = "key-id-issued-by-trustvision",
bootstrapPublicKey = BOOTSTRAP_PUBLIC_KEY, // Provided by TrustVision, compiled into your app
metadataConfig = MetaDataCollectionConfig(
intervalMillis = 2000L,
enabled = true,
),
uploadBlockSize = 10,
sensorConfig = SensorConfig(
samplingIntervalMs = 20L,
sensorTypes = listOf(
SensorType.Gyroscope,
SensorType.Magnetometer,
SensorType.Accelerometer,
SensorType.Light,
),
enabled = true,
),
keyboardConfig = KeyboardConfig(
trackRawText = false,
enabled = true,
),
touchConfig = TouchConfig(
enabled = true,
),
screenTransitionBlockSize = 1,
appStateBlockSize = 1,
batchExtra = mapOf("env" to "production"),
retryConfig = RetryConfig(
enabled = true,
maxRetries = 2,
retryDelayMs = 1000L,
maxDelayMs = 5000L,
maxRetryAttempts = 3,
maxRetryStorageItems = 50,
finalFlushTimeoutMs = 10000L,
retryItemDelayMs = 500L,
),
)
TSBehavioralConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
appId | String | Yes | — | Your app's package name (application ID) |
baseUrl | String | Yes | — | Base URL for TrustVision API. Must be non-blank and start with http:// or https:// |
bootstrapKeyId | String | Yes | — | Identifier of the bootstrap key pair, issued by TrustVision. Must be non-blank. See Bootstrap key |
bootstrapPublicKey | String | Yes | — | Public half of the bootstrap key pair, PEM-armoured or bare base64 DER, RSA-2048 or larger. See Bootstrap key |
metadataConfig | MetaDataCollectionConfig | Yes | — | Device metadata collection settings |
uploadBlockSize | Int | No | 10 | Number of batch entries before auto-upload |
sensorConfig | SensorConfig | No | SensorConfig() | Sensor data collection settings |
keyboardConfig | KeyboardConfig | No | KeyboardConfig() | Keyboard tracking settings |
touchConfig | TouchConfig | No | TouchConfig() | Touch tracking settings |
screenTransitionBlockSize | Int | No | 1 | Screen transition batch size |
appStateBlockSize | Int | No | 1 | Number of app-state events per upload batch |
batchExtra | Map<String, String> | No | emptyMap() | Custom key-value metadata included in every batch upload |
retryConfig | RetryConfig | No | RetryConfig() | Upload retry queue settings |
sslCertificates | List<Int>? | No | null | Raw certificate resource IDs to pin (e.g. R.raw.cert). See SSL Pinning |
maxBufferAgeMillis | Long | No | 300000 (5 min) | A buffered block older than this when a new block arrives (e.g. after the app was in the background) is uploaded in its own batch. Must be > 0 |
challengeTtlMillis | Long | No | 240000 (4 min) | How long a server challenge is used before the SDK replaces it. See Challenge rotation. Must be > 0 |
maxBufferAgeMillis and challengeTtlMillis are checked when the config is constructed: a value ≤ 0 throws IllegalArgumentException. The other fields are checked by initialize(), which throws TSSessionError.InvalidConfiguration.
Bootstrap key
bootstrapKeyId and bootstrapPublicKey identify the key pair the SDK trusts before any server key exists. Both are issued by TrustVision and must name the same key pair — the SDK cannot check that.
Do not fetch bootstrapPublicKey at runtime — not from remote settings, your backend or a config endpoint. Whatever can supply it over the network can also replace it.
Changing bootstrapKeyId re-registers the device: initialize() reports isRegistered = false and getRegistrationStatus() returns Required, so your app must run registration again.
initialize() throws TSSessionError.InvalidConfiguration for a blank bootstrapKeyId, or a bootstrapPublicKey that is blank, unparseable or smaller than RSA-2048.
SSL Pinning
Optional certificate pinning. Pass raw certificate resource IDs via sslCertificates; when the list is non-empty the SDK trusts only servers presenting one of these certificates on every API call. null or an empty list disables pinning and falls back to the system trust store.
val config = TSBehavioralConfig(
appId = "com.example.app",
baseUrl = "https://api.trustingsocial.com",
bootstrapKeyId = "key-id-issued-by-trustvision",
bootstrapPublicKey = BOOTSTRAP_PUBLIC_KEY,
metadataConfig = MetaDataCollectionConfig(intervalMillis = 2000),
sslCertificates = listOf(R.raw.my_server_cert),
)
MetaDataCollectionConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
intervalMillis | Long | Yes | — | Metadata collection interval in milliseconds |
enabled | Boolean | No | true | Enable/disable metadata collection |
typeModes | Map<MetaDataType, MetaDataCollectionMode> | No | DeviceDescriptions→MANUAL, DeviceBehaviors→INTERVAL, UserBehaviors→INTERVAL | Collection mode per metadata type. INTERVAL = repeating timer; MANUAL = explicit calls only |
collectOnceTypes | Set<MetaDataType> | No | {DeviceDescriptions} | Types collected once at session start and stop (fire-and-forget boundary snapshots) |
SensorConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
samplingIntervalMs | Long | No | 20L (50Hz) | Sensor sampling interval in milliseconds. Must be > 0 |
sensorTypes | List<SensorType> | No | All 4 types | Sensors to collect: Gyroscope, Magnetometer, Accelerometer, Light |
enabled | Boolean | No | true | Enable/disable sensor collection |
KeyboardConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
trackRawText | Boolean | No | false | Whether to collect raw typed text. When false, only character type is recorded |
enabled | Boolean | No | true | Enable/disable keyboard tracking |
TouchConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
enabled | Boolean | No | true | Enable/disable touch tracking |
RetryConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
enabled | Boolean | No | true | Enable/disable the retry queue |
maxRetries | Int | No | 2 | Max immediate retries per request (0–10). Total calls = value + 1 |
retryDelayMs | Long | No | 1000 | Base delay before first retry in milliseconds (> 0) |
maxDelayMs | Long | No | 5000 | Maximum delay cap in milliseconds (≥ retryDelayMs) |
maxRetryAttempts | Int | No | 3 | Maximum retry-queue attempts before dropping a failed batch (≥ 1) |
maxRetryStorageItems | Int | No | 50 | Maximum items in retry storage before FIFO eviction (≥ 1) |
finalFlushTimeoutMs | Long | No | 10000 | Timeout in milliseconds for flush at session end (≥ 1) |
retryItemDelayMs | Long | No | 500 | Delay between retry uploads in milliseconds (≥ 0) |
SDK Functions
TSBehavioralSDK is a Kotlin object (singleton). All control-flow methods are suspend functions and must be called from a coroutine scope. Touch, screen transition, keyboard data and enabled-flag methods are non-suspend and can be called from anywhere. TSBehavioralSDK.SDK_VERSION holds the SDK version string.
Do not wait on startCollect, switchJourney, finalizeCollect, stopCollect, initialize, clearUser or clearDeviceIdentity with runBlocking on the main thread. These calls are serialized on one lock, and a session start or a challenge rotation holds that lock while it re-attaches keyboard tracking on the main thread — a blocked main thread deadlocks both. Launch them in a coroutine (lifecycleScope.launch) instead.
Concurrent session calls are queued and run one at a time, so calling startCollect() or finalizeCollect() from several triggers at once is safe.
initialize
Initializes the SDK. Must be called first before any other method. Generates device identity (RSA key pair stored in Android Keystore) and returns device information needed for registration.
Calling initialize() again discards any running session without uploading its remaining data and reinitializes. Call finalizeCollect() first to keep that data.
Signature:
suspend fun initialize(context: Context, config: TSBehavioralConfig): TSDeviceInitInfo
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
context | Context | Yes | Android context (Application or Activity) |
config | TSBehavioralConfig | Yes | SDK configuration |
Returns: TSDeviceInitInfo
| Field | Type | Description |
|---|---|---|
deviceId | String | Unique device identifier (UUID) |
bootstrapKeyId | String | Hardware key ID for encryption |
isRegistered | Boolean | Whether device was previously registered |
encryptedDevicePublicKeyPayload | String | AES-GCM sealed device public key (base64). Pass to device_public_key in register API |
encryptedSecretKey | String | RSA-OAEP encrypted AES secret key (base64). Pass to encrypted_secret_key in register API |
Throws: TSSessionError.InvalidConfiguration, TSSessionError.KeystoreError, TSSessionError.CryptoError
InvalidConfiguration is thrown after any running session has been torn down. The SDK then stays uninitialized: later calls throw NotInitialized until an initialize() succeeds.
Example:
lifecycleScope.launch {
try {
val initInfo = TSBehavioralSDK.initialize(applicationContext, config)
Log.d("BehavioralSDK", "Device ID: ${initInfo.deviceId}")
Log.d("BehavioralSDK", "Already registered: ${initInfo.isRegistered}")
} catch (e: TSSessionError.InvalidConfiguration) {
Log.e("BehavioralSDK", "Invalid ${e.field}: ${e.reason}")
} catch (e: TSSessionError.KeystoreError) {
Log.e("BehavioralSDK", "Keystore error: ${e.message}")
} catch (e: TSSessionError.CryptoError) {
Log.e("BehavioralSDK", "Crypto error: ${e.message}")
}
}
setCusUserId
Sets the cus_user_id the device is registered under. Call it on every sign-in.
A blank or null value keeps the stored one (for example a biometric sign-in that has no user id to give). If none was ever stored, getRegistrationStatus() generates a UUID once and persists it — it is never regenerated, because each new value is a new backend profile. Every call clears the registration cooldown, since a fresh sign-in may have fixed what made the last attempt fail.
Signature:
suspend fun setCusUserId(cusUserId: String?)
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
cusUserId | String? | Yes | Customer user identifier from your system. null/blank keeps the stored one |
Throws: TSSessionError.NotInitialized
getRegistrationStatus
Tells your app whether it has to call its register-device API before startCollect(), and with which values.
Signature:
suspend fun getRegistrationStatus(): TSRegistrationStatus
Returns: TSRegistrationStatus (sealed class)
| Value | Meaning |
|---|---|
Registered | The device is registered for the current cus_user_id. Call startCollect() |
CoolingDown | A registration for this cus_user_id was rejected less than 10 minutes ago. Do not call register-device yet |
Required(...) | Call register-device with its values, then markDeviceRegistered() on success or reportRegistrationFailed() on failure |
TSRegistrationStatus.Required fields:
| Field | Type | Register API field |
|---|---|---|
cusUserId | String | cus_user_id |
deviceId | String | device_id |
bootstrapKeyId | String | key_pair_id |
encryptedDevicePublicKeyPayload | String | device_public_key |
encryptedSecretKey | String | encrypted_secret_key |
Throws: TSSessionError.NotInitialized — also after clearDeviceIdentity() until the next initialize(), so deleted key material is never handed out.
markDeviceRegistered
Marks the device as registered after your backend has completed server-side registration with TrustVision. Must be called after initialize() and before startCollect().
Registration status is persisted locally. On subsequent app launches, initInfo.isRegistered returns true and getRegistrationStatus() returns Registered, so you can skip the registration step. isRegistered = true also records which cus_user_id the device is registered for and clears the registration cooldown.
Signature:
suspend fun markDeviceRegistered(
isRegistered: Boolean,
customerUserDeviceId: String,
cusUserId: String? = null,
)
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
isRegistered | Boolean | Yes | — | Registration status (true to mark as registered) |
customerUserDeviceId | String | Yes | — | Unique user-device identifier returned by your server |
cusUserId | String? | No | null | The cusUserId of the Required status you registered with. Pass it, so a sign-in that lands while the call is in flight is not credited with the other user's registration. null records the current cus_user_id |
Throws: TSSessionError.NotInitialized
Example:
lifecycleScope.launch {
TSBehavioralSDK.initialize(applicationContext, config)
TSBehavioralSDK.setCusUserId("your-user-id")
when (val status = TSBehavioralSDK.getRegistrationStatus()) {
is TSRegistrationStatus.Required -> {
try {
// Your backend calls TrustVision API: POST /v1/register_device
val userDeviceId = yourBackend.registerDevice(
deviceId = status.deviceId,
keyPairId = status.bootstrapKeyId,
devicePublicKey = status.encryptedDevicePublicKeyPayload,
encryptedSecretKey = status.encryptedSecretKey,
cusUserId = status.cusUserId,
)
TSBehavioralSDK.markDeviceRegistered(
isRegistered = true,
customerUserDeviceId = userDeviceId,
cusUserId = status.cusUserId,
)
} catch (e: YourHttpException) {
TSBehavioralSDK.reportRegistrationFailed(e.statusCode, status.cusUserId)
return@launch
}
}
TSRegistrationStatus.CoolingDown -> return@launch // try again later
TSRegistrationStatus.Registered -> Unit
}
TSBehavioralSDK.startCollect(this@MainActivity)
}
reportRegistrationFailed
Reports a failed register-device call.
Signature:
suspend fun reportRegistrationFailed(statusCode: Int?, cusUserId: String? = null)
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
statusCode | Int? | Yes | — | HTTP status your backend answered with. Non-null starts the 10-minute cooldown; null (no answer, e.g. network failure) does not |
cusUserId | String? | No | null | The cusUserId of the Required status that failed, so a user who signed in meanwhile does not inherit the cooldown. null uses the current cus_user_id |
The cooldown is kept in memory: it survives initialize() but not process death.
Throws: TSSessionError.NotInitialized
startCollect
Creates a challenge with the TrustVision server and starts all data collectors (sensors, metadata, touch, keyboard). Requires device to be registered.
Idempotent. If a session is already collecting, the call returns without doing anything — the running session, its challenge and its buffered data are left untouched. To start a new journey, call finalizeCollect() then startCollect(), or switchJourney().
The new session opens with the screen last reported through updateScreenTransition() and with any keyboard tracking registered while no session was collecting.
Session start is all-or-nothing: if anything fails after the challenge was created, the half-started session is torn down, so a retried startCollect() opens a clean one.
Signature:
suspend fun startCollect(context: Context? = null)
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
context | Context? | No | Optional context override. Uses the context from initialize() if null |
Throws: TSSessionError.NotInitialized, TSSessionError.DeviceNotRegistered, TSSessionError.NetworkError, TSSessionError.InvalidChallengeResponse, TSSessionError.ManagerStartError
Example:
lifecycleScope.launch {
try {
TSBehavioralSDK.startCollect(this@MainActivity)
Log.d("BehavioralSDK", "Collection started")
} catch (e: TSSessionError.DeviceNotRegistered) {
Log.e("BehavioralSDK", "Register device first")
} catch (e: TSSessionError.NetworkError) {
Log.e("BehavioralSDK", "Network error: ${e.httpCode} ${e.message}")
}
}
Challenge rotation
The server expires a challenge's nonce after five minutes. While a session is collecting, the SDK replaces the challenge every challengeTtlMillis (default 4 minutes) — it finalizes the current challenge and opens a new one — and also checks when the app returns to the foreground, in case the process was frozen past the deadline. If opening the new challenge fails, the SDK tries up to 5 times in total, waiting 5 s, 10 s, 20 s and 40 s between attempts.
A journey longer than challengeTtlMillis therefore produces several challenge ids. finalizeCollect() returns the id of the challenge collecting at the time it is called. Only that challenge is scored today; scoring all of a journey's challenges together is in preview.
switchJourney
Closes the running session (as finalizeCollect() does) and opens a new one (as startCollect() does) in one call, so no other session call can run in between.
Unlike finalizeCollect(), it keeps the keyboard tracking registered through startKeyboardTracking() and attaches it to the new session — the screen showing those fields is still up. The new session also opens with the last reported screen.
Signature:
suspend fun switchJourney(context: Context? = null): TSFinalizeResult?
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
context | Context? | No | Optional context override. Uses the context from initialize() if null |
Returns: the closed session's TSFinalizeResult, or null if no session was collecting — in which case a session is simply started.
Throws: TSSessionError.NotInitialized, TSSessionError.NetworkError, TSSessionError.InvalidChallengeResponse, TSSessionError.ManagerStartError (the same errors as startCollect())
If opening the new session fails, the previous session has already been finalized. Its result is kept and returned by the next finalizeCollect(), stopCollect() or switchJourney() while nothing is collecting. A startCollect() that opens a new session discards it, so retry with switchJourney() to get it back. Cancelling the calling coroutine does not interrupt the close half.
Example:
// Start each flow with switchJourney so an abandoned flow is not merged into the next one
lifecycleScope.launch {
try {
val previous = TSBehavioralSDK.switchJourney(this@MainActivity)
previous?.challengeId?.let { yourBackend.computeBehaviorScore(it) }
} catch (e: TSSessionError) {
Log.e("BehavioralSDK", "Switch error: ${e.name} - ${e.message}")
}
}
updateScreenTransition (Required)
Important: You must call
updateScreenTransition()whenever the user navigates to a new screen (for example fromonResume()). This is required for behavioral data to be correctly associated with screen context.
Records a screen transition event. Non-suspend. The SDK remembers the last reported screen even when no session is collecting, and replays it into every session startCollect() or switchJourney() opens — so a screen reported before the session started is not lost. The remembered screen survives finalizeCollect() and stopCollect(), and is forgotten by clearUser(), clearDeviceIdentity() and initialize(). Silently no-ops if the SDK is not initialized.
Signatures:
fun updateScreenTransition(value: String)
fun updateScreenTransition(value: ScreenTransitionCollectorListener)
Parameters (String overload):
| Parameter | Type | Required | Description |
|---|---|---|---|
value | String | Yes | Name of the screen being navigated to |
Parameters (Listener overload):
| Parameter | Type | Required | Description |
|---|---|---|---|
value | ScreenTransitionCollectorListener | Yes | Object implementing getScreenName(): String |
Example — using String:
// Report the screen; it is replayed into the session when startCollect() opens it
TSBehavioralSDK.updateScreenTransition("main_screen")
TSBehavioralSDK.startCollect(context)
// Then call again on every screen navigation
TSBehavioralSDK.updateScreenTransition("payment_screen")
Example — using ScreenTransitionCollectorListener:
class PaymentActivity : AppCompatActivity(), ScreenTransitionCollectorListener {
override fun getScreenName() = "payment_screen"
override fun onResume() {
super.onResume()
TSBehavioralSDK.updateScreenTransition(this)
}
}
startKeyboardTracking
Attaches TextWatchers to all EditText fields in the given Activity, View hierarchy, or a specific EditText. Automatically tracks typing patterns (character type, timing, text length).
If no session is collecting yet, the registration is remembered and attached when startCollect() or switchJourney() opens one. Registrations end with the session (finalizeCollect() / stopCollect()) — register again for the next one. switchJourney() carries them into the session it opens.
Signatures:
suspend fun startKeyboardTracking(activity: Activity)
suspend fun startKeyboardTracking(view: View)
suspend fun startKeyboardTracking(editText: EditText)
Uses android.app.Activity, android.view.View, and android.widget.EditText.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
activity | Activity | Yes | Activity containing EditText fields to track |
view | View | Yes | View hierarchy containing EditText fields. Use fragment.requireView() for Fragments |
editText | EditText | Yes | Specific EditText field to track. Call once per field |
Throws: TSSessionError.NotInitialized
Example:
// In your Activity's onResume — tracks all EditTexts
override fun onResume() {
super.onResume()
lifecycleScope.launch {
TSBehavioralSDK.startKeyboardTracking(this@MyActivity)
}
}
// In a Fragment — tracks all EditTexts in the fragment's view
lifecycleScope.launch {
TSBehavioralSDK.startKeyboardTracking(fragment.requireView())
}
// Track a specific EditText
lifecycleScope.launch {
TSBehavioralSDK.startKeyboardTracking(emailEditText)
}
stopKeyboardTracking
Unregisters keyboard tracking listeners. Auto-called by finalizeCollect() and stopCollect(), so you only need to call this explicitly if you want to stop keyboard tracking before the session ends (for example when leaving a sensitive screen). With no session collecting, it only drops the remembered registrations so the next session does not attach them.
Signatures:
suspend fun stopKeyboardTracking()
suspend fun stopKeyboardTracking(editText: EditText)
Parameters (EditText overload):
| Parameter | Type | Required | Description |
|---|---|---|---|
editText | EditText | Yes | Specific EditText field to stop tracking |
Throws: TSSessionError.NotInitialized
Example:
lifecycleScope.launch {
TSBehavioralSDK.stopKeyboardTracking()
}
addKeyboardTrackingData
Sends keyboard tracking data manually. Use this for custom input handling where automatic tracking via startKeyboardTracking() is not suitable.
Non-suspend. Silently no-ops if no session is collecting.
Signature:
fun addKeyboardTrackingData(data: KeyboardData)
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
data | KeyboardData | Yes | Keyboard event data |
KeyboardData fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
timestamp | Long | Yes | — | Event timestamp in milliseconds |
data | String? | No | null | Character value (only sent if trackRawText is true) |
textLength | Int? | No | null | Current text field length |
type | KeyboardDataType | No | Unknown | Type of keystroke |
viewId | String? | No | null | Identifier for the input field |
KeyboardDataType values: LowerText, UpperText, Number, Spacing, SpecialCharacters, Delete, CursorMoving, Mixed, Unknown
Example:
TSBehavioralSDK.addKeyboardTrackingData(
KeyboardData(
timestamp = System.currentTimeMillis(),
type = KeyboardDataType.LowerText,
textLength = 5,
viewId = "email_field",
)
)
trackTextChange
Stateless, framework-agnostic keyboard tracking entry point for Jetpack Compose TextField where TextWatcher/EditText is unavailable. Pass old and new text on every onValueChange — the SDK computes the string diff internally and classifies the keystroke type.
Non-suspend. Silently no-ops if no session is collecting.
Signature:
fun trackTextChange(oldText: String, newText: String, viewId: String)
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
oldText | String | Yes | Text value before the change |
newText | String | Yes | Text value after the change |
viewId | String | Yes | Stable identifier for the input field |
Example — Jetpack Compose:
var email by remember { mutableStateOf("") }
TextField(
value = email,
onValueChange = { newValue ->
TSBehavioralSDK.trackTextChange(
oldText = email,
newText = newValue,
viewId = "email_field",
)
email = newValue
},
label = { Text("Email") }
)
trackTouchEvent
Records a touch event. Non-suspend. Uses WeakReference<Activity> to prevent memory leaks. Silently no-ops if no session is collecting.
Signatures:
fun trackTouchEvent(event: MotionEvent, activity: WeakReference<Activity>)
fun trackTouchEvent(event: PointerEventDto, activity: WeakReference<Activity>)
Parameters (MotionEvent overload):
| Parameter | Type | Required | Description |
|---|---|---|---|
event | MotionEvent | Yes | Android motion event from dispatchTouchEvent |
activity | WeakReference<Activity> | Yes | Weak reference to the current Activity |
Parameters (PointerEventDto overload):
| Parameter | Type | Required | Description |
|---|---|---|---|
event | PointerEventDto | Yes | Pre-converted pointer event data |
activity | WeakReference<Activity> | Yes | Weak reference to the current Activity |
PointerEventDto fields:
| Field | Type | Description |
|---|---|---|
actionName | String? | Action name: ACTION_DOWN, ACTION_POINTER_DOWN, ACTION_MOVE, ACTION_UP, ACTION_POINTER_UP |
actionMasked | Int? | MotionEvent action constant |
pointerId | Int? | Pointer identifier |
globalX | Double? | Screen X coordinate |
globalY | Double? | Screen Y coordinate |
Only the MotionEvent overload feeds the overlay-touch risk signals (touches on an obscured window); PointerEventDto touches are not counted there.
Example — using MotionEvent (recommended):
class MyActivity : AppCompatActivity() {
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
TSBehavioralSDK.trackTouchEvent(event, WeakReference(this))
return super.dispatchTouchEvent(event)
}
}
Example — using PointerEventDto:
TSBehavioralSDK.trackTouchEvent(
PointerEventDto(
actionName = "ACTION_DOWN",
actionMasked = MotionEvent.ACTION_DOWN,
pointerId = 0,
globalX = 150.0,
globalY = 300.0,
),
WeakReference(this)
)
finalizeCollect
Stops all collectors, flushes remaining data, and waits for the upload queue to drain completely. Auto-stops keyboard tracking. The last reported screen is kept for the next session.
Pass the returned challengeId to your backend to trigger behavior score computation via POST /v1/behavior/score.
Signature:
suspend fun finalizeCollect(): TSFinalizeResult
Returns: TSFinalizeResult
| Field | Type | Description |
|---|---|---|
nonce | String? | Session nonce from challenge |
challengeId | String? | Challenge ID — pass to backend for scoring |
lastSeq | Int | Last uploaded batch sequence number |
batchId | String? | Last batch identifier |
droppedBatchCount | Int | Number of event batches dropped without reaching the server (data loss). Defaults to 0; 0 on a clean finalize |
lastUploadError | String? | Description of the last upload error that caused a drop, or null when none |
With no session collecting, finalizeCollect() does not throw. If a switchJourney() or a challenge rotation closed a session but failed to open the next one, that closed session's result is returned here, once.
Throws: TSSessionError.NotInitialized
Example:
lifecycleScope.launch {
try {
val result = TSBehavioralSDK.finalizeCollect()
Log.d("BehavioralSDK", "Challenge ID: ${result.challengeId}")
Log.d("BehavioralSDK", "Last seq: ${result.lastSeq}")
if (result.droppedBatchCount > 0) {
Log.w(
"BehavioralSDK",
"Dropped ${result.droppedBatchCount} batches. Last error: ${result.lastUploadError}",
)
}
// Send challengeId to your backend for scoring
result.challengeId?.let { yourBackend.computeBehaviorScore(it) }
} catch (e: TSSessionError) {
Log.e("BehavioralSDK", "Finalize error: ${e.name} - ${e.message}")
}
}
stopCollect
Stops all collectors immediately without flushing data or draining the upload queue. This is the fast shutdown — use when you need to stop quickly (e.g., app backgrounding). May result in partial data loss. Auto-stops keyboard tracking; the last reported screen is kept for the next session.
Signature:
suspend fun stopCollect(): TSFinalizeResult
Returns: TSFinalizeResult (same structure as finalizeCollect). Because stopCollect() skips the flush and the upload-queue drain, only nonce and challengeId are filled: lastSeq is 0, batchId is null, droppedBatchCount is 0 and lastUploadError is null — they do not report what was lost. (The exception is a result kept from a failed switchJourney() or rotation, which is returned as it was finalized.)
Throws: TSSessionError.NotInitialized
finalizeCollect() | stopCollect() | |
|---|---|---|
| Async | suspend | suspend |
| Flush data | Yes | No |
| Drain upload queue | Yes (waits) | No |
| Data loss risk | None | Possible |
| Use case | Normal completion | App backgrounding |
Example:
lifecycleScope.launch {
try {
val result = TSBehavioralSDK.stopCollect()
Log.d("BehavioralSDK", "Stopped. Challenge ID: ${result.challengeId}")
} catch (e: TSSessionError) {
Log.e("BehavioralSDK", "Stop error: ${e.message}")
}
}
clearUser
Logout. Forgets the cus_user_id, which user the device is registered for, the customerUserDeviceId, the registration flag, the server public key and the stored session. Keeps the device identity (device ID, hardware key) — use clearDeviceIdentity() to drop that too.
A running session ends without uploading its remaining data, and the remembered screen and keyboard registrations are forgotten. Call finalizeCollect() first to keep the session's data. After clearUser(), getRegistrationStatus() returns Required.
Signature:
suspend fun clearUser()
Throws: TSSessionError.NotInitialized
Example:
lifecycleScope.launch {
val result = TSBehavioralSDK.finalizeCollect()
result.challengeId?.let { yourBackend.computeBehaviorScore(it) }
TSBehavioralSDK.clearUser()
}
clearDeviceIdentity
Clears all stored device identity (device ID, registration status, key pair, cus_user_id) from DataStore and Android Keystore. You must call initialize() again after this to generate a new device identity, then register the device again before starting a new session.
A running session ends without uploading its remaining data, and the remembered screen and keyboard registrations are forgotten. Call finalizeCollect() first to keep the session's data.
Use when you need to reset the SDK completely. For a plain logout, clearUser() is enough.
Signature:
suspend fun clearDeviceIdentity()
Throws: TSSessionError.NotInitialized, TSSessionError.KeystoreError
Example:
lifecycleScope.launch {
try {
TSBehavioralSDK.clearDeviceIdentity()
Log.d("BehavioralSDK", "Device identity cleared")
// Must call initialize() again before using SDK
} catch (e: TSSessionError) {
Log.e("BehavioralSDK", "Clear error: ${e.message}")
}
}
getEnabledFlag / setEnabledFlag
Stores your app's on/off switch for the SDK, for example a remote kill switch you want to apply on the next launch. Non-suspend, and readable before initialize(). The SDK only stores the value and does not act on it — what "off" means (for example skipping initialize()) is your app's decision.
Signatures:
fun getEnabledFlag(context: Context): Boolean?
fun setEnabledFlag(context: Context, enabled: Boolean?)
| Parameter | Type | Required | Description |
|---|---|---|---|
context | Context | Yes | Android context |
enabled | Boolean? | Yes | Value to store. null removes it |
getEnabledFlag() returns null when nothing was ever stored — not the same as false. The flag survives clearUser() and clearDeviceIdentity().
The flag is stored in plain SharedPreferences named behavior_sdk_flags. If your app allows Android Auto Backup, exclude it, or a reinstall restores the old value:
<exclude domain="sharedpref" path="behavior_sdk_flags.xml"/>
Example:
// On launch
if (TSBehavioralSDK.getEnabledFlag(applicationContext) != false) {
lifecycleScope.launch { TSBehavioralSDK.initialize(applicationContext, config) }
}
// When your remote config answers — applies from the next launch
TSBehavioralSDK.setEnabledFlag(applicationContext, remoteConfig.behavioralEnabled)
Device Risk Signals
The collected device metadata includes risk signals for phone-scam and remote-control schemes: active call state, screen recording and screenshots, external displays, enabled accessibility services, touches on an obscured window, and installed remote-access apps. Several of them depend on the Android version and are reported as unavailable on older versions.
Your app is responsible for:
- Disclosure and consent. The SDK reads the enabled accessibility services and whether listed remote-access apps are installed without any consent flag. Under Google Play's User Data policy your app must show the in-app Prominent Disclosure & Consent and declare "Installed apps" in the Play Data safety form.
- Screenshot notice. While collection runs on Android 14+, the SDK registers Android's screenshot detection on every started Activity of your app, not only SDK screens. Android then shows the user a system notice that the app detected each screenshot; cover this in your disclosure.
- Passing an Activity. Start collection with the current Activity when a screen is already showing. With an application context, the screen-capture and screenshot signals stay empty until the next Activity start or resume.
- Touches. The overlay-touch counts only come from
trackTouchEvent(MotionEvent, …). WithTouchConfig(enabled = false)they are reported as disabled by configuration.
The SDK never declares or requests READ_PHONE_STATE (the telephony call state is only filled when your app already holds it) and never uses QUERY_ALL_PACKAGES.
Error Handling
All errors are subtypes of TSSessionError (sealed class extending Exception). Each error has a name property for machine-readable identification.
| Error | name | Condition |
|---|---|---|
NotInitialized | notInitialized | SDK not initialized. Call initialize() first |
DeviceNotRegistered | deviceNotRegistered | Server-authoritative: the server reports the device is not registered. Register with your backend, then call markDeviceRegistered() |
InvalidConfiguration(field, reason) | invalidConfiguration | A TSBehavioralConfig value is present but unusable (see TSBehavioralConfig). Exposes field: String and reason: String |
SslPinningError(cause) | sslPinningError | TLS/SSL pinning validation failed — the server certificate did not match a pinned certificate |
ChallengeExpired | challengeExpired | Reserved — not thrown by Android 1.0.1.x, which replaces the challenge before it expires |
InvalidChallengeResponse | invalidChallengeResponse | Empty or invalid challenge from server |
NotStarted | notStarted | Reserved — not thrown by Android 1.0.1.x. finalizeCollect() / stopCollect() with no session do not throw |
KeystoreError(cause) | keychainError | Android Keystore operation failed |
CryptoError(cause) | cryptoError | Encryption or signing operation failed |
NetworkError | networkError | HTTP request failed. Exposes httpCode: Int? and serverErrorCode: String? (the server's errors[].code). Message is formatted "HTTP <code>: <serverMessage>" |
SerializationError(cause) | serializationError | Failed to encode/decode a request or response payload |
UnknownError(cause) | unknownError | An unclassified error — fallback for exceptions that do not map to any known category |
ManagerStartError(cause) | managerStartError | Failed to start data collection managers |
NetworkError surfaces the server's semantic error code so you can branch on it. httpCode is the HTTP status (or null for a connectivity failure), and serverErrorCode mirrors the errors[].code value returned by the API (see the API reference).
try {
TSBehavioralSDK.startCollect(context)
} catch (e: TSSessionError.DeviceNotRegistered) {
// Server says the device is not registered — register via your backend first
} catch (e: TSSessionError.SslPinningError) {
// Certificate pinning failed — the server cert did not match a pinned certificate
} catch (e: TSSessionError.NetworkError) {
// Network issue: check e.httpCode, e.serverErrorCode, and e.message
} catch (e: TSSessionError) {
// Any other SDK error: use e.name and e.message
}
Complete Example
A full Activity integrating the SDK: initialization, user identity and device registration, data collection, touch tracking, keyboard tracking, screen transitions, finalization and logout.
import android.os.Bundle
import android.util.Log
import android.view.MotionEvent
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.ts.behaviorsdk.TSBehavioralSDK
import com.ts.behaviorsdk.external.TSBehavioralConfig
import com.ts.behaviorsdk.external.TSRegistrationStatus
import com.ts.behaviorsdk.external.TSSessionError
import com.ts.behaviorsdk.devicemetadata.MetaDataCollectionConfig
import com.ts.behaviorsdk.sensor.SensorConfig
import com.ts.behaviorsdk.external.KeyboardConfig
import com.ts.behaviorsdk.external.TouchConfig
import com.ts.behaviorsdk.data.SensorType
import kotlinx.coroutines.launch
import java.lang.ref.WeakReference
class BehavioralActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_behavioral)
lifecycleScope.launch {
try {
// 1. Configure SDK
val config = TSBehavioralConfig(
appId = "your-app-id",
baseUrl = "https://your-base-url/api",
bootstrapKeyId = "key-id-issued-by-trustvision",
bootstrapPublicKey = BOOTSTRAP_PUBLIC_KEY,
metadataConfig = MetaDataCollectionConfig(
intervalMillis = 2000L,
),
sensorConfig = SensorConfig(
samplingIntervalMs = 20L,
sensorTypes = listOf(
SensorType.Gyroscope,
SensorType.Magnetometer,
SensorType.Accelerometer,
SensorType.Light,
),
),
keyboardConfig = KeyboardConfig(
trackRawText = false,
),
touchConfig = TouchConfig(
enabled = true,
),
)
// 2. Initialize SDK
TSBehavioralSDK.initialize(this@BehavioralActivity, config)
// 3. Tell the SDK who signed in
TSBehavioralSDK.setCusUserId("your-user-id")
// 4. Register device if needed
when (val status = TSBehavioralSDK.getRegistrationStatus()) {
is TSRegistrationStatus.Required -> {
// Your backend calls:
// POST /v1/register_device
// Body: {
// "device_id": status.deviceId,
// "key_pair_id": status.bootstrapKeyId,
// "device_public_key": status.encryptedDevicePublicKeyPayload,
// "encrypted_secret_key": status.encryptedSecretKey,
// "cus_user_id": status.cusUserId,
// "protocol": "encrypted"
// }
// Response: { "data": { "customer_user_device_id": "..." } }
val userDeviceId = try {
yourBackend.registerDevice(
deviceId = status.deviceId,
keyPairId = status.bootstrapKeyId,
devicePublicKey = status.encryptedDevicePublicKeyPayload,
encryptedSecretKey = status.encryptedSecretKey,
cusUserId = status.cusUserId,
)
} catch (e: YourHttpException) {
TSBehavioralSDK.reportRegistrationFailed(e.statusCode, status.cusUserId)
return@launch
}
TSBehavioralSDK.markDeviceRegistered(
isRegistered = true,
customerUserDeviceId = userDeviceId,
cusUserId = status.cusUserId,
)
}
TSRegistrationStatus.CoolingDown -> return@launch
TSRegistrationStatus.Registered -> Unit
}
// 5. Start data collection (idempotent)
TSBehavioralSDK.startCollect(this@BehavioralActivity)
} catch (e: TSSessionError) {
Log.e("BehavioralSDK", "Error [${e.name}]: ${e.message}")
}
}
}
override fun onResume() {
super.onResume()
// 6. Record the screen — remembered and replayed if the session is not open yet
TSBehavioralSDK.updateScreenTransition("main_screen")
// 7. Start keyboard tracking — remembered and attached when the session opens
lifecycleScope.launch {
TSBehavioralSDK.startKeyboardTracking(this@BehavioralActivity)
}
}
// 8. Track touch events via dispatchTouchEvent
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
TSBehavioralSDK.trackTouchEvent(event, WeakReference(this))
return super.dispatchTouchEvent(event)
}
// 9. Track screen transitions on navigation
private fun navigateToPayment() {
TSBehavioralSDK.updateScreenTransition("payment_screen")
// ... navigate to payment screen
}
// 10. Finalize when the flow is complete
private fun onFlowComplete() {
lifecycleScope.launch {
try {
val result = TSBehavioralSDK.finalizeCollect()
// Send challengeId to your backend to compute behavior score:
// POST /v1/behavior/score
// Body: { "challenge_id": result.challengeId }
result.challengeId?.let { yourBackend.computeBehaviorScore(it) }
} catch (e: TSSessionError) {
Log.e("BehavioralSDK", "Finalize error: ${e.message}")
}
}
}
// 11. Forget the user on logout (device identity is kept)
private fun onLogout() {
lifecycleScope.launch {
TSBehavioralSDK.finalizeCollect()
TSBehavioralSDK.clearUser()
}
}
}
Device registration
After calling initialize(), your app must register the device with TrustVision backend before calling startCollect(). This is a server-side call — typically done from your app via an authenticated API client.
The values to send come from getRegistrationStatus() when it returns Required. The initialize() result (TSDeviceInitInfo) carries the same device fields and its isRegistered flag keeps working, but then your app has to supply and track cus_user_id itself.
API Endpoint
POST {baseUrl}/v1/register_device
Content-Type: application/json
Authorization: TV {accessKeyId}:{signature}
X-TV-Timestamp: {ISO8601 UTC timestamp}
Request Body
| Field | Type | Source | Description |
|---|---|---|---|
device_id | String | status.deviceId | SDK-generated device identifier |
key_pair_id | String | status.bootstrapKeyId | Bootstrap key pair identifier |
device_public_key | String | status.encryptedDevicePublicKeyPayload | AES-GCM sealed device public key (base64) |
encrypted_secret_key | String | status.encryptedSecretKey | RSA-OAEP encrypted AES secret key (base64) |
cus_user_id | String | status.cusUserId | Customer user identifier |
protocol | String | — | Always "encrypted" |
status is the TSRegistrationStatus.Required returned by getRegistrationStatus().
{
"device_id": "from status.deviceId",
"key_pair_id": "from status.bootstrapKeyId",
"device_public_key": "from status.encryptedDevicePublicKeyPayload",
"encrypted_secret_key": "from status.encryptedSecretKey",
"cus_user_id": "from status.cusUserId",
"protocol": "encrypted"
}
Response
{
"data": {
"customer_user_device_id": "server-assigned-id"
}
}
HMAC-SHA256 Authentication
All API requests require HMAC-SHA256 signature in the Authorization header.
String to sign:
{METHOD}\n{PATH}\n{TIMESTAMP}
METHOD— HTTP method (e.g.,POST)PATH— API path without base URL (e.g.,/v1/register_device)TIMESTAMP— ISO 8601 UTC timestamp (e.g.,2026-03-31T12:00:00.000+0000)
Signature: Base64(HMAC-SHA256(accessKeySecret, stringToSign))
Headers:
| Header | Value |
|---|---|
Authorization | TV {accessKeyId}:{signature} |
X-TV-Timestamp | ISO 8601 UTC timestamp |
Kotlin Example
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import android.util.Base64
@Serializable
data class RegisterDeviceRequest(
@SerialName("key_pair_id") val keyPairId: String,
@SerialName("device_public_key") val devicePublicKey: String,
@SerialName("encrypted_secret_key") val encryptedSecretKey: String,
@SerialName("cus_user_id") val cusUserId: String,
@SerialName("device_id") val deviceId: String,
val protocol: String = "encrypted",
)
@Serializable
data class RegisterDeviceResponse(
val data: Data,
) {
@Serializable
data class Data(
@SerialName("customer_user_device_id") val customerUserDeviceId: String,
)
}
// --- API Client ---
suspend fun registerDevice(
httpClient: HttpClient,
request: RegisterDeviceRequest,
): String {
val response: RegisterDeviceResponse = httpClient
.post("/api/v1/register_device") {
contentType(ContentType.Application.Json)
setBody(request)
}.body()
return response.data.customerUserDeviceId
}
// --- HMAC-SHA256 Signing ---
private fun createSignature(
method: String,
path: String,
accessKeySecret: String,
timestamp: String,
): String {
val stringToSign = "$method\n$path\n$timestamp"
val sha256Hmac = Mac.getInstance("HmacSHA256")
val secretKeySpec = SecretKeySpec(accessKeySecret.toByteArray(), "HmacSHA256")
sha256Hmac.init(secretKeySpec)
return Base64.encodeToString(
sha256Hmac.doFinal(stringToSign.toByteArray()),
Base64.NO_WRAP,
)
}
// --- Usage with Ktor Auth Plugin ---
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ")
val timestamp = ZonedDateTime.now(ZoneId.of("UTC")).format(formatter)
val method = "POST"
val path = "/v1/register_device"
val signature = createSignature(method, path, accessKeySecret, timestamp)
// Set headers on request:
// "X-TV-Timestamp" to timestamp
// "Authorization" to "TV $accessKeyId:$signature"
Full Registration Flow
// 1. Initialize SDK and set the signed-in user
TSBehavioralSDK.initialize(context, config)
TSBehavioralSDK.setCusUserId("your-user-id")
// 2. Register device via your backend when the SDK says so
val status = TSBehavioralSDK.getRegistrationStatus()
if (status is TSRegistrationStatus.Required) {
val request = RegisterDeviceRequest(
keyPairId = status.bootstrapKeyId,
devicePublicKey = status.encryptedDevicePublicKeyPayload,
encryptedSecretKey = status.encryptedSecretKey,
cusUserId = status.cusUserId,
deviceId = status.deviceId,
)
val customerUserDeviceId = try {
registerDevice(httpClient, request)
} catch (e: ResponseException) {
// 3a. The backend rejected it: start the 10-minute cooldown
TSBehavioralSDK.reportRegistrationFailed(e.response.status.value, status.cusUserId)
return
}
// 3b. Mark device as registered in SDK
TSBehavioralSDK.markDeviceRegistered(
isRegistered = true,
customerUserDeviceId = customerUserDeviceId,
cusUserId = status.cusUserId,
)
}
// 4. Now you can start collecting (skip while the status is CoolingDown)
if (status !is TSRegistrationStatus.CoolingDown) {
TSBehavioralSDK.startCollect(context)
}