Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions app/lib/backend/http/api/users.dart
Original file line number Diff line number Diff line change
Expand Up @@ -339,18 +339,22 @@ Future<String?> getUserPrimaryLanguage() async {
}
}

Future<bool> setUserPrimaryLanguage(String languageCode) async {
/// Returns the server-decided `single_language_mode` on success, null on
/// failure. The server derives eligibility from the live STT capability
/// policy (#10022) — clients must not re-decide it locally.
Future<bool?> setUserPrimaryLanguage(String languageCode) async {
var response = await makeApiCall(
url: '${Env.apiBaseUrl}v1/users/language',
headers: {},
method: 'PATCH',
body: jsonEncode({'language': languageCode}),
);
if (response == null) return false;
if (response == null) return null;
Logger.debug('setUserPrimaryLanguage response: ${response.body}');
if (response.statusCode != 200) return false;
if (response.statusCode != 200) return null;
final data = wire.GeneratedUserLanguageUpdateResponse.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
return data.status == 'ok';
if (data.status != 'ok') return null;
return data.singleLanguageMode;
}

Future<bool> setPreferredSummarizationAppServer(String appId) async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,21 @@ class _PrimaryLanguageWidgetState extends State<PrimaryLanguageWidget> {
// Update the user's primary language
final homeProvider = Provider.of<HomeProvider>(context, listen: false);
final userProvider = Provider.of<UserProvider>(context, listen: false);
await homeProvider.updateUserPrimaryLanguage(selectedLanguage!, userProvider: userProvider);
final success = await homeProvider.updateUserPrimaryLanguage(
selectedLanguage!,
userProvider: userProvider,
);

if (!context.mounted) return;
if (!success) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.failedToSetLanguage),
backgroundColor: Colors.red,
),
);
return;
}

widget.goNext();
},
Expand Down
85 changes: 5 additions & 80 deletions app/lib/providers/home_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,81 +9,6 @@ import 'package:omi/pages/settings/language_selection_dialog.dart';
import 'package:omi/providers/user_provider.dart';
import 'package:omi/utils/logger.dart';

/// Base language codes eligible for live multi-language auto-detection.
/// Mirrors the backend live STT capability policy
/// (backend/config/stt_provider_policy.py MODULATE_SUPPORTED_LANGUAGES, #10022);
/// regional variants ("pt-BR") are normalized to their base code before lookup.
const multiLanguageSupported = {
'multi',
'af',
'ar',
'az',
'be',
'bg',
'bn',
'bs',
'ca',
'cs',
'cy',
'da',
'de',
'el',
'en',
'es',
'et',
'eu',
'fa',
'fi',
'fr',
'gl',
'gu',
'he',
'hi',
'hr',
'hu',
'id',
'it',
'ja',
'kk',
'kn',
'ko',
'lt',
'lv',
'mk',
'ml',
'mr',
'ms',
'nl',
'no',
'pa',
'pl',
'pt',
'ro',
'ru',
'sk',
'sl',
'sq',
'sr',
'sv',
'sw',
'ta',
'te',
'th',
'tl',
'tr',
'uk',
'ur',
'vi',
'zh',
};

/// Whether a (possibly regional) language code may enter live multi-language
/// mode, matching the backend's normalized policy check.
bool supportsLiveMultilingualMode(String languageCode) {
final base = languageCode.split('-').first.split('_').first.toLowerCase();
return multiLanguageSupported.contains(base);
}

class HomeProvider extends ChangeNotifier {
int _sessionGeneration = 0;
int selectedIndex = 0;
Expand Down Expand Up @@ -316,17 +241,17 @@ class HomeProvider extends ChangeNotifier {

Future<bool> updateUserPrimaryLanguage(String languageCode, {UserProvider? userProvider}) async {
try {
final success = await setUserPrimaryLanguage(languageCode);
if (success) {
final serverSingleLanguageMode = await setUserPrimaryLanguage(languageCode);
if (serverSingleLanguageMode != null) {
userPrimaryLanguage = languageCode;
hasSetPrimaryLanguage = true;
SharedPreferencesUtil().userPrimaryLanguage = languageCode;
SharedPreferencesUtil().hasSetPrimaryLanguage = true;
PlatformManager.instance.analytics.setUserAttribute('Primary Language', languageCode);

// Backend auto-sets single_language_mode — sync local state to match
final singleLanguageMode = !supportsLiveMultilingualMode(languageCode);
userProvider?.updateSingleLanguageModeLocally(singleLanguageMode);
// The server decides single_language_mode from the live STT policy
// (#10022); local state mirrors its response, never a client-side list.
userProvider?.updateSingleLanguageModeLocally(serverSingleLanguageMode);

notifyListeners();
return true;
Expand Down
29 changes: 29 additions & 0 deletions app/test/unit/language_update_response_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import 'package:flutter_test/flutter_test.dart';

import 'package:omi/backend/schema/gen/users_wire.g.dart';

/// #10022 follow-up: the client no longer mirrors the backend's multi-language
/// eligibility list — it trusts the server's `single_language_mode` in the
/// PATCH /v1/users/language response. This pins the wire contract that makes
/// that trust safe.
void main() {
test('language update response carries the server-decided mode', () {
final multilingual = GeneratedUserLanguageUpdateResponse.fromJson(
{'status': 'ok', 'single_language_mode': false},
);
expect(multilingual.status, 'ok');
expect(multilingual.singleLanguageMode, isFalse);

final single = GeneratedUserLanguageUpdateResponse.fromJson(
{'status': 'ok', 'single_language_mode': true},
);
expect(single.singleLanguageMode, isTrue);
});

test('a response without the mode field fails loudly instead of defaulting', () {
expect(
() => GeneratedUserLanguageUpdateResponse.fromJson({'status': 'ok'}),
throwsA(isA<Object>()),
);
});
}
26 changes: 0 additions & 26 deletions app/test/unit/multilingual_eligibility_test.dart

This file was deleted.

Loading