diff --git a/CHANGELOG.md b/CHANGELOG.md index dddda3741..5c98a82ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Add screenshot capturing for ensure/assert events on Android ([#1097](https://github.com/getsentry/sentry-unreal/pull/1097)) + ### Dependencies - Bump Java SDK (Android) from v8.22.0 to v8.23.0 ([#1098](https://github.com/getsentry/sentry-unreal/pull/1098)) diff --git a/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp b/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.cpp index 732741b26..144cb2b5e 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() { @@ -41,6 +46,8 @@ FAndroidSentrySubsystem::~FAndroidSentrySubsystem() void FAndroidSentrySubsystem::InitWithSettings(const USentrySettings* settings, USentryBeforeSendHandler* beforeSendHandler, USentryBeforeBreadcrumbHandler* beforeBreadcrumbHandler, USentryBeforeLogHandler* beforeLogHandler, USentryTraceSampler* traceSampler) { + isScreenshotAttachmentEnabled = settings->AttachScreenshot; + TSharedPtr SettingsJson = MakeShareable(new FJsonObject); SettingsJson->SetStringField(TEXT("dsn"), settings->Dsn); SettingsJson->SetStringField(TEXT("release"), settings->GetEffectiveRelease()); @@ -86,10 +93,24 @@ 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() && isScreenshotAttachmentEnabled) + { + OnHandleSystemErrorDelegateHandle = FCoreDelegates::OnHandleSystemError.AddLambda([this]() + { + TryCaptureScreenshot(); + }); + } } void FAndroidSentrySubsystem::Close() { + if (OnHandleSystemErrorDelegateHandle.IsValid()) + { + FCoreDelegates::OnHandleSystemError.Remove(OnHandleSystemErrorDelegateHandle); + OnHandleSystemErrorDelegateHandle.Reset(); + } + FSentryJavaObjectWrapper::CallStaticMethod(SentryJavaClasses::Sentry, "close", "()V"); } @@ -255,8 +276,29 @@ 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()) + { + 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)); } @@ -391,3 +433,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 aad6653c7..39d70f755 100644 --- a/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.h +++ b/plugin-dev/Source/Sentry/Private/Android/AndroidSentrySubsystem.h @@ -45,6 +45,13 @@ class FAndroidSentrySubsystem : public ISentrySubsystem virtual TSharedPtr ContinueTrace(const FString& sentryTrace, const TArray& baggageHeaders) override; virtual void HandleAssert() override; + + FString TryCaptureScreenshot() const; + +private: + bool isScreenshotAttachmentEnabled = false; + + FDelegateHandle OnHandleSystemErrorDelegateHandle; }; 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 989e441af..f42780715 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; @@ -41,6 +42,7 @@ public class SentryBridgeJava { public static native SentryLogEvent onBeforeLog(long handlerAddr, SentryLogEvent logEvent); 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() { @@ -58,7 +60,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++) { @@ -97,10 +98,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"))); } if (settingJson.has("beforeLogHandler")) { @@ -147,13 +150,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; } @@ -270,26 +279,50 @@ public static void addLogDebug(final String message) { 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) { + SentryOptions options = getOptions(); + + 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()) { + 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); } @@ -312,4 +345,24 @@ public SentryLogEvent execute(SentryLogEvent logEvent) { return logEvent; } } + + private static byte[] readFileToBytes(File file) throws Exception { + FileInputStream fis = new FileInputStream(file); + try { + byte[] buffer = new byte[(int) file.length()]; + 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(); + } + } } diff --git a/plugin-dev/Source/Sentry/Private/Android/Jni/AndroidSentryJni.cpp b/plugin-dev/Source/Sentry/Private/Android/Jni/AndroidSentryJni.cpp index f8bafa32d..86fef67bb 100644 --- a/plugin-dev/Source/Sentry/Private/Android/Jni/AndroidSentryJni.cpp +++ b/plugin-dev/Source/Sentry/Private/Android/Jni/AndroidSentryJni.cpp @@ -154,3 +154,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..7327baf10 100644 --- a/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp +++ b/plugin-dev/Source/Sentry/Private/Utils/SentryScreenshotUtils.cpp @@ -1,13 +1,12 @@ // Copyright (c) 2025 Sentry. All Rights Reserved. #include "SentryScreenshotUtils.h" - -#include "HighResScreenshot.h" #include "SentryDefines.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" @@ -55,6 +54,30 @@ bool SentryScreenshotUtils::CaptureScreenshot(const FString& ScreenshotSavePath) return false; } +#if PLATFORM_ANDROID + FString RHIName = GDynamicRHI ? GDynamicRHI->GetName() : TEXT("Unknown"); + if (RHIName.Contains(TEXT("OpenGL"))) + { + UE_LOG(LogSentrySdk, Log, TEXT("Applying OpenGL flip/mirror correction for captured screenshot")); + + 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]); + } + } + } + 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) GetHighResScreenshotConfig().MergeMaskIntoAlpha(*Bitmap); TArray* CompressedBitmap = new TArray();