Flutter v1.0.1.x (Preview)

Preview
This API is a pre-release that ships ahead of the official Flutter release built on the 1.0.1.x native SDKs, and it may still change before then. Production integrations should stay on [Flutter v1.0.0.3](/api-docs/behavioral-api/sdk-integration/flutter/v1.0.0.3/) unless TrustVision has provided you with the preview package.

Specifications

SpecValue
Package version1.0.1.x (Preview)
Flutter>= 3.3.0
Dart SDK>= 3.5.0 < 4.0.0
Android Min SDK24 (Android 7.0)
Android Compile SDK36
Java17
iOS Min13.0 (15.0 with the native SDK build made with Xcode 27)
Method Channelflutter_tv_behavioral_plugin
Native SDKsAndroid 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.

Building your app with Xcode 27
Use the package built on the `xcode27` native iOS SDK, which requires iOS 15.0 or later. iOS 27 requires apps built with Xcode 27 to adopt the UIScene life cycle (a scene manifest in `Info.plist` and a scene delegate); an app without it does not launch on iOS 27. See the [iOS v1.0.1.x](/api-docs/behavioral-api/sdk-integration/ios/v1.0.1.x/) guide.

What's new in 1.0.1.x

AreaChange
Integration layerNew TSBehavioral: one object for initialization, sign-in and sign-out, device registration, starting and stopping collection, and screen reporting. No method throws
ConfigurationTSBehavioralConfig now requires bootstrapKeyId and bootstrapPublicKey. New isValid check and maxBufferAgeMillis (Android only)
User identityNew setCusUserId(), getRegistrationStatus(), reportRegistrationFailed() and clearUser(): the SDK stores the cus_user_id, its registration, and a 10-minute cooldown after a rejection
User identitymarkDeviceRegistered() takes an optional cusUserId parameter
RegistrationTSRegistrationRequired.registerDeviceBody / registerDeviceHeaders and TSRegistrationOutcome.fromResponse: your backend only forwards the request
Kill switchNew getEnabledFlag() / setEnabledFlag(), and TSBehavioral.setRemoteEnabled() on top of them
Session lifecycleBehavior change: startCollect() is idempotent. New switchJourney(). The native SDK replaces the server challenge before it expires (challenge rotation)
ScreensNew TSScreenTracker (a NavigatorObserver) and TSCollectScope widget. A screen reported before a session starts is carried into it
TouchNew trackNativeTouches(): Android touches are collected natively, WebViews included, with no dispatchTouchEvent override
KeyboardFlutterKeyboardTracker.startAutoTracking() tracks whichever text field has focus. Typed characters are masked before they leave the Dart layer
DiagnosticsNew getSdkVersion() returns the version of the native SDK linked into the build

Migrating from v1.0.0.3

Breaking changes
`TSBehavioralConfig` has two new required parameters, and `startCollect()` no longer restarts a running session. Review each step below before upgrading.

1. Add the bootstrap key to the configuration. Both values are issued by TrustVision.

Before (v1.0.0.3):

Dart
final config = TSBehavioralConfig(
  appId: 'your-app-id',
  baseUrl: 'https://your-base-url/api',
  metadataConfig: const MetaDataCollectionConfig(intervalMillis: 2000),
);

After (v1.0.1.x):

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

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

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

YAML
dependencies:
  flutter_tv_behavioral_plugin:
    path: ./flutter_tv_behavioral_plugin # the 1.0.1.x package provided by TrustVision

Then run:

Shell
flutter pub get

2. Android setup

In your app-level build.gradle.kts, set the SDK levels and enable core library desugaring:

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

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:

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

Shell
cd ios && pod install

4. Permissions (iOS only)

Add to Info.plist if you enable Bluetooth or Location collection:

XML
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Used for device behavioral analysis</string>

<key>NSLocationWhenInUseUsageDescription</key>
<string>Used for device behavioral analysis</string>

Configuration

Dart
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

FieldTypeRequiredDefaultDescription
appIdStringRequired—Your app's package name or bundle identifier
baseUrlStringRequired—Base URL for TrustVision API
bootstrapKeyIdStringRequired—Identifier of the bootstrap key pair. See Bootstrap key
bootstrapPublicKeyStringRequired—Public half of the bootstrap key pair, PEM or bare base64 DER, RSA-2048 or larger
metadataConfigMetaDataCollectionConfigRequired—Device metadata collection settings
uploadBlockSizeintOptional10Number of batch entries before auto-upload
sensorConfigSensorConfigOptionalSensorConfig()Sensor data collection settings
keyboardConfigKeyboardConfigOptionalKeyboardConfig()Keyboard tracking settings
touchConfigTouchConfigOptionalTouchConfig()Touch tracking settings
screenTransitionBlockSizeintOptional1Screen transition batch size
appStateBlockSizeintOptional1Number of app-state events per upload batch
batchExtraMap<String, String>Optional{}Custom key-value metadata in every batch upload
retryConfigRetryConfigOptionalRetryConfig()Upload retry queue settings
sslCertificatesList<TSCertificate>?OptionalnullCertificates to pin. See SSL Pinning
maxBufferAgeMillisintOptional300000 (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.

Compile the bootstrap public key into your app
Do not fetch `bootstrapPublicKey` at runtime — not from remote settings, your backend or a config endpoint. It is the trust anchor for device registration: whoever controls the response carrying it can substitute their own key and complete registration in the server's place.

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 example my_server_cert.cer) in the host app's main bundle.
  • Android: the raw resource android/app/src/main/res/raw/<name>.<ext>. Only name is 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.
Dart
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

FieldTypeRequiredDefaultDescription
intervalMillisintRequired—Metadata collection interval in milliseconds
canAskPermissionboolOptionalfalseWhether the SDK may request system permissions
enabledboolOptionaltrueEnable/disable metadata collection

SensorConfig

FieldTypeRequiredDefaultDescription
samplingIntervalMsintOptional20 (50Hz)Sensor sampling interval in milliseconds
sensorTypesList<SensorType>OptionalAll 4 typesSensors to collect: gyroscope, accelerometer, magnetometer, light
enabledboolOptionaltrueEnable/disable sensor collection

KeyboardConfig

FieldTypeRequiredDefaultDescription
trackRawTextboolOptionalfalseWhether to collect raw typed text. When false, only character type is recorded
enabledboolOptionaltrueEnable/disable keyboard tracking
blockSizeintOptional1Number of keyboard events per upload batch

TouchConfig

FieldTypeRequiredDefaultDescription
enabledboolOptionaltrueEnable/disable touch tracking

RetryConfig

FieldTypeRequiredDefaultDescription
enabledboolOptionaltrueEnable/disable the retry queue
maxRetriesintOptional2Max immediate retries per request (0–10). Total calls = value + 1
retryDelayMsintOptional1000Base delay before first retry in milliseconds (> 0)
maxDelayMsintOptional5000Maximum delay cap in milliseconds (≥ retryDelayMs)
maxRetryAttemptsintOptional3Maximum retry-queue attempts before dropping a failed batch (≥ 1)
maxRetryStorageItemsintOptional50Maximum items in retry storage before FIFO eviction (≥ 1)
finalFlushTimeoutSecondsdoubleOptional10.0Timeout in seconds for flush at session end (≥ 1)
retryItemDelaySecondsdoubleOptional0.5Delay 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.

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';

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:

Dart
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,
})
ParameterTypeRequiredDefaultDescription
configTSBehavioralConfigRequired—Passed to the SDK at initialization. An invalid config (isValid == false) keeps the SDK off
registerDeviceTSRegisterDeviceRequired—Calls your backend's register-device endpoint. See Device registration
enabledByDefaultboolOptionaltrueWhether the SDK runs before your kill switch has ever answered. See Kill switch
screenNameOfString? Function(Route<dynamic>)?Optionalnull (no route is reported)The screen name to report for a route, or null when the route is not collected
typingScreensSet<String>Optional{} (no field is tracked)Screens whose text fields are tracked for keyboard data
startScreensSet<String>?Optionalnull (every screen starts it)Screens that start collection when reported. With a set, only these screens start it; others are still reported
listenerTSBehavioralSessionListenerOptionalNo-op listenerReceives registration outcomes, session starts and ends, screens and errors. See Error handling
onFieldTrackedvoid Function(String viewId)?OptionalnullCalled 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

Dart
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.
Do not also forward touches yourself
With `TSBehavioral`, do not wrap the app in `TvBehavioralTouchTrackingWidget` and do not call `BehavioralTouchTracker.track` from `dispatchTouchEvent`: touches would be counted twice.

Properties:

PropertyTypeDescription
isEnabledboolWhether the SDK runs this launch: enabled and the config is valid
isCollectingbooltrue once a session has started, until it is ended
screenObserverNavigatorObserverAdd to your navigator observers
configTSBehavioralConfigThe config passed to the constructor
sessionTSBehavioralSessionThe lower-level session driver, for switchJourney()
screensTSScreenTrackerThe 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

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

Dart
await behavioral.signIn(cusUserId: 'your-user-id');

signOut

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

Dart
typedef TSRegisterDevice = Future<TSRegistrationOutcome> Function(
    TSRegistrationRequired request);

A typical callback forwards the ready-made body and headers through your backend:

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

FieldTypeRegister API field
cusUserIdStringcus_user_id
deviceIdStringdevice_id
bootstrapKeyIdStringkey_pair_id
encryptedDevicePublicKeyPayloadStringdevice_public_key
encryptedSecretKeyStringencrypted_secret_key
registerDeviceBodyMap<String, dynamic>The complete JSON body above, plus "protocol": "encrypted"
registerDeviceHeadersMap<String, String>Headers to send with the body

TSRegistrationOutcome:

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

Dart
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();
MethodSignatureDescription
setCusUserIdFuture<void> setCusUserId(String? cusUserId)Sets the cus_user_id. null or blank keeps the stored one. Always clears the cooldown
getRegistrationStatusFuture<TSRegistrationStatus> getRegistrationStatus()TSRegistrationRegistered, TSRegistrationCoolingDown or TSRegistrationRequired. Throws notInitialized before initialize and after clearDeviceIdentity
markDeviceRegisteredFuture<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
reportRegistrationFailedFuture<void> reportRegistrationFailed({int? statusCode, String? cusUserId})A non-null statusCode starts the 10-minute cooldown; null does not
clearUserFuture<void> clearUser()Sign-out: forgets the cus_user_id and the registration, keeps the device keys
clearDeviceIdentityFuture<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

Dart
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

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

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

FieldTypeDescription
challengeIdString?Challenge ID — pass to backend for scoring
nonceString?Session nonce
lastSeqintLast uploaded batch sequence number (0 if missing)
batchIdString?Last batch identifier
droppedBatchCountintNumber of event batches dropped without reaching the server. 0 on a clean finish
lastUploadErrorString?Description of the last upload error that caused a drop, or null

switchJourney

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

Dart
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

MethodSignatureDescription
startCollectFuture<bool> startCollect()Opens a session. A no-op while one is collecting. Returns false on failure instead of throwing
finalizeCollectFuture<TSFinalizeResult> finalizeCollect()Stops collection, flushes data and waits for the upload queue to drain
stopCollectFuture<TSFinalizeResult> stopCollect()Stops collection immediately without flushing. Pending data may be lost; TSBehavioral never calls it
switchJourneyFuture<TSFinalizeResult?> switchJourney()Finalizes the running session and opens a new one in one call
getSdkVersionFuture<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.

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

Dart
final behavioral = TSBehavioral(
  config: config,
  registerDevice: registerDevice,
  screenNameOf: (route) => route.settings.name,
  startScreens: {'login', 'payment_amount'},
);

Screens that are not routes

Dart
void showIn(Route<dynamic>? route, String? screen)
Future<T> showOver<T>(String screen, Future<T> Function() show)
Future<void> enterScreen(String screen)
MethodWhen to use
showInA tab in a PageView or TabBarView becomes visible inside one route. Overrides screenNameOf for that route until it is popped; null means not collected
showOverA page is shown inside an unnamed sheet. screen is reported while show runs, then the screen beneath is reported again
enterScreenAny other screen the observer cannot see. Reports it, then starts collection unless startScreens excludes it
Dart
// 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.

Dart
import 'package:flutter_tv_behavioral_plugin/session/ts_collect_scope.dart';

TSCollectScope(
  screen: 'onboarding_web',
  child: WebViewWidget(controller: controller),
)
ParameterTypeRequiredDefaultDescription
screenStringRequired—Screen name to report
childWidgetRequired—Content of the journey
behavioralTSBehavioral?OptionalTSBehavioral.instanceThe 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:

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

Dart
Future<void> setRemoteEnabled(bool enabled)
Future<void> enable()

Call setRemoteEnabled() whenever your remote config answers:

Dart
remoteConfig.onUpdate((values) {
  behavioral.setRemoteEnabled(values.behavioralEnabled);
});
Situation at launchsetRemoteEnabled(true)setRemoteEnabled(false)
No answer stored yet (enabledByDefault)Applies now: the SDK initializes if it was offStored; applies from the next launch
Last stored answer was onStored; already runningStored; applies from the next launch
Last stored answer was offStored; applies from the next launchStored; 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:

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

MethodSignatureCalled when
onRegistrationvoid onRegistration(String outcome, {String? cusUserId, int? statusCode})outcome is registered (no call needed), cooldown, or your callback's result: ok, failed or skipped
onSessionStartedvoid onSessionStarted(String reason)A session opened. reason names the trigger, such as signIn, startFlow or enterScreen(home)
onSessionEndedvoid onSessionEnded(TSFinalizeResult result)A session was finalized, by endFlow, signOut, signIn for another user, or switchJourney
onScreenvoid onScreen(String screen, {required bool reported})A screen report; reported is false when it repeated the last one
onErrorvoid onError(String operation, Object error)A native call failed. operation names it (initialize, start, finalizeCollect, switchJourney, clearUser, …)
Dart
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 failurePlugin methods
Throwsinitialize, setCusUserId, getRegistrationStatus, reportRegistrationFailed, clearUser, finalizeCollect, stopCollect, switchJourney
Returns falsemarkDeviceRegistered, 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.

KeyTypeDescription
httpCodeintHTTP status code of the failed response
serverCodeString?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.

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

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