Flutter v1.0.1.x (Preview)
Specifications
| Spec | Value |
|---|---|
| Package version | 1.0.1.x (Preview) |
| Flutter | >= 3.3.0 |
| Dart SDK | >= 3.5.0 < 4.0.0 |
| Android Min SDK | 24 (Android 7.0) |
| Android Compile SDK | 36 |
| Java | 17 |
| iOS Min | 13.0 (15.0 with the native SDK build made with Xcode 27) |
| Method Channel | flutter_tv_behavioral_plugin |
| Native SDKs | Android and iOS Behavioral SDK 1.0.1.x, bundled inside the package |
The Flutter plugin wraps the native Android and iOS SDKs via platform method channels. On top of the plugin, the package adds a Dart integration layer — TSBehavioral — that drives user identity, device registration, the session and screen reporting for you.
What's new in 1.0.1.x
| Area | Change |
|---|---|
| Integration layer | New TSBehavioral: one object for initialization, sign-in and sign-out, device registration, starting and stopping collection, and screen reporting. No method throws |
| Configuration | TSBehavioralConfig now requires bootstrapKeyId and bootstrapPublicKey. New isValid check and maxBufferAgeMillis (Android only) |
| User identity | New setCusUserId(), getRegistrationStatus(), reportRegistrationFailed() and clearUser(): the SDK stores the cus_user_id, its registration, and a 10-minute cooldown after a rejection |
| User identity | markDeviceRegistered() takes an optional cusUserId parameter |
| Registration | TSRegistrationRequired.registerDeviceBody / registerDeviceHeaders and TSRegistrationOutcome.fromResponse: your backend only forwards the request |
| Kill switch | New getEnabledFlag() / setEnabledFlag(), and TSBehavioral.setRemoteEnabled() on top of them |
| Session lifecycle | Behavior change: startCollect() is idempotent. New switchJourney(). The native SDK replaces the server challenge before it expires (challenge rotation) |
| Screens | New TSScreenTracker (a NavigatorObserver) and TSCollectScope widget. A screen reported before a session starts is carried into it |
| Touch | New trackNativeTouches(): Android touches are collected natively, WebViews included, with no dispatchTouchEvent override |
| Keyboard | FlutterKeyboardTracker.startAutoTracking() tracks whichever text field has focus. Typed characters are masked before they leave the Dart layer |
| Diagnostics | New getSdkVersion() returns the version of the native SDK linked into the build |
Migrating from v1.0.0.3
1. Add the bootstrap key to the configuration. Both values are issued by TrustVision.
Before (v1.0.0.3):
final config = TSBehavioralConfig(
appId: 'your-app-id',
baseUrl: 'https://your-base-url/api',
metadataConfig: const MetaDataCollectionConfig(intervalMillis: 2000),
);
After (v1.0.1.x):
const config = TSBehavioralConfig(
appId: 'your-app-id',
baseUrl: 'https://your-base-url/api',
bootstrapKeyId: 'your-bootstrap-key-id', // Provided by TrustVision
bootstrapPublicKey: 'your-bootstrap-public-key', // Provided by TrustVision
metadataConfig: MetaDataCollectionConfig(intervalMillis: 2000),
);
2. Let TSBehavioral drive registration and collection. In v1.0.0.3 your app decided when to register from initInfo.isRegistered, kept the cus_user_id itself, and called startCollect() and updateScreenTransition() by hand.
Before (v1.0.0.3):
final initInfo = await plugin.initialize(config);
if (!initInfo.isRegistered) {
final userDeviceId = await yourBackend.registerDevice(/* initInfo fields, cusUserId */);
await plugin.markDeviceRegistered(
isRegistered: true,
customerUserDeviceId: userDeviceId,
);
}
await plugin.startCollect();
await plugin.updateScreenTransition('login_screen');
In v1.0.1.x you hand TSBehavioral a register-device callback and tell it who signed in. It asks the SDK whether registration is needed, calls your backend only then, and starts collection.
After (v1.0.1.x):
final behavioral = TSBehavioral(
config: config,
registerDevice: (TSRegistrationRequired request) async {
final response = await yourBackend.registerDevice(
body: request.registerDeviceBody,
headers: request.registerDeviceHeaders,
);
return TSRegistrationOutcome.fromResponse(
statusCode: response.statusCode,
body: response.json['data'],
);
},
screenNameOf: (route) => route.settings.name,
);
MaterialApp(navigatorObservers: [behavioral.screenObserver]);
// After sign-in
await behavioral.signIn(cusUserId: signedInUserId);
3. Close a running session explicitly. Calling startCollect() again no longer discards the running session; it does nothing. End a journey with endFlow() (or finalizeCollect() on the plugin), or close and reopen in one call with switchJourney().
4. Use clearUser() on sign-out. TSBehavioral.signOut() finalizes the running session and calls clearUser(), which forgets the user and the registration but keeps the device keys. Keep clearDeviceIdentity() for a full reset.
5. Remove manual touch forwarding. With TSBehavioral, Android touches are collected natively after initialization. Remove TvBehavioralTouchTrackingWidget and any BehavioralTouchTracker.track call from dispatchTouchEvent, or touches are counted twice.
If you previously kept the cus_user_id yourself, pass it on the first signIn after the upgrade so the existing backend profile is kept. The device then registers once more, because the SDK has no record yet of which cus_user_id it was registered for.
Installation
1. Add the SDK dependency
The 1.0.1.x package is provided by TrustVision. Add it to your pubspec.yaml:
dependencies:
flutter_tv_behavioral_plugin:
path: ./flutter_tv_behavioral_plugin # the 1.0.1.x package provided by TrustVision
Then run:
flutter pub get
2. Android setup
In your app-level build.gradle.kts, set the SDK levels and enable core library desugaring:
android {
compileSdk = 36
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
minSdk = 24
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
}
The SDK uploads the data it collects, so add the INTERNET permission to AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
The plugin registers the Maven repository that serves the native SDK for the whole build, so you do not add one yourself. Only if your project uses dependencyResolutionManagement with RepositoriesMode.FAIL_ON_PROJECT_REPOS do you need to add the plugin's android/repo_behavioral directory as a Maven repository in settings.gradle.kts.
The R8 / ProGuard rules ship with the plugin and the native SDK and apply automatically. Verify that a release build with minification enabled initializes the SDK before you ship.
3. iOS setup
In your Podfile, set the deployment target and keep dynamic frameworks:
platform :ios, '13.0' # '15.0' with the Xcode 27 build
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end
Then run:
cd ios && pod install
4. Permissions (iOS only)
Add to Info.plist if you enable Bluetooth or Location collection:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Used for device behavioral analysis</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Used for device behavioral analysis</string>
Configuration
import 'package:flutter_tv_behavioral_plugin/data/data.dart';
const config = TSBehavioralConfig(
appId: 'your-app-id', // Your app's package name or bundle identifier
baseUrl: 'https://your-base-url/api', // Provided by TrustVision
bootstrapKeyId: 'your-bootstrap-key-id', // Provided by TrustVision
bootstrapPublicKey: 'your-bootstrap-public-key', // Provided by TrustVision
metadataConfig: MetaDataCollectionConfig(
intervalMillis: 2000,
canAskPermission: false,
enabled: true,
),
uploadBlockSize: 10,
sensorConfig: SensorConfig(
samplingIntervalMs: 20,
sensorTypes: [
SensorType.gyroscope,
SensorType.accelerometer,
SensorType.magnetometer,
SensorType.light,
],
enabled: true,
),
keyboardConfig: KeyboardConfig(trackRawText: false, enabled: true),
touchConfig: TouchConfig(enabled: true),
screenTransitionBlockSize: 1,
appStateBlockSize: 1,
retryConfig: RetryConfig(),
);
package:flutter_tv_behavioral_plugin/data/data.dart exports TSBehavioralConfig, its nested configs, TSDeviceInitInfo, TSFinalizeResult and the TSRegistrationStatus types.
TSBehavioralConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
appId | String | Required | — | Your app's package name or bundle identifier |
baseUrl | String | Required | — | Base URL for TrustVision API |
bootstrapKeyId | String | Required | — | Identifier of the bootstrap key pair. See Bootstrap key |
bootstrapPublicKey | String | Required | — | Public half of the bootstrap key pair, PEM or bare base64 DER, RSA-2048 or larger |
metadataConfig | MetaDataCollectionConfig | Required | — | Device metadata collection settings |
uploadBlockSize | int | Optional | 10 | Number of batch entries before auto-upload |
sensorConfig | SensorConfig | Optional | SensorConfig() | Sensor data collection settings |
keyboardConfig | KeyboardConfig | Optional | KeyboardConfig() | Keyboard tracking settings |
touchConfig | TouchConfig | Optional | TouchConfig() | Touch tracking settings |
screenTransitionBlockSize | int | Optional | 1 | Screen transition batch size |
appStateBlockSize | int | Optional | 1 | Number of app-state events per upload batch |
batchExtra | Map<String, String> | Optional | {} | Custom key-value metadata in every batch upload |
retryConfig | RetryConfig | Optional | RetryConfig() | Upload retry queue settings |
sslCertificates | List<TSCertificate>? | Optional | null | Certificates to pin. See SSL Pinning |
maxBufferAgeMillis | int | Optional | 300000 (5 min) | Android only. Age after which a block buffered while the app was backgrounded is flushed to its own batch. Must be > 0 |
config.isValid is false when appId, baseUrl, bootstrapKeyId or bootstrapPublicKey is blank — for example, a build flavor made without its keys. TSBehavioral stays off on an invalid config. The native initialize() validates the values further and fails with invalidConfiguration for a key that is unparseable or smaller than RSA-2048.
The challenge-rotation interval uses the native default (4 minutes); the Flutter config has no challengeTtlMillis field. See Challenge rotation.
Bootstrap key
bootstrapKeyId and bootstrapPublicKey identify the RSA key pair the TrustVision server registers devices with. TrustVision provides both; they must match the environment baseUrl points at.
Changing bootstrapKeyId is treated as a key rotation: the SDK reports the device as not registered, so it registers again.
SSL Pinning
Optional certificate pinning. When the list is non-empty the SDK trusts only servers presenting one of these certificates. Each TSCertificate names one certificate file, bundled on each platform:
- iOS: the file
<name>.<type>(for examplemy_server_cert.cer) in the host app's main bundle. - Android: the raw resource
android/app/src/main/res/raw/<name>.<ext>. Onlynameis used, so it must be a valid Android resource name (lowercase letters, digits and underscores). A name with no matching resource is skipped with a warning in Logcat.
const config = TSBehavioralConfig(
appId: 'com.example.app',
baseUrl: 'https://your-base-url/api',
bootstrapKeyId: 'your-bootstrap-key-id',
bootstrapPublicKey: 'your-bootstrap-public-key',
metadataConfig: MetaDataCollectionConfig(intervalMillis: 2000),
sslCertificates: [
TSCertificate(name: 'my_server_cert', type: 'cer'),
],
);
MetaDataCollectionConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
intervalMillis | int | Required | — | Metadata collection interval in milliseconds |
canAskPermission | bool | Optional | false | Whether the SDK may request system permissions |
enabled | bool | Optional | true | Enable/disable metadata collection |
SensorConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
samplingIntervalMs | int | Optional | 20 (50Hz) | Sensor sampling interval in milliseconds |
sensorTypes | List<SensorType> | Optional | All 4 types | Sensors to collect: gyroscope, accelerometer, magnetometer, light |
enabled | bool | Optional | true | Enable/disable sensor collection |
KeyboardConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
trackRawText | bool | Optional | false | Whether to collect raw typed text. When false, only character type is recorded |
enabled | bool | Optional | true | Enable/disable keyboard tracking |
blockSize | int | Optional | 1 | Number of keyboard events per upload batch |
TouchConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
enabled | bool | Optional | true | Enable/disable touch tracking |
RetryConfig
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
enabled | bool | Optional | true | Enable/disable the retry queue |
maxRetries | int | Optional | 2 | Max immediate retries per request (0–10). Total calls = value + 1 |
retryDelayMs | int | Optional | 1000 | Base delay before first retry in milliseconds (> 0) |
maxDelayMs | int | Optional | 5000 | Maximum delay cap in milliseconds (≥ retryDelayMs) |
maxRetryAttempts | int | Optional | 3 | Maximum retry-queue attempts before dropping a failed batch (≥ 1) |
maxRetryStorageItems | int | Optional | 50 | Maximum items in retry storage before FIFO eviction (≥ 1) |
finalFlushTimeoutSeconds | double | Optional | 10.0 | Timeout in seconds for flush at session end (≥ 1) |
retryItemDelaySeconds | double | Optional | 0.5 | Delay between retry uploads in seconds (≥ 0) |
Initialization
Create one TSBehavioral for the app and add its screenObserver to your navigator. That is the whole setup; the sections below cover what you call from your sign-in and journey code.
import 'package:flutter_tv_behavioral_plugin/data/data.dart';
import 'package:flutter_tv_behavioral_plugin/session/ts_behavioral.dart';
import 'package:flutter_tv_behavioral_plugin/session/ts_behavioral_session.dart';
final behavioral = TSBehavioral(
config: config,
registerDevice: registerDevice, // see Device registration below
screenNameOf: (route) => route.settings.name,
typingScreens: {'login', 'payment_amount'},
);
MaterialApp(
navigatorObservers: [behavioral.screenObserver], // or GoRouter(observers: [...])
// ...
);
Constructor:
TSBehavioral({
required TSBehavioralConfig config,
required TSRegisterDevice registerDevice,
bool enabledByDefault = true,
String? Function(Route<dynamic> route)? screenNameOf,
Set<String> typingScreens = const <String>{},
Set<String>? startScreens,
TSBehavioralSessionListener listener = const TSBehavioralSessionListener(),
void Function(String viewId)? onFieldTracked,
})
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
config | TSBehavioralConfig | Required | — | Passed to the SDK at initialization. An invalid config (isValid == false) keeps the SDK off |
registerDevice | TSRegisterDevice | Required | — | Calls your backend's register-device endpoint. See Device registration |
enabledByDefault | bool | Optional | true | Whether the SDK runs before your kill switch has ever answered. See Kill switch |
screenNameOf | String? Function(Route<dynamic>)? | Optional | null (no route is reported) | The screen name to report for a route, or null when the route is not collected |
typingScreens | Set<String> | Optional | {} (no field is tracked) | Screens whose text fields are tracked for keyboard data |
startScreens | Set<String>? | Optional | null (every screen starts it) | Screens that start collection when reported. With a set, only these screens start it; others are still reported |
listener | TSBehavioralSessionListener | Optional | No-op listener | Receives registration outcomes, session starts and ends, screens and errors. See Error handling |
onFieldTracked | void Function(String viewId)? | Optional | null | Called with the viewId each time a focused text field starts being tracked |
TSBehavioral.instance holds the last instance constructed, for code that cannot be handed it (it is what TSCollectScope uses by default).
initialize
Future<void> initialize()
Initializes the SDK once; later calls return the first call's future. Calling it at launch is optional: signIn(), startFlow() and the first screen report call it themselves. It does nothing while the SDK is disabled or the config is invalid, and a failed initialization is not retried during the same launch.
After a successful initialization, TSBehavioral:
- starts keyboard tracking on the focused text field of every screen in
typingScreens(see Keyboard fields); - on Android, collects every touch of the host Activity natively, WebViews included. iOS collects touches natively already.
Properties:
| Property | Type | Description |
|---|---|---|
isEnabled | bool | Whether the SDK runs this launch: enabled and the config is valid |
isCollecting | bool | true once a session has started, until it is ended |
screenObserver | NavigatorObserver | Add to your navigator observers |
config | TSBehavioralConfig | The config passed to the constructor |
session | TSBehavioralSession | The lower-level session driver, for switchJourney() |
screens | TSScreenTracker | The screen tracker behind screenObserver |
User identity and device registration
The SDK keeps the cus_user_id (your customer user identifier), which cus_user_id the device is registered for, and a 10-minute cooldown after a rejected registration. Your app only says who signed in and how to reach its register-device backend.
signIn / setUserId
void setUserId(String cusUserId)
Future<void> signIn({String? cusUserId})
Call signIn() when sign-in completes. It hands the cus_user_id to the SDK (setCusUserId), which also clears the registration cooldown, then opens a session if none runs — registering first when the SDK asks for it. If a session is running but the device is not registered for the user who just signed in, that session is finalized first and a new one opens.
cusUserId falls back to the value last passed to setUserId() — useful when the id is known before sign-in completes. With neither, the SDK keeps its stored cus_user_id, for example after a biometric sign-in that has no user id to give.
await behavioral.signIn(cusUserId: 'your-user-id');
signOut
Future<void> signOut()
Call on sign-out or account deletion. It stops keyboard tracking, finalizes the running session (its pending batches upload), then calls clearUser(): the SDK forgets the cus_user_id and the registration but keeps the device keys.
Device registration
registerDevice is called only when the SDK reports that registration is required — never while the device is registered for the current user, and never during the cooldown. It receives a TSRegistrationRequired and returns a TSRegistrationOutcome:
typedef TSRegisterDevice = Future<TSRegistrationOutcome> Function(
TSRegistrationRequired request);
A typical callback forwards the ready-made body and headers through your backend:
Future<TSRegistrationOutcome> registerDevice(TSRegistrationRequired request) async {
if (!yourAuth.isSignedIn) {
return const TSRegistrationOutcome.skipped();
}
// Your backend forwards this to TrustVision's POST /v1/register_device
final response = await yourBackend.post(
'register-device',
body: request.registerDeviceBody,
headers: request.registerDeviceHeaders,
);
return TSRegistrationOutcome.fromResponse(
statusCode: response.statusCode,
body: response.json['data'], // the object holding customer_user_device_id
);
}
TSRegistrationRequired:
| 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 |
registerDeviceBody | Map<String, dynamic> | The complete JSON body above, plus "protocol": "encrypted" |
registerDeviceHeaders | Map<String, String> | Headers to send with the body |
TSRegistrationOutcome:
| Constructor | Meaning |
|---|---|
TSRegistrationOutcome.registered(String customerUserDeviceId) | Registration succeeded. The SDK marks the device registered for the request's cusUserId and collection starts |
TSRegistrationOutcome.failed({int? statusCode}) | Registration failed. With the HTTP status your backend answered, the SDK starts the 10-minute cooldown; null (no answer, such as a timeout) starts none |
TSRegistrationOutcome.skipped() | Your app cannot register right now (for example, nobody is signed in). Nothing is reported to the SDK and no cooldown starts |
TSRegistrationOutcome.fromResponse({int? statusCode, Map<String, dynamic>? body}) | Reads the response: registered when body holds a non-empty customer_user_device_id, otherwise failed. A status outside 100–599 counts as no answer |
The callback should not throw; if it does, the attempt is treated as failed with no status code, so no cooldown starts. The request's own cusUserId is what gets marked registered or cooled down, so a user who signs in while the call is in flight neither inherits the registration nor the cooldown.
Calling the plugin directly
TSBehavioral runs the calls below for you. Use them on FlutterTvBehavioralPlugin only if you drive the SDK without TSBehavioral. Call setCusUserId on every sign-in, then follow getRegistrationStatus:
import 'package:flutter_tv_behavioral_plugin/flutter_tv_behavioral_plugin.dart';
final plugin = FlutterTvBehavioralPlugin();
await plugin.initialize(config);
await plugin.setCusUserId('your-user-id');
switch (await plugin.getRegistrationStatus()) {
case TSRegistrationRegistered():
break;
case TSRegistrationCoolingDown():
return; // A registration was rejected less than 10 minutes ago
case final TSRegistrationRequired request:
try {
final customerUserDeviceId = await yourBackend.registerDevice(request);
await plugin.markDeviceRegistered(
isRegistered: true,
customerUserDeviceId: customerUserDeviceId,
cusUserId: request.cusUserId,
);
} on YourHttpException catch (e) {
await plugin.reportRegistrationFailed(
statusCode: e.statusCode, // null when there was no answer
cusUserId: request.cusUserId,
);
return;
}
}
await plugin.startCollect();
| Method | Signature | Description |
|---|---|---|
setCusUserId | Future<void> setCusUserId(String? cusUserId) | Sets the cus_user_id. null or blank keeps the stored one. Always clears the cooldown |
getRegistrationStatus | Future<TSRegistrationStatus> getRegistrationStatus() | TSRegistrationRegistered, TSRegistrationCoolingDown or TSRegistrationRequired. Throws notInitialized before initialize and after clearDeviceIdentity |
markDeviceRegistered | Future<bool> markDeviceRegistered({required bool isRegistered, required String customerUserDeviceId, String? cusUserId}) | Records the registration for cusUserId (null: the current one). Returns false on failure instead of throwing |
reportRegistrationFailed | Future<void> reportRegistrationFailed({int? statusCode, String? cusUserId}) | A non-null statusCode starts the 10-minute cooldown; null does not |
clearUser | Future<void> clearUser() | Sign-out: forgets the cus_user_id and the registration, keeps the device keys |
clearDeviceIdentity | Future<bool> clearDeviceIdentity() | Full reset, device keys included. Call initialize again afterwards. Returns false on failure |
initialize(config) still returns TSDeviceInitInfo (deviceId, bootstrapKeyId, isRegistered, encryptedDevicePublicKeyPayload, encryptedSecretKey), so the v1.0.0.3 registration flow keeps working.
Session lifecycle
One session runs from the first start until endFlow() or signOut(). While it runs, the native SDK replaces the server challenge on its own (see Challenge rotation), so your app does not cut sessions to keep them short.
startFlow
Future<void> startFlow()
Collection starts here — at login, payment or onboarding, for example. Initializes the SDK if needed and registers the device first if the SDK asks for it. Idempotent: a call while a session is collecting does nothing, and concurrent calls share one attempt. Screens reported through screenObserver also start collection (see Starting collection from screens), so many apps never call startFlow() directly.
endFlow
Future<void> endFlow()
Collection stops here: the running session is finalized with finalizeCollect(), so its pending batches upload. Calls made while one runs join it. The next start opens a new session. The finalized session's TSFinalizeResult — including the challengeId to send to your backend for scoring — is delivered to TSBehavioralSessionListener.onSessionEnded:
class MyListener extends TSBehavioralSessionListener {
const MyListener();
@override
void onSessionEnded(TSFinalizeResult result) {
// POST /v1/behavior/score with result.challengeId, from your backend
yourBackend.computeBehaviorScore(challengeId: result.challengeId);
if (result.droppedBatchCount > 0) {
debugPrint('Dropped ${result.droppedBatchCount} batches: ${result.lastUploadError}');
}
}
}
TSFinalizeResult:
| Field | Type | Description |
|---|---|---|
challengeId | String? | Challenge ID — pass to backend for scoring |
nonce | String? | Session nonce |
lastSeq | int | Last uploaded batch sequence number (0 if missing) |
batchId | String? | Last batch identifier |
droppedBatchCount | int | Number of event batches dropped without reaching the server. 0 on a clean finish |
lastUploadError | String? | Description of the last upload error that caused a drop, or null |
switchJourney
Future<void> switchJourney({String reason = 'switchJourney'}) // on behavioral.session
Closes the running session and opens the next as one SDK call, after the screen being reported has landed in the closing session. If nothing is collecting, it simply starts a session. The closed session's result goes to onSessionEnded. Use it only when you want a new challenge for a new journey on the same screen; otherwise one continuous session is enough:
await behavioral.session.switchJourney();
On the plugin, switchJourney() returns Future<TSFinalizeResult?>: the closed session's result, or null when nothing was collecting.
Challenge rotation
The server expires a challenge's nonce after five minutes. While a session is collecting, the native SDK replaces the challenge every 4 minutes, and also checks when the app returns to the foreground. A journey longer than that produces several challenge IDs; finalizeCollect() and switchJourney() return the challenge running when they are called. Only that challenge is scored today; scoring all of a journey's challenges together is in preview.
Plugin session methods
| Method | Signature | Description |
|---|---|---|
startCollect | Future<bool> startCollect() | Opens a session. A no-op while one is collecting. Returns false on failure instead of throwing |
finalizeCollect | Future<TSFinalizeResult> finalizeCollect() | Stops collection, flushes data and waits for the upload queue to drain |
stopCollect | Future<TSFinalizeResult> stopCollect() | Stops collection immediately without flushing. Pending data may be lost; TSBehavioral never calls it |
switchJourney | Future<TSFinalizeResult?> switchJourney() | Finalizes the running session and opens a new one in one call |
getSdkVersion | Future<String> getSdkVersion() | Version of the native SDK linked into the build, useful in bug reports. Returns '' if the call fails |
Screen and flow tracking
TSScreenTracker
behavioral.screenObserver is a TSScreenTracker, a NavigatorObserver. The top route's name, from screenNameOf, is the current screen. An unnamed popup (dialog, bottom sheet, menu) belongs to the page beneath it, so opening or closing one reports nothing; pop, remove and replace report the new top route. Only changes are reported, and the same screen is never reported twice in a row.
The SDK remembers the last reported screen and opens every new session on it, so a screen reported before collection starts is not lost, and you do not report it again after starting.
final collected = {'home', 'login', 'payment_amount', 'payment_confirm'};
final behavioral = TSBehavioral(
config: config,
registerDevice: registerDevice,
// Report only the routes you collect
screenNameOf: (route) =>
collected.contains(route.settings.name) ? route.settings.name : null,
);
Starting collection from screens
By default every reported screen starts collection (a no-op while a session runs). Pass startScreens to start only on some of them; the other screens are still reported into a running session:
final behavioral = TSBehavioral(
config: config,
registerDevice: registerDevice,
screenNameOf: (route) => route.settings.name,
startScreens: {'login', 'payment_amount'},
);
Screens that are not routes
void showIn(Route<dynamic>? route, String? screen)
Future<T> showOver<T>(String screen, Future<T> Function() show)
Future<void> enterScreen(String screen)
| Method | When to use |
|---|---|
showIn | A tab in a PageView or TabBarView becomes visible inside one route. Overrides screenNameOf for that route until it is popped; null means not collected |
showOver | A page is shown inside an unnamed sheet. screen is reported while show runs, then the screen beneath is reported again |
enterScreen | Any other screen the observer cannot see. Reports it, then starts collection unless startScreens excludes it |
// A tab change inside one route
onPageChanged: (index) => behavioral.showIn(ModalRoute.of(context), tabNames[index]),
// A page inside an unnamed bottom sheet
await behavioral.showOver('promo_list', () => showModalBottomSheet(
context: context,
builder: (_) => const PromoList(),
));
TSCollectScope
For a journey that lives in a shared, unnamed route — for example, a WebView route that shows one product's pages — wrap its content in TSCollectScope. When the widget mounts, it reports screen and starts collection. Leaving it does not stop collection; call endFlow() where the journey ends.
import 'package:flutter_tv_behavioral_plugin/session/ts_collect_scope.dart';
TSCollectScope(
screen: 'onboarding_web',
child: WebViewWidget(controller: controller),
)
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
screen | String | Required | — | Screen name to report |
child | Widget | Required | — | Content of the journey |
behavioral | TSBehavioral? | Optional | TSBehavioral.instance | The instance to report through |
Named routes belong in startScreens instead.
Keyboard fields
On the screens listed in typingScreens, the text field that has focus is tracked; it is detached when it loses focus. Its viewId is the identifier of the nearest Semantics widget above it, otherwise <screen>#<n> in first-focus order on that screen. Give a field a stable name with Semantics:
Semantics(
identifier: 'payment_amount',
child: TextField(controller: amountController),
)
Typed characters are masked before they leave the Dart layer — a digit becomes 0, a lowercase letter a, an uppercase letter A, a space stays a space, anything else becomes # — so only the shape and timing of typing are sent. The keystroke type is computed from the real character first.
Kill switch
TSBehavioral keeps your remote on/off switch in the SDK's enabled flag, which survives restarts and can be read before initialization. While the SDK is off, no call reaches the native SDK beyond reading that flag: every TSBehavioral method is a no-op.
Future<void> setRemoteEnabled(bool enabled)
Future<void> enable()
Call setRemoteEnabled() whenever your remote config answers:
remoteConfig.onUpdate((values) {
behavioral.setRemoteEnabled(values.behavioralEnabled);
});
| Situation at launch | setRemoteEnabled(true) | setRemoteEnabled(false) |
|---|---|---|
No answer stored yet (enabledByDefault) | Applies now: the SDK initializes if it was off | Stored; applies from the next launch |
| Last stored answer was on | Stored; already running | Stored; applies from the next launch |
| Last stored answer was off | Stored; applies from the next launch | Stored; stays off |
The last stored answer overrides enabledByDefault. Turning off never stops a running launch half-way, and turning on never starts a launch that began off. enable() turns the SDK on for this launch regardless of the stored answer. A config that is not isValid keeps the SDK off either way.
Without TSBehavioral, use the plugin's getEnabledFlag() (Future<bool?>, null if never stored) and setEnabledFlag(bool? enabled) (null removes it). The SDK only stores the value; what "off" means is up to your app.
On Android the flag is stored in SharedPreferences named behavior_sdk_flags. If your app allows Auto Backup, exclude it, or a reinstall restores the old value:
<exclude domain="sharedpref" path="behavior_sdk_flags.xml"/>
Error handling
TSBehavioral and TSBehavioralSession never throw: behavioral collection must not break your app's flows. Failures are reported to your TSBehavioralSessionListener. Every method has a no-op default, so override only what you log.
| Method | Signature | Called when |
|---|---|---|
onRegistration | void onRegistration(String outcome, {String? cusUserId, int? statusCode}) | outcome is registered (no call needed), cooldown, or your callback's result: ok, failed or skipped |
onSessionStarted | void onSessionStarted(String reason) | A session opened. reason names the trigger, such as signIn, startFlow or enterScreen(home) |
onSessionEnded | void onSessionEnded(TSFinalizeResult result) | A session was finalized, by endFlow, signOut, signIn for another user, or switchJourney |
onScreen | void onScreen(String screen, {required bool reported}) | A screen report; reported is false when it repeated the last one |
onError | void onError(String operation, Object error) | A native call failed. operation names it (initialize, start, finalizeCollect, switchJourney, clearUser, …) |
class LoggingListener extends TSBehavioralSessionListener {
const LoggingListener();
@override
void onError(String operation, Object error) {
if (error is PlatformException) {
// For an HTTP-level networkError, details carries the server context.
final details = error.details as Map?;
debugPrint('Behavioral $operation failed [${error.code}] '
'HTTP ${details?['httpCode']} / ${details?['serverCode']}: ${error.message}');
}
}
}
When you call FlutterTvBehavioralPlugin directly, native errors arrive as a PlatformException whose code is the native TSSessionError name (for example notInitialized, invalidConfiguration, deviceNotRegistered, networkError) and whose message is the platform's description. An error the bridge cannot classify has the code exception. See the error tables of the Android and iOS guides.
| Behavior on failure | Plugin methods |
|---|---|
| Throws | initialize, setCusUserId, getRegistrationStatus, reportRegistrationFailed, clearUser, finalizeCollect, stopCollect, switchJourney |
Returns false | markDeviceRegistered, startCollect, clearDeviceIdentity, addKeyboardTrackingData |
Returns '' | getSdkVersion |
Error details (PlatformException.details)
details is populated only for an HTTP-level networkError (the server responded with a status code), as a Map. For every other error, including a pure connectivity failure, it is null.
| Key | Type | Description |
|---|---|---|
httpCode | int | HTTP status code of the failed response |
serverCode | String? | The server's semantic error code (errors[].code) |
Both keys are identical on Android and iOS. See the API reference for the meaning of serverCode; the server's human-readable message is on message.
Complete Example
A Flutter app wired with TSBehavioral: configuration, the kill switch, device registration through your backend, screen reporting from navigation, keyboard fields, a WebView journey, scoring and sign-out.
import 'package:flutter/material.dart';
import 'package:flutter_tv_behavioral_plugin/data/data.dart';
import 'package:flutter_tv_behavioral_plugin/session/ts_behavioral.dart';
import 'package:flutter_tv_behavioral_plugin/session/ts_behavioral_session.dart';
import 'package:flutter_tv_behavioral_plugin/session/ts_collect_scope.dart';
// 1. Configure the SDK — compile the bootstrap key into the app
const config = TSBehavioralConfig(
appId: 'your-app-id',
baseUrl: 'https://your-base-url/api',
bootstrapKeyId: 'your-bootstrap-key-id',
bootstrapPublicKey: 'your-bootstrap-public-key',
metadataConfig: MetaDataCollectionConfig(intervalMillis: 2000),
);
// 2. Receive session results and errors
class AppBehavioralListener extends TSBehavioralSessionListener {
const AppBehavioralListener();
@override
void onSessionEnded(TSFinalizeResult result) {
// Your backend calls POST /v1/behavior/score with the challenge id
yourBackend.computeBehaviorScore(challengeId: result.challengeId);
}
@override
void onError(String operation, Object error) {
debugPrint('Behavioral $operation failed: $error');
}
}
// 3. One instance for the whole app
final behavioral = TSBehavioral(
config: config,
registerDevice: (TSRegistrationRequired request) async {
if (!yourAuth.isSignedIn) {
return const TSRegistrationOutcome.skipped();
}
try {
final response = await yourBackend.post(
'register-device',
body: request.registerDeviceBody,
headers: request.registerDeviceHeaders,
);
return TSRegistrationOutcome.fromResponse(
statusCode: response.statusCode,
body: response.json['data'],
);
} catch (_) {
return const TSRegistrationOutcome.failed(); // no answer: no cooldown
}
},
screenNameOf: (route) => route.settings.name,
startScreens: {'login', 'payment_amount'},
typingScreens: {'login', 'payment_amount'},
listener: const AppBehavioralListener(),
);
void main() {
WidgetsFlutterBinding.ensureInitialized();
// 4. Feed the kill switch whenever remote config answers
remoteConfig.onUpdate((values) {
behavioral.setRemoteEnabled(values.behavioralEnabled);
});
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
// 5. Screens are reported from navigation
navigatorObservers: [behavioral.screenObserver],
initialRoute: 'login',
routes: {
'login': (_) => const LoginPage(),
'payment_amount': (_) => const PaymentAmountPage(),
'onboarding': (_) => const OnboardingPage(),
},
);
}
}
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
// 6. A stable viewId for keyboard tracking
const Semantics(identifier: 'username', child: TextField()),
ElevatedButton(
onPressed: () async {
final userId = await yourAuth.signIn();
// 7. Hand the user over; registers if needed and starts collection
await behavioral.signIn(cusUserId: userId);
if (context.mounted) {
Navigator.pushNamed(context, 'payment_amount');
}
},
child: const Text('Sign in'),
),
],
),
);
}
}
class PaymentAmountPage extends StatelessWidget {
const PaymentAmountPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
const Semantics(identifier: 'amount', child: TextField()),
ElevatedButton(
onPressed: () async {
await yourBackend.submitPayment();
// 8. The journey ends: finalize and upload
await behavioral.endFlow();
},
child: const Text('Pay'),
),
TextButton(
// 9. Sign-out finalizes the session and forgets the user
onPressed: behavioral.signOut,
child: const Text('Sign out'),
),
],
),
);
}
}
class OnboardingPage extends StatelessWidget {
const OnboardingPage({super.key});
@override
Widget build(BuildContext context) {
// 10. A journey inside a shared WebView route
return TSCollectScope(
screen: 'onboarding_web',
child: WebViewWidget(controller: onboardingController),
);
}
}
Server-Side Device Registration
When the SDK reports that registration is required, your backend forwards request.registerDeviceBody to TrustVision:
POST {baseUrl}/v1/register_device
Content-Type: application/json
Authorization: TV {accessKeyId}:{signature}
X-TV-Timestamp: {ISO8601 UTC timestamp}
The body carries cus_user_id, device_id, key_pair_id, device_public_key, encrypted_secret_key and "protocol": "encrypted", and the response holds the id to pass back:
{
"data": {
"customer_user_device_id": "server-assigned-id"
}
}
Pass the data object as body to TSRegistrationOutcome.fromResponse. Keep the access key secret on your backend; the request signature is described in HMAC-SHA256 Authentication.