diff --git a/lib/sdk_flutter.dart b/lib/sdk_flutter.dart index 9c83490..ec46d0f 100644 --- a/lib/sdk_flutter.dart +++ b/lib/sdk_flutter.dart @@ -20,13 +20,39 @@ export 'src/types.dart'; class DiditSdk { static Map _verificationConfig(DiditConfig? config) { final resolvedConfig = config?.toMap() ?? {}; - resolvedConfig.putIfAbsent( - 'languageCode', - () => WidgetsBinding.instance.platformDispatcher.locale.languageCode, - ); + resolvedConfig.putIfAbsent('languageCode', _deviceLanguageCode); return resolvedConfig; } + /// The device locale as the native SDK names it. + /// + /// The native SDKs distinguish Simplified and Traditional Chinese and + /// Brazilian Portuguese with their own codes, so the two-letter language + /// alone would send those users to the generic variant. + static String _deviceLanguageCode() { + final locale = WidgetsBinding.instance.platformDispatcher.locale; + final country = locale.countryCode?.toUpperCase(); + final script = locale.scriptCode?.toLowerCase(); + + switch (locale.languageCode) { + case 'zh': + if (script == 'hant' || + country == 'TW' || + country == 'HK' || + country == 'MO') { + return 'zh-TW'; + } + if (script == 'hans' || country == 'CN' || country == 'SG') { + return 'zh-CN'; + } + return 'zh'; + case 'pt': + return country == 'BR' ? 'pt-BR' : 'pt'; + default: + return locale.languageCode; + } + } + /// Start identity verification with an existing session token. /// /// This launches the native Didit verification UI as a full-screen modal. diff --git a/packages/didit_sdk_autodetection/lib/sdk_flutter.dart b/packages/didit_sdk_autodetection/lib/sdk_flutter.dart index 9c83490..ec46d0f 100644 --- a/packages/didit_sdk_autodetection/lib/sdk_flutter.dart +++ b/packages/didit_sdk_autodetection/lib/sdk_flutter.dart @@ -20,13 +20,39 @@ export 'src/types.dart'; class DiditSdk { static Map _verificationConfig(DiditConfig? config) { final resolvedConfig = config?.toMap() ?? {}; - resolvedConfig.putIfAbsent( - 'languageCode', - () => WidgetsBinding.instance.platformDispatcher.locale.languageCode, - ); + resolvedConfig.putIfAbsent('languageCode', _deviceLanguageCode); return resolvedConfig; } + /// The device locale as the native SDK names it. + /// + /// The native SDKs distinguish Simplified and Traditional Chinese and + /// Brazilian Portuguese with their own codes, so the two-letter language + /// alone would send those users to the generic variant. + static String _deviceLanguageCode() { + final locale = WidgetsBinding.instance.platformDispatcher.locale; + final country = locale.countryCode?.toUpperCase(); + final script = locale.scriptCode?.toLowerCase(); + + switch (locale.languageCode) { + case 'zh': + if (script == 'hant' || + country == 'TW' || + country == 'HK' || + country == 'MO') { + return 'zh-TW'; + } + if (script == 'hans' || country == 'CN' || country == 'SG') { + return 'zh-CN'; + } + return 'zh'; + case 'pt': + return country == 'BR' ? 'pt-BR' : 'pt'; + default: + return locale.languageCode; + } + } + /// Start identity verification with an existing session token. /// /// This launches the native Didit verification UI as a full-screen modal. diff --git a/packages/didit_sdk_core/lib/sdk_flutter.dart b/packages/didit_sdk_core/lib/sdk_flutter.dart index 9c83490..ec46d0f 100644 --- a/packages/didit_sdk_core/lib/sdk_flutter.dart +++ b/packages/didit_sdk_core/lib/sdk_flutter.dart @@ -20,13 +20,39 @@ export 'src/types.dart'; class DiditSdk { static Map _verificationConfig(DiditConfig? config) { final resolvedConfig = config?.toMap() ?? {}; - resolvedConfig.putIfAbsent( - 'languageCode', - () => WidgetsBinding.instance.platformDispatcher.locale.languageCode, - ); + resolvedConfig.putIfAbsent('languageCode', _deviceLanguageCode); return resolvedConfig; } + /// The device locale as the native SDK names it. + /// + /// The native SDKs distinguish Simplified and Traditional Chinese and + /// Brazilian Portuguese with their own codes, so the two-letter language + /// alone would send those users to the generic variant. + static String _deviceLanguageCode() { + final locale = WidgetsBinding.instance.platformDispatcher.locale; + final country = locale.countryCode?.toUpperCase(); + final script = locale.scriptCode?.toLowerCase(); + + switch (locale.languageCode) { + case 'zh': + if (script == 'hant' || + country == 'TW' || + country == 'HK' || + country == 'MO') { + return 'zh-TW'; + } + if (script == 'hans' || country == 'CN' || country == 'SG') { + return 'zh-CN'; + } + return 'zh'; + case 'pt': + return country == 'BR' ? 'pt-BR' : 'pt'; + default: + return locale.languageCode; + } + } + /// Start identity verification with an existing session token. /// /// This launches the native Didit verification UI as a full-screen modal. diff --git a/packages/didit_sdk_nfc/lib/sdk_flutter.dart b/packages/didit_sdk_nfc/lib/sdk_flutter.dart index 9c83490..ec46d0f 100644 --- a/packages/didit_sdk_nfc/lib/sdk_flutter.dart +++ b/packages/didit_sdk_nfc/lib/sdk_flutter.dart @@ -20,13 +20,39 @@ export 'src/types.dart'; class DiditSdk { static Map _verificationConfig(DiditConfig? config) { final resolvedConfig = config?.toMap() ?? {}; - resolvedConfig.putIfAbsent( - 'languageCode', - () => WidgetsBinding.instance.platformDispatcher.locale.languageCode, - ); + resolvedConfig.putIfAbsent('languageCode', _deviceLanguageCode); return resolvedConfig; } + /// The device locale as the native SDK names it. + /// + /// The native SDKs distinguish Simplified and Traditional Chinese and + /// Brazilian Portuguese with their own codes, so the two-letter language + /// alone would send those users to the generic variant. + static String _deviceLanguageCode() { + final locale = WidgetsBinding.instance.platformDispatcher.locale; + final country = locale.countryCode?.toUpperCase(); + final script = locale.scriptCode?.toLowerCase(); + + switch (locale.languageCode) { + case 'zh': + if (script == 'hant' || + country == 'TW' || + country == 'HK' || + country == 'MO') { + return 'zh-TW'; + } + if (script == 'hans' || country == 'CN' || country == 'SG') { + return 'zh-CN'; + } + return 'zh'; + case 'pt': + return country == 'BR' ? 'pt-BR' : 'pt'; + default: + return locale.languageCode; + } + } + /// Start identity verification with an existing session token. /// /// This launches the native Didit verification UI as a full-screen modal. diff --git a/test/wrapper_contract_test.dart b/test/wrapper_contract_test.dart new file mode 100644 index 0000000..b1c7d3b --- /dev/null +++ b/test/wrapper_contract_test.dart @@ -0,0 +1,372 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/widgets.dart'; +import 'package:didit_sdk/sdk_flutter.dart'; +import 'package:didit_sdk/sdk_flutter_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +/// A platform double that records what the Dart wrapper forwards and returns +/// the map the native bridge would send back. +class RecordingSdkPlatform + with MockPlatformInterfaceMixin + implements SdkFlutterPlatform { + Map? verificationConfig; + Map? workflowConfig; + Map? transactionOptions; + Map? getTransactionOptions; + Map? transactionPayload; + String? workflowId; + String? vendorData; + Map verificationResponse = {}; + Map workflowResponse = {}; + void Function(String callId, Map result)? + transactionUpdateHandler; + + @override + Future> startVerification( + String token, + Map? config, + ) async { + verificationConfig = config; + return verificationResponse; + } + + @override + Future> startVerificationWithWorkflow( + String workflowId, + String? vendorData, + Map? config, + ) async { + this.workflowId = workflowId; + this.vendorData = vendorData; + workflowConfig = config; + return workflowResponse; + } + + @override + Future> submitTransaction( + String transactionToken, + Map transaction, + Map options, + ) async { + transactionPayload = transaction; + transactionOptions = options; + return { + 'transactionId': 'txn-1', + 'status': 'AWAITING_USER', + 'actionRequired': { + 'type': 'wallet_ownership', + 'widgetSessionId': 'widget-1', + }, + }; + } + + @override + Future> getTransaction( + String transactionToken, + String transactionId, + Map options, + ) async { + getTransactionOptions = options; + return {'transactionId': transactionId}; + } + + @override + void setTransactionUpdateHandler( + void Function(String callId, Map result)? handler, + ) { + transactionUpdateHandler = handler; + } +} + +const _everyOption = DiditConfig( + languageCode: 'de', + showLanguageSelector: true, + fontFamily: 'Inter', + loggingEnabled: true, + showCloseButton: false, + showExitConfirmation: false, + closeOnComplete: true, + defaultDocumentCamera: CameraLens.front, + defaultLivenessCamera: CameraLens.back, + showDocumentCameraSwitchButton: false, + showLivenessCameraSwitchButton: false, +); + +const _everyOptionMap = { + 'languageCode': 'de', + 'showLanguageSelector': true, + 'fontFamily': 'Inter', + 'loggingEnabled': true, + 'showCloseButton': false, + 'showExitConfirmation': false, + 'closeOnComplete': true, + 'defaultDocumentCamera': 'front', + 'defaultLivenessCamera': 'back', + 'showDocumentCameraSwitchButton': false, + 'showLivenessCameraSwitchButton': false, +}; + +void main() { + final platform = RecordingSdkPlatform(); + + setUp(() { + SdkFlutterPlatform.instance = platform; + platform.verificationConfig = null; + platform.workflowConfig = null; + platform.transactionOptions = null; + platform.getTransactionOptions = null; + platform.transactionPayload = null; + platform.workflowId = null; + platform.vendorData = null; + platform.verificationResponse = {}; + platform.workflowResponse = {}; + }); + + group('configuration forwarding', () { + test('the token entry point forwards every documented option', () async { + await DiditSdk.startVerification('token', config: _everyOption); + + expect(platform.verificationConfig, _everyOptionMap); + }); + + test('the workflow entry point forwards every documented option', () async { + await DiditSdk.startVerificationWithWorkflow( + 'workflow', + vendorData: 'user-1', + config: _everyOption, + ); + + expect(platform.workflowConfig, _everyOptionMap); + expect(platform.workflowId, 'workflow'); + expect(platform.vendorData, 'user-1'); + }); + + test('unset native defaults are omitted and the documented defaults ' + 'are sent explicitly', () async { + await DiditSdk.startVerification('token', config: const DiditConfig()); + + final config = platform.verificationConfig!; + expect(config['showLanguageSelector'], false); + expect(config['loggingEnabled'], false); + expect(config['showCloseButton'], true); + expect(config['showExitConfirmation'], true); + expect(config['closeOnComplete'], false); + expect(config['showDocumentCameraSwitchButton'], true); + expect(config['showLivenessCameraSwitchButton'], true); + expect(config.containsKey('fontFamily'), false); + expect(config.containsKey('defaultDocumentCamera'), false); + expect(config.containsKey('defaultLivenessCamera'), false); + }); + + testWidgets('the device locale keeps the native language variants', ( + tester, + ) async { + addTearDown(tester.binding.platformDispatcher.clearLocaleTestValue); + const cases = <(Locale, String)>[ + (Locale('zh', 'TW'), 'zh-TW'), + ( + Locale.fromSubtags( + languageCode: 'zh', + scriptCode: 'Hant', + countryCode: 'HK', + ), + 'zh-TW', + ), + (Locale('zh', 'CN'), 'zh-CN'), + (Locale('zh', 'SG'), 'zh-CN'), + (Locale('zh'), 'zh'), + (Locale('pt', 'BR'), 'pt-BR'), + (Locale('pt', 'PT'), 'pt'), + ]; + + for (final (locale, expected) in cases) { + tester.binding.platformDispatcher.localeTestValue = locale; + + await DiditSdk.startVerification('token'); + + expect( + platform.verificationConfig?['languageCode'], + expected, + reason: '$locale', + ); + } + }); + + testWidgets('an explicit language wins over the device locale', ( + tester, + ) async { + tester.binding.platformDispatcher.localeTestValue = const Locale('es'); + addTearDown(tester.binding.platformDispatcher.clearLocaleTestValue); + + await DiditSdk.startVerification( + 'token', + config: const DiditConfig(languageCode: 'he'), + ); + + expect(platform.verificationConfig?['languageCode'], 'he'); + }); + }); + + group('verification callbacks', () { + test('a completed session reaches Dart with its id and status', () async { + platform.verificationResponse = { + 'type': 'completed', + 'sessionId': 'session-1', + 'status': 'Approved', + }; + + final result = await DiditSdk.startVerification('token'); + + expect(result, isA()); + final completed = result as VerificationCompleted; + expect(completed.session.sessionId, 'session-1'); + expect(completed.session.status, VerificationStatus.approved); + }); + + test('a cancelled session with a session reaches Dart', () async { + platform.workflowResponse = { + 'type': 'cancelled', + 'sessionId': 'session-2', + 'status': 'Pending', + }; + + final result = await DiditSdk.startVerificationWithWorkflow('workflow'); + + expect(result, isA()); + final cancelled = result as VerificationCancelled; + expect(cancelled.session?.sessionId, 'session-2'); + expect(cancelled.session?.status, VerificationStatus.pending); + }); + + test('a cancelled session without a session reaches Dart', () async { + platform.verificationResponse = {'type': 'cancelled'}; + + final result = await DiditSdk.startVerification('token'); + + expect(result, isA()); + expect((result as VerificationCancelled).session, isNull); + }); + + test('every native error type reaches the matching Dart enum', () async { + const errorTypes = { + 'sessionExpired': VerificationErrorType.sessionExpired, + 'networkError': VerificationErrorType.networkError, + 'cameraAccessDenied': VerificationErrorType.cameraAccessDenied, + 'notInitialized': VerificationErrorType.notInitialized, + 'apiError': VerificationErrorType.apiError, + 'retryBlocked': VerificationErrorType.retryBlocked, + }; + + for (final entry in errorTypes.entries) { + platform.verificationResponse = { + 'type': 'failed', + 'errorType': entry.key, + 'errorMessage': 'message for ${entry.key}', + 'sessionId': 'session-3', + 'status': 'Declined', + }; + + final result = await DiditSdk.startVerification('token'); + + expect(result, isA()); + final failed = result as VerificationFailed; + expect(failed.error.type, entry.value); + expect(failed.error.message, 'message for ${entry.key}'); + expect(failed.session?.sessionId, 'session-3'); + expect(failed.session?.status, VerificationStatus.declined); + } + }); + + test( + 'an unknown error type and missing message fall back safely', + () async { + platform.verificationResponse = { + 'type': 'failed', + 'errorType': 'somethingNew', + }; + + final result = await DiditSdk.startVerification('token'); + + expect(result, isA()); + final failed = result as VerificationFailed; + expect(failed.error.type, VerificationErrorType.unknown); + expect(failed.error.message, isNotEmpty); + }, + ); + }); + + group('transaction callbacks', () { + test('onTransactionUpdated is delivered only to its own call id', () async { + DiditTransactionResult? updated; + await DiditSdk.submitTransaction( + 'token', + const DiditTransactionPayload(txnId: 'txn-1'), + options: DiditTransactionOptions( + onTransactionUpdated: (result) => updated = result, + ), + ); + + final callId = platform.transactionOptions?['callId'] as String; + + platform.transactionUpdateHandler?.call('other-call', { + 'transactionId': 'txn-other', + }); + expect(updated, isNull); + + platform.transactionUpdateHandler?.call(callId, { + 'transactionId': 'txn-1', + 'status': 'APPROVED', + }); + expect(updated?.transactionId, 'txn-1'); + expect(updated?.status, 'APPROVED'); + + platform.transactionUpdateHandler?.call(callId, { + 'transactionId': 'txn-1', + 'status': 'DECLINED', + }); + expect(updated?.status, 'APPROVED'); + }); + + test( + 'the transaction payload, base URL and auto-launch flag are forwarded', + () async { + await DiditSdk.submitTransaction( + 'token', + const DiditTransactionPayload( + txnId: 'txn-2', + type: 'crypto', + includeCryptoScreening: true, + ), + options: const DiditTransactionOptions( + baseUrl: 'https://staging.example', + autoLaunchAction: false, + ), + ); + + expect(platform.transactionPayload?['txnId'], 'txn-2'); + expect(platform.transactionPayload?['type'], 'crypto'); + expect(platform.transactionPayload?['includeCryptoScreening'], true); + expect( + platform.transactionOptions?['baseUrl'], + 'https://staging.example', + ); + expect(platform.transactionOptions?['autoLaunchAction'], false); + }, + ); + + test('getTransaction forwards the base URL override', () async { + await DiditSdk.getTransaction( + 'token', + 'txn-3', + options: const DiditTransactionOptions( + baseUrl: 'https://staging.example', + ), + ); + + expect( + platform.getTransactionOptions?['baseUrl'], + 'https://staging.example', + ); + }); + }); +}