Flutter OTA Localization: Over-the-Air Translation Updates Without App Store Releases
Updating translations shouldn't require a full app release. Over-the-air (OTA) localization lets you push new translations, fix typos, and add languages instantly. This guide shows you how to implement OTA translation updates in Flutter with proper caching, versioning, and fallback strategies.
Why OTA Localization?
Traditional localization requires app store releases for every change:
- Slow deployment - App review can take days
- User friction - Users must update the app
- High cost - Each release has overhead
- Inflexible - Can't respond quickly to issues
OTA localization solves these problems:
- Instant updates - Changes go live immediately
- No user action - Translations update silently
- A/B testing - Test different translations
- Quick fixes - Correct typos without releases
- New languages - Add locales on demand
Architecture Overview
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Translation │────▶│ CDN/Server │────▶│ Flutter App │
│ Management │ │ (Versioned) │ │ (Cached) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
▼ ▼ ▼
Upload new Version check Load & cache
translations + download translations
Setting Up OTA Localization
Translation Model
class OTATranslation {
final String locale;
final int version;
final DateTime updatedAt;
final Map<String, String> translations;
final Map<String, dynamic>? metadata;
OTATranslation({
required this.locale,
required this.version,
required this.updatedAt,
required this.translations,
this.metadata,
});
factory OTATranslation.fromJson(Map<String, dynamic> json) {
return OTATranslation(
locale: json['locale'] as String,
version: json['version'] as int,
updatedAt: DateTime.parse(json['updatedAt'] as String),
translations: Map<String, String>.from(json['translations'] as Map),
metadata: json['metadata'] as Map<String, dynamic>?,
);
}
Map<String, dynamic> toJson() => {
'locale': locale,
'version': version,
'updatedAt': updatedAt.toIso8601String(),
'translations': translations,
'metadata': metadata,
};
String? get(String key) => translations[key];
String getOrDefault(String key, String defaultValue) {
return translations[key] ?? defaultValue;
}
}
OTA Translation Service
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
class OTALocalizationService {
final String baseUrl;
final Duration checkInterval;
final Map<String, OTATranslation> _cache = {};
final Map<String, OTATranslation> _bundledTranslations;
Timer? _updateTimer;
bool _isInitialized = false;
OTALocalizationService({
required this.baseUrl,
required Map<String, OTATranslation> bundledTranslations,
this.checkInterval = const Duration(hours: 1),
}) : _bundledTranslations = bundledTranslations;
Future<void> initialize() async {
if (_isInitialized) return;
// Load cached translations first
await _loadCachedTranslations();
// Start background update checks
_startUpdateTimer();
// Check for updates now (non-blocking)
_checkForUpdates();
_isInitialized = true;
}
Future<void> _loadCachedTranslations() async {
try {
final directory = await getApplicationDocumentsDirectory();
final cacheDir = Directory('${directory.path}/translations');
if (!await cacheDir.exists()) {
// Use bundled translations
_cache.addAll(_bundledTranslations);
return;
}
// Load each cached locale
await for (final file in cacheDir.list()) {
if (file is File && file.path.endsWith('.json')) {
try {
final content = await file.readAsString();
final json = jsonDecode(content) as Map<String, dynamic>;
final translation = OTATranslation.fromJson(json);
// Use cached if newer than bundled
final bundled = _bundledTranslations[translation.locale];
if (bundled == null || translation.version > bundled.version) {
_cache[translation.locale] = translation;
} else {
_cache[translation.locale] = bundled;
}
} catch (e) {
print('Error loading cached translation: $e');
}
}
}
// Add any bundled translations not in cache
for (final entry in _bundledTranslations.entries) {
_cache.putIfAbsent(entry.key, () => entry.value);
}
} catch (e) {
// Fall back to bundled translations
_cache.addAll(_bundledTranslations);
}
}
void _startUpdateTimer() {
_updateTimer?.cancel();
_updateTimer = Timer.periodic(checkInterval, (_) => _checkForUpdates());
}
Future<void> _checkForUpdates() async {
try {
// Get version manifest from server
final response = await http.get(
Uri.parse('$baseUrl/translations/manifest.json'),
).timeout(Duration(seconds: 10));
if (response.statusCode != 200) return;
final manifest = jsonDecode(response.body) as Map<String, dynamic>;
final remoteVersions = Map<String, int>.from(manifest['versions'] as Map);
// Check each locale for updates
for (final entry in remoteVersions.entries) {
final locale = entry.key;
final remoteVersion = entry.value;
final cachedVersion = _cache[locale]?.version ?? 0;
if (remoteVersion > cachedVersion) {
await _downloadTranslation(locale);
}
}
} catch (e) {
print('Error checking for translation updates: $e');
}
}
Future<void> _downloadTranslation(String locale) async {
try {
final response = await http.get(
Uri.parse('$baseUrl/translations/$locale.json'),
).timeout(Duration(seconds: 30));
if (response.statusCode != 200) return;
final json = jsonDecode(response.body) as Map<String, dynamic>;
final translation = OTATranslation.fromJson(json);
// Update cache
_cache[locale] = translation;
// Persist to disk
await _saveTranslation(translation);
// Notify listeners
_notifyListeners(locale);
} catch (e) {
print('Error downloading translation for $locale: $e');
}
}
Future<void> _saveTranslation(OTATranslation translation) async {
try {
final directory = await getApplicationDocumentsDirectory();
final cacheDir = Directory('${directory.path}/translations');
if (!await cacheDir.exists()) {
await cacheDir.create(recursive: true);
}
final file = File('${cacheDir.path}/${translation.locale}.json');
await file.writeAsString(jsonEncode(translation.toJson()));
} catch (e) {
print('Error saving translation: $e');
}
}
// Listeners for translation updates
final List<Function(String)> _listeners = [];
void addListener(Function(String) listener) {
_listeners.add(listener);
}
void removeListener(Function(String) listener) {
_listeners.remove(listener);
}
void _notifyListeners(String locale) {
for (final listener in _listeners) {
listener(locale);
}
}
// Translation access
String translate(String key, String locale, {String? defaultValue}) {
final translation = _cache[locale];
if (translation != null) {
final value = translation.get(key);
if (value != null) return value;
}
// Try fallback locale (e.g., 'en' if 'en_US' not found)
final fallbackLocale = locale.split('_').first;
if (fallbackLocale != locale) {
final fallback = _cache[fallbackLocale];
if (fallback != null) {
final value = fallback.get(key);
if (value != null) return value;
}
}
// Try English as final fallback
if (locale != 'en') {
final english = _cache['en'];
if (english != null) {
final value = english.get(key);
if (value != null) return value;
}
}
// Return default or key
return defaultValue ?? key;
}
OTATranslation? getTranslation(String locale) => _cache[locale];
List<String> get availableLocales => _cache.keys.toList();
int? getVersion(String locale) => _cache[locale]?.version;
Future<void> forceUpdate() async {
await _checkForUpdates();
}
void dispose() {
_updateTimer?.cancel();
}
}
Integration with Flutter Localization
OTA Localization Delegate
class OTALocalizationsDelegate extends LocalizationsDelegate<OTALocalizations> {
final OTALocalizationService _service;
OTALocalizationsDelegate(this._service);
@override
bool isSupported(Locale locale) {
return _service.availableLocales.contains(locale.languageCode) ||
_service.availableLocales.contains(locale.toString());
}
@override
Future<OTALocalizations> load(Locale locale) async {
return OTALocalizations(_service, locale);
}
@override
bool shouldReload(OTALocalizationsDelegate old) => false;
}
class OTALocalizations {
final OTALocalizationService _service;
final Locale locale;
OTALocalizations(this._service, this.locale);
static OTALocalizations of(BuildContext context) {
return Localizations.of<OTALocalizations>(context, OTALocalizations)!;
}
String get(String key, {String? defaultValue}) {
return _service.translate(
key,
locale.toString(),
defaultValue: defaultValue,
);
}
// Convenience getters for common strings
String get appTitle => get('app_title', defaultValue: 'My App');
String get welcomeMessage => get('welcome_message', defaultValue: 'Welcome!');
String get loginButton => get('login_button', defaultValue: 'Log In');
String get logoutButton => get('logout_button', defaultValue: 'Log Out');
String get settingsTitle => get('settings_title', defaultValue: 'Settings');
String get languageLabel => get('language_label', defaultValue: 'Language');
String get errorGeneric => get('error_generic', defaultValue: 'An error occurred');
}
App Integration
class MyApp extends StatefulWidget {
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final OTALocalizationService _localizationService;
Locale _currentLocale = Locale('en');
int _translationVersion = 0;
@override
void initState() {
super.initState();
_initializeLocalization();
}
Future<void> _initializeLocalization() async {
_localizationService = OTALocalizationService(
baseUrl: 'https://api.example.com',
bundledTranslations: {
'en': _loadBundledEnglish(),
'es': _loadBundledSpanish(),
'de': _loadBundledGerman(),
},
);
await _localizationService.initialize();
// Listen for translation updates
_localizationService.addListener(_onTranslationUpdated);
// Load saved locale preference
final prefs = await SharedPreferences.getInstance();
final savedLocale = prefs.getString('locale');
if (savedLocale != null) {
setState(() => _currentLocale = Locale(savedLocale));
}
}
void _onTranslationUpdated(String locale) {
// Trigger rebuild when translations update
if (locale == _currentLocale.languageCode ||
locale == _currentLocale.toString()) {
setState(() => _translationVersion++);
}
}
OTATranslation _loadBundledEnglish() {
return OTATranslation(
locale: 'en',
version: 1,
updatedAt: DateTime(2025, 1, 1),
translations: {
'app_title': 'My App',
'welcome_message': 'Welcome!',
'login_button': 'Log In',
'logout_button': 'Log Out',
// ... more translations
},
);
}
OTATranslation _loadBundledSpanish() {
return OTATranslation(
locale: 'es',
version: 1,
updatedAt: DateTime(2025, 1, 1),
translations: {
'app_title': 'Mi App',
'welcome_message': '¡Bienvenido!',
'login_button': 'Iniciar Sesión',
'logout_button': 'Cerrar Sesión',
// ... more translations
},
);
}
OTATranslation _loadBundledGerman() {
return OTATranslation(
locale: 'de',
version: 1,
updatedAt: DateTime(2025, 1, 1),
translations: {
'app_title': 'Meine App',
'welcome_message': 'Willkommen!',
'login_button': 'Anmelden',
'logout_button': 'Abmelden',
// ... more translations
},
);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
key: ValueKey(_translationVersion), // Force rebuild on translation update
locale: _currentLocale,
supportedLocales: [
Locale('en'),
Locale('es'),
Locale('de'),
// Add more as they become available
],
localizationsDelegates: [
OTALocalizationsDelegate(_localizationService),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: HomePage(),
);
}
@override
void dispose() {
_localizationService.dispose();
super.dispose();
}
}
Server-Side Implementation
Translation API Endpoints
// Example using shelf for Dart backend
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
class TranslationApi {
final TranslationRepository _repository;
TranslationApi(this._repository);
Router get router {
final router = Router();
// Get version manifest
router.get('/translations/manifest.json', _getManifest);
// Get translations for locale
router.get('/translations/<locale>.json', _getTranslations);
// Update translations (admin only)
router.put('/translations/<locale>.json', _updateTranslations);
return router;
}
Future<Response> _getManifest(Request request) async {
final versions = await _repository.getAllVersions();
return Response.ok(
jsonEncode({
'versions': versions,
'updatedAt': DateTime.now().toIso8601String(),
}),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'max-age=300', // Cache for 5 minutes
},
);
}
Future<Response> _getTranslations(Request request, String locale) async {
final translation = await _repository.getTranslation(locale);
if (translation == null) {
return Response.notFound('Translation not found');
}
final etag = 'v${translation.version}';
final ifNoneMatch = request.headers['if-none-match'];
// Return 304 if client has current version
if (ifNoneMatch == etag) {
return Response(304);
}
return Response.ok(
jsonEncode(translation.toJson()),
headers: {
'Content-Type': 'application/json',
'ETag': etag,
'Cache-Control': 'max-age=3600', // Cache for 1 hour
},
);
}
Future<Response> _updateTranslations(Request request, String locale) async {
// Verify admin authentication
if (!_isAdmin(request)) {
return Response.forbidden('Admin access required');
}
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
final translation = OTATranslation(
locale: locale,
version: (await _repository.getVersion(locale) ?? 0) + 1,
updatedAt: DateTime.now(),
translations: Map<String, String>.from(json['translations'] as Map),
);
await _repository.saveTranslation(translation);
return Response.ok(jsonEncode({'success': true, 'version': translation.version}));
}
bool _isAdmin(Request request) {
// Implement admin authentication
return true;
}
}
CDN Configuration
# Example CloudFront configuration
CloudFrontDistribution:
Origins:
- DomainName: translations-bucket.s3.amazonaws.com
S3OriginConfig:
OriginAccessIdentity: origin-access-identity/cloudfront/XXXXX
DefaultCacheBehavior:
TargetOriginId: S3Origin
ViewerProtocolPolicy: redirect-to-https
CachePolicyId: !Ref TranslationCachePolicy
Compress: true
TranslationCachePolicy:
MinTTL: 300 # 5 minutes minimum
MaxTTL: 86400 # 24 hours maximum
DefaultTTL: 3600 # 1 hour default
HeadersConfig:
HeaderBehavior: whitelist
Headers:
- Accept-Language
Delta Updates
Efficient Partial Updates
class DeltaUpdateService {
final OTALocalizationService _baseService;
DeltaUpdateService(this._baseService);
Future<void> applyDelta(String locale, Map<String, dynamic> delta) async {
final currentTranslation = _baseService.getTranslation(locale);
if (currentTranslation == null) return;
final added = Map<String, String>.from(delta['added'] ?? {});
final updated = Map<String, String>.from(delta['updated'] ?? {});
final removed = List<String>.from(delta['removed'] ?? []);
final newTranslations = Map<String, String>.from(
currentTranslation.translations,
);
// Apply changes
newTranslations.addAll(added);
newTranslations.addAll(updated);
for (final key in removed) {
newTranslations.remove(key);
}
final newVersion = delta['version'] as int;
final updatedTranslation = OTATranslation(
locale: locale,
version: newVersion,
updatedAt: DateTime.now(),
translations: newTranslations,
);
// Save updated translation
await _baseService._saveTranslation(updatedTranslation);
_baseService._cache[locale] = updatedTranslation;
_baseService._notifyListeners(locale);
}
Future<void> checkForDeltaUpdates(String locale) async {
final currentVersion = _baseService.getVersion(locale) ?? 0;
try {
final response = await http.get(
Uri.parse(
'${_baseService.baseUrl}/translations/$locale/delta?from=$currentVersion',
),
);
if (response.statusCode == 200) {
final delta = jsonDecode(response.body) as Map<String, dynamic>;
await applyDelta(locale, delta);
} else if (response.statusCode == 204) {
// No updates available
} else if (response.statusCode == 410) {
// Delta not available, full download required
await _baseService._downloadTranslation(locale);
}
} catch (e) {
print('Error checking for delta updates: $e');
}
}
}
Testing OTA Localization
Unit Tests
void main() {
group('OTALocalizationService', () {
late OTALocalizationService service;
late MockHttpClient mockClient;
setUp(() {
mockClient = MockHttpClient();
service = OTALocalizationService(
baseUrl: 'https://api.example.com',
bundledTranslations: {
'en': OTATranslation(
locale: 'en',
version: 1,
updatedAt: DateTime.now(),
translations: {'hello': 'Hello'},
),
},
);
});
test('returns bundled translation when no cache', () async {
await service.initialize();
expect(service.translate('hello', 'en'), 'Hello');
});
test('updates cache when server has newer version', () async {
when(mockClient.get(any)).thenAnswer((_) async {
return http.Response(
jsonEncode({
'versions': {'en': 2},
}),
200,
);
});
await service._checkForUpdates();
// Should trigger download of new version
verify(mockClient.get(
Uri.parse('https://api.example.com/translations/en.json'),
)).called(1);
});
test('falls back to English for missing keys', () async {
await service.initialize();
expect(
service.translate('missing_key', 'es', defaultValue: 'fallback'),
'fallback',
);
});
test('notifies listeners on translation update', () async {
await service.initialize();
String? updatedLocale;
service.addListener((locale) => updatedLocale = locale);
service._notifyListeners('en');
expect(updatedLocale, 'en');
});
});
}
Best Practices
OTA Localization Checklist
- Always bundle fallback translations - App must work offline
- Version translations - Track what version users have
- Use ETags/conditional requests - Reduce bandwidth
- Cache aggressively - Store on disk for offline use
- Handle errors gracefully - Never break app if update fails
- Test update flow - Verify hot reload works correctly
- Monitor analytics - Track version adoption
Security Considerations
- Sign translation payloads to prevent tampering
- Use HTTPS for all translation requests
- Validate JSON structure before parsing
- Sanitize translations to prevent XSS
Conclusion
OTA localization gives you the flexibility to update translations instantly without app store releases. By combining bundled fallback translations with server-side updates, you get the best of both worlds: reliability and agility. Implement proper caching, versioning, and error handling to ensure a smooth experience.