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));
+ });
+ });
+ });
+}