Skip to main content

Flutter Integration

mubeen_flutter Documentation

Version 1.5October 2026

This guide covers mubeen_flutter, the Flutter plugin for Mubeen device intelligence.

The plugin is a thin bridge. It contains no fingerprinting logic of its own: it calls the native Android and iOS SDKs, which do the collection. Everything you use comes from one import:

import 'package:mubeen_flutter/mubeen_flutter.dart';

Overview​

mubeen_flutter gives each device a persistent identifier and sends device fingerprints to Mubeen for risk analysis, from a single Dart API on both platforms:

  • Persistent Device ID — one identifier per device. On iOS it survives a reinstall; on Android it does through Auto Backup, which covers it by default and needs one line if your app defines its own backup rules.
  • Fingerprint submission — sendFingerprint(), with collection off the main thread.
  • Risk Signals — root, tamper and emulator detection on Android; jailbreak checks on iOS.
  • Honest platform differences — what one platform cannot do is reported, never faked.
The native SDKs come with the plugin

You do not add deviceintel-lib or MubeenDeviceSDK to your app yourself; the plugin brings both. On Android, Gradle resolves deviceintel-lib 1.7.3 from Maven Central as a dependency of the plugin. On iOS, the SDK binary is committed inside the plugin for CocoaPods, and Swift Package Manager downloads the same checksum-verified release from GitHub. Your app declares one package.


Requirements​

RequirementVersion
Flutter3.44.0+
Dart3.12+
Android7.0+ (API 24)
iOS15.0+
Xcode16+

Xcode 16 is required even though the native iOS SDK's own guide lists Xcode 15: the SDK binary bundled with the plugin needs a newer Swift compiler than Xcode 15 ships.

Flutter 3.44 is a hard floor

Below 3.44 the package will not resolve. If your app is on an older Flutter, contact us before you start so we can discuss the options with you.


Installation​

Add the plugin as a git dependency pinned to a release tag. No GitHub account is needed.

pubspec.yaml
dependencies:
mubeen_flutter:
git:
url: https://github.com/mubeen-ai/mubeen_flutter_dist.git
ref: v0.1.4

Then:

flutter pub get

Platform minimums your app must meet​

The plugin cannot lower these for you, so check them before your first build.

Android. Your app's minSdk must be 24 or higher. A lower value fails the build with a manifest merger error naming the plugin.

android/app/build.gradle.kts
defaultConfig {
minSdk = 24
}

iOS. Your app's deployment target must be 15.0 or higher. In Xcode, select the Runner target, open General, and set Minimum Deployments to iOS 15.0.

If your app uses CocoaPods, which is the case when you have turned Swift Package Manager off, also set the platform line in the Podfile. Keep the rest of the file as Flutter generated it, including use_frameworks!.

ios/Podfile
platform :ios, '15.0'

If pod install complains that the plugin requires a higher minimum deployment target, that line is missing. The native SDK binary ships inside the plugin, so pod install downloads nothing extra.

Beyond those minimums there is nothing else to wire up, with one optional exception covered under Android Auto Backup.


Quick Start​

1. Configure​

Call configure once, during app startup, and await it before anything else.

import 'package:flutter/widgets.dart';
import 'package:mubeen_flutter/mubeen_flutter.dart';

Future<void> main() async {
// Subscribe BEFORE configure: errors raised during startup are not replayed.
Mubeen.instance.errors.listen((MubeenError error) {
debugPrint('mubeen: ${error.code} ${error.message}');
});

await Mubeen.instance.configure(
const MubeenConfig(
apiKey: 'public_your_key',
endpoint: 'https://fp.sa.mubeen.ai',
),
);

runApp(const MyApp());
}
Your key and your endpoint must be from the same region

A key issued in one region returns 401 invalid credentials against another region's endpoint, even though the key is perfectly valid where it was issued. On iOS, sendFingerprint() throws MubeenAuthError. On Android nothing reports it: fingerprints simply never arrive. Use the endpoint for your region from your onboarding, for example https://fp.sa.mubeen.ai or https://fp.de.mubeen.ai.

2. Read the Device ID​

final String deviceId = await Mubeen.instance.getDeviceId();

Reading the ID never uploads anything.

3. Send a Fingerprint​

await Mubeen.instance.sendFingerprint();

Platform capabilities​

This is the part that differs from a single-platform SDK. The two native SDKs do not offer the same features, so the plugin exposes everything each one can do and tells you which is which, rather than reducing the API to what both share.

AndroidiOS
configure, getDeviceId, sendFingerprint, resetDeviceIdyesyes
linkage and custom data sentno (accepted, not sent)yes
getFingerprint() (returns the raw payload)yesno
pendingUploadCount()yesno

Calling an unsupported method (getFingerprint() on iOS, pendingUploadCount() on iOS) throws MubeenUnsupportedError with a message naming the reason. It never returns a plausible-looking zero.

linkage and custom are the one exception, so that the same code runs on both platforms: pass them everywhere. On Android, sendFingerprint() accepts them, uploads the fingerprint without them, and does not throw. To read whether they were actually sent, check capabilities.linkage.

Android linkage is coming

The current Android SDK's payload has no field for linkage or custom, so on Android they are not sent. The first time this happens, one MubeenUnsupportedError warning arrives on Mubeen.instance.errors; it is not repeated. When a plugin release adds Android support, updating the ref is the only change: your code stays the same.


Configuration​

const MubeenConfig(
apiKey: 'public_your_key',
endpoint: 'https://fp.sa.mubeen.ai',
logLevel: MubeenLogLevel.warning,
allowedCustomKeys: <String>{'checkout_step'},
android: MubeenAndroidOptions(),
ios: MubeenIosOptions(),
);
FieldTypeDefaultNotes
apiKeyStringrequiredA public - JS client side key
endpointString?https://fp.sa.mubeen.aiSame region as the key
logLevelMubeenLogLevelwarningnone to debug
allowedCustomKeysSet<String>emptyKeys allowed in custom
androidMubeenAndroidOptionsall offOptional collectors
iosMubeenIosOptionsjailbreak onOptional collectors

endpoint must match the region your key was issued in, and null means the default. An empty string is an error, not a request for the default. It must be a plain base URL: configure() rejects an endpoint that contains a query string, a fragment, credentials (user:password@), whitespace or a backslash, with an ArgumentError. It must be an absolute https URL with a host; http is accepted only for localhost, 127.0.0.1, ::1 and 10.0.2.2, because both platforms block cleartext in release builds. A trailing slash is harmless; the plugin strips it.

android carries collectLocation, locationMaxAge, collectAdvancedSignals and collectAdvertisingId. ios carries environment, enableJailbreakChecks and enableBehavioralSignals. Each platform ignores the other's group.

collectAdvertisingId needs the play-services-ads-identifier dependency in your app and, for apps targeting API 33 or higher, the AD_ID permission. If either is missing, configure() still succeeds and a MubeenUnsupportedError about that one collector arrives on Mubeen.instance.errors. It is a warning: everything else keeps working, so do not disable the SDK because of it.

Use the right key type

A private - server side key is accepted by iOS and rejected by Android. Ship the wrong type and you get a working iOS build while every Android upload is rejected with a 401 that your app never sees: no error on the Future, nothing on the error stream. Android fingerprints simply never arrive, which reads like an Android bug rather than a key mistake.

MubeenLogLevel.debug prints your API key

At debug, the Android SDK writes the API key and the full request body to logcat, in release builds too. Use it while integrating, not in what you ship.

Configure once. The native SDKs are process-wide, and the plugin applies your configuration once per process. Calling configure() again with identical values is a no-op. With different values, including a different allowedCustomKeys, it throws MubeenAlreadyConfiguredError rather than pretending the new values took effect. During development this also means a Flutter hot restart keeps the previous configuration: to change the key or endpoint, fully restart the app.


Device ID​

final String deviceId = await Mubeen.instance.getDeviceId();
final String fresh = await Mubeen.instance.resetDeviceId();

resetDeviceId() discards the current identifier and mints a new one. Use it when a user signs out of a shared device, not routinely: a device that changes identity constantly is indistinguishable from a device that is trying to. On Android it also discards uploads still waiting to retry.

On iOS the identifier is stored in the Keychain and survives a reinstall. On Android, surviving a reinstall depends on your app's Auto Backup rules. See below.


Sending Fingerprints​

await Mubeen.instance.sendFingerprint();

// Same code on both platforms. On Android, linkage and custom are
// accepted but not sent yet (see Platform capabilities).
await Mubeen.instance.sendFingerprint(
linkage: const MubeenLinkage(
accountId: 'acct_123',
phone: '+966551234567',
),
custom: <String, String>{'checkout_step': 'payment-review'},
);

Linkage values are hashed on the device. The raw email or phone never leaves it.

When to send​

Send once when the app is opened, then at moments that matter for risk: sign-in, sign-up, checkout, payment method change, password reset. Sending on every screen wastes battery and adds nothing, and on Android the SDK's own rate limiter (5 per minute) discards the excess.

Response handling, and what the Future actually means​

What a completed await tells you depends on the platform.

Android: the Future completes on handoff, within milliseconds. Collection and upload run afterwards, off the main thread. If the server rejects the upload (a 401, for example), the SDK re-queues it and neither the Future nor Mubeen.instance.errors hears about it. pendingUploadCount() is the backstop.

iOS: the Future completes after collection and the first upload attempt.

  • A 2xx response completes it normally.
  • A 404 or 405 throws MubeenInvalidConfigError: the endpoint does not serve the fingerprint route, so check the URL, not the key.
  • Any other 4xx except 429 (for example 400, 401 or 403) throws MubeenAuthError.
  • While Mubeen has switched submissions off remotely, it throws MubeenDisabledError.
  • A 429 or a 5xx completes it normally. The SDK queues the payload and retries it, and each 429 or 5xx response is reported on Mubeen.instance.errors.
  • With no network (offline, DNS, TLS or a timeout), it completes normally and the payload is queued for retry, but no error is reported anywhere.
  • A queued payload that later gets a terminal 4xx on retry is dropped without any report.

Typical timings on real hardware: on Android, collection took 0.5 to 0.7 seconds on current phones, and a repeat collection on the same device was faster. On iOS, sendFingerprint() completed in about 150 ms, upload included. Slow or emulated Android devices were measured at 10 to 25 seconds.

configure() does not wait on the network and returns in milliseconds, so it does not slow app startup. On iOS the SDK starts a background fetch of its remote configuration, which configure() does not wait for.

The custom map​

custom carries non-identifying extras. It is checked in Dart before anything is sent, in release builds as well as debug, and a failure throws ArgumentError:

  1. Every key must be registered in allowedCustomKeys. The linkage names account_id, payment_token_id, email and phone are refused as keys, and also in allowedCustomKeys.
  2. A value that contains an email address or an international phone number is rejected.
  3. At most 30 keys, and at most 4096 bytes as JSON.

Personal identifiers belong in linkage, which hashes them. The second rule is a safety net for mistakes, not a PII detector: national-format phone numbers, government IDs, timestamps and order IDs such as ORD-00123456789 or INV_0098765432 all pass. Only a 00 directly after identifier punctuation (-, _, ., ,, (, ) or a Unicode dash) is read as a zero-padded ID. A 00 or + number that stands as its own token is rejected, including at the start of the value and in shapes like tel:00966… or phone=00966…; one glued to a letter or digit, such as INV00966501234567, is not.


Error Handling​

Failures reach you on exactly two paths.

Thrown through the awaited Future when the call itself fails:

try {
await Mubeen.instance.sendFingerprint();
} on MubeenUnsupportedError {
// not available on this platform
} on MubeenError catch (e) {
debugPrint('mubeen: ${e.code} ${e.message}');
} on ArgumentError {
// invalid input, e.g. a rejected custom map
}

Delivered on Mubeen.instance.errors when something fails outside a call. Subscribe before configure(). What arrives there differs by platform:

  • iOS: an upload answered with a 429 or a 5xx, which the SDK queues for retry.
  • Android: a transport failure (offline, DNS, TLS, timeout) as MubeenNetworkError with code network, usually twice per failed call because the SDK re-sends immediately; a fault during collection, a failure to store the device ID, or a missing INTERNET permission (the plugin declares it, so this only happens if your manifest removes it) as MubeenUnknownError; the collectAdvertisingId warning described under Configuration; and a one-time warning when linkage or custom were not sent.

A rejected upload is not delivered on the stream on either platform. On iOS a rejected first attempt throws from sendFingerprint() (a rejected retry is dropped silently, as above); on Android it is re-queued silently (see the caution below).

The only thing thrown synchronously is the capabilities getter, if you read it before configure() has completed. Every method call reports failure through its Future, so unawaited(...), .catchError and FutureBuilder all behave.

ErrorMeaning
MubeenNotConfiguredErrorconfigure() has not completed.
MubeenAlreadyConfiguredErrorConfigured already, with different values. Restart the app.
MubeenInvalidConfigErrorConfiguration rejected, or (iOS) the endpoint answered 404/405.
MubeenUnsupportedErrorNot available on this platform.
MubeenAuthErrorUpload rejected. iOS only.
MubeenNetworkErrorUpload failed transiently and was queued.
MubeenDisabledErrorSubmissions switched off remotely. iOS only.
MubeenUnknownErrorAnything unrecognised.
ArgumentErrorInvalid input, including PII in custom.

MubeenUnsupportedError comes from calling a feature this platform lacks (check capabilities first). On Android it also arrives on the stream as a warning, in two cases: the collectAdvertisingId warning, and once when linkage or custom were accepted but not sent. Neither means the SDK stopped working.

MubeenInvalidConfigError comes from the native SDK rejecting the configuration, or, on iOS, from sendFingerprint() when the endpoint answers 404 or 405: a wrong URL, not a wrong key.

MubeenAuthError is thrown by sendFingerprint() on iOS when the upload gets a 4xx other than 404, 405 or 429 (typically 400, 401 or 403). The Android SDK never reports an HTTP status, so you will not see this error there.

MubeenNetworkError arrives on the stream when an upload fails transiently and is queued for retry: on iOS for a 429 or 5xx response, where code tells the two apart (rate_limited or network); on Android for a transport failure (offline, DNS, TLS, timeout), code network, usually twice per call. Being offline is not reported on iOS.

MubeenDisabledError is iOS only: Mubeen has switched submissions off remotely. It is not a key problem and retrying will not help, so catch it and carry on with your flow.

A rejected upload can look like a success on Android

The current Android SDK logs and re-queues rejected uploads without notifying the app, so neither the Future nor the error stream sees them. pendingUploadCount() is the backstop: it counts uploads that already failed and are waiting to retry.


Android Auto Backup​

The Android SDK stores the device ID in a SharedPreferences file (MB_device_intel_secure_prefs) and relies on Android's Auto Backup to carry it across a reinstall. The plugin cannot wire this up for you: declaring backup rules in the plugin's own manifest would conflict with any your app already declares and break the manifest merge.

If your app already defines backup rules, add the <include> line into your existing files. Whole files shown for clarity:

res/xml/your_full_backup_content.xml (API 30 and below)
<full-backup-content>
<include domain="sharedpref" path="MB_device_intel_secure_prefs.xml" />
</full-backup-content>
res/xml/your_data_extraction_rules.xml (API 31+)
<data-extraction-rules>
<cloud-backup>
<include domain="sharedpref" path="MB_device_intel_secure_prefs.xml" />
</cloud-backup>
</data-extraction-rules>

Do not point your android:fullBackupContent or android:dataExtractionRules attribute at our file directly. These attributes are include-only, so doing that would restrict your app's entire backup to our preferences and exclude your own data.

If your app has no backup rules at all, change nothing: the platform default already backs up shared preferences.

Auto Backup restores the ID on reinstall, for the same account, with backup enabled. It does not survive Clear Data, which always mints a new ID.

A cloud restore or a device-to-device transfer (API 31+) can also carry the ID onto a new phone, so treat the device ID on Android as backup-scoped, not strictly device-bound. Use <exclude> under <device-transfer> if you want each physical device to get its own ID.


Privacy and store compliance​

You declare the data, because it is collected under your app's identity.

  • Device identifiers, always.
  • Email, phone number and user ID, if you use linkage, as linked data (plus Payment Info if you pass paymentTokenId). They are hashed on the device, and hashing does not remove the declaration requirement.
  • Usage data, if you turn on enableBehavioralSignals (iOS).
  • Each Android opt-in collector you enable (location, advertising ID, advanced signals) adds its own Google Play Data Safety entry.

The bundled privacy manifests do not cover linkage or behavioral signals, so Xcode's aggregated privacy report will not fill these in for you.


Troubleshooting​

SymptomCauseFix
Build fails with a manifest merger error naming the pluginYour app's minSdk is below 24Raise minSdk to 24
pod install says the plugin needs a higher deployment targetThe Podfile platform line is missingSet platform :ios, '15.0'
iOS throws MubeenAuthError with a key you know is validThe key and the endpoint are from different regionsUse the endpoint for the region the key was issued in
Android fingerprints never arrive, no error on Android, iOS worksA private - server side keyUse a public - JS client side key
Android: MubeenUnsupportedError on the stream after configure()collectAdvertisingId without its dependency or AD_IDAdd them, or turn the option off
MubeenAlreadyConfiguredError during developmentFlutter hot restart keeps the native SDK configuredFully restart the app, not hot restart
MubeenNotConfiguredError on a call you expected to workconfigure() was not awaited before itAwait configure() during startup
MubeenUnsupportedErrorThe feature does not exist on this platformCheck capabilities first; see Platform capabilities
sendFingerprint() returns fine on Android but nothing arrivesOn Android the Future reports handoff, not deliveryCheck pendingUploadCount(), and the key type and region

Known limitations​

LimitationDetail
Android cannot attach linkage or customBoth are accepted and validated, but not sent: the SDK's payload has no field for either yet.
A rejected upload can look like success on AndroidSee the caution above.
Offline queues differAndroid's retry queue is in memory and can be lost when the process dies; iOS persists it.
No certificate pinningA device with a user-installed CA can intercept traffic.
debug log level prints the API keyIn release builds too.
Repeated sendFingerprint() callsEach runs its own collection on Android; there is no coalescing.