# Connecting a Flutter App to the Tester Analytics Platform

One dashboard serves **every app**. Create each app in the Developer portal first so you can
copy its `appId` and API key. Testers and events then register automatically on the first
telemetry event.

The Flutter app reports **directly to this dashboard** over HTTPS. No Firebase, no Google
Analytics / PostHog / Mixpanel / Amplitude — no third-party analytics of any kind except your
own server.

There is **no Flutter plugin to install**: the dashboard is a plain HTTP + JSON API
(`POST /api/events`), so a small Dart client (~250 lines, copy-paste below) is enough.

You get:

- Persistent anonymous tester ID (UUID v4)
- Offline event queue (survives no network)
- Automatic tester/event registration after you create the app
- Screen, foreground/background, and crash tracking
- **Active-usage tracking** — the server measures how many minutes each tester was actually
  using the app, per day
- Bug reports that land in the dashboard **Issues** board

**Requirements:** Flutter 3.13+ / Dart 3.0+ (uses `AppLifecycleListener` and switch expressions).

---

## 1. Add the dependencies

```bash
flutter pub add http shared_preferences
flutter pub add --dev flutter_lints
```

Optional but recommended (device + version metadata, crash-free labels):

```bash
flutter pub add device_info_plus package_info_plus
```

---

## 2. Configure the app (build-time `--dart-define`)

Get your **API key** from the dashboard → **Developer portal** → your app → *Copy key*
(the key looks like `hk_…`). The `appId` is the slug shown next to your app name.

```bash
flutter run \
  --dart-define=TESTER_API_URL=https://saraayab.shop \
  --dart-define=TESTER_APP_ID=my-flutter-app \
  --dart-define=TESTER_API_KEY=hk_your_key_here \
  --dart-define=TESTER_APP_VERSION=1.0.3
```

For release builds put the same flags in your build script / CI:

```bash
flutter build apk --release \
  --dart-define=TESTER_API_URL=https://saraayab.shop \
  --dart-define=TESTER_APP_ID=my-flutter-app \
  --dart-define=TESTER_API_KEY=hk_your_key_here \
  --dart-define=TESTER_APP_VERSION=1.0.3
```

> The `x-api-key` header is **required in production** (`src/lib/appAuth.ts:16`). In local
> development you may leave the key empty.
>
> The app-specific key is included in the mobile build and can be extracted. Treat it as
> public; never use the dashboard admin password or another sensitive credential here.

---

## 3. Add the client

Create `lib/tester_analytics.dart`:

```dart
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:ui' show PlatformDispatcher;

import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';

/// Wire names accepted by POST /api/events.
enum TesterEvent {
  register,
  appOpened,
  appForeground,
  appBackground,
  screenView,
  crash,
  bugReport,
  sessionStart,
  sessionResume,
  sessionPause,
  sessionHeartbeat;

  String get wireName => switch (this) {
        TesterEvent.register => 'register',
        TesterEvent.appOpened => 'app_opened',
        TesterEvent.appForeground => 'app_foreground',
        TesterEvent.appBackground => 'app_background',
        TesterEvent.screenView => 'screen_view',
        TesterEvent.crash => 'crash',
        TesterEvent.bugReport => 'bug_report',
        TesterEvent.sessionStart => 'session_start',
        TesterEvent.sessionResume => 'session_resume',
        TesterEvent.sessionPause => 'session_pause',
        TesterEvent.sessionHeartbeat => 'session_heartbeat',
      };
}

class TesterConfig {
  const TesterConfig({
    required this.apiUrl,
    required this.appId,
    this.apiKey = '',
    this.appVersion = '',
    this.device = const <String, dynamic>{},
    this.debug = false,
  });

  /// Reads TESTER_* values passed with --dart-define.
  factory TesterConfig.fromEnvironment() => TesterConfig(
        apiUrl: const String.fromEnvironment('TESTER_API_URL'),
        appId: const String.fromEnvironment('TESTER_APP_ID'),
        apiKey: const String.fromEnvironment('TESTER_API_KEY'),
        appVersion: const String.fromEnvironment('TESTER_APP_VERSION'),
      );

  final String apiUrl;
  final String appId;
  final String apiKey;
  final String appVersion;
  final Map<String, dynamic> device;
  final bool debug;
}

class TesterAnalytics {
  TesterAnalytics._();

  static const _kTesterId = 'tester_analytics.tester_id';
  static const _kQueue = 'tester_analytics.queue';
  static const _kOptedOut = 'tester_analytics.opted_out';
  static const _kContactName = 'tester_analytics.contact_name';
  static const _kContact = 'tester_analytics.contact';

  static final RegExp _uuidV4 = RegExp(
    r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
    caseSensitive: false,
  );

  static TesterConfig? _config;
  static SharedPreferences? _prefs;
  static String? _testerId;
  static String? _currentScreen;
  static String? _contactName;
  static Map<String, dynamic>? _contact;
  static bool _optedOut = false;
  static bool _initialized = false;
  static bool _sending = false;
  static bool _foreground = true;
  static DateTime? _foregroundSince;
  static Timer? _heartbeat;
  static AppLifecycleListener? _lifecycle;
  static final List<Map<String, dynamic>> _queue = <Map<String, dynamic>>[];

  /// Boots the client: creates the tester ID, sends `register`/`app_opened`,
  /// starts the session and the ~60s heartbeat.
  static Future<void> init({TesterConfig? config}) async {
    if (_initialized) return;

    final cfg = config ?? TesterConfig.fromEnvironment();
    if (cfg.apiUrl.isEmpty || cfg.appId.isEmpty) {
      throw ArgumentError('TESTER_API_URL and TESTER_APP_ID are required');
    }
    _config = cfg;

    _prefs = await SharedPreferences.getInstance();

    var id = _prefs!.getString(_kTesterId);
    final isNewTester = id == null || !_uuidV4.hasMatch(id);
    if (isNewTester) {
      id = _uuidV4();
      await _prefs!.setString(_kTesterId, id);
    }
    _testerId = id;
    _optedOut = _prefs!.getBool(_kOptedOut) ?? false;
    _contactName = _prefs!.getString(_kContactName);
    final contact = _prefs!.getString(_kContact);
    if (contact != null) {
      _contact = (jsonDecode(contact) as Map).cast<String, dynamic>();
    }

    final stored = _prefs!.getStringList(_kQueue) ?? <String>[];
    for (final entry in stored) {
      try {
        _queue.add((jsonDecode(entry) as Map).cast<String, dynamic>());
      } catch (_) {
        // ignore a corrupted queue entry
      }
    }

    _initialized = true;
    _installCrashHooks();

    _lifecycle = AppLifecycleListener(
      onResume: _handleResume,
      onPause: _handlePause,
      onHide: _handlePause,
      onDetach: _handleDetach,
    );

    if (_optedOut) return;

    _foregroundSince = DateTime.now();
    unawaited(_flushQueue());
    // `register` counts as the first open, so only one of the two is sent.
    await _send(isNewTester ? TesterEvent.register : TesterEvent.appOpened);
    await _send(TesterEvent.sessionStart);
    _startHeartbeat();
  }

  /// The persistent anonymous tester ID (UUID v4). Show it in your settings screen.
  static String? get testerId => _testerId;

  static bool get optedOut => _optedOut;

  /// Tracks a screen change. Names longer than 80 chars are truncated.
  static Future<void> setScreen(String name) async {
    if (_optedOut) return;
    final trimmed = name.length > 80 ? name.substring(0, 80) : name;
    _currentScreen = trimmed;
    await _send(TesterEvent.screenView, extra: <String, dynamic>{'screens': <String>[trimmed]});
  }

  /// Reports a crash (message + stack). Both are truncated to the server limits.
  static Future<void> reportCrash(String message, [String? stack]) async {
    await _send(TesterEvent.crash, extra: <String, dynamic>{
      'message': _truncate(message, 500),
      'stack': _truncate(stack ?? '', 2600),
    });
  }

  /// Files a bug report. It shows up in the dashboard → Issues board.
  /// `params` may contain: title, steps, screenName, screenshot.
  static Future<void> reportBug(String message, {Map<String, dynamic>? params}) async {
    await _send(TesterEvent.bugReport, extra: <String, dynamic>{
      'message': _truncate(message, 500),
      'params': params,
    });
  }

  /// Optional PII — only send what the tester typed in themselves.
  static Future<void> setContactName(String name) async {
    _contactName = name.trim();
    await _prefs?.setString(_kContactName, _contactName!);
    if (_currentScreen != null) {
      await _send(TesterEvent.screenView, extra: <String, dynamic>{
        'screens': <String>[_currentScreen!],
      });
    }
  }

  /// Optional contact channel: `{'type': 'telegram'|'whatsapp'|'email'|'none', 'value': '…'}`.
  static Future<void> setContact({required String type, required String value}) async {
    _contact = <String, dynamic>{'type': type, 'value': value};
    await _prefs?.setString(_kContact, jsonEncode(_contact));
  }

  /// Opt-out: stops every request. The server also ignores an opted-out tester.
  static Future<void> setOptedOut(bool value) async {
    _optedOut = value;
    await _prefs?.setBool(_kOptedOut, value);
    _heartbeat?.cancel();
    if (value) {
      _queue.clear();
      await _persistQueue();
    }
  }

  /// GDPR self-erase: deletes the tester, activity and usage rows on the server,
  /// then wipes the local copy and starts over with a brand new tester ID.
  static Future<void> eraseMyData() async {
    final cfg = _config;
    final id = _testerId;
    if (cfg == null || id == null) return;
    await _post(<String, dynamic>{'appId': cfg.appId, 'testerId': id}, path: '/api/tester/erase');
    await _prefs?.remove(_kTesterId);
    await _prefs?.remove(_kQueue);
    await _prefs?.remove(_kContactName);
    await _prefs?.remove(_kContact);
    _queue.clear();
    _testerId = _uuidV4();
    await _prefs?.setString(_kTesterId, _testerId!);
  }

  /// Flushes the offline queue (called automatically on resume).
  static Future<void> flush() async => _flushQueue();

  static Future<void> dispose() async {
    _heartbeat?.cancel();
    _heartbeat = null;
    _lifecycle?.dispose();
    _lifecycle = null;
  }

  // ---------------------------------------------------------------- internals

  static void _installCrashHooks() {
    final previous = FlutterError.onError;
    FlutterError.onError = (FlutterErrorDetails details) {
      unawaited(reportCrash(details.exceptionAsString(), details.stack?.toString()));
      previous?.call(details);
    };

    PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
      unawaited(reportCrash(error.toString(), stack.toString()));
      return false; // let the engine keep its default crash behaviour
    };
  }

  static void _handleResume() {
    if (_optedOut) return;
    _foreground = true;
    _foregroundSince = DateTime.now();
    unawaited(_send(TesterEvent.appForeground));
    unawaited(_send(TesterEvent.sessionResume));
    _startHeartbeat();
    unawaited(_flushQueue());
  }

  static void _handlePause() {
    if (_optedOut) return;
    _foreground = false;
    final since = _foregroundSince;
    final sessionMs = since == null ? null : DateTime.now().difference(since).inMilliseconds;
    unawaited(_send(
      TesterEvent.appBackground,
      extra: <String, dynamic>{'sessionMs': sessionMs},
    ));
    unawaited(_send(TesterEvent.sessionPause));
    _heartbeat?.cancel();
    _heartbeat = null;
  }

  static void _handleDetach() {
    _handlePause();
  }

  static void _startHeartbeat() {
    _heartbeat?.cancel();
    _heartbeat = Timer.periodic(const Duration(seconds: 60), (Timer _) {
      if (_optedOut || !_foreground) return;
      unawaited(_send(TesterEvent.sessionHeartbeat));
    });
  }

  static Future<void> _send(TesterEvent event, {Map<String, dynamic>? extra}) async {
    final cfg = _config;
    final id = _testerId;
    if (cfg == null || id == null || _optedOut) return;

    final payload = <String, dynamic>{
      'appId': cfg.appId,
      'testerId': id,
      'event': event.wireName,
      'appVersion': cfg.appVersion,
      'device': cfg.device,
      if (_contactName != null && _contactName!.isNotEmpty) 'contactName': _contactName,
      if (_contact != null) 'contact': _contact,
      ...?extra,
    }..removeWhere((String key, dynamic value) => value == null);

    final ok = await _post(payload);
    if (!ok) await _enqueue(payload);
  }

  static Future<bool> _post(Map<String, dynamic> payload, {String path = '/api/events'}) async {
    final cfg = _config;
    if (cfg == null) return false;
    try {
      final base = cfg.apiUrl.endsWith('/') ? cfg.apiUrl.substring(0, cfg.apiUrl.length - 1) : cfg.apiUrl;
      final uri = Uri.parse('$base$path');
      final res = await http
          .post(
            uri,
            headers: <String, String>{
              'Content-Type': 'application/json',
              if (cfg.apiKey.isNotEmpty) 'x-api-key': cfg.apiKey,
            },
            body: jsonEncode(payload),
          )
          .timeout(const Duration(seconds: 15));
      if (res.statusCode >= 200 && res.statusCode < 300) return true;
      _debug('${payload['event']} -> HTTP ${res.statusCode}: ${res.body}');
      return false;
    } catch (e) {
      _debug('${payload['event']} -> $e');
      return false;
    }
  }

  static Future<void> _enqueue(Map<String, dynamic> payload) async {
    if (_queue.length >= 200) _queue.removeAt(0);
    _queue.add(payload);
    await _persistQueue();
  }

  static Future<void> _persistQueue() async {
    await _prefs?.setStringList(
      _kQueue,
      _queue.map((Map<String, dynamic> e) => jsonEncode(e)).toList(),
    );
  }

  static Future<void> _flushQueue() async {
    if (_sending || _optedOut) return;
    _sending = true;
    try {
      while (_queue.isNotEmpty) {
        final ok = await _post(_queue.first);
        if (!ok) break; // still offline — keep the queue
        _queue.removeAt(0);
        await _persistQueue();
      }
    } finally {
      _sending = false;
    }
  }

  static String _truncate(String value, int max) =>
      value.length > max ? value.substring(0, max) : value;

  static void _debug(String message) {
    final cfg = _config;
    if (cfg?.debug ?? false) {
      debugPrint('[tester-analytics] $message');
    }
  }

  /// Cryptographically-random UUID v4 (matches the server's UUID v4 pattern).
  static String _uuidV4() {
    final rnd = Random.secure();
    final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
    bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
    bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx
    final hex = bytes.map((int b) => b.toRadixString(16).padLeft(2, '0')).join();
    return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-'
        '${hex.substring(16, 20)}-${hex.substring(20, 32)}';
  }
}

/// Add to `MaterialApp.navigatorObservers` to track screen changes automatically.
class TesterNavigatorObserver extends NavigatorObserver {
  @override
  void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
    super.didPush(route, previousRoute);
    final name = route.settings.name;
    if (name != null) unawaited(TesterAnalytics.setScreen(name));
  }

  @override
  void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
    super.didPop(route, previousRoute);
    final name = previousRoute?.settings.name;
    if (name != null) unawaited(TesterAnalytics.setScreen(name));
  }

  @override
  void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
    super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
    final name = newRoute?.settings.name;
    if (name != null) unawaited(TesterAnalytics.setScreen(name));
  }
}
```

---

## 4. Wire it into your app

### 4.1 Boot the client in `main()`

```dart
import 'package:flutter/widgets.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:package_info_plus/package_info_plus.dart';

import 'tester_analytics.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final device = await _buildDevice();
  final info = await PackageInfo.fromPlatform();

  await TesterAnalytics.init(
    config: TesterConfig(
      apiUrl: const String.fromEnvironment('TESTER_API_URL'),
      appId: const String.fromEnvironment('TESTER_APP_ID'),
      apiKey: const String.fromEnvironment('TESTER_API_KEY'),
      appVersion: info.version,
      device: device,
    ),
  );

  runApp(const MyApp());
}

/// Only these keys are accepted by the server (the `device` object is a strict schema).
Future<Map<String, dynamic>> _buildDevice() async {
  final android = await DeviceInfoPlugin().androidInfo;
  return <String, dynamic>{
    'os': 'android',
    'osVersion': android.version.release,
    'systemVersion': android.version.sdkInt.toString(),
    'deviceModel': android.model,
    'deviceBrand': android.manufacturer,
    'manufacturer': android.manufacturer,
    'model': android.model,
  };
}
```

### 4.2 Track screens

Give your routes names, then add the observer:

```dart
MaterialApp(
  navigatorObservers: [TesterNavigatorObserver()],
  initialRoute: '/home',
  onGenerateRoute: (settings) => MaterialPageRoute(
    builder: (_) => HomeScreen(),
    settings: settings,
  ),
);
```

Or call it manually:

```dart
TesterAnalytics.setScreen('Checkout');
```

### 4.3 Capture crashes

`TesterAnalytics.init()` already installs `FlutterError.onError` and
`PlatformDispatcher.instance.onError`. To also catch errors thrown outside the Flutter
zone, wrap the app in `runZonedGuarded`:

```dart
void main() {
  runZonedGuarded(() async {
    WidgetsFlutterBinding.ensureInitialized();
    await TesterAnalytics.init(...);
    runApp(const MyApp());
  }, (error, stack) {
    TesterAnalytics.reportCrash(error.toString(), stack.toString());
  });
}
```

### 4.4 Settings screen (tester ID, opt-out, erase)

```dart
final id = TesterAnalytics.testerId;           // show this so testers can report it
await TesterAnalytics.setContactName('Sara');  // optional, opt-in PII
await TesterAnalytics.setOptedOut(true);        // stop all reporting
await TesterAnalytics.eraseMyData();            // GDPR self-erase
```

### 4.5 Report a bug from the app

```dart
TesterAnalytics.reportBug(
  'Checkout button does nothing on the cart screen',
  params: <String, dynamic>{
    'title': 'Checkout dead button',
    'steps': 'Open cart → tap Checkout',
    'screenName': 'CartScreen',
  },
);
```

The report appears in the dashboard → **Issues** board with the tester's device, version and
activity history.

---

## 5. Run it & verify

```bash
flutter run --dart-define=TESTER_API_URL=http://10.0.2.2:3000 \
            --dart-define=TESTER_APP_ID=my-flutter-app \
            --dart-define=TESTER_APP_VERSION=1.0.3
```

> `10.0.2.2` is the host machine seen from the Android emulator. On a physical device use the
> LAN IP of the machine running the dashboard (e.g. `http://192.168.1.20:3000`).

1. Open the app once on the device.
2. Go back to the dashboard → the app appears in the **app selector** automatically.
3. The tester shows as **Active** with version, device, opens and activity history.

Without a device you can simulate a tester with plain HTTP:

```bash
curl -X POST http://localhost:3000/api/events \
  -H "Content-Type: application/json" \
  -d '{
    "appId":"my-flutter-app",
    "testerId":"123e4567-e89b-42d3-a456-426614174000",
    "event":"app_opened",
    "appVersion":"1.0.3",
    "device":{"os":"android","osVersion":"14","brand":"samsung","model":"Galaxy A54"}
  }'
```

---

## Payload reference

`POST {apiUrl}/api/events` — header `x-api-key: <app api key>` (required in production).

| Field | Type | Rules |
| --- | --- | --- |
| `appId` | string | required, `^[a-z0-9-]{1,40}$` (lowercase slug) |
| `testerId` | string | required, UUID v4 |
| `event` | string | required, one of the events below |
| `appVersion` | string | ≤ 40 chars |
| `device` | object | **strict** — only `os`, `osVersion`, `systemVersion`, `deviceModel`, `deviceBrand`, `manufacturer`, `model`, `brand` |
| `contactName` | string | ≤ 120 chars, opt-in PII |
| `contact` | object | `{ "type": "telegram"｜"whatsapp"｜"email"｜"none", "value": "…" }` |
| `screens` | string[] | ≤ 10 items, each ≤ 80 chars |
| `sessionMs` | number | ≥ 0, ≤ 48 h (milliseconds in the foreground) |
| `message` | string | ≤ 500 chars (crash / bug report) |
| `stack` | string | ≤ 2600 chars (crash) |
| `params` | object | values must be string / number / boolean |

### Events

| Event | Sent when | Used for |
| --- | --- | --- |
| `register` | First launch (new tester ID) | Stats, tester creation |
| `app_opened` | App opens (after the first launch) | Stats |
| `app_foreground` | App returns to the foreground | Activity history |
| `app_background` | App goes to the background (with `sessionMs`) | Activity history |
| `screen_view` | Screen changes (with `screens[]`) | Activity history |
| `crash` | JS/Dart error or error boundary | Crash row |
| `bug_report` | Tester submits a bug | Issues board |
| `session_start` | App launch / session begins | Active-usage |
| `session_resume` | App returns to the foreground | Active-usage |
| `session_pause` | App is backgrounded or closed | Active-usage |
| `session_heartbeat` | ~every 60 s while foreground | Active-usage |

The server (not the device) measures elapsed active time and splits it into per-day buckets
(UTC). Background time is excluded. A force-close, crash, or network drop is recovered
automatically — the server closes any session with no signal for
`USAGE_SESSION_TIMEOUT_SECONDS` (default 10 min).

---

## Active usage tracking

For every tester the dashboard measures **actual foreground / active usage time**, grouped by
date (e.g. `tester_123 → 2026-09-07 → 42 minutes`).

- The client only sends **signals** (`session_start` / `session_resume` / `session_pause` /
  `session_heartbeat`). The server measures the elapsed time with **its own clock** and never
  trusts client timestamps for totals.
- Background time is excluded: a `session_pause` closes the measured interval.
- Force-close / crash / no network: the server auto-closes any session with no signal within
  `USAGE_SESSION_TIMEOUT_SECONDS`, so a lost pause or heartbeat can't inflate totals.
- Duplicate / out-of-order signals are ignored, and a single continuous session is capped at
  `USAGE_MAX_SESSION_SECONDS` (default 30 min).

**View it:** the dashboard **Usage** tab (or `GET /api/usage?appId=`).

---

## Opt-out & privacy

- The tester ID is a random UUID stored in `shared_preferences` — no name, email or phone is
  required. Contact name/channel fields are optional and tester-provided.
- `setOptedOut(true)` stops future requests from this app; it does not delete data already
  received by the dashboard.
- `eraseMyData()` deletes the tester, their activity and their usage rows for this app
  (`POST /api/tester/erase`) and wipes the local copy.

## Privacy-policy disclosure for a testing app

Add a clear disclosure to your app's privacy policy and provide that policy to
testers and in your store listing. Adapt the sample below to your actual app,
hosting provider, data handling, and retention period:

> **Temporary testing data:** While this app is in its [closed/open testing
> period], [developer or organization] uses TesterFlow to evaluate test builds,
> diagnose crashes and bug reports, and understand test-session activity. The
> app sends a random tester ID, app version, device information, screen and
> activity events, and foreground session signals to [hosting provider/region].
> Crash details and bug-report information are sent when generated or submitted.
> Testers may optionally provide contact details. This data is used only to
> operate this testing program, not for advertising or production-user
> analytics. Collection will stop when [specific test end condition/date], and
> data will be retained for [actual retention period]. To stop future collection,
> use [in-app opt-out instructions]; to request deletion, use [erase instructions
> or contact details].

Only describe collection as temporary or say it is not used in production if
that is true for your app and you will stop or change collection when the test
ends. Specify a retention period you can actually meet; TesterFlow does not
automatically delete data just because a test ends. Keep the privacy policy,
store listing/Data safety disclosures, and app behavior consistent. This sample
does not guarantee approval by Google Play; review and follow the current
Google Play policies for your app and testing track.

---

## Troubleshooting

| Symptom | Cause / fix |
| --- | --- |
| `401 Missing or invalid x-api-key header` | The dashboard runs in production mode. Pass `TESTER_API_KEY` matching the key in the developer portal. |
| `400 testerId must be a valid UUID v4` | The stored ID is corrupt — clear app data or use `eraseMyData()`. |
| `400 device: Unrecognized key` | The `device` object is strict — only send the 8 allowed keys. |
| `429 Too many requests` | The limit is 120 requests/minute per key. Keep the heartbeat at 60 s and don't batch screen events on every frame. |
| Nothing shows in the dashboard | The app must be registered first: open the app once so `register` is sent. Check `appId` matches the app slug exactly. |
| No usage minutes | The 4 `session_*` signals are missing — check that `init()` runs and the app is not killed immediately. |
| Android cleartext HTTP blocked | Use HTTPS, or add `android:usesCleartextTraffic="true"` for local development only. |

---

## Notes & limitations

- **Reinstall → new tester ID** — the ID lives in app storage; reinstalling or clearing app
  data generates a fresh ID.
- **Offline queue** — failed events are stored (max 200) in `shared_preferences` and flushed on
  the next launch/resume. Only the latest 200 survive.
- **No web build** — the client works on web too, but `shared_preferences` on web is
  browser-local; for Android test builds this is irrelevant.
- **Rate limit** — 120 requests/minute per API key (`src/lib/appAuth.ts:33`).
