From fed5eef8d02ff60e1688d02fcd3958568e39d587 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Tue, 30 Sep 2025 10:30:51 +0300 Subject: [PATCH 01/10] Add custom screenshot capturing for asserts on Android --- .../Android/AndroidSentrySubsystem.cpp | 25 +++++++++++++++++ .../Private/Android/AndroidSentrySubsystem.h | 2 ++ .../Android/Java/SentryBridgeJava.java | 27 +++++++++++++----- .../Private/Android/Jni/AndroidSentryJni.cpp | 13 +++++++++ .../Sentry/Private/Utils/SentryFileUtils.cpp | 28 +++++++++++++++++++ .../Sentry/Private/Utils/SentryFileUtils.h | 2 ++ .../Private/Utils/SentryScreenshotUtils.cpp | 14 ++++++++++ 7 files changed, 104 insertions(+), 7 deletions(-) diff --git a/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp b/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp index 6f7e1093e..15d8d5a5e 100644 --- a/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp +++ b/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp @@ -26,8 +26,13 @@ #include "Utils/SentryFileUtils.h" #include "Dom/JsonObject.h" +#include "HAL/FileManager.h" +#include "Misc/CoreDelegates.h" +#include "Misc/FileHelper.h" #include "Misc/OutputDeviceError.h" +#include "Misc/Paths.h" #include "Serialization/JsonSerializer.h" +#include "Utils/SentryScreenshotUtils.h" FAndroidSentrySubsystem::FAndroidSentrySubsystem() { @@ -81,6 +86,14 @@ void FAndroidSentrySubsystem::InitWithSettings(const USentrySettings* settings, FSentryJavaObjectWrapper::CallStaticMethod(SentryJavaClasses::SentryBridgeJava, "init", "(Landroid/app/Activity;Ljava/lang/String;)V", FJavaWrapper::GameActivityThis, *FSentryJavaObjectWrapper::GetJString(SettingsJsonStr)); + + if (IsEnabled()) + { + FCoreDelegates::OnHandleSystemError.AddLambda([this]() + { + TryCaptureScreenshot(); + }); + } } void FAndroidSentrySubsystem::Close() @@ -340,3 +353,15 @@ void FAndroidSentrySubsystem::HandleAssert() GError->HandleError(); PLATFORM_BREAK(); } + +FString FAndroidSentrySubsystem::TryCaptureScreenshot() const +{ + FString ScreenshotPath = SentryFileUtils::GetScreenshotPath(); + + if (!SentryScreenshotUtils::CaptureScreenshot(ScreenshotPath)) + { + return FString(""); + } + + return ScreenshotPath; +} diff --git a/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.h b/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.h index 386e6bf74..e2eeec1cf 100644 --- a/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.h +++ b/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.h @@ -44,6 +44,8 @@ class FAndroidSentrySubsystem : public ISentrySubsystem virtual TSharedPtr ContinueTrace(const FString& sentryTrace, const TArray& baggageHeaders) override; virtual void HandleAssert() override; + + FString TryCaptureScreenshot() const; }; typedef FAndroidSentrySubsystem FPlatformSentrySubsystem; diff --git a/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java b/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java index cf3a7f630..b53938bc8 100644 --- a/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java +++ b/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java @@ -39,6 +39,7 @@ public class SentryBridgeJava { public static native Breadcrumb onBeforeBreadcrumb(long handlerAddr, Breadcrumb breadcrumb, Hint hint); public static native float onTracesSampler(long samplerAddr, SamplingContext samplingContext); public static native String getLogFilePath(boolean isCrash); + public static native String getScreenshotFilePath(); public static void init(Activity activity, final String settingsJsonStr) { SentryAndroid.init(activity, new Sentry.OptionsConfiguration() { @@ -56,7 +57,6 @@ public void configure(SentryAndroidOptions options) { options.setDebug(settingJson.getBoolean("debug")); options.setSampleRate(settingJson.getDouble("sampleRate")); options.setMaxBreadcrumbs(settingJson.getInt("maxBreadcrumbs")); - options.setAttachScreenshot(settingJson.getBoolean("attachScreenshot")); options.setSendDefaultPii(settingJson.getBoolean("sendDefaultPii")); JSONArray Includes = settingJson.getJSONArray("inAppInclude"); for (int i = 0; i < Includes.length(); i++) { @@ -94,10 +94,12 @@ public Breadcrumb execute(Breadcrumb breadcrumb, Hint hint) { }); } if (settingJson.has("beforeSendHandler")) { - options.setBeforeSend(new SentryUnrealBeforeSendCallback(settingJson.getBoolean("enableAutoLogAttachment"), settingJson.getLong("beforeSendHandler"))); + options.setBeforeSend(new SentryUnrealBeforeSendCallback( + settingJson.getBoolean("enableAutoLogAttachment"), settingJson.getBoolean("attachScreenshot"), settingJson.getLong("beforeSendHandler"))); } else { - options.setBeforeSend(new SentryUnrealBeforeSendCallback(settingJson.getBoolean("enableAutoLogAttachment"))); + options.setBeforeSend(new SentryUnrealBeforeSendCallback( + settingJson.getBoolean("enableAutoLogAttachment"), settingJson.getBoolean("attachScreenshot"))); } } catch (JSONException e) { throw new RuntimeException(e); @@ -117,6 +119,8 @@ public static void addBreadcrumb(final String message, final String category, fi } Sentry.addBreadcrumb(breadcrumb); + + Sentry.getCurrentScopes().captureEnvelope() } public static SentryId captureMessageWithScope(final String message, final SentryLevel level, final long callback) throws InterruptedException { @@ -243,26 +247,35 @@ public static void clearAttachments() { private static class SentryUnrealBeforeSendCallback implements SentryOptions.BeforeSendCallback { private final boolean attachLog; + private final boolean attachScreenshot; private final long beforeSendAddr; - public SentryUnrealBeforeSendCallback(boolean attachLog) { + public SentryUnrealBeforeSendCallback(boolean attachLog, boolean attachScreenshot) { this.attachLog = attachLog; + this.attachScreenshot = attachScreenshot; this.beforeSendAddr = 0; } - public SentryUnrealBeforeSendCallback(boolean attachLog, long beforeSendAddr) { + public SentryUnrealBeforeSendCallback(boolean attachLog, boolean attachScreenshot, long beforeSendAddr) { this.attachLog = attachLog; + this.attachScreenshot = attachScreenshot; this.beforeSendAddr = beforeSendAddr; } @Override public SentryEvent execute(SentryEvent event, Hint hint) { - if(attachLog) { + if (attachLog) { String logFilePath = getLogFilePath(event.isCrashed()); - if(!logFilePath.isEmpty()) { + if (!logFilePath.isEmpty()) { hint.addAttachment(new Attachment(logFilePath, new File(logFilePath).getName(), "text/plain")); } } + if (attachScreenshot && event.isCrashed()) { + String screenshotFilePath = getScreenshotFilePath(); + if (!screenshotFilePath.isEmpty()) { + hint.addAttachment(new Attachment(screenshotFilePath, "screenshot.png", "text/plain")); + } + } if (beforeSendAddr != 0) { return onBeforeSend(beforeSendAddr, event, hint); } diff --git a/plugin-dev/Source/Sentry/Private/Android/Jni/AndroidSentryJni.cpp b/plugin-dev/Source/Sentry/Private/Android/Jni/AndroidSentryJni.cpp index 5e163b069..33c7b73f1 100644 --- a/plugin-dev/Source/Sentry/Private/Android/Jni/AndroidSentryJni.cpp +++ b/plugin-dev/Source/Sentry/Private/Android/Jni/AndroidSentryJni.cpp @@ -130,3 +130,16 @@ JNI_METHOD jstring Java_io_sentry_unreal_SentryBridgeJava_getLogFilePath(JNIEnv* return env->NewStringUTF(TCHAR_TO_UTF8(*LogFilePath)); } + +JNI_METHOD jstring Java_io_sentry_unreal_SentryBridgeJava_getScreenshotFilePath(JNIEnv* env, jclass clazz) +{ + const FString ScreenshotFilePath = IFileManager::Get().ConvertToAbsolutePathForExternalAppForRead(*SentryFileUtils::GetLatestScreenshot()); + + IFileManager& FileManager = IFileManager::Get(); + if (!FileManager.FileExists(*ScreenshotFilePath)) + { + return env->NewStringUTF(""); + } + + return env->NewStringUTF(TCHAR_TO_UTF8(*ScreenshotFilePath)); +} diff --git a/plugin-dev/Source/Sentry/Private/Utils/SentryFileUtils.cpp b/plugin-dev/Source/Sentry/Private/Utils/SentryFileUtils.cpp index 9beaba0a0..106750f4b 100644 --- a/plugin-dev/Source/Sentry/Private/Utils/SentryFileUtils.cpp +++ b/plugin-dev/Source/Sentry/Private/Utils/SentryFileUtils.cpp @@ -71,3 +71,31 @@ FString SentryFileUtils::GetGpuDumpPath() return IFileManager::Get().ConvertToAbsolutePathForExternalAppForRead(*GpuDumpFiles[0]); } + +FString SentryFileUtils::GetScreenshotPath() +{ + return FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("SentryScreenshots"), FString::Printf(TEXT("screenshot-%s.png"), *FDateTime::Now().ToString())); +} + +FString SentryFileUtils::GetLatestScreenshot() +{ + const FString& ScreenshotsDir = FPaths::Combine(FPaths::ProjectSavedDir(), TEXT("SentryScreenshots")); + + TArray Screenshots; + IFileManager::Get().FindFiles(Screenshots, *ScreenshotsDir, TEXT("*.png")); + + if (Screenshots.Num() == 0) + { + UE_LOG(LogSentrySdk, Log, TEXT("There are no screenshots found.")); + return FString(""); + } + + for (int i = 0; i < Screenshots.Num(); ++i) + { + Screenshots[i] = ScreenshotsDir / Screenshots[i]; + } + + Screenshots.Sort(FSentrySortFileByDatePredicate()); + + return Screenshots[0]; +} diff --git a/plugin-dev/Source/Sentry/Private/Utils/SentryFileUtils.h b/plugin-dev/Source/Sentry/Private/Utils/SentryFileUtils.h index 0a6e7a8b2..1e9536432 100644 --- a/plugin-dev/Source/Sentry/Private/Utils/SentryFileUtils.h +++ b/plugin-dev/Source/Sentry/Private/Utils/SentryFileUtils.h @@ -11,4 +11,6 @@ class SentryFileUtils static FString GetGameLogPath(); static FString GetGameLogBackupPath(); static FString GetGpuDumpPath(); + static FString GetScreenshotPath(); + static FString GetLatestScreenshot(); }; \ No newline at end of file diff --git a/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp b/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp index a6aeb21ff..57f930cce 100644 --- a/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp +++ b/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp @@ -55,6 +55,20 @@ bool SentryScreenshotUtils::CaptureScreenshot(const FString& ScreenshotSavePath) return false; } +#if PLATFORM_ANDROID + Algo::Reverse(*Bitmap); + + for (int32 Y = 0; Y < ViewportSize.Y; ++Y) + { + int32 RowStart = Y * ViewportSize.X; + int32 RowEnd = RowStart + ViewportSize.X - 1; + for (int32 X = 0; X < ViewportSize.X / 2; ++X) + { + Swap((*Bitmap)[RowStart + X], (*Bitmap)[RowEnd - X]); + } + } +#endif + #if UE_VERSION_OLDER_THAN(5, 0, 0) GetHighResScreenshotConfig().MergeMaskIntoAlpha(*Bitmap); TArray* CompressedBitmap = new TArray(); From c9612900b70f0c0354408a0df67f733c1bb5566d Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Tue, 30 Sep 2025 18:27:03 +0300 Subject: [PATCH 02/10] Try uploading screenshots via separate envelope --- .../Android/AndroidSentrySubsystem.cpp | 14 +++++++- .../Private/Android/AndroidSentrySubsystem.h | 3 ++ .../Android/Java/SentryBridgeJava.java | 34 +++++++++++++++++-- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp b/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp index 15d8d5a5e..b7b09a66e 100644 --- a/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp +++ b/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp @@ -46,6 +46,8 @@ FAndroidSentrySubsystem::~FAndroidSentrySubsystem() void FAndroidSentrySubsystem::InitWithSettings(const USentrySettings* settings, USentryBeforeSendHandler* beforeSendHandler, USentryBeforeBreadcrumbHandler* beforeBreadcrumbHandler, USentryTraceSampler* traceSampler) { + isScreenshotAttachmentEnabled = settings->AttachScreenshot; + TSharedPtr SettingsJson = MakeShareable(new FJsonObject); SettingsJson->SetStringField(TEXT("dsn"), settings->Dsn); SettingsJson->SetStringField(TEXT("release"), settings->GetEffectiveRelease()); @@ -87,7 +89,7 @@ void FAndroidSentrySubsystem::InitWithSettings(const USentrySettings* settings, FSentryJavaObjectWrapper::CallStaticMethod(SentryJavaClasses::SentryBridgeJava, "init", "(Landroid/app/Activity;Ljava/lang/String;)V", FJavaWrapper::GameActivityThis, *FSentryJavaObjectWrapper::GetJString(SettingsJsonStr)); - if (IsEnabled()) + if (IsEnabled() && isScreenshotAttachmentEnabled) { FCoreDelegates::OnHandleSystemError.AddLambda([this]() { @@ -220,6 +222,16 @@ TSharedPtr FAndroidSentrySubsystem::CaptureEnsure(const FString& type auto id = FSentryJavaObjectWrapper::CallStaticObjectMethod(SentryJavaClasses::SentryBridgeJava, "captureException", "(Ljava/lang/String;Ljava/lang/String;)Lio/sentry/protocol/SentryId;", *FSentryJavaObjectWrapper::GetJString(type), *FSentryJavaObjectWrapper::GetJString(message)); + if (isScreenshotAttachmentEnabled) + { + const FString& screenshotPath = TryCaptureScreenshot(); + if (!screenshotPath.IsEmpty()) + { + FSentryJavaObjectWrapper::CallStaticMethod(SentryJavaClasses::SentryBridgeJava, "uploadScreenshotForEvent", "(Lio/sentry/protocol/SentryId;Ljava/lang/String;)V", + *id, *FSentryJavaObjectWrapper::GetJString(IFileManager::Get().ConvertToAbsolutePathForExternalAppForRead(*screenshotPath))); + } + } + return MakeShareable(new FAndroidSentryId(*id)); } diff --git a/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.h b/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.h index e2eeec1cf..4f7656c23 100644 --- a/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.h +++ b/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.h @@ -46,6 +46,9 @@ class FAndroidSentrySubsystem : public ISentrySubsystem virtual void HandleAssert() override; FString TryCaptureScreenshot() const; + +protected: + bool isScreenshotAttachmentEnabled = false; }; typedef FAndroidSentrySubsystem FPlatformSentrySubsystem; diff --git a/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java b/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java index b53938bc8..5d443f8f5 100644 --- a/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java +++ b/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java @@ -23,6 +23,8 @@ import io.sentry.IScope; import io.sentry.ScopeCallback; import io.sentry.Sentry; +import io.sentry.SentryEnvelope; +import io.sentry.SentryEnvelopeItem; import io.sentry.SentryEvent; import io.sentry.SentryLevel; import io.sentry.SentryOptions; @@ -119,8 +121,6 @@ public static void addBreadcrumb(final String message, final String category, fi } Sentry.addBreadcrumb(breadcrumb); - - Sentry.getCurrentScopes().captureEnvelope() } public static SentryId captureMessageWithScope(final String message, final SentryLevel level, final long callback) throws InterruptedException { @@ -245,6 +245,34 @@ public static void clearAttachments() { Sentry.getGlobalScope().clearAttachments(); } + public static void uploadScreenshotForEvent(SentryId eventId, String filePath) { + // Screenshot capturing is a best-effort solution so if one wasn't captured (path is empty) skip the upload + File screenshotFile = new File(filePath); + if (!screenshotFile.exists()) { + return; + } + uploadAttachmentForEvent(eventId, filePath, "screenshot.png", "image/png", true); + } + + public static void uploadAttachmentForEvent(SentryId eventId, String filePath, String name, String contentType, boolean deleteAfterUpload) { + SentryOptions options = getOptions(); + Attachment attachment = new Attachment(filePath, name, contentType); + SentryEnvelopeItem item = SentryEnvelopeItem.fromAttachment( + options.getSerializer(), + options.getLogger(), + attachment, + options.getMaxAttachmentSize() + ); + SentryEnvelope envelope = new SentryEnvelope(eventId, options.getSdkVersion(), item); + Sentry.getCurrentScopes().captureEnvelope(envelope); + if (deleteAfterUpload) { +// File attachmentFile = new File(filePath); +// if (!attachmentFile.delete()) { +// options.getLogger().log(SentryLevel.ERROR, "Failed to delete file: %s", filePath); +// } + } + } + private static class SentryUnrealBeforeSendCallback implements SentryOptions.BeforeSendCallback { private final boolean attachLog; private final boolean attachScreenshot; @@ -273,7 +301,7 @@ public SentryEvent execute(SentryEvent event, Hint hint) { if (attachScreenshot && event.isCrashed()) { String screenshotFilePath = getScreenshotFilePath(); if (!screenshotFilePath.isEmpty()) { - hint.addAttachment(new Attachment(screenshotFilePath, "screenshot.png", "text/plain")); + SentryBridgeJava.uploadScreenshotForEvent(event.getEventId(), screenshotFilePath); } } if (beforeSendAddr != 0) { From da8b30f48ac03baafc79aba2575b424310446400 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Wed, 1 Oct 2025 12:22:52 +0300 Subject: [PATCH 03/10] Fix screenshot for emulator --- .../Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp b/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp index 57f930cce..fe33ee24e 100644 --- a/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp +++ b/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp @@ -55,7 +55,8 @@ bool SentryScreenshotUtils::CaptureScreenshot(const FString& ScreenshotSavePath) return false; } -#if PLATFORM_ANDROID + // On Android bitmap fill order is different so we flip and mirror it accordingly to get a proper image +#if PLATFORM_ANDROID_ARM64 Algo::Reverse(*Bitmap); for (int32 Y = 0; Y < ViewportSize.Y; ++Y) From 7911e5ee6a813c280adea085803ae1531f2e3961 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Wed, 1 Oct 2025 12:25:48 +0300 Subject: [PATCH 04/10] Add captured screenshots cleanup --- .../Android/AndroidSentrySubsystem.cpp | 23 +++++-- .../Android/Java/SentryBridgeJava.java | 67 ++++++++++--------- 2 files changed, 53 insertions(+), 37 deletions(-) diff --git a/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp b/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp index b7b09a66e..08df0ec7e 100644 --- a/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp +++ b/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp @@ -219,19 +219,30 @@ TSharedPtr FAndroidSentrySubsystem::CaptureEventWithScope(TSharedPtr< TSharedPtr FAndroidSentrySubsystem::CaptureEnsure(const FString& type, const FString& message) { - auto id = FSentryJavaObjectWrapper::CallStaticObjectMethod(SentryJavaClasses::SentryBridgeJava, "captureException", "(Ljava/lang/String;Ljava/lang/String;)Lio/sentry/protocol/SentryId;", - *FSentryJavaObjectWrapper::GetJString(type), *FSentryJavaObjectWrapper::GetJString(message)); + TSharedPtr ScreenshotAttachment = nullptr; if (isScreenshotAttachmentEnabled) { - const FString& screenshotPath = TryCaptureScreenshot(); - if (!screenshotPath.IsEmpty()) + const FString& ScreenshotPath = TryCaptureScreenshot(); + if (!ScreenshotPath.IsEmpty()) { - FSentryJavaObjectWrapper::CallStaticMethod(SentryJavaClasses::SentryBridgeJava, "uploadScreenshotForEvent", "(Lio/sentry/protocol/SentryId;Ljava/lang/String;)V", - *id, *FSentryJavaObjectWrapper::GetJString(IFileManager::Get().ConvertToAbsolutePathForExternalAppForRead(*screenshotPath))); + TArray ScreenshotData; + if (FFileHelper::LoadFileToArray(ScreenshotData, *ScreenshotPath)) + { + ScreenshotAttachment = MakeShareable(new FAndroidSentryAttachment(ScreenshotData, TEXT("screenshot.png"), TEXT("image/png"))); + } + + if (!IFileManager::Get().Delete(*ScreenshotPath)) + { + UE_LOG(LogSentrySdk, Error, TEXT("Failed to delete screenshot attachment: %s"), *ScreenshotPath); + } } } + auto id = FSentryJavaObjectWrapper::CallStaticObjectMethod(SentryJavaClasses::SentryBridgeJava, "captureException", "(Ljava/lang/String;Ljava/lang/String;Lio/sentry/Attachment;)Lio/sentry/protocol/SentryId;", + *FSentryJavaObjectWrapper::GetJString(type), *FSentryJavaObjectWrapper::GetJString(message), + ScreenshotAttachment.IsValid() ? ScreenshotAttachment->GetJObject() : nullptr); + return MakeShareable(new FAndroidSentryId(*id)); } diff --git a/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java b/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java index 5d443f8f5..eeb192eef 100644 --- a/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java +++ b/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java @@ -11,6 +11,7 @@ import org.json.JSONObject; import java.io.File; +import java.io.FileInputStream; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -144,13 +145,19 @@ public void run(@NonNull IScope scope) { return eventId; } - public static SentryId captureException(final String type, final String value) { + public static SentryId captureException(final String type, final String value, final Attachment screenshotAttachment) { SentryException exception = new SentryException(); exception.setType(type); exception.setValue(value); SentryEvent event = new SentryEvent(); event.setExceptions(Collections.singletonList(exception)); - SentryId eventId = Sentry.captureEvent(event); + + Hint hint = new Hint(); + if (screenshotAttachment != null) { + hint.addAttachment(screenshotAttachment); + } + + SentryId eventId = Sentry.captureEvent(event, hint); return eventId; } @@ -245,34 +252,6 @@ public static void clearAttachments() { Sentry.getGlobalScope().clearAttachments(); } - public static void uploadScreenshotForEvent(SentryId eventId, String filePath) { - // Screenshot capturing is a best-effort solution so if one wasn't captured (path is empty) skip the upload - File screenshotFile = new File(filePath); - if (!screenshotFile.exists()) { - return; - } - uploadAttachmentForEvent(eventId, filePath, "screenshot.png", "image/png", true); - } - - public static void uploadAttachmentForEvent(SentryId eventId, String filePath, String name, String contentType, boolean deleteAfterUpload) { - SentryOptions options = getOptions(); - Attachment attachment = new Attachment(filePath, name, contentType); - SentryEnvelopeItem item = SentryEnvelopeItem.fromAttachment( - options.getSerializer(), - options.getLogger(), - attachment, - options.getMaxAttachmentSize() - ); - SentryEnvelope envelope = new SentryEnvelope(eventId, options.getSdkVersion(), item); - Sentry.getCurrentScopes().captureEnvelope(envelope); - if (deleteAfterUpload) { -// File attachmentFile = new File(filePath); -// if (!attachmentFile.delete()) { -// options.getLogger().log(SentryLevel.ERROR, "Failed to delete file: %s", filePath); -// } - } - } - private static class SentryUnrealBeforeSendCallback implements SentryOptions.BeforeSendCallback { private final boolean attachLog; private final boolean attachScreenshot; @@ -292,22 +271,48 @@ public SentryUnrealBeforeSendCallback(boolean attachLog, boolean attachScreensho @Override public SentryEvent execute(SentryEvent event, Hint hint) { + SentryOptions options = getOptions(); + if (attachLog) { String logFilePath = getLogFilePath(event.isCrashed()); if (!logFilePath.isEmpty()) { hint.addAttachment(new Attachment(logFilePath, new File(logFilePath).getName(), "text/plain")); } } + if (attachScreenshot && event.isCrashed()) { String screenshotFilePath = getScreenshotFilePath(); if (!screenshotFilePath.isEmpty()) { - SentryBridgeJava.uploadScreenshotForEvent(event.getEventId(), screenshotFilePath); + try { + File screenshotFile = new File(screenshotFilePath); + if (screenshotFile.exists()) { + byte[] screenshotBytes = readFileToBytes(screenshotFile); + hint.addAttachment(new Attachment(screenshotBytes, "screenshot.png", "image/png")); + if (!screenshotFile.delete()) { + options.getLogger().log(SentryLevel.WARNING, "Failed to delete screenshot: %s", screenshotFilePath); + } + } + } catch (Exception e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to process screenshot", e); + } } } + if (beforeSendAddr != 0) { return onBeforeSend(beforeSendAddr, event, hint); } return event; } } + + private static byte[] readFileToBytes(File file) throws Exception { + FileInputStream fis = new FileInputStream(file); + try { + byte[] buffer = new byte[(int) file.length()]; + fis.read(buffer); + return buffer; + } finally { + fis.close(); + } + } } From c85c42487c2fa6fc0e3d33eaf43d473c9d4d0c63 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Wed, 1 Oct 2025 13:43:10 +0300 Subject: [PATCH 05/10] Clean up imports --- .../Source/Sentry/Private/Android/Java/SentryBridgeJava.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java b/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java index c57aabb56..732464e5e 100644 --- a/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java +++ b/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java @@ -24,8 +24,6 @@ import io.sentry.IScope; import io.sentry.ScopeCallback; import io.sentry.Sentry; -import io.sentry.SentryEnvelope; -import io.sentry.SentryEnvelopeItem; import io.sentry.SentryEvent; import io.sentry.SentryLevel; import io.sentry.SentryOptions; From 7619f2ebad89c9ac71a64d8b371eef5eb52e378e Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Wed, 1 Oct 2025 13:43:44 +0300 Subject: [PATCH 06/10] Update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b799dad38..e2c4df70d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add screenshot capturing for ensure/assert events on Android ([#1097](https://github.com/getsentry/sentry-unreal/pull/1097)) + ## 1.2.0-beta.1 ### Features From 99edd234f0b169ed3ac389ca21b7d54e2fdaa202 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Wed, 1 Oct 2025 14:48:52 +0300 Subject: [PATCH 07/10] Handle system error delegate properly --- .../Sentry/Private/Android/AndroidSentrySubsystem.cpp | 8 +++++++- .../Sentry/Private/Android/AndroidSentrySubsystem.h | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp b/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp index e28791c3a..144cb2b5e 100644 --- a/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp +++ b/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp @@ -96,7 +96,7 @@ void FAndroidSentrySubsystem::InitWithSettings(const USentrySettings* settings, if (IsEnabled() && isScreenshotAttachmentEnabled) { - FCoreDelegates::OnHandleSystemError.AddLambda([this]() + OnHandleSystemErrorDelegateHandle = FCoreDelegates::OnHandleSystemError.AddLambda([this]() { TryCaptureScreenshot(); }); @@ -105,6 +105,12 @@ void FAndroidSentrySubsystem::InitWithSettings(const USentrySettings* settings, void FAndroidSentrySubsystem::Close() { + if (OnHandleSystemErrorDelegateHandle.IsValid()) + { + FCoreDelegates::OnHandleSystemError.Remove(OnHandleSystemErrorDelegateHandle); + OnHandleSystemErrorDelegateHandle.Reset(); + } + FSentryJavaObjectWrapper::CallStaticMethod(SentryJavaClasses::Sentry, "close", "()V"); } diff --git a/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.h b/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.h index ab463a996..39d70f755 100644 --- a/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.h +++ b/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.h @@ -48,8 +48,10 @@ class FAndroidSentrySubsystem : public ISentrySubsystem FString TryCaptureScreenshot() const; -protected: +private: bool isScreenshotAttachmentEnabled = false; + + FDelegateHandle OnHandleSystemErrorDelegateHandle; }; typedef FAndroidSentrySubsystem FPlatformSentrySubsystem; From a819facae6a206d589d3fc0ffc686d2663c02bfa Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Wed, 1 Oct 2025 14:55:16 +0300 Subject: [PATCH 08/10] Update Java file reading --- .../Sentry/Private/Android/Java/SentryBridgeJava.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java b/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java index 732464e5e..f42780715 100644 --- a/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java +++ b/plugin-dev/Source/Sentry/Private/Android/Java/SentryBridgeJava.java @@ -350,7 +350,16 @@ private static byte[] readFileToBytes(File file) throws Exception { FileInputStream fis = new FileInputStream(file); try { byte[] buffer = new byte[(int) file.length()]; - fis.read(buffer); + int offset = 0; + int remaining = buffer.length; + while (remaining > 0) { + int bytesRead = fis.read(buffer, offset, remaining); + if (bytesRead < 0) { + throw new Exception("Unexpected end of file while reading: " + file.getAbsolutePath()); + } + offset += bytesRead; + remaining -= bytesRead; + } return buffer; } finally { fis.close(); From d11e2774e1e8ebf80e47ecb4a2270fc6eeeb1bf1 Mon Sep 17 00:00:00 2001 From: Ivan Tustanivskyi Date: Fri, 3 Oct 2025 10:31:59 +0300 Subject: [PATCH 09/10] Fix check for Android screenshot orientation change (OpenGL vs Vulkan) --- .../Private/Utils/SentryScreenshotUtils.cpp | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp b/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp index fe33ee24e..a71fe6029 100644 --- a/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp +++ b/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp @@ -1,10 +1,9 @@ // Copyright (c) 2025 Sentry. All Rights Reserved. #include "SentryScreenshotUtils.h" - -#include "HighResScreenshot.h" #include "SentryDefines.h" +#include "HighResScreenshot.h" #include "Engine/Engine.h" #include "Engine/GameViewportClient.h" #include "Framework/Application/SlateApplication.h" @@ -55,19 +54,28 @@ bool SentryScreenshotUtils::CaptureScreenshot(const FString& ScreenshotSavePath) return false; } - // On Android bitmap fill order is different so we flip and mirror it accordingly to get a proper image -#if PLATFORM_ANDROID_ARM64 - Algo::Reverse(*Bitmap); - - for (int32 Y = 0; Y < ViewportSize.Y; ++Y) +#if PLATFORM_ANDROID + FString RHIName = GDynamicRHI ? GDynamicRHI->GetName() : TEXT("Unknown"); + if (RHIName.Contains(TEXT("OpenGL"))) { - int32 RowStart = Y * ViewportSize.X; - int32 RowEnd = RowStart + ViewportSize.X - 1; - for (int32 X = 0; X < ViewportSize.X / 2; ++X) + UE_LOG(LogSentrySdk, Log, TEXT("Applying OpenGL flip/mirror correction for captured screenshot")); + + Algo::Reverse(*Bitmap); + + for (int32 Y = 0; Y < ViewportSize.Y; ++Y) { - Swap((*Bitmap)[RowStart + X], (*Bitmap)[RowEnd - X]); + int32 RowStart = Y * ViewportSize.X; + int32 RowEnd = RowStart + ViewportSize.X - 1; + for (int32 X = 0; X < ViewportSize.X / 2; ++X) + { + Swap((*Bitmap)[RowStart + X], (*Bitmap)[RowEnd - X]); + } } } + else + { + UE_LOG(LogSentrySdk, Log, TEXT("No flip/mirror correction required captured screenshot (Vulkan or other RHI)")); + } #endif #if UE_VERSION_OLDER_THAN(5, 0, 0) From 985148779c142f197dadf5346989f0cfd567b42b Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Fri, 3 Oct 2025 07:32:20 +0000 Subject: [PATCH 10/10] Format code --- .../Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp b/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp index a71fe6029..7327baf10 100644 --- a/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp +++ b/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp @@ -3,10 +3,10 @@ #include "SentryScreenshotUtils.h" #include "SentryDefines.h" -#include "HighResScreenshot.h" #include "Engine/Engine.h" #include "Engine/GameViewportClient.h" #include "Framework/Application/SlateApplication.h" +#include "HighResScreenshot.h" #include "ImageUtils.h" #include "Misc/EngineVersionComparison.h" #include "Misc/FileHelper.h"