Tokens
Track, visualize, and compete on AI coding assistant token usage across Claude Code, Cursor, OpenCode, Codex, Gemini, Kimi, and Qwen. The Kardashev Scale for AI Devs.
Know exactly how Africans use your mobile app.
Translation pending
DataDart · SwiftGitHub @Unilitix-hqOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Official Flutter SDK for Unilitix — African-first mobile UX analytics
Translation pending
Taken from the repository README
Loading the full README.
Read the full README ↗Version: 2.7.4 · pub.dev · GitHub
Unilitix is a mobile analytics SDK built for African markets. It captures session replays, screen flows, custom events, and user identity — with offline-first data delivery and Africa-specific context (network type, carrier, power outage correlation).
| Platform | Minimum | Recommended |
|---|---|---|
| Flutter | 3.10+ | 3.22+ |
| Dart | 3.0+ | — |
| Android | API 21+ | API 26+ (full session replay) |
| iOS | 13.0+ | — |
flutter pub add unilitix
Or add manually to pubspec.yaml:
dependencies:
unilitix: ^2.7.4
Then run:
flutter pub get
In your main.dart, initialize Unilitix before running your app:
import 'package:unilitix/unilitix_flutter.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Unilitix.init('YOUR_API_KEY');
Unilitix.runApp(MyApp());
}
Get your API key from Dashboard → Settings → Apps.
Add Unilitix.observer to navigatorObservers and wrap your app content with UnilitixWidget inside the builder:
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorObservers: [Unilitix.observer],
builder: (context, child) => UnilitixWidget(child: child!),
);
}
}
⚠️ Always use the
builderpattern. Do not wrapMaterialAppitself withUnilitixWidget— this causes blank frames in session replay.
Run your app in debug mode. Within 5 seconds you should see:
[Unilitix] SDK initialized ✅ v2.7.4
[Unilitix] Session started ✅ abc123…
[Unilitix] Screen → /home
If you see Observer ⚠️ not connected, ensure Unilitix.observer is in navigatorObservers. The warning clears automatically on first navigation.
Screen tracking is automatic when Unilitix.observer is added. No additional code needed for standard MaterialApp routing.
GoRouter(
observers: [Unilitix.observer],
routes: [...],
)
For screen names to appear correctly in the dashboard, all routes must have a name:
Navigator.push(context, MaterialPageRoute(
settings: RouteSettings(name: '/payment'),
builder: (context) => PaymentScreen(),
));
Without a name, routes appear as MaterialPageRoute<dynamic> in your analytics.
Associate sessions with a user ID after login. Call identify() in two places:
// 1. After successful login
await AuthService.login(email, password);
Unilitix.identify(
user.id,
{
"email": user.email,
"name": user.name,
"phone": user.phoneNumber,
"account_type": user.accountType,
},
);
// 2. On app startup — if already logged in
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Unilitix.init('YOUR_API_KEY');
final user = await AuthService.getCurrentUser();
if (user != null) {
Unilitix.identify(user.id, {"name": user.name, "email": user.email});
}
Unilitix.runApp(MyApp());
}
⚠️ Calling
identify()only on login means returning users who skip login will appear as anonymous.
Unilitix.track("loan_applied", {
"amount": 50000,
"loan_type": "personal",
"currency": "NGN",
});
Event names should be snake_case. Property values can be String, int, double, or bool.
Events are written to the on-device queue immediately and uploaded in batches
(every flushIntervalSeconds, default 30 s, or as soon as flushBatchSize
events are waiting). Call Unilitix.flush() when an event must leave the
device now — for example right before the user signs out.
Retry-After says) and keeps
everything on-device; one success clears the backoff.maxOfflineEvents), 5 MB, 7 days.
Oldest batches are dropped first.Crash reporting is on by default (autoTrackCrashes). Errors are recorded
the way Crashlytics and Sentry classify them, and ANRs (Android) and app
hangs (iOS) on their own:
| Type | Source | Marks session crashed |
|---|---|---|
EXCEPTION (non-fatal) | FlutterError.onError — errors the framework caught (overflow, build errors) and captureException | No |
CRASH (fatal: false) | PlatformDispatcher.onError — an uncaught Dart error | Yes |
CRASH (fatal: true) | Native: JVM uncaught exception, NSException, fatal signal — recorded by the plugin and reported on the next launch against the session that was running | Yes |
ANR | Android 11+: the app stopped responding and the system killed it — reported on the next launch against the session that was running | No |
ANR | iOS: the main thread was blocked for appHangTimeoutMs (2 s) — reported when it recovers, or on the next launch if the app did not survive | No |
Native fatal signals (SIGSEGV, SIGABRT, SIGBUS, SIGILL, SIGFPE, SIGTRAP) —
a crash in C/C++ code, the Flutter engine or a plugin's native library — are
captured on both platforms. On Android 11+ (API 30+) they are read from the
system's record of how earlier processes ended (ApplicationExitInfo; no
native code runs); on Android 5–10 a small signal handler
(libunilitix-flutter-native.so, a few KB per ABI) records them. Each is
reported once, on the next launch, as SIGNAL: Fatal signal 11 (SIGSEGV).
On Android 11+ the first launch with 2.4.0 starts from that moment: exits
recorded before it are not reported.
Apps that also ship the Unilitix Android SDK (com.unilitix:unilitix-android):
from Android SDK 1.8.0 and plugin 2.5.0, a native crash is reported once, by
the Android SDK, against its own session. The plugin finds the SDK at runtime
(no dependency between the two) and, when it captures native crashes
(Unilitix.nativeCrashCaptureActive()), installs no signal handler and sends
none of the system's exit records. Initialize the Android SDK in
Application.onCreate, before any Flutter engine starts. With an Android SDK
older than 1.8.0 both still report native crashes. JVM crashes and sessions
are recorded by each SDK separately.
From 2.6.0, when Android kills the app because it stopped responding (an
ANR: input not handled for 5 seconds, so the system showed the "isn't
responding" dialog and the user closed the app, or killed a background app
outright), the next launch reports an ANR event (SDK contract 1.3.0)
against the session that was running. On by default with
autoTrackCrashes; no configuration.
ApplicationExitInfo scan as native crashes, with the
same watermark, so each exit is reported once; at most 5 ANRs per launch
(the newest). Upgrading from 2.4.0/2.5.0 keeps the watermark, so an ANR of
the last process before the upgrade is reported and older ones are not; a
new install starts from its first launch.Input dispatching timed out …) as the message and the main thread's stack from the ANR thread
dump as stackTrace (thread properties dropped, capped at 16 KB), plus
metadata.fingerprint (type ANR over the main thread, so ANRs group by
where the main thread was stuck), platform, importance and
foreground.Limitations:
libflutter.so / libapp.so frames, not Dart frames; the
fingerprint counts only the Java at frames, so such ANRs group by those.Unilitix.nativeCrashCaptureActive()), ANRs are left to it like native
crashes. Android SDK 1.9.0+ reports them; 1.8.x does not, and the plugin
cannot tell 1.8.x from 1.9.x at runtime in a minified release build, so
with 1.8.x no ANRs are reported — update the Android SDK to 1.9.0. With
an Android SDK older than 1.8.0, or one without autoTrackCrashes, the
plugin reports them.ANR type.From 2.7.0, the iOS plugin watches the main thread and reports an ANR
event when it does not respond for appHangTimeoutMs (default 2000 ms)
while the app is in the foreground. On by default with autoTrackCrashes;
appHangTimeoutMs: 0 turns it off. It uses the same watchdog and stack
sampler as the native iOS SDK (UnilitixCore 0.6.0).
What counts. In a Flutter app the iOS main thread is the platform
thread: it runs UIKit, platform-channel handlers and plugins' native code,
and delivers touches to Flutter. Hangs reported are those that block it: a
slow synchronous platform-channel handler (yours or a plugin's), main-thread
file or database I/O, a deadlock or a blocking wait in native code. Pure
Dart jank is not reported: Dart runs on the UI thread, so a long build or
a busy main isolate drops frames without blocking the main thread (see
the performance metrics for that).
The main thread was blocked for 3200 ms.appTerminated: true and the duration measured so far
(…blocked for at least 2000 ms; the app was terminated before it recovered).exceptionType ANR, the main thread's stack at
detection as stackTrace (callStackSymbols format), timestamped when
the hang began, and metadata with fingerprint (contract fingerprint of
type ANR over that stack, so hangs group by where the main thread was
stuck), platform, hangDurationMs, appHangTimeoutMs, appTerminated
(when terminated) and debugImages (the binaries in the stack, with their
UUIDs and load addresses, so the server can symbolicate the frames
against the matching dSYMs).flutter run debug sessions report none;
test with a profile or release build started from the home screen.appHangTimeoutMs: 0 here (or appHangTimeoutMs = 0 there) to keep one.Handlers the app or another SDK installed before init() still run. Every
report carries a stable fingerprint (type + top frames, line numbers
ignored) so one bug is counted once across devices; the same fingerprint is
reported at most 3 times per session, and 50 reports per session in total.
// Report an error you handled yourself
try {
await payments.charge(card);
} catch (e, st) {
Unilitix.captureException(e, stackTrace: st);
}
// Leave a trail for the next crash report (last 50 kept)
Unilitix.addBreadcrumb('checkout started', category: 'flow', data: {'items': 3});
Release builds with --obfuscate --split-debug-info produce stack traces
the dashboard cannot read until the symbols are uploaded. Reports carry
buildId and isolateDsoBase; upload the matching symbols once per build:
flutter build apk --release --obfuscate --split-debug-info=build/symbols
UNILITIX_API_KEY=ul_live_… dart run unilitix:unilitix_symbols upload build/symbols \
--app-version 3.4.0 --build-number 120
Session replay is enabled by default. The SDK captures screenshots and widget tree snapshots and uploads them incrementally every 30 seconds.
Android: Uses native PixelCopy (API 26+) as the primary capture method, with automatic fallback to Dart-based capture for API 21–25. No configuration needed.
iOS: Uses UIView.drawHierarchy via the Flutter platform channel, through
the same capture code as the native iOS and React Native SDKs (UnilitixCore).
To disable:
await Unilitix.init(
'YOUR_API_KEY',
config: UnilitixConfig(
captureScreenshots: false,
captureSnapshots: false,
),
);
Session replay requires the Growth plan or above.
| Data | Collected | Switch |
|---|---|---|
Anonymous id (random UUID, rotated by reset()), install id | yes | — |
Your user id and traits, only after you call identify() | yes | call identify() or not |
| Device make/model, OS version, screen size, locale, timezone | yes | — |
| App version, build number, package name | yes | — |
| Network type and transitions, carrier name, battery level | yes | — |
| Screen names, tap and scroll coordinates | yes | autoTrackTaps |
| Crash and handled-exception stack traces, last 50 breadcrumbs | yes | autoTrackCrashes |
| Memory and frame-drop metrics | yes | — |
| Wireframe snapshots (layout only, text redacted) | yes | captureSnapshots, replaySampleRate |
| Pixel screenshots (text and inputs painted over) | yes | captureScreenshots, replaySampleRate |
| Plug/unplug events with an H3 cell (~5 km²) and a daily device hash | only if you opt in | powerOutageContribution (see below) |
| Precise location or coordinates, advertising id, IMEI, contacts, clipboard | never | — |
The SDK is private by default, in both replay formats:
maskAllText, on by default). In wireframe snapshots
every Text widget's content becomes same-length bullets; in pixel
screenshots the bounds of every text widget are painted over on the device
before the frame is encoded. Wrap copy that is safe and useful to see in
UnilitixUnmasked:UnilitixUnmasked(child: Text('Checkout'))
TextField, TextFormField, CupertinoTextField) are
masked in snapshots and painted over in screenshots (maskInputs, on by
default), even inside an UnilitixUnmasked subtree.UnilitixPrivate paints over any subtree in screenshots and collapses
it in snapshots — for charts, QR codes, documents and avatars:UnilitixPrivate(child: BalanceChart())
maskImages: true (off by default).await Unilitix.init(
'YOUR_API_KEY',
config: UnilitixConfig(
maskAllText: true, // redact all text in snapshots and screenshots (default)
maskInputs: true, // mask TextField content (default)
maskScreenshots: true, // paint over masked regions in screenshots (default)
maskImages: false, // black out images
replaySampleRate: 1.0, // fraction of sessions with replay; 0 disables it
),
);
For apps under NDPR, GDPR or similar, start in pending: the SDK captures to
its on-device queue but uploads nothing until the user decides.
await Unilitix.init('YOUR_API_KEY', consent: UnilitixConsent.pending);
// After your consent screen:
await Unilitix.setConsent(UnilitixConsent.granted); // sends everything held
// or
await Unilitix.setConsent(UnilitixConsent.denied); // deletes everything held
The decision is persisted; on later launches it overrides the consent:
argument. optOut() / optIn() are shortcuts for denied / granted.
setConsent (and optOut() / optIn()) may be called before init: the
decision is saved at once and init starts in it.
denied stops capture, clears memory buffers, purges the whole on-device
queue including crash reports and session stubs, and gates every upload path.
The anonymous id is a random UUID stored on the device — it is never derived
from the advertising id, IMEI or any hardware identifier — and Unilitix.reset()
rotates it.
New in 2.2.0. Off by default.
When the grid drops in a neighbourhood, every phone that was plugged in stops charging within the same minute. With this feature on, the SDK reports charging transitions against a coarse map cell, and the Unilitix server turns synchronised drops into a crowd-sourced power-outage map.
Nothing is collected unless all of these hold:
UnilitixConfig(powerOutageContribution: true).UnilitixConsent.granted. While consent is pending or
denied this feature collects nothing and holds nothing; moving to
pending or denied deletes anything it had queued.await Unilitix.init(
'YOUR_API_KEY',
config: const UnilitixConfig(
apiKey: 'YOUR_API_KEY',
powerOutageContribution: true,
),
);
POST /v1/signals/power:
{
"cell": "87589c984ffffff",
"device": "27042224245e18fcd6d8999c93dbef66",
"events": [
{"type": "disconnect", "ts": "2025-10-09T08:53:20.000Z"},
{"type": "sample", "charging": true, "ts": "2025-10-09T09:10:02.000Z"}
]
}
cell — the H3 resolution-7 cell (~5 km²)
containing the device, computed on the device. Latitude and longitude
never leave the device. A location fix older than 24 h is not used.device — a hash that changes every UTC day: the first 128 bits of
SHA-256 over the install id, a random salt that never leaves the device, and
the date. The server can count distinct devices in a cell on one day, but
cannot link a device across days or to its analytics sessions. The raw
install id is never sent with these events.events — connect / disconnect (plugged in / unplugged) and
sample (whether the device was plugged in at that moment), at most 200 per
request. Events older than 48 h are dropped, never sent; at most 500 are
kept on the device.No user id, anonymous id, session id, device model or app data is attached.
The server only uses a cell when enough distinct devices report from it, and only publishes outage areas, never individual devices or their events. Cells with too few contributors are not shown.
ACTION_POWER_CONNECTED /
ACTION_POWER_DISCONNECTED appends one line to a small file in the app's
no-backup directory (no Flutter engine, no network) — it is disabled in the
manifest and only enabled at runtime when you opt in. A WorkManager job
(every 15 min, when a network is available) records a charging sample and
uploads; the SDK also uploads on every app foreground. The cell comes from
LocationManager.getLastKnownLocation (network / passive provider; no Google
Play services).CLLocationManager().location, only with when-in-use or always
authorization.Android — android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
and request it at runtime yourself (e.g. with permission_handler). The SDK
adds the receiver and WorkManager to the merged manifest; it declares no
location permission.
iOS — ios/Runner/Info.plist:
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your approximate area helps map power outages near you.</string>
and request when-in-use authorization yourself (e.g. with geolocator or
permission_handler). Reduced ("approximate") accuracy is enough. Mention
coarse location in your App Store privacy label and Play data-safety form.
await Unilitix.init(
'YOUR_API_KEY',
config: UnilitixConfig(
debug: false, // disable SDK logs in production
captureSnapshots: true, // wireframe replay
captureScreenshots: true, // pixel screenshot replay
maskInputs: true, // mask text field input
maskAllText: true, // redact text in snapshots and screenshots
flushIntervalSeconds: 30, // how often to upload data
sessionTimeoutSeconds: 1800, // session inactivity timeout (30 min)
uploadScreenshotsOnWifiOnly: true, // hold screenshot uploads until Wi-Fi (default)
maxScreenshotsPerMinute: 20, // cap for continuously redrawing screens
screenshotHeartbeatMs: 10000, // frame at least this often when static
appHangTimeoutMs: 2000, // iOS app hangs; 0 turns them off
),
);
// Manually start a new session
await Unilitix.startSession();
// End the current session
await Unilitix.endSession();
// Force upload all queued data immediately
await Unilitix.flush();
// Stop tracking: ends replay capture, discards buffered data and purges the
// on-device queue so nothing captured afterwards leaves the device
await Unilitix.optOut();
// Resume tracking
Unilitix.optIn();
// Clear user identity and reset anonymous ID
Unilitix.reset();
No additional setup required (see Power outage contribution
for the one opt-in feature that needs a permission). Ensure your
android/app/build.gradle has:
compileSdk 35
minSdk 21
The plugin compiles a little C (native crash capture) with the NDK your app
build uses; an NDK is installed automatically by the Android Gradle Plugin
when missing. To pin the version (Flutter's default is flutter.ndkVersion):
// android/app/build.gradle.kts
android {
ndkVersion = "27.0.12077973"
}
Add the following to your ios/Podfile if not already present:
platform :ios, '13.0'
Then run:
cd ios && pod install
No additional Info.plist entries are required, unless you enable
power outage contribution.
Ensure your app meets these requirements:
compileSdk 35 or higherEnsure Unilitix.observer is added to navigatorObservers. Sessions require at least one navigation event to complete properly.
[Unilitix] Observer ⚠️ not connected
This warning fires during SDK init before MaterialApp is built. It clears automatically on first navigation. If it persists, confirm Unilitix.observer is in navigatorObservers.
If upgrading from v2.0.70 or earlier to v2.0.74, users will receive a new anonymous ID on first launch. This is a one-time break — anonymous IDs are now stable across all future app updates.
Your workspace has reached its event limit. Upgrade your plan or contact support to increase your limit.
Since 2.7.4 the SDK keeps what it has captured (within its on-device caps)
and pauses uploads for 15 minutes (or the server's Retry-After) at a time,
so nothing is lost while the plan is topped up.
See CHANGELOG.md for full release history.