ReRune: OTA localization for Flutter

Update translations and add languages in installed Flutter apps without waiting
for an app store release, while continuing to use generated
AppLocalizations APIs.
ReRune overlays remotely published messages on Flutter's generated localizations, so existing widget calls stay unchanged.
- Keep generated getters and methods with full type safety for plain values, placeholders, and cardinal plurals.
- Deliver approved text changes and new languages at runtime.
- Restore cached translations at startup for offline use on mobile, desktop, and web.
- Fall back per message through published source language content and the bundled Flutter value.
Explore ReRune · Report an issue
Remote Language Delivery
ReRune's core OTA value is remote language delivery: add a new supported language in the ReRune dashboard and make it available to existing apps without shipping a new app store build just for that language.
After the SDK fetches or loads the dashboard language bundle from cache, the
locale appears in ReRune.supportedLocales. Apps can let the device locale
resolve to it automatically, or build a language picker from
ReRune.supportedLocales so remote languages appear next to compiled app
locales.
The app still ships the generated localization API and at least one compiled fallback locale. ReRune updates values for existing keys and expands the runtime locale list from dashboard data.
Requirements
- Flutter
>=3.22.0 - Dart
>=3.4.0
Install
dependencies:
rerune: ^1.2.0
dev_dependencies:
# Optional: needed only if you use the build_runner path below.
build_runner: ^2.4.13
Standard Integration (Recommended)
1) Generate Flutter localizations
flutter gen-l10n
2) Generate ReRune localization config
Fast path using just ReRune:
dart run rerune
Alternative (build_runner pipeline):
dart run build_runner build
Both commands generate identical *.rerune.g.dart artifacts.
Dedicated parity tests protect this behavior in CI.
With default Flutter l10n naming, ReRune generates:
lib/.../app_localizations.rerune.g.dartreRuneAppLocalizationsConfig
No manual anchor class or annotation file is needed.
3) Wire ReRune.setup(...) in main()
import 'package:flutter/widgets.dart';
import 'package:rerune/rerune.dart';
import 'l10n/gen/app_localizations.rerune.g.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await ReRune.setup(
otaPublishId: 'your-ota-publish-id',
localizations: reRuneAppLocalizationsConfig, // Generated by rerune/build_runner
updatePolicy: const ReRuneUpdatePolicy(checkOnStart: true),
);
runApp(const MyApp());
}
4) Use ReRune delegates/locales in your app
MaterialApp(
localizationsDelegates: ReRune.localizationsDelegates,
supportedLocales: ReRune.supportedLocales,
)
Use ReRune.supportedLocales anywhere you need the current runtime locale
list, including custom language pickers. The list starts with compiled app
locales and expands with fetched or cached dashboard languages.
Runtime APIs
- Setup and load cached bundles:
await ReRune.setup(...) - Check manually:
await ReRune.checkForUpdates() - Change the active translation variant:
await ReRune.setVariant(...) - Restore main translation content:
await ReRune.resetVariant() - Listen when fetched OTA message values are applied:
ReRune.onFetchedTextsApplied.listen(...) - Rebuild subtree on any localization revision:
ReRuneBuilder(builder: ...) - Listen to any localization revision:
ReRune.localizationsRevisionListenable
ReRune does not force Flutter's root element to rebuild. Without
ReRuneBuilder or a revision listener managed by your app, existing widgets
remain unchanged until the application rebuilds them for another reason.
Generated localization getters resolve against the current OTA bundles in
memory on every invocation, so newly built widgets still receive the latest
loaded values.
Translation variants
Variants solve an audience problem. Use one when the same translation key and language needs different wording for a customer, white-label brand, plan, role, region, experiment, or partner. Use a locale when the reader's language changes and a plural when grammar changes with quantity.
Main content is the default and the per-key fallback. Named Variants can stay sparse because a key without an override continues to use Main. Create a stable Variant name in the ReRune project, publish the content, then select its exact lowercase slug during setup:
await ReRune.setup(
otaPublishId: 'your-ota-publish-id',
localizations: reRuneAppLocalizationsConfig,
variant: ReRuneVariant.named('customer_acme'),
);
If the selection becomes known later, change it without another setup call:
await ReRune.setVariant(
variant: ReRuneVariant.named('customer_acme'),
persist: true,
);
The change affects later localization lookups immediately and advances
ReRune.localizationsRevisionListenable. Existing widgets still need
ReRuneBuilder or an application-owned listener to rebuild.
Read the active selection with ReRune.variant.
persist: false changes only the current runtime and leaves stored state
unchanged. persist: true restores the selection during later setup calls for
the same otaPublishId. A stored selection takes precedence over setup's
variant argument, including a stored main selection.
await ReRune.setVariant(); // Main for this runtime only.
await ReRune.setVariant(persist: true); // Select and persist Main.
await ReRune.resetVariant(); // The same persisted Main behavior.
Variant slugs are exact lowercase values published by ReRune. A missing variant for a key or locale uses that localized entry's main content. Plain values, placeholders, and structured plurals continue through the same rendering path after content selection.
See the Translation Variants guide for dashboard workflow, use cases, and the distinction between audience, locale, and plural selection.
Diagnostic logging
ReRune logging is disabled by default. Consumers can select a level during setup:
await ReRune.setup(
otaPublishId: 'your-ota-publish-id',
localizations: reRuneAppLocalizationsConfig,
logLevel: ReRuneLogLevel.info,
);
| Level | Output |
|---|---|
ReRuneLogLevel.off |
No diagnostics. This is the default. |
ReRuneLogLevel.error |
General failures without causes, stack traces, headers, or response bodies. |
ReRuneLogLevel.info |
Failures plus request URLs, operations, and response status codes. |
ReRuneLogLevel.verbose |
All available diagnostics, including raw headers, response bodies, causes, and stack traces. |
verbose gives explicit access to sensitive data. It can expose the OTA
publish ID and translated content in application logs. The lower levels never
emit request headers or response bodies.
Dashboard Locale Additions
This is the remote language delivery path for apps that want language expansion
to be controlled from the dashboard. await ReRune.setup(...) loads the cached
manifest and generic locale bundles before the first app frame, so a
dashboard locale fetched during a previous run is available in
ReRune.supportedLocales before MaterialApp resolves the device locale. If
checkOnStart is enabled, the network refresh starts in the background after
cached bundles are loaded; it does not block setup.
After a successful manifest/update fetch, ReRune.supportedLocales contains
the app's compiled locales plus dashboard locales whose locale bundle has been
fetched or loaded from cache. The generated delegate uses the app's first
compiled locale as the fallback base when Flutter's generated delegate cannot
load a dashboard locale.
When a newly fetched manifest no longer lists a previously published locale,
ReRune removes that locale's OTA bundle from memory and persistent cache. A
dashboard locale then disappears from ReRune.supportedLocales; a
compiled locale remains supported by the app but falls back through the
remaining OTA chain and bundled translations.
OTA Fallback for Missing Keys
The backend manifest declares the project's source locale through
the required main_language field. The value must identify one of the
manifest's locales. When a key is missing from the requested OTA locale,
ReRune resolves it in this order:
- Requested OTA locale variants
- OTA
main_languagevariants - Bundled Flutter localization
For example, an es_MX request with main_language: "en" resolves through
es_MX, es, and then OTA en before using the bundled Flutter value. Lookup
is per key, so an available es_MX bundle that lacks one key does not prevent
that key from falling back through es and en.
Placeholder, plural, and select messages use the locale of the OTA bundle that supplied the value. An English OTA fallback therefore uses English plural rules even when the active app locale is Spanish, Polish, or Arabic.
Manifests without a valid main_language are rejected. ReRune does not infer a
main language from locale ordering, compiled locales, or the active app locale.
Language Pickers
Build language pickers from ReRune.supportedLocales rather than from the
compiled AppLocalizations.supportedLocales list. That gives consumers one
source of truth for:
- locales bundled into the app at build time
- dashboard locales that were fetched in this app session
- dashboard locales loaded from cache during startup
If a picker must update immediately after a background fetch in the current app
session, rebuild it from ReRuneBuilder or listen to
ReRune.localizationsRevisionListenable. Without that rebuild, the newly
fetched language is available on the next app launch after
await ReRune.setup(...) loads it from cache.
Use ReRune.localeName(locale) for the display name from the backend, committed
with a locale's cached translations. It follows the same normalized locale
fallback order as translation lookup and returns null when no successfully
applied cache has a matching entry. Changes to names alone advance the general
localization revision after the locale metadata is persisted.
ReRune does not own the selected app locale. Keep that state in your app, for
example with a small LocaleNotifier, Riverpod, Bloc, or your existing app
settings model. Use ReRune.supportedLocales for picker options and pass your
selected locale to MaterialApp.locale. Apps without a language picker can
omit MaterialApp.locale entirely and keep Flutter's normal device locale
resolution.
class LocaleNotifier extends ValueNotifier<Locale?> {
LocaleNotifier(super.value);
}
final LocaleNotifier localeNotifier = LocaleNotifier(null);
ReRuneBuilder(
builder: (_) => ValueListenableBuilder<Locale?>(
valueListenable: localeNotifier,
builder: (_, locale, __) {
return MaterialApp(
locale: locale, // Optional: only for picker overrides managed by the app.
localizationsDelegates: ReRune.localizationsDelegates,
supportedLocales: ReRune.supportedLocales,
);
},
),
)
Set the notifier from your picker. Setting it to null returns to Flutter's
normal device locale resolution.
DropdownButton<Locale?>(
value: localeNotifier.value,
hint: const Text('System default'),
items: [
const DropdownMenuItem<Locale?>(
value: null,
child: Text('System default'),
),
...ReRune.supportedLocales.map((locale) {
return DropdownMenuItem<Locale?>(
value: locale,
child: Text(locale.toLanguageTag()),
);
}),
],
onChanged: (locale) {
localeNotifier.value = locale;
},
)
Any selected locale that is not null should come from
ReRune.supportedLocales. A custom LocaleNotifier does not collide with
ReRune because ReRune does not expose a setter for the selected locale. ReRune
does not fetch unavailable languages from picker selection. Dashboard languages
appear in the list only after their locale bundle is fetched or loaded from
cache.
Startup Modes
Use awaited setup when you want deterministic locale support on subsequent launches without waiting on the network:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await ReRune.setup(
otaPublishId: 'your-ota-publish-id',
localizations: reRuneAppLocalizationsConfig,
);
runApp(const MyApp());
}
Use the builder when you also want the app to follow the device language immediately after a background fetch in the current app session:
ReRuneBuilder(
builder: (_) => MaterialApp(
localizationsDelegates: ReRune.localizationsDelegates,
supportedLocales: ReRune.supportedLocales,
),
)
Limits:
- Without
ReRuneBuilderor another rebuild trigger, existing widgets do not refresh solely because OTA content changed. Newly built widgets still resolve current OTA values for their loaded locale. - A dashboard locale fetched during the current run requires
MaterialAppto rebuild before Flutter can reconsidersupportedLocalesand locale selection. Otherwise it is picked up on the next launch afterawait ReRune.setup(...)loads it from cache. - ReRune does not store the selected app locale. Apps with language pickers
should keep that state themselves and pass it to
MaterialApp.locale;nullstill means Flutter resolves the system language normally. - New localization keys or changed placeholder signatures still require running
flutter gen-l10nanddart run rerune, then shipping an app update. - Platform language metadata, app store language metadata, and operating system language listings are not changed by OTA localization.
Advanced And Custom Integrations
API catalogs containing offset:0
SDK version 1.2.0 includes catalog preparation for Flutter's localization
generator. Keep downloaded ARBs in lib/l10n, then run:
dart run rerune prepare-arb
flutter gen-l10n
dart run build_runner build
Set arb-dir: lib/l10n/flutter in l10n.yaml; retain the original template
filename and generated Dart output directory. The preparation command owns
lib/l10n/flutter, updates its derived ARBs, and removes stale locale files.
Do not put authored files there. Commit these generated inputs alongside
generated Dart so Flutter's automatic generation can run after a fresh
checkout. Keep translation synchronization pointed at the original catalogs.
For build_runner automation, enable this additional builder in build.yaml:
targets:
$default:
builders:
rerune:re_rune_arb_compatibility:
enabled: true
After each pull, run preparation before flutter gen-l10n, then regenerate
the overlay. With build_runner alone, run a preparation pass using
dart run build_runner build --build-filter='lib/l10n/flutter/*.arb'
before flutter gen-l10n, followed by a normal build_runner pass.
The SDK leaves API data and source ARBs unchanged. It removes only redundant
zero offset declarations from derived messages, preserving metadata and
literal text. Flutter still validates the remaining ARB grammar.
Nonzero offsets in bundled ARBs are rejected because Flutter cannot generate
equivalent fallbacks from them. Delivered JSON supports nonnegative integer
offsets: exact selectors such as =2 match the original count first, then
locale categories use the count minus the offset. Inside plural text, #
formats that adjusted count; '#' prints a literal hash. Explicit arguments
keep their original values. This does not claim complete ICU MessageFormat
support or change the API.
Custom Flutter l10n class and file names
If your app does not use app_localizations.dart / AppLocalizations, add a
build.yaml in the app root:
targets:
$default:
builders:
rerune|re_rune_localizations_overlay:
generate_for:
- lib/**/my_localizations.dart
options:
localizations_file_name: my_localizations.dart
localizations_class_name: MyLocalizations
Then run:
flutter gen-l10n
dart run rerune
# or
dart run build_runner build
This generates my_localizations.rerune.g.dart and
reRuneMyLocalizationsConfig.
Use it in startup:
import 'package:rerune/rerune.dart';
import 'l10n/gen/my_localizations.rerune.g.dart';
void main() {
ReRune.setup(
otaPublishId: 'your-ota-publish-id',
localizations: reRuneMyLocalizationsConfig,
);
runApp(const MyApp());
}
Disable automatic startup fetch
ReRune.setup(
otaPublishId: 'your-ota-publish-id',
localizations: reRuneAppLocalizationsConfig, // Generated by rerune/build_runner
updatePolicy: const ReRuneUpdatePolicy(checkOnStart: false),
);
Schedule periodic refresh in hours or days
ReRune.setup(
otaPublishId: 'your-ota-publish-id',
localizations: reRuneAppLocalizationsConfig,
updatePolicy: const ReRuneUpdatePolicy(
checkOnStart: true,
periodicIntervalInHours: 2,
periodicIntervalInDays: 3,
),
);
ReRuneUpdatePolicy only accepts periodic refresh intervals in whole hours or
whole days.
If both fields are set, they are combined into a single refresh cadence.
On web, if the combined interval exceeds the supported timer limit, ReRune
clamps it to the maximum supported delay (24 days and 20 hours) instead of
throwing.
Trigger updates from UI actions
final result = await ReRune.checkForUpdates();
if (result.hasErrors) {
// show error state
}
for (final locale in result.updatedLocales) {
// Fetched OTA message values changed.
}
for (final locale in result.updatedLocaleNames) {
// The successfully applied manifest display name changed.
}
for (final locale in result.removedLocales) {
// The locale is no longer published in the manifest.
}
result.hasUpdates is true when fetched values, locale names, or publication
membership changed. Changes to names alone and unpublishing do not emit
ReRune.onFetchedTextsApplied; they increment
ReRune.localizationsRevisionListenable when visible runtime state changes.
Provide your own cache store
If you need custom storage behavior, implement ReRuneCacheStore and pass it
to ReRune.setup(cacheStore: ...). Custom stores must persist all
ReRuneCachedLocaleBundle fields: the serialized locale document data and
the applied manifest locale name, version,
minimumDeltaBaseVersion, and url. Implement readLocaleBundle(...),
writeLocaleBundle(...), and deleteLocaleBundle(...) for locale state. A
custom write must not expose a candidate as committed when it throws; preserve
the previous complete payload and metadata until the replacement succeeds.
Existing custom stores remain source compatible with variant selection.
Override readSetting(...) and writeSetting(...) when the application uses
persisted variants. A custom store that leaves the default methods in place
supports runtime variant changes but reports an unsupported operation when
persist: true is requested.
Custom stores must return null from readManifest() or
readLocaleBundle(...) only when the requested entry does not exist. If
persisted state exists but cannot be read or decoded, the read must throw so
ReRune can report a storage error and repair locale content through a complete
request.
The default cache uses the new rerune_locale_cache_v1 namespace
(locale_<locale>.json on IO platforms and _locale_<locale> preference
keys on web). Entries in the former ota_localizations ARB namespace are
ignored and are not migrated or deleted.
Troubleshooting
Target of URI hasn't been generated: runflutter gen-l10n, thendart run rerune(ordart run build_runner build).Undefined name reRune...Config: generated.rerune.g.dartfile is missing, stale, or imported from a wrong path.- Changed l10n keys/signatures: rerun both generators.
License
This package is available under the MIT License. See LICENSE.