Skip to content

Commit 8deddf5

Browse files
authored
Native log streaming (#169)
1 parent 83163a7 commit 8deddf5

File tree

14 files changed

+336
-23
lines changed

14 files changed

+336
-23
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
# 0.9.12
2+
3+
- Stream native (iOS/Android) logs to the Flutter `DescopeLogger`
4+
- **Breaking Change**: Renamed `DescopeLogger` properties to `level` and `unsafe`
5+
16
# 0.9.11
27
- Support links in iOS native flows
38
- Support `mailto:` links everywhere

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,10 @@ Descope.setup('<Your-Project-ID>');
3737
Descope.setup('<Your-Project-Id>', (config) {
3838
// set a custom base URL (needs to be set up in the Descope console)
3939
config.baseUrl = 'https://my.app.com';
40-
// enable the logger
41-
if (kDebugMode) {
42-
config.logger = DescopeLogger();
43-
}
40+
// Enable the logger for debugging.
41+
// You can use DescopeLogger.debugLogger (or DescopeLogger.unsafeLogger) during development or to diagnose issues.
42+
// For production it is advised to provide a custom implementation hooked up to the application's log monitoring system.
43+
config.logger = DescopeLogger.debugLogger;
4444
});
4545
4646
// Load any available sessions

android/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ android {
5151
implementation "androidx.browser:browser:1.8.0"
5252
implementation "androidx.security:security-crypto:1.1.0"
5353
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
54-
implementation "com.descope:descope-kotlin:0.17.9"
54+
implementation "com.descope:descope-kotlin:0.18.0"
5555
}
5656

5757
testOptions {

android/src/main/kotlin/com/descope/flutter/DescopePlugin.kt

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@ package com.descope.flutter
22

33
import android.content.Context
44
import android.net.Uri
5+
import android.os.Handler
6+
import android.os.Looper
57
import androidx.browser.customtabs.CustomTabsIntent
68
import androidx.security.crypto.EncryptedSharedPreferences
79
import androidx.security.crypto.MasterKeys
10+
import com.descope.Descope
811
import com.descope.android.DescopeSystemInfo
912
import com.descope.internal.routes.getPackageOrigin
1013
import com.descope.internal.routes.performAssertion
1114
import com.descope.internal.routes.performNativeAuthorization
1215
import com.descope.internal.routes.performRegister
16+
import com.descope.sdk.DescopeLogger
1317
import io.flutter.embedding.engine.plugins.FlutterPlugin
1418
import io.flutter.embedding.engine.plugins.activity.ActivityAware
1519
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -25,6 +29,7 @@ import kotlinx.coroutines.launch
2529
/** DescopePlugin */
2630
class DescopePlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
2731
private var channel : MethodChannel? = null
32+
private var logChannel: MethodChannel? = null
2833
private var context: Context? = null
2934
private lateinit var storage: Store
3035

@@ -180,6 +185,38 @@ class DescopePlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
180185
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "descope_flutter/methods")
181186
channel?.setMethodCallHandler(this)
182187

188+
// Set up the log channel with a handler for logger configuration from Flutter
189+
val logsChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "descope_flutter/logs")
190+
logChannel = logsChannel
191+
val applicationContext = flutterPluginBinding.applicationContext
192+
193+
logsChannel.setMethodCallHandler { call, result ->
194+
if (call.method == "configure") {
195+
val levelString = call.argument<String>("level")
196+
val unsafe = call.argument<Boolean>("unsafe")
197+
198+
if (levelString == null || unsafe == null) {
199+
result.error("INVALID_ARGS", "Missing level or unsafe arguments", null)
200+
return@setMethodCallHandler
201+
}
202+
203+
val level = when (levelString) {
204+
"error" -> DescopeLogger.Level.Error
205+
"info" -> DescopeLogger.Level.Info
206+
else -> DescopeLogger.Level.Debug
207+
}
208+
209+
// Initialize SDK with the logger configured from Flutter
210+
Descope.setup(applicationContext, projectId = "") {
211+
logger = FlutterDescopeLogger(logsChannel, level, unsafe)
212+
}
213+
214+
result.success(null)
215+
} else {
216+
result.notImplemented()
217+
}
218+
}
219+
183220
flutterPluginBinding.platformViewRegistry.registerViewFactory(
184221
"descope_flutter/descope_flow_view",
185222
DescopeFlowViewFactory(flutterPluginBinding.binaryMessenger)
@@ -189,6 +226,8 @@ class DescopePlugin: FlutterPlugin, MethodCallHandler, ActivityAware {
189226
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
190227
channel?.setMethodCallHandler(null)
191228
channel = null
229+
logChannel?.setMethodCallHandler(null)
230+
logChannel = null
192231
}
193232

194233
// ActivityAware
@@ -272,3 +311,31 @@ private fun createEncryptedStore(context: Context, projectId: String): Store {
272311
}
273312
}
274313
}
314+
315+
// Logger
316+
317+
/**
318+
* A DescopeLogger subclass that forwards all logs to Flutter via a MethodChannel.
319+
* This logger mirrors the level and unsafe settings from the Flutter layer.
320+
*/
321+
private class FlutterDescopeLogger(private val channel: MethodChannel, level: Level, unsafe: Boolean) : DescopeLogger(level, unsafe) {
322+
private val handler = Handler(Looper.getMainLooper())
323+
324+
override fun output(level: Level, message: String, values: List<Any>) {
325+
val levelString = when (level) {
326+
Level.Error -> "error"
327+
Level.Info -> "info"
328+
Level.Debug -> "debug"
329+
}
330+
331+
val valuesArray = values.map { it.toString() }
332+
333+
handler.post {
334+
channel.invokeMethod("log", mapOf(
335+
"level" to levelString,
336+
"message" to message,
337+
"values" to valuesArray,
338+
))
339+
}
340+
}
341+
}

ios/Classes/DescopePlugin.swift

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,44 @@ public class DescopePlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
1414
public static func register(with registrar: FlutterPluginRegistrar) {
1515
let methodChannel = FlutterMethodChannel(name: "descope_flutter/methods", binaryMessenger: registrar.messenger())
1616
let eventChannel = FlutterEventChannel(name: "descope_flutter/events", binaryMessenger: registrar.messenger())
17+
let logChannel = FlutterMethodChannel(name: "descope_flutter/logs", binaryMessenger: registrar.messenger())
1718

1819
let instance = DescopePlugin()
1920

21+
// Set up log channel handler for logger configuration from Flutter
22+
logChannel.setMethodCallHandler { call, result in
23+
if call.method == "configure" {
24+
guard let args = call.arguments as? [String: Any],
25+
let levelString = args["level"] as? String,
26+
let unsafe = args["unsafe"] as? Bool else {
27+
result(FlutterError(code: "INVALID_ARGS", message: "Missing level or unsafe arguments", details: nil))
28+
return
29+
}
30+
31+
let level: DescopeLogger.Level
32+
switch levelString {
33+
case "error":
34+
level = .error
35+
case "info":
36+
level = .info
37+
default:
38+
level = .debug
39+
}
40+
41+
// Initialize SDK with the logger configured from Flutter
42+
// Descope.setup is @MainActor so we ensure we're on the main thread
43+
DispatchQueue.main.async {
44+
Descope.setup(projectId: "") { config in
45+
config.logger = FlutterDescopeLogger(channel: logChannel, level: level, unsafe: unsafe)
46+
}
47+
}
48+
49+
result(nil)
50+
} else {
51+
result(FlutterMethodNotImplemented)
52+
}
53+
}
54+
2055
eventChannel.setStreamHandler(instance)
2156
registrar.addMethodCallDelegate(instance, channel: methodChannel)
2257

@@ -306,3 +341,36 @@ extension FlutterError {
306341
self.init(code: code, message: message, details: nil)
307342
}
308343
}
344+
345+
/// A DescopeLogger subclass that forwards all logs to Flutter via a MethodChannel.
346+
/// This logger is configured with the level and unsafe settings from the Flutter layer.
347+
private class FlutterDescopeLogger: DescopeLogger {
348+
private let channel: FlutterMethodChannel
349+
350+
init(channel: FlutterMethodChannel, level: Level, unsafe: Bool) {
351+
self.channel = channel
352+
super.init(level: level, unsafe: unsafe)
353+
}
354+
355+
override func output(level: Level, message: String, unsafe values: [Any]) {
356+
let levelString: String
357+
switch level {
358+
case .error:
359+
levelString = "error"
360+
case .info:
361+
levelString = "info"
362+
case .debug:
363+
levelString = "debug"
364+
}
365+
366+
let valuesArray = values.map { String(describing: $0) }
367+
368+
DispatchQueue.main.async {
369+
self.channel.invokeMethod("log", arguments: [
370+
"level": levelString,
371+
"message": message,
372+
"values": valuesArray,
373+
])
374+
}
375+
}
376+
}

ios/Classes/descope-swift-sdk/flows/Flow.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,23 @@ extension DescopeFlow: CustomStringConvertible {
170170
return "DescopeFlow(url: \"\(url)\")"
171171
}
172172
}
173+
174+
/// Convenience constructor when use Descope's Flow hosting service
175+
extension DescopeFlow {
176+
/// Creates a new ``DescopeFlow`` object that encapsulates a single flow run.
177+
///
178+
/// - Important: This method of creating a ``DescopeFlow`` is only applicable when
179+
/// using Descope's Flow hosting service. If you host your own flows, use the
180+
/// default initializer instead.
181+
///
182+
/// - Parameters:
183+
/// - flowId: The ID of the flow
184+
/// - descope: An optional ``DescopeSDK`` to use instead of the ``Descope`` singleton.
185+
public convenience init(flowId: String, descope: DescopeSDK? = nil) {
186+
let sdk = descope ?? Descope.sdk
187+
precondition(!sdk.config.projectId.isEmpty, "The Descope SDK must be initialized before use")
188+
let url = "\(sdk.client.baseURL)/login/\(sdk.client.config.projectId)?mobile=true&flow=\(flowId)"
189+
self.init(url: url)
190+
self.descope = descope
191+
}
192+
}

ios/Classes/descope-swift-sdk/flows/FlowBridge.swift

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,15 @@ extension FlowBridge {
186186
if tag == "fail" {
187187
logger.error("Bridge encountered script error in webpage", message)
188188
} else if logger.isUnsafeEnabled {
189-
logger.debug("Webview console.\(tag): \(message)")
189+
let logMessage = "Webview console.\(tag): \(message)"
190+
switch tag {
191+
case "error":
192+
logger.error(logMessage)
193+
case "warn", "info", "log":
194+
logger.info(logMessage)
195+
default:
196+
logger.debug(logMessage)
197+
}
190198
}
191199
case .found:
192200
logger.info("Bridge received found event")
@@ -438,12 +446,21 @@ private struct FlowNativeOptions: Encodable {
438446
/// Redirects errors and console logs to the bridge
439447
private let loggingScript = """
440448
441-
window.onerror = (s) => { window.webkit.messageHandlers.\(FlowBridgeMessage.log.rawValue).postMessage({ tag: 'fail', message: s }) }
442-
window.console.error = (s) => { window.webkit.messageHandlers.\(FlowBridgeMessage.log.rawValue).postMessage({ tag: 'error', message: s }) }
443-
window.console.warn = (s) => { window.webkit.messageHandlers.\(FlowBridgeMessage.log.rawValue).postMessage({ tag: 'warn', message: s }) }
444-
window.console.info = (s) => { window.webkit.messageHandlers.\(FlowBridgeMessage.log.rawValue).postMessage({ tag: 'info', message: s }) }
445-
window.console.debug = (s) => { window.webkit.messageHandlers.\(FlowBridgeMessage.log.rawValue).postMessage({ tag: 'debug', message: s }) }
446-
window.console.log = (s) => { window.webkit.messageHandlers.\(FlowBridgeMessage.log.rawValue).postMessage({ tag: 'log', message: s }) }
449+
(function() {
450+
function stringify(args) {
451+
return Array.from(args).map(arg => {
452+
if (!arg) return ""
453+
if (typeof arg === 'string') return arg
454+
return JSON.stringify(arg)
455+
}).join(' ')
456+
}
457+
window.onerror = function() { window.webkit.messageHandlers.\(FlowBridgeMessage.log.rawValue).postMessage({ tag: 'fail', message: stringify(arguments) }) };
458+
window.console.error = function() { window.webkit.messageHandlers.\(FlowBridgeMessage.log.rawValue).postMessage({ tag: 'error', message: stringify(arguments) }) };
459+
window.console.warn = function() { window.webkit.messageHandlers.\(FlowBridgeMessage.log.rawValue).postMessage({ tag: 'warn', message: stringify(arguments) }) };
460+
window.console.info = function() { window.webkit.messageHandlers.\(FlowBridgeMessage.log.rawValue).postMessage({ tag: 'info', message: stringify(arguments) }) };
461+
window.console.debug = function() { window.webkit.messageHandlers.\(FlowBridgeMessage.log.rawValue).postMessage({ tag: 'debug', message: stringify(arguments) }) };
462+
window.console.log = function() { window.webkit.messageHandlers.\(FlowBridgeMessage.log.rawValue).postMessage({ tag: 'log', message: stringify(arguments) }) };
463+
})();
447464
448465
"""
449466

ios/Classes/descope-swift-sdk/sdk/SDK.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ public extension DescopeSDK {
143143
static let name = "DescopeKit"
144144

145145
/// The Descope SDK version
146-
static let version = "0.10.4"
146+
static let version = "0.10.5"
147147
}
148148

149149
// Internal
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Native log bridging is only available on iOS and Android.
2+
// On other platforms, a no-op stub will be used.
3+
export 'native_log_bridge_unsupported.dart' if (dart.library.io) 'native_log_bridge_native.dart';
4+
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import 'package:flutter/services.dart';
2+
3+
import '/src/sdk/config.dart';
4+
5+
/// Bridges log streaming from native SDK layers (iOS/Android) to Flutter.
6+
///
7+
/// This class listens on the `descope_flutter/logs` MethodChannel and forwards
8+
/// received native logs to the Flutter DescopeLogger, which filters based on
9+
/// its own `level` and `unsafe` settings.
10+
class NativeLogBridge {
11+
static const _logChannel = MethodChannel('descope_flutter/logs');
12+
static DescopeLogger? _logger;
13+
static bool _isInitialized = false;
14+
15+
/// Sets the logger to receive native logs and initializes the channel listener.
16+
///
17+
/// This should be called once during SDK initialization. If [logger] is null,
18+
/// native logging will not be initialized on the native side.
19+
static void pipeNativeLogs(DescopeLogger? logger) {
20+
_logger = logger;
21+
_initChannelIfNeeded();
22+
23+
// Send logger configuration to native if a logger is set
24+
if (logger != null) {
25+
final levelString = switch (logger.level) {
26+
DescopeLogger.error => 'error',
27+
DescopeLogger.info => 'info',
28+
_ => 'debug',
29+
};
30+
_logChannel.invokeMethod('configure', {
31+
'level': levelString,
32+
'unsafe': logger.unsafe,
33+
});
34+
}
35+
}
36+
37+
/// Ensures the channel listener is set up.
38+
static void _initChannelIfNeeded() {
39+
if (!_isInitialized) {
40+
_isInitialized = true;
41+
_logChannel.setMethodCallHandler(_handleNativeLog);
42+
}
43+
}
44+
45+
static Future<dynamic> _handleNativeLog(MethodCall call) async {
46+
if (call.method == 'log') {
47+
final logger = _logger;
48+
if (logger == null) return;
49+
50+
final args = call.arguments as Map<dynamic, dynamic>;
51+
final levelString = args['level'] as String;
52+
final message = args['message'] as String;
53+
final values = (args['values'] as List<dynamic>).cast<String>();
54+
55+
// Convert level string to DescopeLogger level constant
56+
final int level;
57+
switch (levelString) {
58+
case 'error':
59+
level = DescopeLogger.error;
60+
break;
61+
case 'info':
62+
level = DescopeLogger.info;
63+
break;
64+
case 'debug':
65+
default:
66+
level = DescopeLogger.debug;
67+
break;
68+
}
69+
70+
// Forward to the Flutter logger, which will filter based on its settings
71+
logger.log(
72+
level: level,
73+
message: message,
74+
values: values,
75+
);
76+
}
77+
}
78+
}
79+

0 commit comments

Comments
 (0)