dbrij_ship
Dbrij Ship for Flutter and Dart: feature flags, remote config, a kill switch and store version policy, resolved for each device in one check in.
Plus asset packs: a zip of the files your app reads at runtime (copy, translations, images, price tables) staged and rolled back on the same release rail a JS app's code push bundle moves down.
There is no code push here, and the reason is worth stating plainly rather than burying: a Flutter release build compiles Dart to machine code, and downloading machine code is the thing both app stores forbid. What a Flutter app gets instead is the dynamic layer and asset packs, which between them cover most of what teams reach for updates over the air to fix, plus the store version policy for the half that genuinely needs a review.
Install
dependencies:
dbrij_ship: ^0.1.0
Use
import 'package:dbrij_ship/dbrij_ship.dart';
final ship = Ship(
appKey: 'shp_…', // Setup card on the Ship dashboard
channel: 'production',
binaryVersion: packageInfo.version, // read it, never type it
platform: Platform.isIOS ? 'ios' : 'android', // picks the store link
storage: PrefsShipStorage(), // see below, and do not skip it
);
try {
await ship.checkIn();
} catch (_) {
// Never let a release desk break the app it exists to protect.
}
if (ship.getFlag('new_checkout')) { /* … */ }
final banner = ship.getConfig('banner', '');
getFlag and getConfig read the last answer, which is cached, so they keep working
offline after the first successful check in. The fallback is what shipped in your binary:
it runs on first launch, on a plane, and under the kill switch, so choose it deliberately.
getConfig requires its fallback for exactly that reason.
The storage adapter is not optional
Leave storage out and the device id lives in memory. That runs, which is what makes it
dangerous, and it quietly breaks two things:
- Your bill. The device id is what the monthly active device meter counts. A fresh id every launch turns one phone into thirty devices a month.
- Your rollouts. Bucketing is deterministic on the device id, which is what makes a 10% rollout the same 10% tomorrow. Reroll it and a phone flickers in and out of every staged flag you have.
Any get/set pair works. Over shared_preferences:
class PrefsShipStorage implements ShipStorage {
@override
Future<String?> getItem(String key) async =>
(await SharedPreferences.getInstance()).getString(key);
@override
Future<void> setItem(String key, String value) async =>
(await SharedPreferences.getInstance()).setString(key, value);
}
Store versions: the half OTA cannot fix
Set a latest and a minimum supported version on the app's App version tab, and every device learns its verdict on the next check in.
final answer = await ship.checkIn();
final update = answer.binaryUpdate;
if (update?.required ?? false) {
// Below the minimum: this build is not supported any more. Block.
showBlockingUpdateScreen(update!.message, update.url);
} else if (update != null) {
// Behind, but fine. Offer it once and let them dismiss it.
showUpdateBanner(update.message, update.url);
}
Send the real build version. A hardcoded binaryVersion nobody bumps means every device
reports the same number forever, adoption reads as zero, and the block never fires for the
people who need it.
Privacy
disabled: true, or ship.disabled = true at runtime, stops every request. Check in then
answers from the cache, or empty, and your fallbacks run. Wire it to whatever consent
switch your app already has.
The device id is an opaque random string this package generates. It is a bucketing handle, not an identity: never substitute an advertising identifier, because the store review teams treat those two things very differently.
Asset packs
Everything the app reads at runtime can change without a store release, because files are data rather than code.
final assets = ShipAssets(
ship: ship,
directory: await getApplicationSupportDirectory(),
);
await assets.restore(); // BEFORE the first frame: use the pack already on disk
unawaited(assets.sync()); // then bring it into line with the server
restore matters more than it looks: without it the app shows its built in assets for one
whole launch after every update lands, which reads to everybody as the update not having
worked.
// A pack is an OVERRIDE, never a replacement. The app has to work with none of it:
// on first launch, offline, and under the kill switch there is none.
final path = assets.path('copy/en.json');
final json = path != null
? await File(path).readAsString()
: await rootBundle.loadString('assets/copy/en.json');
A pack is checked against the sha256 the API served over TLS before anything is written
where the app might read it, entries that climb out of their own directory are refused, and
a pack is unpacked beside its target and moved into place so a half written one is never
left for the next launch. Signature checking is optional and off unless you pass a
verifier: its extra value is surviving a compromise of the API itself, which matters
enormously for code and much less for data, and it is not worth a crypto dependency in
every app. The docs show it in ten lines with package:cryptography.
Not available on Flutter web: there is no writable directory to unpack into.
Full guide
Libraries
- dbrij_ship
- Dbrij Ship for Flutter and Dart: feature flags, remote config, a kill switch and store version policy, resolved per device on one check in.