ctrlapp — remote control for your Flutter app
Change how your live app behaves from the CtrlApp dashboard — without publishing a new version.
Turn off a broken feature, show a maintenance screen, force an update, change a button label, or run an experiment, and your users' apps pick it up automatically, usually within a minute.
What you can do
| You want to... | You use |
|---|---|
| Switch off the whole app, or one broken feature, instantly | the kill-switch (isBlocked, boolFlag) |
| Show a "please update" screen to old versions | forced update (blockChanges) |
| Turn features on for 5% of users, then 100% | feature flags (boolFlag) |
| Change numbers, texts, images or links remotely | remote config (configValue, text, image) |
| Test two versions of something (A/B) | experiments (treatment) |
| Collect feedback or sign-ups | forms (submitForm) and an in-app inbox |
| Stop screenshots and screen recording of sensitive screens | screen capture protection (a switch in the dashboard) |
It keeps working offline: the last known decision is cached on the device, so a killed feature stays killed even if the user opens the app without internet.
Get started in 3 steps
1. Create an app at ctrlapp.dev and copy its API key
(it starts with ck_sdk_).
2. Add the package
flutter pub add ctrlapp
3. Start it when your app launches
import 'package:ctrlapp/ctrlapp.dart';
import 'package:flutter/material.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await CtrlApp.init(apiKey: 'ck_sdk_YOUR_KEY');
runApp(const MyApp());
}
That's it. From anywhere in your app you can now use CtrlApp.instance.
Everyday use
final ctrl = CtrlApp.instance;
// Kill-switch: should the app (or a feature) be blocked right now?
if (ctrl.isBlocked) {
showMaintenanceScreen(ctrl.blockReason());
}
// React when you flip the switch in the dashboard while the app is open.
ctrl.blockChanges.listen((block) {
if (block.action == 'force_update') openStore(block.forceUpdateUrl);
});
// Feature flag
if (ctrl.boolFlag('new_checkout')) {
showNewCheckout();
}
// Remote config, text and images (with safe defaults)
final maxItems = ctrl.configValue<int>('cart.max_items', defaultValue: 10);
final title = ctrl.text('home.title', fallback: 'Welcome');
final banner = ctrl.image('home.banner'); // null if not set
// Experiment: which version should this user see?
final variant = await ctrl.treatment('checkout_test', defaultValue: 'control');
// Track what people do
ctrl.track('purchase', props: {'plan': 'pro'});
Every call returns the default you pass in if the control doesn't exist, so your app never breaks because a key is missing.
Modals, banners and ads
Send them from the dashboard; the package shows them for you. There is nothing
to add to your app: they use your app's Theme, so they look like part of it.
| Type | What it looks like |
|---|---|
| Modal | A card in the middle with picture, text and a button |
| Banner | A slim card at the top that hides itself |
| Ad · Interstitial | A full-screen ad with a countdown ring |
| Ad · Banner | A banner ad along the bottom edge |
| Ad · Inline | A "Sponsored" card you place in a list (see below) |
Countdown ads. In the dashboard you can set "Must watch for (seconds)" on an ad. The close button stays locked while a timer counts down, and the back button does nothing. When it ends the person can close the ad and will not see it again. If they leave the app early, the ad is removed unfinished and shown again the next time they open the app. The dashboard counts every showing as a view, and the number of different devices as viewers.
// Inline ads: put this where you want a sponsored card. It is empty when there
// is no ad, so it is safe to leave in place.
ListView(children: [item1, item2, const CtrlAppInlineAd(), item3]);
Make it your own. Change the colors and text, or replace any design:
await CtrlApp.init(
apiKey: 'ck_sdk_YOUR_KEY',
messageUi: CtrlAppMessageUi(
accentColor: Colors.deepOrange,
labelsBuilder: () => CtrlAppMessageLabels(open: 'Ouvrir', close: 'Fermer'),
// Replace a design completely. `controller` gives you the countdown.
interstitialBuilder: (context, message, controller) => MyFullScreenAd(
message: message,
secondsLeft: controller.secondsLeft, // ValueListenable<int>
onClose: controller.close, // ignored until the countdown ends
onTap: controller.click, // opens the link, counts the tap
),
),
);
Use CtrlAppMessageUi(enabled: false) to draw nothing and handle
CtrlApp.instance.messages yourself. Push and inbox messages are never drawn
by this.
Send things to a group (tags)
Tag your users from your app, then target a campaign or a feature flag at that tag in the dashboard:
await CtrlApp.instance.addTags(['bidder', 'vip']);
await CtrlApp.instance.removeTags(['new-user']);
Tags you set in the dashboard are kept. Tags are per device, and each device can have up to 50 of up to 64 characters.
Screen capture protection
Turn it on per app in the dashboard (it is off by default). The package then applies it automatically:
- Android: blocks screenshots and screen recording of your app (
FLAG_SECURE). It never clears a flag your own code set. - iOS: hides your app's content in the app switcher and covers it while the screen is being recorded or mirrored. Apple provides no way to block a plain screenshot, so iOS cannot prevent those.
More in this package
Everything is one package. Next to CtrlApp you can use these on their own:
| Need | Use |
|---|---|
| Crash and error reports (opt-in, sensitive data removed) | CtrlAppDiagnostics, CtrlAppHandlers |
| Detect rooted / jailbroken devices and emulators | CtrlAppIntegrity.detect() |
| Device model and OS version | CtrlAppDeviceInfo.detect() |
| Ask for notification permission | CtrlAppPush.requestPermission() |
| Re-check the kill-switch while the app stays open | CtrlAppBlockWatch |
| Sign-in with Google and other providers | package:ctrlapp/auth.dart |
| Test your app without a network | MockCtrlAppTransport |
final diagnostics = CtrlAppDiagnostics();
CtrlAppHandlers(diagnostics).install(); // collects Flutter and platform errors
final signals = await CtrlAppIntegrity.detect();
final permission = await CtrlAppPush.requestPermission();
The sign-in classes have short, generic names (Loading, Offline, ...), so
they live in their own import: import 'package:ctrlapp/auth.dart';
Requirements
- Flutter 3.10 or newer, Dart 3
- Android 5.0 (API 21) or newer, iOS 12 or newer
Help
- Documentation: ctrlapp.dev/docs
- Questions: contact@ctrlapp.dev
MIT license.