Why We Built Custom Device Info (and Didn't Use device_info_plus)
device_info_plus is a fine package. It's the community standard. We use a custom one anyway.
This isn't NIH syndrome. It's about the difference between "calling a platform channel" and "exposing device data to your app." If you've ever needed getPlatformVersion() synchronously inside a render method, you already know the difference.
Here's what custom buys you, and why every meaningful Flutter app eventually writes one.
What device_info_plus Gives You
A nullable, async, raw map per platform.
final info = await DeviceInfoPlugin().androidInfo;
print(info.model); // "Pixel 7"
print(info.version.sdkInt); // 33Useful. Honest about what it is — a thin wrapper over MethodChannel. But:
- Every read is
Future. Render code can't use it. - Every read crosses the platform boundary. On Android this is a Binder IPC; on iOS it's a Swift bridge. Cheap individually, not when called 50 times during a single screen render.
- Platform-specific shapes.
IosDeviceInfoandAndroidDeviceInfoshare almost nothing. Every consumer ends up withif (Platform.isIOS) { ... } else { ... }. - No semantic types.
installerStoreis a String."com.android.vending"means "Play Store.""com.amazon.venezia"means "Amazon Appstore." You will write that mapping. - No caching. The plugin has no memory of yesterday. You will write that too.
In a small app, none of this matters. In a feature-flag-driven app with 30+ surfaces all asking "is this user on Android 14+?", every one of these becomes a real cost.
What Custom Buys You
1. Sync Getters After Pre-Resolve
The pattern, in three lines:
@preResolve
@LazySingleton(as: DeviceInfoSDK)
static Future<DeviceInfoSDK> create(DeviceInfoPlugin plugin) async {
final raw = await plugin.deviceInfo;
return DeviceInfoSDKImpl(_buildModel(raw));
}@preResolve runs once during DI bootstrap, in the pre-launch phase, blocking until the device data is ready. After that, every consumer reads sync:
class DeviceInfoSDKImpl implements DeviceInfoSDK {
DeviceInfoSDKImpl(this._info);
final DeviceInfo _info;
@override
String get model => _info.model;
@override
int get sdkInt => _info.sdkInt;
@override
bool get isTablet => _info.isTablet;
}The render method that asks if (deviceInfo.sdkInt >= 33) doesn't await anything. It doesn't trigger a Future rebuild. It doesn't cause a frame drop. It's a field read.
This is the single biggest win.
2. A Unified DeviceInfo Model
Cross-platform code shouldn't branch on Platform.isIOS to read a manufacturer name. Custom flattens both platforms into one model:
class DeviceInfo extends Equatable {
final String model; // "Pixel 7" or "iPhone 15 Pro"
final String manufacturer; // "Google" or "Apple"
final String osVersion; // "14" or "17.2"
final int sdkInt; // Android API level; iOS major version
final bool isTablet;
final bool isPhysicalDevice;
final InstallerSource installerSource;
final String advertisingId; // hashed; useful for crash dedup
// ...
}Every field has a sensible value on both platforms. Where one platform genuinely doesn't have an analog (e.g., iOS has no "manufacturer"), the model picks a stable default and documents it.
3. Typed InstallerSource
Raw installerStore is a String. Typed:
enum InstallerSource {
googlePlay,
amazonAppstore,
huaweiAppGallery,
oppoAppMarket,
vivoAppStore,
xiaomiGetApps,
samsungGalaxyStore,
sideloaded,
testFlight, // iOS
appStore, // iOS
unknown,
}Now your analytics segmentation reads naturally:
if (deviceInfo.installerSource == InstallerSource.sideloaded) {
analytics.track('sideloaded_user_event', ...);
}Instead of a String comparison. Refactors don't silently break (the enum value disappears at compile time). New stores get added in one place.
4. TTL Caching Without Effort
device_info_plus re-walks platform channels every read. For data that changes once per app launch (model, OS version) or once per OS update (SDK int), this is wasted work. Custom caches by construction:
DeviceInfoSDKImpl(this._info); // immutableThe cache lifetime is the app process. That's the right TTL for almost everything device_info exposes.
For data that genuinely changes (e.g., isPhysicalDevice is constant; isTablet is constant; even osVersion doesn't change while the app is running), there's no expiry. For things that DO change (battery level, network state — though those belong in different SDKs), use a different package.
5. Sync Equality + Equatable
Comparing two device snapshots — a real thing in crash dedup logic — works naturally:
if (currentInfo == lastReportedInfo) return; // same device, skipdevice_info_plus returns plugin-platform records that don't override ==. You'd write that yourself anyway.
What About package_info_plus?
Same story, smaller surface. The pattern is identical:
@preResolve
@LazySingleton(as: PackageInfoSDK)
static Future<PackageInfoSDK> create(PackageInfoPlatform plugin) async {
final raw = await PackageInfo.fromPlatform();
return PackageInfoSDKImpl(
appName: raw.appName,
packageName: raw.packageName,
version: raw.version,
buildNumber: int.parse(raw.buildNumber),
flavor: _detectFlavor(raw),
);
}Sync after pre-resolve. Typed flavor field (debug / staging / production / your custom flavors). Build number as int, not String. Equatable.
The bonus: package_info also lets you derive things like "is this a development build?" once at startup instead of on every feature flag check.
When You Don't Need This
Custom is overhead. Don't bother if:
- You're shipping a one-off app where two
awaitcalls don't matter. - You only read device info from background services (no render-thread cost).
- You're prototyping. Use
device_info_plusdirectly until the second time you copy-paste anif (Platform.isIOS)branch.
The pattern earns its keep at scale: ~10+ features, 5+ engineers, observability that depends on stable typed identifiers. Below that, it's polish.
How Archipelago Generates It
The device_info_sdk brick (planned) generates the impl + DI wiring + a DeviceInfo model class with all of the above. The pre-resolve happens during the existing two-phase init we ship in every Archipelago project. You get the API/Impl split for free.
If you've shipped Archipelago, you already have the right shape — device_info_api defines the contract, device_info_impl wraps device_info_plus, the bootstrap pre-resolves, and your render code just reads getters.
It's not a different package. It's the same idea, structured.
The TL;DR
device_info_plus is correct as a platform channel wrapper. It's not the right shape for app code that needs sync access, typed semantics, and stable identifiers across platforms. Wrap it once at the DI layer; consume the wrapper everywhere else.
That's a five-line decision in DI that pays off on every render frame for the rest of the app's life.