diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e530c5..c988a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Optimizely Flutter SDK Changelog +## 3.3.0 +October 29th, 2025 + +### New Feature +* Android custom logger support added ([#90](https://github.com/optimizely/optimizely-flutter-sdk/pull/90)) + +## 3.2.0 +October 24th, 2025 + +### New Feature +* Swift custom logger support added ([#88](https://github.com/optimizely/optimizely-flutter-sdk/pull/88)) + +## 3.1.0 +October 9th, 2025 + +This minor release added the following support: +* Android 15 support ([#84](https://github.com/optimizely/optimizely-flutter-sdk/pull/84)) +* Update AGP version to 8.7.0 +* Update gradle version to 8.10.2 +* Update kotlin version to 2.1.0 + ## 3.0.1 Jun 4th, 2025 diff --git a/README.md b/README.md index 1b125d5..3f7d5c8 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Other Flutter platforms are not currently supported by this SDK. To add the flutter-sdk to your project dependencies, include the following in your app's pubspec.yaml: ``` - optimizely_flutter_sdk: ^3.0.1 + optimizely_flutter_sdk: ^3.3.0 ``` Then run diff --git a/android/build.gradle b/android/build.gradle index 5d4e3ff..7d1edec 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -24,19 +24,22 @@ rootProject.allprojects { ext { - compile_sdk_version = 32 - build_tools_version = "30.0.3" + compile_sdk_version = 35 min_sdk_version = 21 - target_sdk_version = 29 } android { - compileSdkVersion compile_sdk_version - buildToolsVersion build_tools_version + namespace 'com.optimizely.optimizely_flutter_sdk' + compileSdkVersion rootProject.hasProperty('flutter.compileSdkVersion') + ? rootProject.flutter.compileSdkVersion.toInteger() + : compile_sdk_version + + buildFeatures { + buildConfig true + } defaultConfig { minSdkVersion min_sdk_version - targetSdkVersion target_sdk_version versionCode 1 versionName version_name buildConfigField "String", "CLIENT_VERSION", "\"$version_name\"" @@ -53,14 +56,6 @@ android { packagingOptions { exclude 'androidsupportmultidexversion.txt' } - - buildTypes { - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - } - } - } dependencies { @@ -73,9 +68,9 @@ dependencies { implementation 'com.github.tony19:logback-android:3.0.0' implementation 'org.slf4j:slf4j-api:2.0.7' - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.10" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.0" implementation "com.optimizely.ab:android-sdk:5.0.1" - implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.4' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2' implementation ('com.google.guava:guava:19.0') { exclude group:'com.google.guava', module:'listenablefuture' } diff --git a/android/proguard-rules.txt b/android/proguard-rules.txt index 1564ced..e9d6827 100644 --- a/android/proguard-rules.txt +++ b/android/proguard-rules.txt @@ -12,4 +12,10 @@ -keep class com.fasterxml.jackson.** {*;} # Logback -keep class ch.qos.** { *; } + +# Mail classes (Logback SMTP appender) +-dontwarn javax.mail.** +-dontwarn javax.mail.internet.** +-dontwarn javax.activation.** + ##---------------End: proguard configuration ---------- diff --git a/android/src/main/assets/logback.xml b/android/src/main/assets/logback.xml index 8e6e0d6..7f531a8 100644 --- a/android/src/main/assets/logback.xml +++ b/android/src/main/assets/logback.xml @@ -1,18 +1 @@ - - - - Optimizely - - - %msg - - - - - - - + diff --git a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/FlutterLogbackAppender.java b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/FlutterLogbackAppender.java new file mode 100644 index 0000000..2252cdd --- /dev/null +++ b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/FlutterLogbackAppender.java @@ -0,0 +1,62 @@ +package com.optimizely.optimizely_flutter_sdk; +import com.optimizely.optimizely_flutter_sdk.helper_classes.Constants; + +import android.os.Handler; +import android.os.Looper; + +import java.util.HashMap; +import java.util.Map; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.AppenderBase; +import io.flutter.plugin.common.MethodChannel; + +public class FlutterLogbackAppender extends AppenderBase { + + public static final String CHANNEL_NAME = "optimizely_flutter_sdk_logger"; + public static MethodChannel channel; + private static final Handler mainThreadHandler = new Handler(Looper.getMainLooper()); + + public static void setChannel(MethodChannel channel) { + FlutterLogbackAppender.channel = channel; + } + + @Override + protected void append(ILoggingEvent event) { + if (channel == null) { + return; + } + + String message = event.getFormattedMessage(); + String level = event.getLevel().toString(); + int logLevel = convertLogLevel(level); + Map logData = new HashMap<>(); + logData.put("level", logLevel); + logData.put("message", message); + + mainThreadHandler.post(() -> { + if (channel != null) { + channel.invokeMethod("log", logData); + } + }); + } + + int convertLogLevel(String logLevel) { + if (logLevel == null || logLevel.isEmpty()) { + return 3; + } + + switch (logLevel.toUpperCase()) { + case "ERROR": + return 1; + case "WARN": + return 2; + case "INFO": + return 3; + case "DEBUG": + return 4; + default: + return 3; + } + } +} diff --git a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterClient.java b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterClient.java index cf85e3d..a4f6ce4 100644 --- a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterClient.java +++ b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterClient.java @@ -25,7 +25,6 @@ import com.optimizely.ab.UnknownEventTypeException; import com.optimizely.ab.android.event_handler.DefaultEventHandler; import com.optimizely.ab.android.sdk.OptimizelyClient; - import java.util.HashMap; import java.util.Map; @@ -187,6 +186,7 @@ protected void initializeOptimizely(@NonNull ArgumentsParser argumentsParser, @N if (enableVuid) { optimizelyManagerBuilder.withVuidEnabled(); } + OptimizelyManager optimizelyManager = optimizelyManagerBuilder.build(context); optimizelyManager.initialize(context, null, (OptimizelyClient client) -> { diff --git a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterSdkPlugin.java b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterSdkPlugin.java index 89f787c..5ca2d8e 100644 --- a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterSdkPlugin.java +++ b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterSdkPlugin.java @@ -32,10 +32,19 @@ import io.flutter.embedding.engine.plugins.activity.ActivityAware; +import org.slf4j.LoggerFactory; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.Appender; + + /** OptimizelyFlutterSdkPlugin */ public class OptimizelyFlutterSdkPlugin extends OptimizelyFlutterClient implements FlutterPlugin, ActivityAware, MethodCallHandler { public static MethodChannel channel; + private Appender flutterLogbackAppender; @Override public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { @@ -157,11 +166,32 @@ public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { channel = new MethodChannel(binding.getBinaryMessenger(), "optimizely_flutter_sdk"); channel.setMethodCallHandler(this); context = binding.getApplicationContext(); + + MethodChannel loggerChannel = new MethodChannel(binding.getBinaryMessenger(), FlutterLogbackAppender.CHANNEL_NAME); + FlutterLogbackAppender.setChannel(loggerChannel); + + // Add appender to logback + flutterLogbackAppender = new FlutterLogbackAppender(); + LoggerContext lc = (LoggerContext) LoggerFactory.getILoggerFactory(); + flutterLogbackAppender.setContext(lc); + flutterLogbackAppender.start(); + Logger rootLogger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); + rootLogger.setLevel(ch.qos.logback.classic.Level.ALL); + rootLogger.addAppender(flutterLogbackAppender); } @Override public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { channel.setMethodCallHandler(null); + // Stop and detach the appender + if (flutterLogbackAppender != null) { + Logger rootLogger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); + rootLogger.detachAppender(flutterLogbackAppender); + flutterLogbackAppender.stop(); + flutterLogbackAppender = null; + } + // Clean up the channel + FlutterLogbackAppender.setChannel(null); } @Override diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 415ec79..bd22a12 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -24,8 +24,9 @@ if (flutterVersionName == null) { } android { - compileSdkVersion 32 - ndkVersion flutter.ndkVersion + namespace "com.optimizely.optimizely_flutter_sdk_example" + + compileSdkVersion flutter.compileSdkVersion compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 @@ -37,8 +38,9 @@ android { applicationId "com.optimizely.optimizely_flutter_sdk_example" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. - minSdkVersion 21 - targetSdkVersion 32 + minSdkVersion flutter.minSdkVersion + targetSdkVersion flutter.targetSdkVersion + versionCode flutterVersionCode.toInteger() versionName flutterVersionName } diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index cc5527d..db18181 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle index 5710b01..97284d6 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -26,8 +26,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.2.1" apply false - id "org.jetbrains.kotlin.android" version "1.6.10" apply false + id "com.android.application" version "8.7.0" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false } include ":app" diff --git a/example/lib/custom_logger.dart b/example/lib/custom_logger.dart new file mode 100644 index 0000000..e031fff --- /dev/null +++ b/example/lib/custom_logger.dart @@ -0,0 +1,11 @@ +import 'package:optimizely_flutter_sdk/optimizely_flutter_sdk.dart'; +import 'package:flutter/foundation.dart'; + +class CustomLogger implements OptimizelyLogger { + @override + void log(OptimizelyLogLevel level, String message) { + if (kDebugMode) { + print('[OPTIMIZELY] ${level.name.toUpperCase()}: $message'); + } + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index e7db8fa..9b8f1eb 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'dart:async'; import 'dart:math'; import 'package:optimizely_flutter_sdk/optimizely_flutter_sdk.dart'; +import 'package:optimizely_flutter_sdk_example/custom_logger.dart'; void main() { runApp(const MyApp()); @@ -28,16 +29,20 @@ class _MyAppState extends State { OptimizelyDecideOption.includeReasons, OptimizelyDecideOption.excludeVariables }; + final customLogger = CustomLogger(); + var flutterSDK = OptimizelyFlutterSdk("X9mZd2WDywaUL9hZXyh9A", datafilePeriodicDownloadInterval: 10 * 60, eventOptions: const EventOptions( batchSize: 1, timeInterval: 60, maxQueueSize: 10000), defaultLogLevel: OptimizelyLogLevel.debug, - defaultDecideOptions: defaultOptions); + defaultDecideOptions: defaultOptions, + logger: customLogger, + ); var response = await flutterSDK.initializeClient(); setState(() { - uiResponse = "Optimizely Client initialized: ${response.success} "; + uiResponse = "[Optimizely] Client initialized: ${response.success} "; }); var rng = Random(); diff --git a/ios/Classes/OptimizelyFlutterLogger.swift b/ios/Classes/OptimizelyFlutterLogger.swift new file mode 100644 index 0000000..7b9217a --- /dev/null +++ b/ios/Classes/OptimizelyFlutterLogger.swift @@ -0,0 +1,39 @@ +import Flutter +import Optimizely + +public class OptimizelyFlutterLogger: NSObject, OPTLogger { + static var LOGGER_CHANNEL: String = "optimizely_flutter_sdk_logger"; + + public static var logLevel: OptimizelyLogLevel = .info + + private static var loggerChannel: FlutterMethodChannel? + + public required override init() { + super.init() + } + + public static func setChannel(_ channel: FlutterMethodChannel) { + loggerChannel = channel + } + + public func log(level: OptimizelyLogLevel, message: String) { + // Early return if level check fails + guard level.rawValue <= OptimizelyFlutterLogger.logLevel.rawValue else { + return + } + + // Ensure we have a valid channel + guard let channel = Self.loggerChannel else { + print("[OptimizelyFlutterLogger] ERROR: No logger channel available!") + return + } + + // https://docs.flutter.dev/platform-integration/platform-channels#jumping-to-the-main-thread-in-ios + DispatchQueue.main.async { + channel.invokeMethod("log", arguments: [ + "level": level.rawValue, + "message": message + ]) + } + } +} diff --git a/ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift b/ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift index 7c093c4..be81576 100644 --- a/ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift +++ b/ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift @@ -41,6 +41,14 @@ public class SwiftOptimizelyFlutterSdkPlugin: NSObject, FlutterPlugin { channel = FlutterMethodChannel(name: "optimizely_flutter_sdk", binaryMessenger: registrar.messenger()) let instance = SwiftOptimizelyFlutterSdkPlugin() registrar.addMethodCallDelegate(instance, channel: channel) + + // Separate logger channel for outgoing log calls + let taskQueue = registrar.messenger().makeBackgroundTaskQueue?() + let loggerChannel = FlutterMethodChannel(name: OptimizelyFlutterLogger.LOGGER_CHANNEL, + binaryMessenger: registrar.messenger(), + codec: FlutterStandardMethodCodec.sharedInstance(), + taskQueue: taskQueue) + OptimizelyFlutterLogger.setChannel(loggerChannel) } /// Part of FlutterPlugin protocol to handle communication with flutter sdk @@ -110,6 +118,7 @@ public class SwiftOptimizelyFlutterSdkPlugin: NSObject, FlutterPlugin { var defaultLogLevel = OptimizelyLogLevel.info if let logLevel = parameters[RequestParameterKey.defaultLogLevel] as? String { defaultLogLevel = Utils.getDefaultLogLevel(logLevel) + OptimizelyFlutterLogger.logLevel = defaultLogLevel } // SDK Settings Default Values @@ -163,9 +172,14 @@ public class SwiftOptimizelyFlutterSdkPlugin: NSObject, FlutterPlugin { notificationIdsTracker.removeValue(forKey: sdkKey) optimizelyClientsTracker.removeValue(forKey: sdkKey) + // OptimizelyFlutterLogger bridges iOS logs to Flutter via Method Channel + // iOS SDK log → OptimizelyFlutterLogger → Flutter Method Channel → Flutter console + var logger: OPTLogger = OptimizelyFlutterLogger() + // Creating new instance let optimizelyInstance = OptimizelyClient( sdkKey:sdkKey, + logger:logger, eventDispatcher: eventDispatcher, datafileHandler: datafileHandler, periodicDownloadInterval: datafilePeriodicDownloadInterval, diff --git a/lib/optimizely_flutter_sdk.dart b/lib/optimizely_flutter_sdk.dart index 51dc9af..cc7d11d 100644 --- a/lib/optimizely_flutter_sdk.dart +++ b/lib/optimizely_flutter_sdk.dart @@ -28,6 +28,8 @@ import 'package:optimizely_flutter_sdk/src/data_objects/optimizely_config_respon import 'package:optimizely_flutter_sdk/src/optimizely_client_wrapper.dart'; import 'package:optimizely_flutter_sdk/src/user_context/optimizely_user_context.dart'; import 'package:optimizely_flutter_sdk/src/data_objects/log_level.dart'; +import 'package:optimizely_flutter_sdk/src/logger/flutter_logger.dart'; +import 'package:optimizely_flutter_sdk/src/logger/logger_bridge.dart'; export 'package:optimizely_flutter_sdk/src/optimizely_client_wrapper.dart' show ClientPlatform, ListenerType; @@ -53,6 +55,8 @@ export 'package:optimizely_flutter_sdk/src/data_objects/datafile_options.dart' show DatafileHostOptions; export 'package:optimizely_flutter_sdk/src/data_objects/log_level.dart' show OptimizelyLogLevel; +export 'package:optimizely_flutter_sdk/src/logger/flutter_logger.dart' + show OptimizelyLogger; /// The main client class for the Optimizely Flutter SDK. /// @@ -68,20 +72,29 @@ class OptimizelyFlutterSdk { final Set _defaultDecideOptions; final OptimizelyLogLevel _defaultLogLevel; final SDKSettings _sdkSettings; + static OptimizelyLogger? _customLogger; + /// Get the current logger + static OptimizelyLogger? get logger { + return _customLogger; + } OptimizelyFlutterSdk(this._sdkKey, - {EventOptions eventOptions = const EventOptions(), - int datafilePeriodicDownloadInterval = - 10 * 60, // Default time interval in seconds - Map datafileHostOptions = const {}, - Set defaultDecideOptions = const {}, - OptimizelyLogLevel defaultLogLevel = OptimizelyLogLevel.info, - SDKSettings sdkSettings = const SDKSettings()}) - : _eventOptions = eventOptions, - _datafilePeriodicDownloadInterval = datafilePeriodicDownloadInterval, - _datafileHostOptions = datafileHostOptions, - _defaultDecideOptions = defaultDecideOptions, - _defaultLogLevel = defaultLogLevel, - _sdkSettings = sdkSettings; + {EventOptions eventOptions = const EventOptions(), + int datafilePeriodicDownloadInterval = 10 * 60, + Map datafileHostOptions = const {}, + Set defaultDecideOptions = const {}, + OptimizelyLogLevel defaultLogLevel = OptimizelyLogLevel.info, + SDKSettings sdkSettings = const SDKSettings(), + OptimizelyLogger? logger}) + : _eventOptions = eventOptions, + _datafilePeriodicDownloadInterval = datafilePeriodicDownloadInterval, + _datafileHostOptions = datafileHostOptions, + _defaultDecideOptions = defaultDecideOptions, + _defaultLogLevel = defaultLogLevel, + _sdkSettings = sdkSettings { + // Set the logger if provided + _customLogger = logger ?? DefaultOptimizelyLogger(); + LoggerBridge.initialize(_customLogger); + } /// Starts Optimizely SDK (Synchronous) with provided sdkKey. Future initializeClient() async { @@ -92,7 +105,9 @@ class OptimizelyFlutterSdk { _datafileHostOptions, _defaultDecideOptions, _defaultLogLevel, - _sdkSettings); + _sdkSettings, + _customLogger + ); } /// Use the activate method to start an experiment. diff --git a/lib/package_info.dart b/lib/package_info.dart index 0bdd780..df427cd 100644 --- a/lib/package_info.dart +++ b/lib/package_info.dart @@ -3,5 +3,5 @@ class PackageInfo { static const String name = 'optimizely_flutter_sdk'; - static const String version = '3.0.1'; + static const String version = '3.3.0'; } diff --git a/lib/src/logger/flutter_logger.dart b/lib/src/logger/flutter_logger.dart new file mode 100644 index 0000000..f561ed9 --- /dev/null +++ b/lib/src/logger/flutter_logger.dart @@ -0,0 +1,29 @@ +import 'package:flutter/foundation.dart'; +import 'package:optimizely_flutter_sdk/src/data_objects/log_level.dart'; + +abstract class OptimizelyLogger { + /// Log a message at a certain level + void log(OptimizelyLogLevel level, String message); +} + +class DefaultOptimizelyLogger implements OptimizelyLogger { + @override + void log(OptimizelyLogLevel level, String message) { + if (kDebugMode) { + print('[OPTIMIZELY] [${level.name.toUpperCase()}]: $message'); + } + } +} + +/// App logger instance +final _appLogger = DefaultOptimizelyLogger(); + +/// App logging functions +void logError(String message) => + _appLogger.log(OptimizelyLogLevel.error, message); +void logWarning(String message) => + _appLogger.log(OptimizelyLogLevel.warning, message); +void logInfo(String message) => + _appLogger.log(OptimizelyLogLevel.info, message); +void logDebug(String message) => + _appLogger.log(OptimizelyLogLevel.debug, message); diff --git a/lib/src/logger/logger_bridge.dart b/lib/src/logger/logger_bridge.dart new file mode 100644 index 0000000..2e2271a --- /dev/null +++ b/lib/src/logger/logger_bridge.dart @@ -0,0 +1,98 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:optimizely_flutter_sdk/src/logger/flutter_logger.dart'; +import 'package:optimizely_flutter_sdk/optimizely_flutter_sdk.dart'; + +class LoggerBridge { + static const MethodChannel _loggerChannel = + MethodChannel('optimizely_flutter_sdk_logger'); + static OptimizelyLogger? _customLogger; + + /// Initialize the logger bridge to receive calls from native + static void initialize(OptimizelyLogger? logger) { + logInfo('[LoggerBridge] Initializing with logger: ${logger != null}'); + _customLogger = logger; + _loggerChannel.setMethodCallHandler(_handleMethodCall); + } + + /// Handle incoming method calls from native Swift/Java code + static Future _handleMethodCall(MethodCall call) async { + try { + switch (call.method) { + case 'log': + await _handleLogCall(call); + break; + default: + logWarning('[LoggerBridge] Unknown method call: ${call.method}'); + } + } catch (e) { + logError('[LoggerBridge] Error handling method call: $e'); + } + } + + /// Process the log call from Swift/Java + static Future _handleLogCall(MethodCall call) async { + try { + final args = Map.from(call.arguments ?? {}); + + final levelRawValue = args['level'] as int?; + final message = args['message'] as String?; + + if (levelRawValue == null || message == null) { + logError('[LoggerBridge] Warning: Missing level or message in log call'); + return; + } + + final level = _convertLogLevel(levelRawValue); + + if (_customLogger != null) { + _customLogger!.log(level, message); + } else { + logInfo('[Optimizely ${level.name}] $message'); + } + } catch (e) { + logError('[LoggerBridge] Error processing log call: $e'); + } + } + + /// Convert native log level to Flutter enum + static OptimizelyLogLevel _convertLogLevel(int rawValue) { + switch (rawValue) { + case 1: + return OptimizelyLogLevel.error; + case 2: + return OptimizelyLogLevel.warning; + case 3: + return OptimizelyLogLevel.info; + case 4: + return OptimizelyLogLevel.debug; + default: + return OptimizelyLogLevel.info; + } + } + + /// Expose convertLogLevel + static OptimizelyLogLevel convertLogLevel(int rawValue) { + return _convertLogLevel(rawValue); + } + + /// Check if a custom logger is set + static bool hasLogger() { + return _customLogger != null; + } + + /// Get the current logger + static OptimizelyLogger? getCurrentLogger() { + return _customLogger; + } + + /// Reset logger state + static void reset() { + _customLogger = null; + } + + /// Simulate method calls + static Future handleMethodCallForTesting(MethodCall call) async { + await _handleMethodCall(call); + } +} diff --git a/lib/src/optimizely_client_wrapper.dart b/lib/src/optimizely_client_wrapper.dart index a0869b9..a7c092d 100644 --- a/lib/src/optimizely_client_wrapper.dart +++ b/lib/src/optimizely_client_wrapper.dart @@ -63,7 +63,8 @@ class OptimizelyClientWrapper { Map datafileHostOptions, Set defaultDecideOptions, OptimizelyLogLevel defaultLogLevel, - SDKSettings sdkSettings) async { + SDKSettings sdkSettings, + OptimizelyLogger? logger) async { _channel.setMethodCallHandler(methodCallHandler); final convertedOptions = Utils.convertDecideOptions(defaultDecideOptions); final convertedLogLevel = Utils.convertLogLevel(defaultLogLevel); diff --git a/pubspec.yaml b/pubspec.yaml index 188939d..e48e93d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,10 +1,10 @@ name: optimizely_flutter_sdk description: This repository houses the Flutter SDK for use with Optimizely Feature Experimentation, Optimizely Full Stack (legacy), and Optimizely Rollouts. -version: 3.0.1 +version: 3.3.0 homepage: https://github.com/optimizely/optimizely-flutter-sdk environment: - sdk: '>=2.16.2 <4.0.0' + sdk: ">=2.16.2 <4.0.0" flutter: ">=2.5.0" dependencies: diff --git a/test/logger_test.dart b/test/logger_test.dart new file mode 100644 index 0000000..8c0161e --- /dev/null +++ b/test/logger_test.dart @@ -0,0 +1,506 @@ +import "package:flutter/services.dart"; +import "package:flutter_test/flutter_test.dart"; +import 'package:optimizely_flutter_sdk/src/logger/logger_bridge.dart'; +import 'package:optimizely_flutter_sdk/src/logger/flutter_logger.dart'; +import 'package:optimizely_flutter_sdk/src/data_objects/log_level.dart'; + +/// Test implementation of OptimizelyLogger for testing +class TestLogger implements OptimizelyLogger { + final List logs = []; + + @override + void log(OptimizelyLogLevel level, String message) { + logs.add(LogEntry(level, message)); + } + + void clear() { + logs.clear(); + } +} + +/// Data class for log entries +class LogEntry { + final OptimizelyLogLevel level; + final String message; + + LogEntry(this.level, this.message); + + @override + String toString() => '${level.name}: $message'; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group("Logger Tests", () { + setUp(() { + // Reset logger state before each test + LoggerBridge.reset(); + }); + + test("should handle log method call from native", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + // Simulate native log call by directly invoking the method handler + final methodCall = const MethodCall('log', { + 'level': 3, // INFO + 'message': 'Test log message from native' + }); + + await LoggerBridge.handleMethodCallForTesting(methodCall); + + expect(testLogger.logs.length, equals(1)); + expect(testLogger.logs.first.level, equals(OptimizelyLogLevel.info)); + expect(testLogger.logs.first.message, equals('Test log message from native')); + }); + + test("should convert log levels correctly", () { + expect(LoggerBridge.convertLogLevel(1), equals(OptimizelyLogLevel.error)); + expect(LoggerBridge.convertLogLevel(2), equals(OptimizelyLogLevel.warning)); + expect(LoggerBridge.convertLogLevel(3), equals(OptimizelyLogLevel.info)); + expect(LoggerBridge.convertLogLevel(4), equals(OptimizelyLogLevel.debug)); + }); + + test("should default to info for invalid log levels", () { + expect(LoggerBridge.convertLogLevel(0), equals(OptimizelyLogLevel.info)); + expect(LoggerBridge.convertLogLevel(5), equals(OptimizelyLogLevel.info)); + expect(LoggerBridge.convertLogLevel(-1), equals(OptimizelyLogLevel.info)); + }); + + test("should reset state correctly", () { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + expect(LoggerBridge.hasLogger(), isTrue); + + LoggerBridge.reset(); + + expect(LoggerBridge.hasLogger(), isFalse); + expect(LoggerBridge.getCurrentLogger(), isNull); + }); + + group("Error Handling", () { + test("should handle null arguments gracefully", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + final methodCall = const MethodCall('log', null); + await LoggerBridge.handleMethodCallForTesting(methodCall); + + expect(testLogger.logs.isEmpty, isTrue); + }); + + test("should handle empty arguments gracefully", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + final methodCall = const MethodCall('log', {}); + await LoggerBridge.handleMethodCallForTesting(methodCall); + + expect(testLogger.logs.isEmpty, isTrue); + }); + + test("should handle missing level argument", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + final methodCall = const MethodCall('log', { + 'message': 'Test message without level' + }); + await LoggerBridge.handleMethodCallForTesting(methodCall); + + expect(testLogger.logs.isEmpty, isTrue); + }); + + test("should handle missing message argument", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + final methodCall = const MethodCall('log', { + 'level': 3 + }); + await LoggerBridge.handleMethodCallForTesting(methodCall); + + expect(testLogger.logs.isEmpty, isTrue); + }); + + test("should handle invalid level data types", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + // Test with string level + var methodCall = const MethodCall('log', { + 'level': 'invalid', + 'message': 'Test message' + }); + await LoggerBridge.handleMethodCallForTesting(methodCall); + + expect(testLogger.logs.isEmpty, isTrue); + + // Test with null level + methodCall = const MethodCall('log', { + 'level': null, + 'message': 'Test message' + }); + await LoggerBridge.handleMethodCallForTesting(methodCall); + + expect(testLogger.logs.isEmpty, isTrue); + + testLogger.clear(); + }); + + test("should handle unknown method calls", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + final methodCall = const MethodCall('unknownMethod', { + 'level': 3, + 'message': 'Test message' + }); + + // Should not throw + expect(() async { + await LoggerBridge.handleMethodCallForTesting(methodCall); + }, returnsNormally); + + expect(testLogger.logs.isEmpty, isTrue); + }); + }); + + group("Multiple Log Levels", () { + test("should handle all log levels in sequence", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + final testCases = [ + {'level': 1, 'message': 'Error message', 'expected': OptimizelyLogLevel.error}, + {'level': 2, 'message': 'Warning message', 'expected': OptimizelyLogLevel.warning}, + {'level': 3, 'message': 'Info message', 'expected': OptimizelyLogLevel.info}, + {'level': 4, 'message': 'Debug message', 'expected': OptimizelyLogLevel.debug}, + ]; + + for (var testCase in testCases) { + final methodCall = MethodCall('log', { + 'level': testCase['level'], + 'message': testCase['message'] + }); + + await LoggerBridge.handleMethodCallForTesting(methodCall); + } + + expect(testLogger.logs.length, equals(4)); + + for (int i = 0; i < testCases.length; i++) { + expect(testLogger.logs[i].level, equals(testCases[i]['expected'])); + expect(testLogger.logs[i].message, equals(testCases[i]['message'])); + } + }); + + test("should handle mixed valid and invalid log levels", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + final testCases = [ + {'level': 1, 'message': 'Valid error', 'shouldLog': true}, + {'level': 'invalid', 'message': 'Invalid level', 'shouldLog': false}, + {'level': 3, 'message': 'Valid info', 'shouldLog': true}, + {'level': 999, 'message': 'Out of range level', 'shouldLog': true}, // Maps to info + {'level': -1, 'message': 'Negative level', 'shouldLog': true}, // Maps to info + ]; + + for (var testCase in testCases) { + final methodCall = MethodCall('log', { + 'level': testCase['level'], + 'message': testCase['message'] + }); + + await LoggerBridge.handleMethodCallForTesting(methodCall); + } + + // Should have 4 logs (all except the 'invalid' string level) + expect(testLogger.logs.length, equals(4)); + expect(testLogger.logs[0].level, equals(OptimizelyLogLevel.error)); + expect(testLogger.logs[1].level, equals(OptimizelyLogLevel.info)); + expect(testLogger.logs[2].level, equals(OptimizelyLogLevel.info)); // 999 maps to info + expect(testLogger.logs[3].level, equals(OptimizelyLogLevel.info)); // -1 maps to info + }); + }); + + group("DefaultOptimizelyLogger", () { + test("should create default logger instance", () { + var defaultLogger = DefaultOptimizelyLogger(); + expect(defaultLogger, isNotNull); + }); + + test("should handle logging without throwing", () { + var defaultLogger = DefaultOptimizelyLogger(); + + expect(() { + defaultLogger.log(OptimizelyLogLevel.error, "Error message"); + defaultLogger.log(OptimizelyLogLevel.warning, "Warning message"); + defaultLogger.log(OptimizelyLogLevel.info, "Info message"); + defaultLogger.log(OptimizelyLogLevel.debug, "Debug message"); + }, returnsNormally); + }); + }); + group("Global Logging Functions", () { + test("should call global logging functions without error", () { + expect(() { + logError("Global error message"); + logWarning("Global warning message"); + logInfo("Global info message"); + logDebug("Global debug message"); + }, returnsNormally); + }); + + test("should handle empty messages in global functions", () { + expect(() { + logError(""); + logWarning(""); + logInfo(""); + logDebug(""); + }, returnsNormally); + }); + + test("should handle special characters in global functions", () { + var specialMessage = "Special: 🚀 \n\t 世界"; + + expect(() { + logError(specialMessage); + logWarning(specialMessage); + logInfo(specialMessage); + logDebug(specialMessage); + }, returnsNormally); + }); + + test("should handle rapid calls to global functions", () { + expect(() { + for (int i = 0; i < 25; i++) { + logError("Rapid error $i"); + logWarning("Rapid warning $i"); + logInfo("Rapid info $i"); + logDebug("Rapid debug $i"); + } + }, returnsNormally); + }); + }); + group("Concurrent Access", () { + test("should handle multiple concurrent log calls", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + // Create multiple concurrent log calls + var futures = []; + for (int i = 0; i < 25; i++) { + futures.add(LoggerBridge.handleMethodCallForTesting( + MethodCall('log', { + 'level': (i % 4) + 1, // Cycle through levels 1-4 + 'message': 'Concurrent message $i' + }) + )); + } + + await Future.wait(futures); + + expect(testLogger.logs.length, equals(25)); + + // Verify all messages are present + for (int i = 0; i < 25; i++) { + expect(testLogger.logs.any((log) => log.message == 'Concurrent message $i'), isTrue); + } + }); + + test("should handle logger reinitialization during concurrent access", () async { + var testLogger1 = TestLogger(); + var testLogger2 = TestLogger(); + + LoggerBridge.initialize(testLogger1); + + // Start some async operations + var futures = []; + for (int i = 0; i < 5; i++) { + futures.add(LoggerBridge.handleMethodCallForTesting( + MethodCall('log', { + 'level': 3, + 'message': 'Message before reinit $i' + }) + )); + } + + // Reinitialize with a different logger mid-flight + LoggerBridge.initialize(testLogger2); + + // Add more operations + for (int i = 0; i < 5; i++) { + futures.add(LoggerBridge.handleMethodCallForTesting( + MethodCall('log', { + 'level': 3, + 'message': 'Message after reinit $i' + }) + )); + } + + await Future.wait(futures); + + // The total logs should be distributed between the two loggers + var totalLogs = testLogger1.logs.length + testLogger2.logs.length; + expect(totalLogs, equals(10)); + expect(LoggerBridge.getCurrentLogger(), equals(testLogger2)); + }); + }); + + group("Performance", () { + test("should handle high volume of logs efficiently", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + var stopwatch = Stopwatch()..start(); + + // Send 100 log messages + for (int i = 0; i < 100; i++) { + await LoggerBridge.handleMethodCallForTesting( + MethodCall('log', { + 'level': (i % 4) + 1, + 'message': 'Performance test log $i' + }) + ); + } + + stopwatch.stop(); + + expect(testLogger.logs.length, equals(100)); + expect(stopwatch.elapsedMilliseconds, lessThan(2000)); // Should complete in < 2 seconds + + // Verify first and last messages + expect(testLogger.logs.first.message, equals('Performance test log 0')); + expect(testLogger.logs.last.message, equals('Performance test log 99')); + }); + + test("should handle large message content efficiently", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + // Create a large message (10KB) + var largeMessage = 'X' * 10240; + + var stopwatch = Stopwatch()..start(); + + await LoggerBridge.handleMethodCallForTesting( + MethodCall('log', { + 'level': 3, + 'message': largeMessage + }) + ); + + stopwatch.stop(); + + expect(testLogger.logs.length, equals(1)); + expect(testLogger.logs.first.message.length, equals(10240)); + expect(stopwatch.elapsedMilliseconds, lessThan(100)); // Should be very fast + }); + }); + + group("State Management", () { + test("should maintain state across multiple operations", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + // Perform various operations + await LoggerBridge.handleMethodCallForTesting( + const MethodCall('log', {'level': 1, 'message': 'First message'}) + ); + + expect(LoggerBridge.hasLogger(), isTrue); + expect(testLogger.logs.length, equals(1)); + + await LoggerBridge.handleMethodCallForTesting( + const MethodCall('log', {'level': 2, 'message': 'Second message'}) + ); + + expect(LoggerBridge.hasLogger(), isTrue); + expect(testLogger.logs.length, equals(2)); + + LoggerBridge.reset(); + + expect(LoggerBridge.hasLogger(), isFalse); + expect(testLogger.logs.length, equals(2)); // Logger keeps its own state + }); + + test("should handle logger replacement", () async { + var testLogger1 = TestLogger(); + var testLogger2 = TestLogger(); + + // Initialize with first logger + LoggerBridge.initialize(testLogger1); + await LoggerBridge.handleMethodCallForTesting( + const MethodCall('log', {'level': 3, 'message': 'Message to logger 1'}) + ); + + expect(testLogger1.logs.length, equals(1)); + expect(testLogger2.logs.length, equals(0)); + + // Replace with second logger + LoggerBridge.initialize(testLogger2); + await LoggerBridge.handleMethodCallForTesting( + const MethodCall('log', {'level': 3, 'message': 'Message to logger 2'}) + ); + + expect(testLogger1.logs.length, equals(1)); // Unchanged + expect(testLogger2.logs.length, equals(1)); // New message + expect(LoggerBridge.getCurrentLogger(), equals(testLogger2)); + }); + }); + + group("Edge Cases", () { + test("should handle empty message", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + await LoggerBridge.handleMethodCallForTesting( + const MethodCall('log', { + 'level': 3, + 'message': '' + }) + ); + + expect(testLogger.logs.length, equals(1)); + expect(testLogger.logs.first.message, equals('')); + expect(testLogger.logs.first.level, equals(OptimizelyLogLevel.info)); + }); + + test("should handle special characters in message", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + var specialMessage = 'Special chars: 🚀 ñáéíóú 中文 \n\t\r\\'; + + await LoggerBridge.handleMethodCallForTesting( + MethodCall('log', { + 'level': 3, + 'message': specialMessage + }) + ); + + expect(testLogger.logs.length, equals(1)); + expect(testLogger.logs.first.message, equals(specialMessage)); + }); + + test("should handle invalid data types gracefully", () async { + var testLogger = TestLogger(); + LoggerBridge.initialize(testLogger); + + // Test with double level - should fail gracefully + await LoggerBridge.handleMethodCallForTesting( + const MethodCall('log', { + 'level': 3.0, // Double instead of int + 'message': 'Message with double level' + }) + ); + + // Should not log anything due to type casting error + expect(testLogger.logs.length, equals(0)); + }); + }); + }); +}