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..9739082 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,7 @@ import com.optimizely.ab.UnknownEventTypeException; import com.optimizely.ab.android.event_handler.DefaultEventHandler; import com.optimizely.ab.android.sdk.OptimizelyClient; - +import org.slf4j.Logger; import java.util.HashMap; import java.util.Map; @@ -187,6 +187,15 @@ protected void initializeOptimizely(@NonNull ArgumentsParser argumentsParser, @N if (enableVuid) { optimizelyManagerBuilder.withVuidEnabled(); } + + // Check if custom logger is requested + Boolean useCustomLogger = argumentsParser.getCustomLogger(); + Logger customLogger = null; + if (useCustomLogger != null && useCustomLogger) { + customLogger = new OptimizelyFlutterLogger("OptimizelySDK"); + optimizelyManagerBuilder.withLogger(customLogger); + } + OptimizelyManager optimizelyManager = optimizelyManagerBuilder.build(context); optimizelyManager.initialize(context, null, (OptimizelyClient client) -> { diff --git a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterLogger.java b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterLogger.java new file mode 100644 index 0000000..adfa377 --- /dev/null +++ b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/OptimizelyFlutterLogger.java @@ -0,0 +1,378 @@ +/**************************************************************************** + * Copyright 2022-2023, Optimizely, Inc. and contributors * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); * + * you may not use this file except in compliance with the License. * + * You may obtain a copy of the License at * + * * + * https://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + ***************************************************************************/ +package com.optimizely.optimizely_flutter_sdk; + +import android.os.Handler; +import android.os.Looper; +import io.flutter.plugin.common.MethodChannel; +import org.slf4j.Logger; +import org.slf4j.Marker; +import java.util.HashMap; +import java.util.Map; + +public class OptimizelyFlutterLogger implements Logger { + static final String LOGGER_CHANNEL = "optimizely_flutter_sdk_logger"; + private static MethodChannel loggerChannel; + private final String tag; + + public OptimizelyFlutterLogger(String name) { + tag = name; + } + + public static void setChannel(MethodChannel channel) { + loggerChannel = channel; + } + + @Override + public String getName() { + return "OptimizelyLogger"; + } + + // Trace methods + @Override + public boolean isTraceEnabled() { + return false; + } + + @Override + public void trace(String msg) { + // Not implemented + } + + @Override + public void trace(String format, Object arg) { + // Not implemented + } + + @Override + public void trace(String format, Object arg1, Object arg2) { + // Not implemented + } + + @Override + public void trace(String format, Object... arguments) { + // Not implemented + } + + @Override + public void trace(String msg, Throwable t) { + // Not implemented + } + + @Override + public boolean isTraceEnabled(Marker marker) { + return false; + } + + @Override + public void trace(Marker marker, String msg) { + // Not implemented + } + + @Override + public void trace(Marker marker, String format, Object arg) { + // Not implemented + } + + @Override + public void trace(Marker marker, String format, Object arg1, Object arg2) { + // Not implemented + } + + @Override + public void trace(Marker marker, String format, Object... argArray) { + // Not implemented + } + + @Override + public void trace(Marker marker, String msg, Throwable t) { + // Not implemented + } + + // Debug methods + @Override + public boolean isDebugEnabled() { + return true; + } + + @Override + public void debug(String msg) { + sendLogToFlutter(4, msg); + } + + @Override + public void debug(String format, Object arg) { + debug(formatMessage(format, arg)); + } + + @Override + public void debug(String format, Object arg1, Object arg2) { + debug(formatMessage(format, arg1, arg2)); + } + + @Override + public void debug(String format, Object... arguments) { + debug(formatMessage(format, arguments)); + } + + @Override + public void debug(String msg, Throwable t) { + debug(formatThrowable(msg, t)); + } + + @Override + public boolean isDebugEnabled(Marker marker) { + return true; + } + + @Override + public void debug(Marker marker, String msg) { + debug(msg); + } + + @Override + public void debug(Marker marker, String format, Object arg) { + debug(format, arg); + } + + @Override + public void debug(Marker marker, String format, Object arg1, Object arg2) { + debug(format, arg1, arg2); + } + + @Override + public void debug(Marker marker, String format, Object... arguments) { + debug(format, arguments); + } + + @Override + public void debug(Marker marker, String msg, Throwable t) { + debug(msg, t); + } + + // Info methods + @Override + public boolean isInfoEnabled() { + return true; + } + + @Override + public void info(String msg) { + sendLogToFlutter(3, msg); + } + + @Override + public void info(String format, Object arg) { + info(formatMessage(format, arg)); + } + + @Override + public void info(String format, Object arg1, Object arg2) { + info(formatMessage(format, arg1, arg2)); + } + + @Override + public void info(String format, Object... arguments) { + info(formatMessage(format, arguments)); + } + + @Override + public void info(String msg, Throwable t) { + info(formatThrowable(msg, t)); + } + + @Override + public boolean isInfoEnabled(Marker marker) { + return true; + } + + @Override + public void info(Marker marker, String msg) { + info(msg); + } + + @Override + public void info(Marker marker, String format, Object arg) { + info(format, arg); + } + + @Override + public void info(Marker marker, String format, Object arg1, Object arg2) { + info(format, arg1, arg2); + } + + @Override + public void info(Marker marker, String format, Object... arguments) { + info(format, arguments); + } + + @Override + public void info(Marker marker, String msg, Throwable t) { + info(msg, t); + } + + // Warn methods + @Override + public boolean isWarnEnabled() { + return true; + } + + @Override + public void warn(String msg) { + sendLogToFlutter(2, msg); + } + + @Override + public void warn(String format, Object arg) { + warn(formatMessage(format, arg)); + } + + @Override + public void warn(String format, Object... arguments) { + warn(formatMessage(format, arguments)); + } + + @Override + public void warn(String format, Object arg1, Object arg2) { + warn(formatMessage(format, arg1, arg2)); + } + + @Override + public void warn(String msg, Throwable t) { + warn(formatThrowable(msg, t)); + } + + @Override + public boolean isWarnEnabled(Marker marker) { + return true; + } + + @Override + public void warn(Marker marker, String msg) { + warn(msg); + } + + @Override + public void warn(Marker marker, String format, Object arg) { + warn(format, arg); + } + + @Override + public void warn(Marker marker, String format, Object arg1, Object arg2) { + warn(format, arg1, arg2); + } + + @Override + public void warn(Marker marker, String format, Object... arguments) { + warn(format, arguments); + } + + @Override + public void warn(Marker marker, String msg, Throwable t) { + warn(msg, t); + } + + // Error methods + @Override + public boolean isErrorEnabled() { + return true; + } + + @Override + public void error(String msg) { + sendLogToFlutter(1, msg); // ERROR level = 1 + } + + @Override + public void error(String format, Object arg) { + error(formatMessage(format, arg)); + } + + @Override + public void error(String format, Object arg1, Object arg2) { + error(formatMessage(format, arg1, arg2)); + } + + @Override + public void error(String format, Object... arguments) { + error(formatMessage(format, arguments)); + } + + @Override + public void error(String msg, Throwable t) { + error(formatThrowable(msg, t)); + } + + @Override + public boolean isErrorEnabled(Marker marker) { + return true; + } + + @Override + public void error(Marker marker, String msg) { + error(msg); + } + + @Override + public void error(Marker marker, String format, Object arg) { + error(format, arg); + } + + @Override + public void error(Marker marker, String format, Object arg1, Object arg2) { + error(format, arg1, arg2); + } + + @Override + public void error(Marker marker, String format, Object... arguments) { + error(format, arguments); + } + + @Override + public void error(Marker marker, String msg, Throwable t) { + error(msg, t); + } + + // Helper methods + private void sendLogToFlutter(int level, String message) { + if (loggerChannel == null) { + return; + } + + // Ensure we're on the main thread when calling Flutter + Handler mainHandler = new Handler(Looper.getMainLooper()); + mainHandler.post(() -> { + Map arguments = new HashMap<>(); + arguments.put("level", level); + arguments.put("message", message); + loggerChannel.invokeMethod("log", arguments); + }); + } + + private String formatMessage(String format, Object... args) { + try { + // SLF4J uses {} placeholders, replace with %s for String.format + String formatString = format.replace("{}", "%s"); + return String.format(formatString, args); + } catch (Exception e) { + return format; + } + } + + private String formatThrowable(String msg, Throwable t) { + return msg + " - " + t.getMessage(); + } +} \ No newline at end of file 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..7a68b36 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 @@ -36,6 +36,7 @@ public class OptimizelyFlutterSdkPlugin extends OptimizelyFlutterClient implements FlutterPlugin, ActivityAware, MethodCallHandler { public static MethodChannel channel; + private static MethodChannel loggerChannel; @Override public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { @@ -156,12 +157,18 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { channel = new MethodChannel(binding.getBinaryMessenger(), "optimizely_flutter_sdk"); channel.setMethodCallHandler(this); + + loggerChannel = new MethodChannel(binding.getBinaryMessenger(), OptimizelyFlutterLogger.LOGGER_CHANNEL); + OptimizelyFlutterLogger.setChannel(loggerChannel); + context = binding.getApplicationContext(); } @Override public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { channel.setMethodCallHandler(null); + loggerChannel.setMethodCallHandler(null); + OptimizelyFlutterLogger.setChannel(null); } @Override diff --git a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/ArgumentsParser.java b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/ArgumentsParser.java index 6d2741f..092ea64 100644 --- a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/ArgumentsParser.java +++ b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/ArgumentsParser.java @@ -80,6 +80,10 @@ public String getDefaultLogLevel() { return (String) arguments.get(Constants.RequestParameterKey.DEFAULT_LOG_LEVEL); } + public Boolean getCustomLogger() { + return (Boolean) arguments.get(Constants.RequestParameterKey.CUSTOM_LOGGER); + } + public String getFlagKey() { return (String) arguments.get(Constants.RequestParameterKey.FLAG_KEY); } diff --git a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/Constants.java b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/Constants.java index 62f0ce9..ce67f74 100644 --- a/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/Constants.java +++ b/android/src/main/java/com/optimizely/optimizely_flutter_sdk/helper_classes/Constants.java @@ -69,6 +69,7 @@ public static class RequestParameterKey { public static final String DECIDE_KEYS = "keys"; public static final String DECIDE_OPTIONS = "optimizelyDecideOption"; public static final String DEFAULT_LOG_LEVEL = "defaultLogLevel"; + public static final String CUSTOM_LOGGER = "customLogger"; public static final String EVENT_BATCH_SIZE = "eventBatchSize"; public static final String EVENT_TIME_INTERVAL = "eventTimeInterval"; public static final String EVENT_MAX_QUEUE_SIZE = "eventMaxQueueSize"; 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/HelperClasses/Constants.swift b/ios/Classes/HelperClasses/Constants.swift index a29370a..c7af4fc 100644 --- a/ios/Classes/HelperClasses/Constants.swift +++ b/ios/Classes/HelperClasses/Constants.swift @@ -91,6 +91,7 @@ struct RequestParameterKey { static let reasons = "reasons" static let decideOptions = "optimizelyDecideOption" static let defaultLogLevel = "defaultLogLevel" + static let customLogger = "customLogger" static let eventBatchSize = "eventBatchSize" static let eventTimeInterval = "eventTimeInterval" static let eventMaxQueueSize = "eventMaxQueueSize" 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..9dcc1a6 100644 --- a/ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift +++ b/ios/Classes/SwiftOptimizelyFlutterSdkPlugin.swift @@ -36,11 +36,21 @@ public class SwiftOptimizelyFlutterSdkPlugin: NSObject, FlutterPlugin { return UUID().uuidString } + static var registrar: FlutterPluginRegistrar? /// Registers optimizely_flutter_sdk channel to communicate with the flutter sdk to receive requests and send responses public static func register(with registrar: FlutterPluginRegistrar) { + self.registrar = registrar 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 +120,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 +174,16 @@ public class SwiftOptimizelyFlutterSdkPlugin: NSObject, FlutterPlugin { notificationIdsTracker.removeValue(forKey: sdkKey) optimizelyClientsTracker.removeValue(forKey: sdkKey) + // Check if custom logger is requested + var logger: OPTLogger? + if let useCustomLogger = parameters[RequestParameterKey.customLogger] as? Bool, useCustomLogger { + logger = 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..9a5ed5b 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,37 @@ class OptimizelyFlutterSdk { final Set _defaultDecideOptions; final OptimizelyLogLevel _defaultLogLevel; final SDKSettings _sdkSettings; + static OptimizelyLogger? _customLogger; + /// Set a custom logger for the SDK + static void setLogger(OptimizelyLogger logger) { + _customLogger = logger; + LoggerBridge.initialize(logger); + } + /// 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}) // Add logger parameter + : _eventOptions = eventOptions, + _datafilePeriodicDownloadInterval = datafilePeriodicDownloadInterval, + _datafileHostOptions = datafileHostOptions, + _defaultDecideOptions = defaultDecideOptions, + _defaultLogLevel = defaultLogLevel, + _sdkSettings = sdkSettings { + // Set the logger if provided + if (logger != null) { + setLogger(logger); + } else { + logWarning("Logger not provided."); + } + } /// Starts Optimizely SDK (Synchronous) with provided sdkKey. Future initializeClient() async { @@ -92,7 +113,9 @@ class OptimizelyFlutterSdk { _datafileHostOptions, _defaultDecideOptions, _defaultLogLevel, - _sdkSettings); + _sdkSettings, + _customLogger + ); } /// Use the activate method to start an experiment. diff --git a/lib/src/logger/flutter_logger.dart b/lib/src/logger/flutter_logger.dart new file mode 100644 index 0000000..ad3ec67 --- /dev/null +++ b/lib/src/logger/flutter_logger.dart @@ -0,0 +1,26 @@ +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) { + 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..9c8dba7 --- /dev/null +++ b/lib/src/logger/logger_bridge.dart @@ -0,0 +1,101 @@ +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 { + logInfo('[LoggerBridge] Received method call: ${call.method}'); + 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); + + logInfo('[LoggerBridge] Processing log: level=$levelRawValue, message=$message'); + + 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..1e5ecf3 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); @@ -79,6 +80,7 @@ class OptimizelyClientWrapper { Constants.eventBatchSize: eventOptions.batchSize, Constants.eventTimeInterval: eventOptions.timeInterval, Constants.eventMaxQueueSize: eventOptions.maxQueueSize, + Constants.customLogger: logger != null, }; // Odp Request params diff --git a/lib/src/utils/constants.dart b/lib/src/utils/constants.dart index 2bb5421..4b502be 100644 --- a/lib/src/utils/constants.dart +++ b/lib/src/utils/constants.dart @@ -86,6 +86,7 @@ class Constants { static const String optimizelyDecideOption = "optimizelyDecideOption"; static const String optimizelySegmentOption = "optimizelySegmentOption"; static const String optimizelySdkSettings = "optimizelySdkSettings"; + static const String customLogger = 'customLogger'; static const String defaultLogLevel = "defaultLogLevel"; static const String payload = "payload"; static const String value = "value"; 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)); + }); + }); + }); +}