Skip to content
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -41,6 +46,8 @@ FAndroidSentrySubsystem::~FAndroidSentrySubsystem()

void FAndroidSentrySubsystem::InitWithSettings(const USentrySettings* settings, USentryBeforeSendHandler* beforeSendHandler, USentryBeforeBreadcrumbHandler* beforeBreadcrumbHandler, USentryBeforeLogHandler* beforeLogHandler, USentryTraceSampler* traceSampler)
{
isScreenshotAttachmentEnabled = settings->AttachScreenshot;

TSharedPtr<FJsonObject> SettingsJson = MakeShareable(new FJsonObject);
SettingsJson->SetStringField(TEXT("dsn"), settings->Dsn);
SettingsJson->SetStringField(TEXT("release"), settings->GetEffectiveRelease());
Expand Down Expand Up @@ -86,10 +93,24 @@ void FAndroidSentrySubsystem::InitWithSettings(const USentrySettings* settings,

FSentryJavaObjectWrapper::CallStaticMethod<void>(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<void>(SentryJavaClasses::Sentry, "close", "()V");
}

Expand Down Expand Up @@ -255,8 +276,29 @@ TSharedPtr<ISentryId> FAndroidSentrySubsystem::CaptureEventWithScope(TSharedPtr<

TSharedPtr<ISentryId> FAndroidSentrySubsystem::CaptureEnsure(const FString& type, const FString& message)
{
auto id = FSentryJavaObjectWrapper::CallStaticObjectMethod<jobject>(SentryJavaClasses::SentryBridgeJava, "captureException", "(Ljava/lang/String;Ljava/lang/String;)Lio/sentry/protocol/SentryId;",
*FSentryJavaObjectWrapper::GetJString(type), *FSentryJavaObjectWrapper::GetJString(message));
TSharedPtr<FAndroidSentryAttachment> ScreenshotAttachment = nullptr;

if (isScreenshotAttachmentEnabled)
{
const FString& ScreenshotPath = TryCaptureScreenshot();
if (!ScreenshotPath.IsEmpty())
{
TArray<uint8> 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<jobject>(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));
}
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ class FAndroidSentrySubsystem : public ISentrySubsystem
virtual TSharedPtr<ISentryTransactionContext> ContinueTrace(const FString& sentryTrace, const TArray<FString>& baggageHeaders) override;

virtual void HandleAssert() override;

FString TryCaptureScreenshot() const;

private:
bool isScreenshotAttachmentEnabled = false;

FDelegateHandle OnHandleSystemErrorDelegateHandle;
};

typedef FAndroidSentrySubsystem FPlatformSentrySubsystem;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<SentryAndroidOptions>() {
Expand All @@ -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++) {
Expand Down Expand Up @@ -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")) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
}
Expand All @@ -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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
28 changes: 28 additions & 0 deletions plugin-dev/Source/Sentry/Private/Utils/SentryFileUtils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<FString> 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];
}
2 changes: 2 additions & 0 deletions plugin-dev/Source/Sentry/Private/Utils/SentryFileUtils.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ class SentryFileUtils
static FString GetGameLogPath();
static FString GetGameLogBackupPath();
static FString GetGpuDumpPath();
static FString GetScreenshotPath();
static FString GetLatestScreenshot();
};
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Android Screenshot Orientation Correction Flaw

The Android screenshot correction logic has two issues. Algo::Reverse reverses the entire bitmap, causing the subsequent row-based mirroring to operate on incorrect pixel positions. Also, this correction is limited to PLATFORM_ANDROID_ARM64, potentially leaving other Android architectures with incorrectly oriented screenshots.

Fix in Cursor Fix in Web

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By default, screenshot image is upside-down and mirrored on Android so Algo::Reverse flips rows vertically and the other part mirrors each row (flips horizontally).

Untitled

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PLATFORM_ANDROID_ARM64 limits this logic to physical Android devices while on emulators default implementation works as expected.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know what the performance impact for this operation is? For high resolution screens for example?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screen rotation on Samsung Galaxy S24 Ultra takes ~2% of total time which seems to be negligible:

image

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Xiaomi Mi A1 (Android 8) same test shows that rotation takes ~5% of the time:

image


#if UE_VERSION_OLDER_THAN(5, 0, 0)
GetHighResScreenshotConfig().MergeMaskIntoAlpha(*Bitmap);
TArray<uint8>* CompressedBitmap = new TArray<uint8>();
Expand Down