diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a736deffd..e8c805533e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Enhancements - Refactor `AndroidReplayRecorder` to use the new worker isolate api [#3296](https://github.com/getsentry/sentry-dart/pull/3296/) +- Refactor fetching app start and display refresh rate to use FFI and JNI [#3288](https://github.com/getsentry/sentry-dart/pull/3288/) - Offload `captureEnvelope` to background isolate for Cocoa and Android [#3232](https://github.com/getsentry/sentry-dart/pull/3232) ## 9.7.0 diff --git a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt index 2d24d12f5d..da755ecd10 100644 --- a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt +++ b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt @@ -41,10 +41,6 @@ class SentryFlutterPlugin : ActivityAware { private lateinit var channel: MethodChannel private lateinit var context: Context - private lateinit var sentryFlutter: SentryFlutter - - private var activity: WeakReference? = null - private var pluginRegistrationTime: Long? = null override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { pluginRegistrationTime = System.currentTimeMillis() @@ -65,7 +61,6 @@ class SentryFlutterPlugin : when (call.method) { "initNativeSdk" -> initNativeSdk(call, result) "closeNativeSdk" -> closeNativeSdk(result) - "fetchNativeAppStart" -> fetchNativeAppStart(result) "setContexts" -> setContexts(call.argument("key"), call.argument("value"), result) "removeContexts" -> removeContexts(call.argument("key"), result) "setUser" -> setUser(call.argument("user"), result) @@ -75,7 +70,6 @@ class SentryFlutterPlugin : "removeExtra" -> removeExtra(call.argument("key"), result) "setTag" -> setTag(call.argument("key"), call.argument("value"), result) "removeTag" -> removeTag(call.argument("key"), result) - "displayRefreshRate" -> displayRefreshRate(result) "nativeCrash" -> crash() "setReplayConfig" -> setReplayConfig(call, result) "captureReplay" -> captureReplay(result) @@ -151,106 +145,6 @@ class SentryFlutterPlugin : } } - private fun fetchNativeAppStart(result: Result) { - if (!sentryFlutter.autoPerformanceTracingEnabled) { - result.success(null) - return - } - - val appStartMetrics = AppStartMetrics.getInstance() - - if (!appStartMetrics.isAppLaunchedInForeground || - appStartMetrics.appStartTimeSpan.durationMs > APP_START_MAX_DURATION_MS - ) { - Log.w( - "Sentry", - "Invalid app start data: app not launched in foreground or app start took too long (>60s)", - ) - result.success(null) - return - } - - val appStartTimeSpan = appStartMetrics.appStartTimeSpan - val appStartTime = appStartTimeSpan.startTimestamp - val isColdStart = appStartMetrics.appStartType == AppStartMetrics.AppStartType.COLD - - if (appStartTime == null) { - Log.w("Sentry", "App start won't be sent due to missing appStartTime") - result.success(null) - } else { - val appStartTimeMillis = DateUtils.nanosToMillis(appStartTime.nanoTimestamp().toDouble()) - val item = - - mutableMapOf( - "pluginRegistrationTime" to pluginRegistrationTime, - "appStartTime" to appStartTimeMillis, - "isColdStart" to isColdStart, - ) - - val androidNativeSpans = mutableMapOf() - - val processInitSpan = - TimeSpan().apply { - description = "Process Initialization" - setStartUnixTimeMs(appStartTimeSpan.startTimestampMs) - setStartedAt(appStartTimeSpan.startUptimeMs) - setStoppedAt(appStartMetrics.classLoadedUptimeMs) - } - processInitSpan.addToMap(androidNativeSpans) - - val applicationOnCreateSpan = appStartMetrics.applicationOnCreateTimeSpan - applicationOnCreateSpan.addToMap(androidNativeSpans) - - val contentProviderSpans = appStartMetrics.contentProviderOnCreateTimeSpans - contentProviderSpans.forEach { span -> - span.addToMap(androidNativeSpans) - } - - appStartMetrics.activityLifecycleTimeSpans.forEach { span -> - span.onCreate.addToMap(androidNativeSpans) - span.onStart.addToMap(androidNativeSpans) - } - - item["nativeSpanTimes"] = androidNativeSpans - - result.success(item) - } - } - - private fun displayRefreshRate(result: Result) { - var refreshRate: Int? = null - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val display = activity?.get()?.display - if (display != null) { - refreshRate = display.refreshRate.toInt() - } - } else { - val display = - activity - ?.get() - ?.window - ?.windowManager - ?.defaultDisplay - if (display != null) { - refreshRate = display.refreshRate.toInt() - } - } - - result.success(refreshRate) - } - - private fun TimeSpan.addToMap(map: MutableMap) { - if (startTimestamp == null) return - - description?.let { description -> - map[description] = - mapOf( - "startTimestampMsSinceEpoch" to startTimestampMs, - "stopTimestampMsSinceEpoch" to projectedStopTimestampMs, - ) - } - } private fun setContexts( key: String?, value: Any?, @@ -374,6 +268,7 @@ class SentryFlutterPlugin : result.success("") } + @Suppress("TooManyFunctions") companion object { @SuppressLint("StaticFieldLeak") private var replay: ReplayIntegration? = null @@ -381,16 +276,129 @@ class SentryFlutterPlugin : @SuppressLint("StaticFieldLeak") private var applicationContext: Context? = null + @SuppressLint("StaticFieldLeak") + private var activity: WeakReference? = null + + private var pluginRegistrationTime: Long? = null + + private lateinit var sentryFlutter: SentryFlutter + private const val NATIVE_CRASH_WAIT_TIME = 500L @Suppress("unused") // Used by native/jni bindings @JvmStatic fun privateSentryGetReplayIntegration(): ReplayIntegration? = replay + @Suppress("unused") // Used by native/jni bindings + @JvmStatic + fun getDisplayRefreshRate(): Int? { + var refreshRate: Int? = null + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val display = activity?.get()?.display + if (display != null) { + refreshRate = display.refreshRate.toInt() + } + } else { + val display = + activity + ?.get() + ?.window + ?.windowManager + ?.defaultDisplay + if (display != null) { + refreshRate = display.refreshRate.toInt() + } + } + + return refreshRate + } + + @Suppress("unused", "ReturnCount", "TooGenericExceptionCaught") // Used by native/jni bindings + @JvmStatic + fun fetchNativeAppStartAsBytes(): ByteArray? { + if (!sentryFlutter.autoPerformanceTracingEnabled) { + return null + } + + val appStartMetrics = AppStartMetrics.getInstance() + + if (!appStartMetrics.isAppLaunchedInForeground || + appStartMetrics.appStartTimeSpan.durationMs > APP_START_MAX_DURATION_MS + ) { + Log.w( + "Sentry", + "Invalid app start data: app not launched in foreground or app start took too long (>60s)", + ) + return null + } + + val appStartTimeSpan = appStartMetrics.appStartTimeSpan + val appStartTime = appStartTimeSpan.startTimestamp + val isColdStart = appStartMetrics.appStartType == AppStartMetrics.AppStartType.COLD + + if (appStartTime == null) { + Log.w("Sentry", "App start won't be sent due to missing appStartTime") + return null + } + + val appStartTimeMillis = DateUtils.nanosToMillis(appStartTime.nanoTimestamp().toDouble()) + val item = + mutableMapOf( + "pluginRegistrationTime" to pluginRegistrationTime, + "appStartTime" to appStartTimeMillis, + "isColdStart" to isColdStart, + ) + + val androidNativeSpans = mutableMapOf() + + val processInitSpan = + TimeSpan().apply { + description = "Process Initialization" + setStartUnixTimeMs(appStartTimeSpan.startTimestampMs) + setStartedAt(appStartTimeSpan.startUptimeMs) + setStoppedAt(appStartMetrics.classLoadedUptimeMs) + } + addTimeSpanToMap(processInitSpan, androidNativeSpans) + + val applicationOnCreateSpan = appStartMetrics.applicationOnCreateTimeSpan + addTimeSpanToMap(applicationOnCreateSpan, androidNativeSpans) + + val contentProviderSpans = appStartMetrics.contentProviderOnCreateTimeSpans + contentProviderSpans.forEach { span -> + addTimeSpanToMap(span, androidNativeSpans) + } + + appStartMetrics.activityLifecycleTimeSpans.forEach { span -> + addTimeSpanToMap(span.onCreate, androidNativeSpans) + addTimeSpanToMap(span.onStart, androidNativeSpans) + } + + item["nativeSpanTimes"] = androidNativeSpans + + val json = JSONObject(item).toString() + return json.toByteArray(Charsets.UTF_8) + } + + private fun addTimeSpanToMap( + span: TimeSpan, + map: MutableMap, + ) { + if (span.startTimestamp == null) return + + span.description?.let { description -> + map[description] = + mapOf( + "startTimestampMsSinceEpoch" to span.startTimestampMs, + "stopTimestampMsSinceEpoch" to span.projectedStopTimestampMs, + ) + } + } + @JvmStatic fun getApplicationContext(): Context? = applicationContext - @Suppress("unused") // Used by native/jni bindings + @Suppress("unused", "ReturnCount", "TooGenericExceptionCaught") // Used by native/jni bindings @JvmStatic fun loadContextsAsBytes(): ByteArray? { val options = ScopesAdapter.getInstance().options @@ -405,11 +413,16 @@ class SentryFlutterPlugin : options, currentScope, ) - val json = JSONObject(serializedScope).toString() - return json.toByteArray(Charsets.UTF_8) + try { + val json = JSONObject(serializedScope).toString() + return json.toByteArray(Charsets.UTF_8) + } catch (e: Exception) { + Log.e("Sentry", "Failed to serialize scope", e) + return null + } } - @Suppress("unused") // Used by native/jni bindings + @Suppress("unused", "TooGenericExceptionCaught") // Used by native/jni bindings @JvmStatic fun loadDebugImagesAsBytes(addresses: Set): ByteArray? { val options = ScopesAdapter.getInstance().options as SentryAndroidOptions @@ -428,8 +441,13 @@ class SentryFlutterPlugin : .serialize() } - val json = JSONArray(debugImages).toString() - return json.toByteArray(Charsets.UTF_8) + try { + val json = JSONArray(debugImages).toString() + return json.toByteArray(Charsets.UTF_8) + } catch (e: Exception) { + Log.e("Sentry", "Failed to serialize debug images", e) + return null + } } private fun List?.serialize() = this?.map { it.serialize() } diff --git a/packages/flutter/example/integration_test/integration_test.dart b/packages/flutter/example/integration_test/integration_test.dart index 4fa3fc4153..82b2c0e703 100644 --- a/packages/flutter/example/integration_test/integration_test.dart +++ b/packages/flutter/example/integration_test/integration_test.dart @@ -599,6 +599,60 @@ void main() { expect(debugImageByStacktrace.first.imageAddr, expectedImage.imageAddr); }); + testWidgets('fetchNativeAppStart returns app start data', (tester) async { + await restoreFlutterOnErrorAfter(() async { + await setupSentryAndApp(tester); + }); + + if (Platform.isAndroid || Platform.isIOS) { + // fetchNativeAppStart should return data on mobile platforms + final appStart = await SentryFlutter.native?.fetchNativeAppStart(); + + expect(appStart, isNotNull, reason: 'App start data should be available'); + + if (appStart != null) { + expect(appStart.appStartTime, greaterThan(0), + reason: 'App start time should be positive'); + expect(appStart.pluginRegistrationTime, greaterThan(0), + reason: 'Plugin registration time should be positive'); + expect(appStart.isColdStart, isA(), + reason: 'isColdStart should be a boolean'); + expect(appStart.nativeSpanTimes, isA(), + reason: 'Native span times should be a map'); + } + } else { + // On other platforms, it should return null + final appStart = await SentryFlutter.native?.fetchNativeAppStart(); + expect(appStart, isNull, + reason: 'App start should be null on non-mobile platforms'); + } + }); + + testWidgets('displayRefreshRate returns valid refresh rate', (tester) async { + await restoreFlutterOnErrorAfter(() async { + await setupSentryAndApp(tester); + }); + + if (Platform.isAndroid || Platform.isIOS) { + final refreshRate = await SentryFlutter.native?.displayRefreshRate(); + + // Refresh rate should be available on mobile platforms + expect(refreshRate, isNotNull, + reason: 'Display refresh rate should be available'); + + if (refreshRate != null) { + expect(refreshRate, greaterThan(0), + reason: 'Refresh rate should be positive'); + expect(refreshRate, lessThanOrEqualTo(1000), + reason: 'Refresh rate should be reasonable (<=1000Hz)'); + } + } else { + final refreshRate = await SentryFlutter.native?.displayRefreshRate(); + expect(refreshRate, isNull, + reason: 'Refresh rate should be null or positive on other platforms'); + } + }); + group('e2e', () { var output = find.byKey(const Key('output')); late Fixture fixture; diff --git a/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter/SentryFlutterPlugin.swift b/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter/SentryFlutterPlugin.swift index 044e3d4757..7d275bcca8 100644 --- a/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter/SentryFlutterPlugin.swift +++ b/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter/SentryFlutterPlugin.swift @@ -76,9 +76,6 @@ public class SentryFlutterPlugin: NSObject, FlutterPlugin { case "closeNativeSdk": closeNativeSdk(call, result: result) - case "fetchNativeAppStart": - fetchNativeAppStart(result: result) - case "setContexts": let arguments = call.arguments as? [String: Any?] let key = arguments?["key"] as? String @@ -133,9 +130,6 @@ public class SentryFlutterPlugin: NSObject, FlutterPlugin { collectProfile(call, result) #endif - case "displayRefreshRate": - displayRefreshRate(result) - case "pauseAppHangTracking": pauseAppHangTracking(result) @@ -294,83 +288,6 @@ public class SentryFlutterPlugin: NSObject, FlutterPlugin { return !name.isEmpty } - struct TimeSpan { - var startTimestampMsSinceEpoch: NSNumber - var stopTimestampMsSinceEpoch: NSNumber - var description: String - - func addToMap(_ map: inout [String: Any]) { - map[description] = [ - "startTimestampMsSinceEpoch": startTimestampMsSinceEpoch, - "stopTimestampMsSinceEpoch": stopTimestampMsSinceEpoch - ] - } - } - - private func fetchNativeAppStart(result: @escaping FlutterResult) { - #if os(iOS) || os(tvOS) - guard let appStartMeasurement = PrivateSentrySDKOnly.appStartMeasurement else { - print("warning: appStartMeasurement is null") - result(nil) - return - } - - var nativeSpanTimes: [String: Any] = [:] - - let appStartTimeMs = appStartMeasurement.appStartTimestamp.timeIntervalSince1970.toMilliseconds() - let runtimeInitTimeMs = appStartMeasurement.runtimeInitTimestamp.timeIntervalSince1970.toMilliseconds() - let moduleInitializationTimeMs = - appStartMeasurement.moduleInitializationTimestamp.timeIntervalSince1970.toMilliseconds() - let sdkStartTimeMs = appStartMeasurement.sdkStartTimestamp.timeIntervalSince1970.toMilliseconds() - - if !appStartMeasurement.isPreWarmed { - let preRuntimeInitDescription = "Pre Runtime Init" - let preRuntimeInitSpan = TimeSpan( - startTimestampMsSinceEpoch: NSNumber(value: appStartTimeMs), - stopTimestampMsSinceEpoch: NSNumber(value: runtimeInitTimeMs), - description: preRuntimeInitDescription - ) - preRuntimeInitSpan.addToMap(&nativeSpanTimes) - - let moduleInitializationDescription = "Runtime init to Pre Main initializers" - let moduleInitializationSpan = TimeSpan( - startTimestampMsSinceEpoch: NSNumber(value: runtimeInitTimeMs), - stopTimestampMsSinceEpoch: NSNumber(value: moduleInitializationTimeMs), - description: moduleInitializationDescription - ) - moduleInitializationSpan.addToMap(&nativeSpanTimes) - } - - let uiKitInitDescription = "UIKit init" - let uiKitInitSpan = TimeSpan( - startTimestampMsSinceEpoch: NSNumber(value: moduleInitializationTimeMs), - stopTimestampMsSinceEpoch: NSNumber(value: sdkStartTimeMs), - description: uiKitInitDescription - ) - uiKitInitSpan.addToMap(&nativeSpanTimes) - - // Info: We don't have access to didFinishLaunchingTimestamp, - // On HybridSDKs, the Cocoa SDK misses the didFinishLaunchNotification and the - // didBecomeVisibleNotification. Therefore, we can't set the - // didFinishLaunchingTimestamp - - let appStartTime = appStartMeasurement.appStartTimestamp.timeIntervalSince1970 * 1000 - let isColdStart = appStartMeasurement.type == .cold - - let item: [String: Any] = [ - "pluginRegistrationTime": SentryFlutterPlugin.pluginRegistrationTime, - "appStartTime": appStartTime, - "isColdStart": isColdStart, - "nativeSpanTimes": nativeSpanTimes - ] - - result(item) - #else - print("note: appStartMeasurement not available on this platform") - result(nil) - #endif - } - private func setContexts(key: String?, value: Any?, result: @escaping FlutterResult) { guard let key = key else { result("") @@ -514,42 +431,6 @@ public class SentryFlutterPlugin: NSObject, FlutterPlugin { result(nil) } - #if os(iOS) - // Taken from the Flutter engine: - // https://github.com/flutter/engine/blob/main/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.mm#L150 - private func displayRefreshRate(_ result: @escaping FlutterResult) { - let displayLink = CADisplayLink(target: self, selector: #selector(onDisplayLink(_:))) - displayLink.add(to: .main, forMode: .common) - displayLink.isPaused = true - - let preferredFPS = displayLink.preferredFramesPerSecond - displayLink.invalidate() - - if preferredFPS != 0 { - result(preferredFPS) - return - } - - if #available(iOS 13.0, *) { - guard let windowScene = UIApplication.shared.windows.first?.windowScene else { - result(nil) - return - } - result(windowScene.screen.maximumFramesPerSecond) - } else { - result(UIScreen.main.maximumFramesPerSecond) - } - } - - @objc private func onDisplayLink(_ displayLink: CADisplayLink) { - // No-op - } - #elseif os(macOS) - private func displayRefreshRate(_ result: @escaping FlutterResult) { - result(nil) - } - #endif - private func pauseAppHangTracking(_ result: @escaping FlutterResult) { SentrySDK.pauseAppHangTracking() result("") @@ -569,6 +450,99 @@ public class SentryFlutterPlugin: NSObject, FlutterPlugin { // Group of methods exposed to the Objective-C runtime via `@objc`. // // Purpose: Called from the Flutter plugin's native bridge (FFI) - bindings are created from SentryFlutterPlugin.h + + #if os(iOS) + // Taken from the Flutter engine: + // https://github.com/flutter/engine/blob/main/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.mm#L150 + @objc public class func getDisplayRefreshRate() -> NSNumber? { + let displayLink = CADisplayLink(target: self, selector: #selector(onDisplayLinkStatic(_:))) + displayLink.isPaused = true + + let preferredFPS = displayLink.preferredFramesPerSecond + displayLink.invalidate() + + if preferredFPS != 0 { + return NSNumber(value: preferredFPS) + } + + if #available(iOS 13.0, *) { + guard let windowScene = UIApplication.shared.windows.first?.windowScene else { + return nil + } + return NSNumber(value: windowScene.screen.maximumFramesPerSecond) + } else { + return NSNumber(value: UIScreen.main.maximumFramesPerSecond) + } + } + + @objc private class func onDisplayLinkStatic(_ displayLink: CADisplayLink) { + // No-op + } + #elseif os(macOS) + @objc public class func getDisplayRefreshRate() -> NSNumber? { + return nil + } + #endif + + @objc public class func fetchNativeAppStartAsBytes() -> NSData? { + #if os(iOS) || os(tvOS) + guard let appStartMeasurement = PrivateSentrySDKOnly.appStartMeasurement else { + return nil + } + + var nativeSpanTimes: [String: Any] = [:] + + let appStartTimeMs = appStartMeasurement.appStartTimestamp.timeIntervalSince1970.toMilliseconds() + let runtimeInitTimeMs = appStartMeasurement.runtimeInitTimestamp.timeIntervalSince1970.toMilliseconds() + let moduleInitializationTimeMs = + appStartMeasurement.moduleInitializationTimestamp.timeIntervalSince1970.toMilliseconds() + let sdkStartTimeMs = appStartMeasurement.sdkStartTimestamp.timeIntervalSince1970.toMilliseconds() + + if !appStartMeasurement.isPreWarmed { + let preRuntimeInitDescription = "Pre Runtime Init" + let preRuntimeInitSpan: [String: Any] = [ + "startTimestampMsSinceEpoch": NSNumber(value: appStartTimeMs), + "stopTimestampMsSinceEpoch": NSNumber(value: runtimeInitTimeMs) + ] + nativeSpanTimes[preRuntimeInitDescription] = preRuntimeInitSpan + + let moduleInitializationDescription = "Runtime init to Pre Main initializers" + let moduleInitializationSpan: [String: Any] = [ + "startTimestampMsSinceEpoch": NSNumber(value: runtimeInitTimeMs), + "stopTimestampMsSinceEpoch": NSNumber(value: moduleInitializationTimeMs) + ] + nativeSpanTimes[moduleInitializationDescription] = moduleInitializationSpan + } + + let uiKitInitDescription = "UIKit init" + let uiKitInitSpan: [String: Any] = [ + "startTimestampMsSinceEpoch": NSNumber(value: moduleInitializationTimeMs), + "stopTimestampMsSinceEpoch": NSNumber(value: sdkStartTimeMs) + ] + nativeSpanTimes[uiKitInitDescription] = uiKitInitSpan + + let appStartTime = appStartMeasurement.appStartTimestamp.timeIntervalSince1970 * 1000 + let isColdStart = appStartMeasurement.type == .cold + + let item: [String: Any] = [ + "pluginRegistrationTime": pluginRegistrationTime, + "appStartTime": appStartTime, + "isColdStart": isColdStart, + "nativeSpanTimes": nativeSpanTimes + ] + + do { + let data = try JSONSerialization.data(withJSONObject: item, options: []) + return data as NSData + } catch { + print("Failed to load native app start as bytes: \(error)") + return nil + } + #else + return nil + #endif + } + @objc(loadDebugImagesAsBytes:) public class func loadDebugImagesAsBytes(instructionAddresses: Set) -> NSData? { var debugImages: [DebugMeta] = [] @@ -595,10 +569,13 @@ public class SentryFlutterPlugin: NSObject, FlutterPlugin { } let serializedImages = debugImages.map { $0.serialize() } - if let data = try? JSONSerialization.data(withJSONObject: serializedImages, options: []) { + do { + let data = try JSONSerialization.data(withJSONObject: serializedImages, options: []) return data as NSData + } catch { + print("Failed to load debug images as bytes: \(error)") + return nil } - return nil } // swiftlint:disable:next cyclomatic_complexity @@ -680,10 +657,13 @@ public class SentryFlutterPlugin: NSObject, FlutterPlugin { "sdk_name": "cocoapods:sentry-cocoa"] } - if let data = try? JSONSerialization.data(withJSONObject: infos, options: []) { + do { + let data = try JSONSerialization.data(withJSONObject: infos, options: []) return data as NSData + } catch { + print("Failed to load contexts as bytes: \(error)") + return nil } - return nil } } // swiftlint:enable type_body_length diff --git a/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter_objc/SentryFlutterPlugin.h b/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter_objc/SentryFlutterPlugin.h index a18f29ed10..6f2e25eb54 100644 --- a/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter_objc/SentryFlutterPlugin.h +++ b/packages/flutter/ios/sentry_flutter/Sources/sentry_flutter_objc/SentryFlutterPlugin.h @@ -4,6 +4,8 @@ #import #else @interface SentryFlutterPlugin : NSObject ++ (nullable NSNumber *)getDisplayRefreshRate; ++ (nullable NSData *)fetchNativeAppStartAsBytes; + (nullable NSData *)loadContextsAsBytes; + (nullable NSData *)loadDebugImagesAsBytes:(NSSet *)instructionAddresses; @end diff --git a/packages/flutter/lib/src/integrations/native_app_start_handler.dart b/packages/flutter/lib/src/integrations/native_app_start_handler.dart index 4eda35c0e7..79ee45d91d 100644 --- a/packages/flutter/lib/src/integrations/native_app_start_handler.dart +++ b/packages/flutter/lib/src/integrations/native_app_start_handler.dart @@ -80,8 +80,8 @@ class NativeAppStartHandler { return null; } - final appStartDateTime = DateTime.fromMillisecondsSinceEpoch( - nativeAppStart.appStartTime.toInt()); + final appStartDateTime = + DateTime.fromMillisecondsSinceEpoch(nativeAppStart.appStartTime); final pluginRegistrationDateTime = DateTime.fromMillisecondsSinceEpoch( nativeAppStart.pluginRegistrationTime); diff --git a/packages/flutter/lib/src/native/cocoa/binding.dart b/packages/flutter/lib/src/native/cocoa/binding.dart index dfa2b80f88..903062aa10 100644 --- a/packages/flutter/lib/src/native/cocoa/binding.dart +++ b/packages/flutter/lib/src/native/cocoa/binding.dart @@ -1121,6 +1121,10 @@ class SentryId$1 extends objc.NSObject { } late final _class_SentryFlutterPlugin = objc.getClass("SentryFlutterPlugin"); +late final _sel_getDisplayRefreshRate = + objc.registerName("getDisplayRefreshRate"); +late final _sel_fetchNativeAppStartAsBytes = + objc.registerName("fetchNativeAppStartAsBytes"); late final _sel_loadContextsAsBytes = objc.registerName("loadContextsAsBytes"); late final _sel_loadDebugImagesAsBytes_ = objc.registerName("loadDebugImagesAsBytes:"); @@ -1146,6 +1150,24 @@ class SentryFlutterPlugin extends objc.NSObject { obj.ref.pointer, _sel_isKindOfClass_, _class_SentryFlutterPlugin); } + /// getDisplayRefreshRate + static objc.NSNumber? getDisplayRefreshRate() { + final _ret = _objc_msgSend_151sglz( + _class_SentryFlutterPlugin, _sel_getDisplayRefreshRate); + return _ret.address == 0 + ? null + : objc.NSNumber.castFromPointer(_ret, retain: true, release: true); + } + + /// fetchNativeAppStartAsBytes + static objc.NSData? fetchNativeAppStartAsBytes() { + final _ret = _objc_msgSend_151sglz( + _class_SentryFlutterPlugin, _sel_fetchNativeAppStartAsBytes); + return _ret.address == 0 + ? null + : objc.NSData.castFromPointer(_ret, retain: true, release: true); + } + /// loadContextsAsBytes static objc.NSData? loadContextsAsBytes() { final _ret = _objc_msgSend_151sglz( diff --git a/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart b/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart index 0beb6e26b1..2683ef14f0 100644 --- a/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart +++ b/packages/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart @@ -5,6 +5,7 @@ import 'package:objective_c/objective_c.dart'; import '../../../sentry_flutter.dart'; import '../../replay/replay_config.dart'; +import '../native_app_start.dart'; import '../sentry_native_channel.dart'; import '../utils/utf8_json.dart'; import 'binding.dart' as cocoa; @@ -153,4 +154,26 @@ class SentryNativeCocoa extends SentryNativeChannel { return startTime; }, ); + + @override + int? displayRefreshRate() => tryCatchSync( + 'displayRefreshRate', + () { + final refreshRate = cocoa.SentryFlutterPlugin.getDisplayRefreshRate(); + return refreshRate?.intValue; + }, + ); + + @override + NativeAppStart? fetchNativeAppStart() => tryCatchSync( + 'fetchNativeAppStart', + () { + final appStartUtf8JsonBytes = + cocoa.SentryFlutterPlugin.fetchNativeAppStartAsBytes(); + if (appStartUtf8JsonBytes == null) return null; + + final json = decodeUtf8JsonMap(appStartUtf8JsonBytes.toList()); + return NativeAppStart.fromJson(json); + }, + ); } diff --git a/packages/flutter/lib/src/native/java/binding.dart b/packages/flutter/lib/src/native/java/binding.dart index 6eb935200c..feae89319b 100644 --- a/packages/flutter/lib/src/native/java/binding.dart +++ b/packages/flutter/lib/src/native/java/binding.dart @@ -1305,6 +1305,57 @@ class SentryFlutterPlugin$Companion extends jni$_.JObject { .object(const $ReplayIntegration$NullableType()); } + static final _id_getDisplayRefreshRate = _class.instanceMethodId( + r'getDisplayRefreshRate', + r'()Ljava/lang/Integer;', + ); + + static final _getDisplayRefreshRate = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>>('globalEnv_CallObjectMethod') + .asFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>(); + + /// from: `public final java.lang.Integer getDisplayRefreshRate()` + /// The returned object must be released after use, by calling the [release] method. + jni$_.JInteger? getDisplayRefreshRate() { + return _getDisplayRefreshRate( + reference.pointer, _id_getDisplayRefreshRate as jni$_.JMethodIDPtr) + .object(const jni$_.JIntegerNullableType()); + } + + static final _id_fetchNativeAppStartAsBytes = _class.instanceMethodId( + r'fetchNativeAppStartAsBytes', + r'()[B', + ); + + static final _fetchNativeAppStartAsBytes = + jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>>('globalEnv_CallObjectMethod') + .asFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>(); + + /// from: `public final byte[] fetchNativeAppStartAsBytes()` + /// The returned object must be released after use, by calling the [release] method. + jni$_.JByteArray? fetchNativeAppStartAsBytes() { + return _fetchNativeAppStartAsBytes(reference.pointer, + _id_fetchNativeAppStartAsBytes as jni$_.JMethodIDPtr) + .object(const jni$_.JByteArrayNullableType()); + } + static final _id_getApplicationContext = _class.instanceMethodId( r'getApplicationContext', r'()Landroid/content/Context;', @@ -1765,6 +1816,57 @@ class SentryFlutterPlugin extends jni$_.JObject { .object(const $ReplayIntegration$NullableType()); } + static final _id_getDisplayRefreshRate = _class.staticMethodId( + r'getDisplayRefreshRate', + r'()Ljava/lang/Integer;', + ); + + static final _getDisplayRefreshRate = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>>('globalEnv_CallStaticObjectMethod') + .asFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>(); + + /// from: `static public final java.lang.Integer getDisplayRefreshRate()` + /// The returned object must be released after use, by calling the [release] method. + static jni$_.JInteger? getDisplayRefreshRate() { + return _getDisplayRefreshRate(_class.reference.pointer, + _id_getDisplayRefreshRate as jni$_.JMethodIDPtr) + .object(const jni$_.JIntegerNullableType()); + } + + static final _id_fetchNativeAppStartAsBytes = _class.staticMethodId( + r'fetchNativeAppStartAsBytes', + r'()[B', + ); + + static final _fetchNativeAppStartAsBytes = + jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>>('globalEnv_CallStaticObjectMethod') + .asFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>(); + + /// from: `static public final byte[] fetchNativeAppStartAsBytes()` + /// The returned object must be released after use, by calling the [release] method. + static jni$_.JByteArray? fetchNativeAppStartAsBytes() { + return _fetchNativeAppStartAsBytes(_class.reference.pointer, + _id_fetchNativeAppStartAsBytes as jni$_.JMethodIDPtr) + .object(const jni$_.JByteArrayNullableType()); + } + static final _id_getApplicationContext = _class.staticMethodId( r'getApplicationContext', r'()Landroid/content/Context;', diff --git a/packages/flutter/lib/src/native/java/sentry_native_java.dart b/packages/flutter/lib/src/native/java/sentry_native_java.dart index 58fb8657ce..d873f82203 100644 --- a/packages/flutter/lib/src/native/java/sentry_native_java.dart +++ b/packages/flutter/lib/src/native/java/sentry_native_java.dart @@ -6,6 +6,7 @@ import 'package:meta/meta.dart'; import '../../../sentry_flutter.dart'; import '../../replay/scheduled_recorder_config.dart'; +import '../native_app_start.dart'; import '../sentry_native_channel.dart'; import '../utils/utf8_json.dart'; import 'android_envelope_sender.dart'; @@ -171,6 +172,33 @@ class SentryNativeJava extends SentryNativeChannel { return null; } + @override + int? displayRefreshRate() => tryCatchSync('displayRefreshRate', () { + return native.SentryFlutterPlugin.Companion + .getDisplayRefreshRate() + ?.intValue(releaseOriginal: true); + }); + + @override + NativeAppStart? fetchNativeAppStart() { + JByteArray? appStartUtf8JsonBytes; + + return tryCatchSync('fetchNativeAppStart', () { + appStartUtf8JsonBytes = + native.SentryFlutterPlugin.Companion.fetchNativeAppStartAsBytes(); + if (appStartUtf8JsonBytes == null) return null; + + final byteRange = + appStartUtf8JsonBytes!.getRange(0, appStartUtf8JsonBytes!.length); + final bytes = Uint8List.view( + byteRange.buffer, byteRange.offsetInBytes, byteRange.length); + final appStartMap = decodeUtf8JsonMap(bytes); + return NativeAppStart.fromJson(appStartMap); + }, finallyFn: () { + appStartUtf8JsonBytes?.release(); + }); + } + @override Future close() async { await _replayRecorder?.stop(); diff --git a/packages/flutter/lib/src/native/native_app_start.dart b/packages/flutter/lib/src/native/native_app_start.dart index 71f0c1eb58..dc56381b68 100644 --- a/packages/flutter/lib/src/native/native_app_start.dart +++ b/packages/flutter/lib/src/native/native_app_start.dart @@ -9,18 +9,28 @@ class NativeAppStart { required this.isColdStart, required this.nativeSpanTimes}); - double appStartTime; + int appStartTime; int pluginRegistrationTime; bool isColdStart; Map nativeSpanTimes; static NativeAppStart? fromJson(Map json) { - final appStartTime = json['appStartTime']; + final appStartTimeValue = json['appStartTime']; final pluginRegistrationTime = json['pluginRegistrationTime']; final isColdStart = json['isColdStart']; final nativeSpanTimes = json['nativeSpanTimes']; - if (appStartTime is! double || + // Convert appStartTime to int (iOS returns double, Android returns int) + final int? appStartTime; + if (appStartTimeValue is int) { + appStartTime = appStartTimeValue; + } else if (appStartTimeValue is double) { + appStartTime = appStartTimeValue.toInt(); + } else { + appStartTime = null; + } + + if (appStartTime == null || pluginRegistrationTime is! int || isColdStart is! bool || nativeSpanTimes is! Map) { diff --git a/packages/flutter/lib/src/native/sentry_native_channel.dart b/packages/flutter/lib/src/native/sentry_native_channel.dart index 20d72dd317..384bdcaca7 100644 --- a/packages/flutter/lib/src/native/sentry_native_channel.dart +++ b/packages/flutter/lib/src/native/sentry_native_channel.dart @@ -91,10 +91,10 @@ class SentryNativeChannel Future close() async => channel.invokeMethod('closeNativeSdk'); @override - Future fetchNativeAppStart() async { - final json = - await channel.invokeMapMethod('fetchNativeAppStart'); - return (json != null) ? NativeAppStart.fromJson(json) : null; + FutureOr fetchNativeAppStart() async { + assert(false, + 'fetchNativeAppStart should not be used through method channels.'); + return null; } @override @@ -223,8 +223,11 @@ class SentryNativeChannel } @override - Future displayRefreshRate() => - channel.invokeMethod('displayRefreshRate'); + FutureOr displayRefreshRate() { + assert(false, + 'displayRefreshRate should not be used through method channels.'); + return null; + } @override Future pauseAppHangTracking() => diff --git a/packages/flutter/lib/src/native/sentry_native_invoker.dart b/packages/flutter/lib/src/native/sentry_native_invoker.dart index 6b20aff03d..31b1c187b6 100644 --- a/packages/flutter/lib/src/native/sentry_native_invoker.dart +++ b/packages/flutter/lib/src/native/sentry_native_invoker.dart @@ -22,7 +22,8 @@ mixin SentryNativeSafeInvoker { } } - T? tryCatchSync(String nativeMethodName, T? Function() fn) { + T? tryCatchSync(String nativeMethodName, T? Function() fn, + {void Function()? finallyFn}) { try { return fn(); } catch (error, stackTrace) { @@ -31,6 +32,8 @@ mixin SentryNativeSafeInvoker { rethrow; } return null; + } finally { + finallyFn?.call(); } } diff --git a/packages/flutter/test/sentry_native_channel_test.dart b/packages/flutter/test/sentry_native_channel_test.dart index 1a5c7dd476..f35d68ea8b 100644 --- a/packages/flutter/test/sentry_native_channel_test.dart +++ b/packages/flutter/test/sentry_native_channel_test.dart @@ -38,34 +38,15 @@ void main() { // TODO move other methods here, e.g. init_native_sdk_test.dart test('fetchNativeAppStart', () async { - when(channel.invokeMethod('fetchNativeAppStart')) - .thenAnswer((_) async => { - 'pluginRegistrationTime': 1, - 'appStartTime': 0.1, - 'isColdStart': true, - // ignore: inference_failure_on_collection_literal - 'nativeSpanTimes': {}, - }); - - final actual = await sut.fetchNativeAppStart(); - - expect(actual?.appStartTime, 0.1); - expect(actual?.isColdStart, true); - }); - - test('invalid fetchNativeAppStart returns null', () async { - when(channel.invokeMethod('fetchNativeAppStart')) - .thenAnswer((_) async => { - 'pluginRegistrationTime': 'invalid', - 'appStartTime': 'invalid', - 'isColdStart': 'invalid', - // ignore: inference_failure_on_collection_literal - 'nativeSpanTimes': 'invalid', - }); + final matcher = _nativeUnavailableMatcher( + mockPlatform, + includeLookupSymbol: true, + includeFailedToLoadClassException: true, + ); - final actual = await sut.fetchNativeAppStart(); + expect(() => sut.fetchNativeAppStart(), matcher); - expect(actual, isNull); + verifyZeroInteractions(channel); }); test('setUser', () async { @@ -269,6 +250,18 @@ void main() { verifyZeroInteractions(channel); }); + test('displayRefreshRate', () async { + final matcher = _nativeUnavailableMatcher( + mockPlatform, + includeLookupSymbol: true, + includeFailedToLoadClassException: true, + ); + + expect(() => sut.displayRefreshRate(), matcher); + + verifyZeroInteractions(channel); + }); + test('pauseAppHangTracking', () async { when(channel.invokeMethod('pauseAppHangTracking')) .thenAnswer((_) => Future.value());