Android v1.0.1.x

Specifications

SpecValue
SDK version1.0.1.x
Min SDK23 (Android 6.0). The collect-only build: 21 (Android 5.0)
Compile SDK35
Kotlin1.9.0 (version the SDK is built with)
AGP8.11.1 (version the SDK is built with)
Java target1.8
LanguageKotlin
CoroutinesRequired
Serializationkotlinx.serialization
DIKoin (isolated context)

What's new in 1.0.1.x

AreaChange
ConfigurationTSBehavioralConfig now requires bootstrapKeyId and bootstrapPublicKey. New optional challengeTtlMillis and (now documented) maxBufferAgeMillis
Configurationinitialize() validates the config and throws the new TSSessionError.InvalidConfiguration
User identityNew 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 identitymarkDeviceRegistered() takes an optional cusUserId parameter
Kill switchNew getEnabledFlag() / setEnabledFlag() to cache your app's on/off switch
Session lifecycleBehavior change: startCollect() is idempotent — it no longer finalizes a running session and opens a new one
Session lifecycleNew switchJourney() closes the running session and opens a new one in one call
Session lifecycleThe SDK replaces the server challenge before it expires (challenge rotation). A long journey produces several challenge ids
Session lifecycleAll session calls are serialized; the last reported screen and keyboard registrations made before startCollect() are carried into the session
Device risk signalsNew 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
BuildsThe 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):

Kotlin
val config = TSBehavioralConfig(
    appId = "your-app-id",
    baseUrl = "https://your-base-url/api",
    metadataConfig = MetaDataCollectionConfig(intervalMillis = 2000L),
)

After (1.0.1.x):

Kotlin
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):

Kotlin
// Implicitly ended the previous session and opened a new one
TSBehavioralSDK.startCollect(context)

After (1.0.1.x):

Kotlin
// 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):

Kotlin
TSBehavioralSDK.startCollect(context)
TSBehavioralSDK.updateScreenTransition("main_screen")

After (1.0.1.x):

Kotlin
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:

Kotlin
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:

Kotlin
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:

EntryWhy
ACCESS_NETWORK_STATENetwork 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 packagesPackage 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.

BuildContainsMin SDK
Standard (networked)TSBehavioralSDK: E2EE upload, server challenges, device registration. Everything on this page23
Collect-only (offline)TSMetadataCollector only: device metadata is handed to your app, which uploads it itself. No network stack, no Koin21

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:

Kotlin
object TSMetadataCollector {
    fun start(
        context: Context,
        config: MetaDataCollectionConfig,
        appId: String,
        excludeTypes: Set<MetaDataType> = emptySet(),
        logger: SdkLogger = LogcatSdkLogger(),
        onRecord: (Map<String, Any>) -> Unit,
    )

    fun stop()
}
ParameterTypeRequiredDefaultDescription
contextContextYes—Any context; the application context is used internally. Pass the current Activity when a screen is already showing
configMetaDataCollectionConfigYes—Collection settings. intervalMillis controls the cadence
appIdStringYes—Your app's package name (application ID)
excludeTypesSet<MetaDataType>NoemptySet()Metadata groups to skip on each interval tick
loggerSdkLoggerNoLogcatSdkLogger()Logger for SDK messages
onRecord(Map<String, Any>) -> UnitYes—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.

Kotlin
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

Kotlin
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

FieldTypeRequiredDefaultDescription
appIdStringYes—Your app's package name (application ID)
baseUrlStringYes—Base URL for TrustVision API. Must be non-blank and start with http:// or https://
bootstrapKeyIdStringYes—Identifier of the bootstrap key pair, issued by TrustVision. Must be non-blank. See Bootstrap key
bootstrapPublicKeyStringYes—Public half of the bootstrap key pair, PEM-armoured or bare base64 DER, RSA-2048 or larger. See Bootstrap key
metadataConfigMetaDataCollectionConfigYes—Device metadata collection settings
uploadBlockSizeIntNo10Number of batch entries before auto-upload
sensorConfigSensorConfigNoSensorConfig()Sensor data collection settings
keyboardConfigKeyboardConfigNoKeyboardConfig()Keyboard tracking settings
touchConfigTouchConfigNoTouchConfig()Touch tracking settings
screenTransitionBlockSizeIntNo1Screen transition batch size
appStateBlockSizeIntNo1Number of app-state events per upload batch
batchExtraMap<String, String>NoemptyMap()Custom key-value metadata included in every batch upload
retryConfigRetryConfigNoRetryConfig()Upload retry queue settings
sslCertificatesList<Int>?NonullRaw certificate resource IDs to pin (e.g. R.raw.cert). See SSL Pinning
maxBufferAgeMillisLongNo300000 (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
challengeTtlMillisLongNo240000 (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.

Compile the bootstrap public key into your app

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.

Kotlin
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

FieldTypeRequiredDefaultDescription
intervalMillisLongYes—Metadata collection interval in milliseconds
enabledBooleanNotrueEnable/disable metadata collection
typeModesMap<MetaDataType, MetaDataCollectionMode>NoDeviceDescriptions→MANUAL, DeviceBehaviors→INTERVAL, UserBehaviors→INTERVALCollection mode per metadata type. INTERVAL = repeating timer; MANUAL = explicit calls only
collectOnceTypesSet<MetaDataType>No{DeviceDescriptions}Types collected once at session start and stop (fire-and-forget boundary snapshots)

SensorConfig

FieldTypeRequiredDefaultDescription
samplingIntervalMsLongNo20L (50Hz)Sensor sampling interval in milliseconds. Must be > 0
sensorTypesList<SensorType>NoAll 4 typesSensors to collect: Gyroscope, Magnetometer, Accelerometer, Light
enabledBooleanNotrueEnable/disable sensor collection

KeyboardConfig

FieldTypeRequiredDefaultDescription
trackRawTextBooleanNofalseWhether to collect raw typed text. When false, only character type is recorded
enabledBooleanNotrueEnable/disable keyboard tracking

TouchConfig

FieldTypeRequiredDefaultDescription
enabledBooleanNotrueEnable/disable touch tracking

RetryConfig

FieldTypeRequiredDefaultDescription
enabledBooleanNotrueEnable/disable the retry queue
maxRetriesIntNo2Max immediate retries per request (0–10). Total calls = value + 1
retryDelayMsLongNo1000Base delay before first retry in milliseconds (> 0)
maxDelayMsLongNo5000Maximum delay cap in milliseconds (≥ retryDelayMs)
maxRetryAttemptsIntNo3Maximum retry-queue attempts before dropping a failed batch (≥ 1)
maxRetryStorageItemsIntNo50Maximum items in retry storage before FIFO eviction (≥ 1)
finalFlushTimeoutMsLongNo10000Timeout in milliseconds for flush at session end (≥ 1)
retryItemDelayMsLongNo500Delay 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.

Never block the main thread on a session call

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:

Kotlin
suspend fun initialize(context: Context, config: TSBehavioralConfig): TSDeviceInitInfo

Parameters:

ParameterTypeRequiredDescription
contextContextYesAndroid context (Application or Activity)
configTSBehavioralConfigYesSDK configuration

Returns: TSDeviceInitInfo

FieldTypeDescription
deviceIdStringUnique device identifier (UUID)
bootstrapKeyIdStringHardware key ID for encryption
isRegisteredBooleanWhether device was previously registered
encryptedDevicePublicKeyPayloadStringAES-GCM sealed device public key (base64). Pass to device_public_key in register API
encryptedSecretKeyStringRSA-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:

Kotlin
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:

Kotlin
suspend fun setCusUserId(cusUserId: String?)

Parameters:

ParameterTypeRequiredDescription
cusUserIdString?YesCustomer 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:

Kotlin
suspend fun getRegistrationStatus(): TSRegistrationStatus

Returns: TSRegistrationStatus (sealed class)

ValueMeaning
RegisteredThe device is registered for the current cus_user_id. Call startCollect()
CoolingDownA 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:

FieldTypeRegister API field
cusUserIdStringcus_user_id
deviceIdStringdevice_id
bootstrapKeyIdStringkey_pair_id
encryptedDevicePublicKeyPayloadStringdevice_public_key
encryptedSecretKeyStringencrypted_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:

Kotlin
suspend fun markDeviceRegistered(
    isRegistered: Boolean,
    customerUserDeviceId: String,
    cusUserId: String? = null,
)

Parameters:

ParameterTypeRequiredDefaultDescription
isRegisteredBooleanYes—Registration status (true to mark as registered)
customerUserDeviceIdStringYes—Unique user-device identifier returned by your server
cusUserIdString?NonullThe 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:

Kotlin
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:

Kotlin
suspend fun reportRegistrationFailed(statusCode: Int?, cusUserId: String? = null)

Parameters:

ParameterTypeRequiredDefaultDescription
statusCodeInt?Yes—HTTP status your backend answered with. Non-null starts the 10-minute cooldown; null (no answer, e.g. network failure) does not
cusUserIdString?NonullThe 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:

Kotlin
suspend fun startCollect(context: Context? = null)

Parameters:

ParameterTypeRequiredDescription
contextContext?NoOptional context override. Uses the context from initialize() if null

Throws: TSSessionError.NotInitialized, TSSessionError.DeviceNotRegistered, TSSessionError.NetworkError, TSSessionError.InvalidChallengeResponse, TSSessionError.ManagerStartError

Example:

Kotlin
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:

Kotlin
suspend fun switchJourney(context: Context? = null): TSFinalizeResult?

Parameters:

ParameterTypeRequiredDescription
contextContext?NoOptional 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:

Kotlin
// 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 from onResume()). 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:

Kotlin
fun updateScreenTransition(value: String)
fun updateScreenTransition(value: ScreenTransitionCollectorListener)

Parameters (String overload):

ParameterTypeRequiredDescription
valueStringYesName of the screen being navigated to

Parameters (Listener overload):

ParameterTypeRequiredDescription
valueScreenTransitionCollectorListenerYesObject implementing getScreenName(): String

Example — using String:

Kotlin
// 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:

Kotlin
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:

Kotlin
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:

ParameterTypeRequiredDescription
activityActivityYesActivity containing EditText fields to track
viewViewYesView hierarchy containing EditText fields. Use fragment.requireView() for Fragments
editTextEditTextYesSpecific EditText field to track. Call once per field

Throws: TSSessionError.NotInitialized

Example:

Kotlin
// 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:

Kotlin
suspend fun stopKeyboardTracking()
suspend fun stopKeyboardTracking(editText: EditText)

Parameters (EditText overload):

ParameterTypeRequiredDescription
editTextEditTextYesSpecific EditText field to stop tracking

Throws: TSSessionError.NotInitialized

Example:

Kotlin
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:

Kotlin
fun addKeyboardTrackingData(data: KeyboardData)

Parameters:

ParameterTypeRequiredDescription
dataKeyboardDataYesKeyboard event data

KeyboardData fields:

FieldTypeRequiredDefaultDescription
timestampLongYes—Event timestamp in milliseconds
dataString?NonullCharacter value (only sent if trackRawText is true)
textLengthInt?NonullCurrent text field length
typeKeyboardDataTypeNoUnknownType of keystroke
viewIdString?NonullIdentifier for the input field

KeyboardDataType values: LowerText, UpperText, Number, Spacing, SpecialCharacters, Delete, CursorMoving, Mixed, Unknown

Example:

Kotlin
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:

Kotlin
fun trackTextChange(oldText: String, newText: String, viewId: String)

Parameters:

ParameterTypeRequiredDescription
oldTextStringYesText value before the change
newTextStringYesText value after the change
viewIdStringYesStable identifier for the input field

Example — Jetpack Compose:

Kotlin
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:

Kotlin
fun trackTouchEvent(event: MotionEvent, activity: WeakReference<Activity>)
fun trackTouchEvent(event: PointerEventDto, activity: WeakReference<Activity>)

Parameters (MotionEvent overload):

ParameterTypeRequiredDescription
eventMotionEventYesAndroid motion event from dispatchTouchEvent
activityWeakReference<Activity>YesWeak reference to the current Activity

Parameters (PointerEventDto overload):

ParameterTypeRequiredDescription
eventPointerEventDtoYesPre-converted pointer event data
activityWeakReference<Activity>YesWeak reference to the current Activity

PointerEventDto fields:

FieldTypeDescription
actionNameString?Action name: ACTION_DOWN, ACTION_POINTER_DOWN, ACTION_MOVE, ACTION_UP, ACTION_POINTER_UP
actionMaskedInt?MotionEvent action constant
pointerIdInt?Pointer identifier
globalXDouble?Screen X coordinate
globalYDouble?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):

Kotlin
class MyActivity : AppCompatActivity() {

    override fun dispatchTouchEvent(event: MotionEvent): Boolean {
        TSBehavioralSDK.trackTouchEvent(event, WeakReference(this))
        return super.dispatchTouchEvent(event)
    }
}

Example — using PointerEventDto:

Kotlin
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:

Kotlin
suspend fun finalizeCollect(): TSFinalizeResult

Returns: TSFinalizeResult

FieldTypeDescription
nonceString?Session nonce from challenge
challengeIdString?Challenge ID — pass to backend for scoring
lastSeqIntLast uploaded batch sequence number
batchIdString?Last batch identifier
droppedBatchCountIntNumber of event batches dropped without reaching the server (data loss). Defaults to 0; 0 on a clean finalize
lastUploadErrorString?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:

Kotlin
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:

Kotlin
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()
Asyncsuspendsuspend
Flush dataYesNo
Drain upload queueYes (waits)No
Data loss riskNonePossible
Use caseNormal completionApp backgrounding

Example:

Kotlin
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:

Kotlin
suspend fun clearUser()

Throws: TSSessionError.NotInitialized

Example:

Kotlin
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:

Kotlin
suspend fun clearDeviceIdentity()

Throws: TSSessionError.NotInitialized, TSSessionError.KeystoreError

Example:

Kotlin
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:

Kotlin
fun getEnabledFlag(context: Context): Boolean?
fun setEnabledFlag(context: Context, enabled: Boolean?)
ParameterTypeRequiredDescription
contextContextYesAndroid context
enabledBoolean?YesValue 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:

XML
<exclude domain="sharedpref" path="behavior_sdk_flags.xml"/>

Example:

Kotlin
// 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, …). With TouchConfig(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.

ErrornameCondition
NotInitializednotInitializedSDK not initialized. Call initialize() first
DeviceNotRegistereddeviceNotRegisteredServer-authoritative: the server reports the device is not registered. Register with your backend, then call markDeviceRegistered()
InvalidConfiguration(field, reason)invalidConfigurationA TSBehavioralConfig value is present but unusable (see TSBehavioralConfig). Exposes field: String and reason: String
SslPinningError(cause)sslPinningErrorTLS/SSL pinning validation failed — the server certificate did not match a pinned certificate
ChallengeExpiredchallengeExpiredReserved — not thrown by Android 1.0.1.x, which replaces the challenge before it expires
InvalidChallengeResponseinvalidChallengeResponseEmpty or invalid challenge from server
NotStartednotStartedReserved — not thrown by Android 1.0.1.x. finalizeCollect() / stopCollect() with no session do not throw
KeystoreError(cause)keychainErrorAndroid Keystore operation failed
CryptoError(cause)cryptoErrorEncryption or signing operation failed
NetworkErrornetworkErrorHTTP request failed. Exposes httpCode: Int? and serverErrorCode: String? (the server's errors[].code). Message is formatted "HTTP <code>: <serverMessage>"
SerializationError(cause)serializationErrorFailed to encode/decode a request or response payload
UnknownError(cause)unknownErrorAn unclassified error — fallback for exceptions that do not map to any known category
ManagerStartError(cause)managerStartErrorFailed 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).

Kotlin
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.

Kotlin
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

FieldTypeSourceDescription
device_idStringstatus.deviceIdSDK-generated device identifier
key_pair_idStringstatus.bootstrapKeyIdBootstrap key pair identifier
device_public_keyStringstatus.encryptedDevicePublicKeyPayloadAES-GCM sealed device public key (base64)
encrypted_secret_keyStringstatus.encryptedSecretKeyRSA-OAEP encrypted AES secret key (base64)
cus_user_idStringstatus.cusUserIdCustomer user identifier
protocolString—Always "encrypted"

status is the TSRegistrationStatus.Required returned by getRegistrationStatus().

JSON
{
  "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

JSON
{
  "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:

HeaderValue
AuthorizationTV {accessKeyId}:{signature}
X-TV-TimestampISO 8601 UTC timestamp

Kotlin Example

Kotlin
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

Kotlin
// 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)
}