diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index fe85514b4a7..fa0bbbff64d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -41,7 +41,7 @@ jobs: gradle-home-cache-cleanup: true - name: Initialize CodeQL - uses: github/codeql-action/init@8214744c546c1e5c8f03dde8fab3a7353211988d # pin@v2 + uses: github/codeql-action/init@294a9d92911152fe08befb9ec03e240add280cb3 # pin@v2 with: languages: ${{ matrix.language }} @@ -55,4 +55,4 @@ jobs: ./gradlew buildForCodeQL - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@8214744c546c1e5c8f03dde8fab3a7353211988d # pin@v2 + uses: github/codeql-action/analyze@294a9d92911152fe08befb9ec03e240add280cb3 # pin@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index af547dfd57f..433e199e973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,13 @@ - Throw IllegalArgumentException when calling Sentry.init on Android ([#3596](https://github.com/getsentry/sentry-java/pull/3596)) - Metrics have been removed from the SDK ([#3774](https://github.com/getsentry/sentry-java/pull/3774)) - - Metrics will return but we don't know in what exact form yet + - Metrics will return but we don't know in what exact form yet - `enableTracing` option (a.k.a `enable-tracing`) has been removed from the SDK ([#3776](https://github.com/getsentry/sentry-java/pull/3776)) - - Please set `tracesSampleRate` to a value >= 0.0 for enabling performance instead. The default value is `null` which means performance is disabled. + - Please set `tracesSampleRate` to a value >= 0.0 for enabling performance instead. The default value is `null` which means performance is disabled. - Change OkHttp sub-spans to span attributes ([#3556](https://github.com/getsentry/sentry-java/pull/3556)) - - This will reduce the number of spans created by the SDK -- `options.experimental.sessionReplay.errorSampleRate` was renamed to `options.experimental.sessionReplay.onErrorSampleRate` ([#3637](https://github.com/getsentry/sentry-java/pull/3637)) -- Manifest option `io.sentry.session-replay.error-sample-rate` was renamed to `io.sentry.session-replay.on-error-sample-rate` ([#3637](https://github.com/getsentry/sentry-java/pull/3637)) + - This will reduce the number of spans created by the SDK - Replace `synchronized` methods and blocks with `ReentrantLock` (`AutoClosableReentrantLock`) ([#3715](https://github.com/getsentry/sentry-java/pull/3715)) - - If you are subclassing any Sentry classes, please check if the parent class used `synchronized` before. Please make sure to use the same lock object as the parent class in that case. + - If you are subclassing any Sentry classes, please check if the parent class used `synchronized` before. Please make sure to use the same lock object as the parent class in that case. - `traceOrigins` option (`io.sentry.traces.tracing-origins` in manifest) has been removed, please use `tracePropagationTargets` (`io.sentry.traces.trace-propagation-targets` in manifest`) instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) - `profilingEnabled` option (`io.sentry.traces.profiling.enable` in manifest) has been removed, please use `profilesSampleRate` (`io.sentry.traces.profiling.sample-rate` instead) instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) - `shutdownTimeout` option has been removed, please use `shutdownTimeoutMillis` instead ([#3780](https://github.com/getsentry/sentry-java/pull/3780)) @@ -34,48 +32,39 @@ ### Features - Add init priority settings ([#3674](https://github.com/getsentry/sentry-java/pull/3674)) - - You may now set `forceInit=true` (`force-init` for `.properties` files) to ensure a call to Sentry.init / SentryAndroid.init takes effect + - You may now set `forceInit=true` (`force-init` for `.properties` files) to ensure a call to Sentry.init / SentryAndroid.init takes effect - Add force init option to Android Manifest ([#3675](https://github.com/getsentry/sentry-java/pull/3675)) - - Use `` to ensure Sentry Android auto init is not easily overwritten + - Use `` to ensure Sentry Android auto init is not easily overwritten - Attach request body for `application/x-www-form-urlencoded` requests in Spring ([#3731](https://github.com/getsentry/sentry-java/pull/3731)) - - Previously request body was only attached for `application/json` requests + - Previously request body was only attached for `application/json` requests - Set breadcrumb level based on http status ([#3771](https://github.com/getsentry/sentry-java/pull/3771)) - Support `graphql-java` v22 via a new module `sentry-graphql-22` ([#3740](https://github.com/getsentry/sentry-java/pull/3740)) - - If you are using `graphql-java` v21 or earlier, you can use the `sentry-graphql` module - - For `graphql-java` v22 and newer please use the `sentry-graphql-22` module + - If you are using `graphql-java` v21 or earlier, you can use the `sentry-graphql` module + - For `graphql-java` v22 and newer please use the `sentry-graphql-22` module - We now provide a `SentryInstrumenter` bean directly for Spring (Boot) if there is none yet instead of using `GraphQlSourceBuilderCustomizer` to add the instrumentation ([#3744](https://github.com/getsentry/sentry-java/pull/3744)) - - It is now also possible to provide a bean of type `SentryGraphqlInstrumentation.BeforeSpanCallback` which is then used by `SentryInstrumenter` + - It is now also possible to provide a bean of type `SentryGraphqlInstrumentation.BeforeSpanCallback` which is then used by `SentryInstrumenter` ### Fixes - Use OpenTelemetry span name as fallback for transaction name ([#3557](https://github.com/getsentry/sentry-java/pull/3557)) - - In certain cases we were sending transactions as "" when using OpenTelemetry + - In certain cases we were sending transactions as "" when using OpenTelemetry - Add OpenTelemetry span data to Sentry span ([#3593](https://github.com/getsentry/sentry-java/pull/3593)) - No longer selectively copy OpenTelemetry attributes to Sentry spans / transactions `data` ([#3663](https://github.com/getsentry/sentry-java/pull/3663)) - Remove `PROCESS_COMMAND_ARGS` (`process.command_args`) OpenTelemetry span attribute as it can be very large ([#3664](https://github.com/getsentry/sentry-java/pull/3664)) - Use RECORD_ONLY sampling decision if performance is disabled ([#3659](https://github.com/getsentry/sentry-java/pull/3659)) - Also fix check whether Performance is enabled when making a sampling decision in the OpenTelemetry sampler - Sentry OpenTelemetry Java Agent now sets Instrumenter to SENTRY (used to be OTEL) ([#3697](https://github.com/getsentry/sentry-java/pull/3697)) -- Avoid stopping appStartProfiler after application creation ([#3630](https://github.com/getsentry/sentry-java/pull/3630)) -- Session Replay: Correctly detect dominant color for `TextView`s with Spans ([#3682](https://github.com/getsentry/sentry-java/pull/3682)) -- Session Replay: Add options to selectively redact/ignore views from being captured. The following options are available: ([#3689](https://github.com/getsentry/sentry-java/pull/3689)) - - `android:tag="sentry-redact|sentry-ignore"` in XML or `view.setTag("sentry-redact|sentry-ignore")` in code tags - - if you already have a tag set for a view, you can set a tag by id: `` in XML or `view.setTag(io.sentry.android.replay.R.id.sentry_privacy, "redact|ignore")` in code - - `view.sentryReplayRedact()` or `view.sentryReplayIgnore()` extension functions - - redact/ignore `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addRedactViewClass()` or `options.experimental.sessionReplay.addIgnoreViewClass()`. Note, that all of the view subclasses/subtypes will be redacted/ignored as well - - For example, (this is already a default behavior) to redact all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addRedactViewClass("android.widget.TextView")` - - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified - Set span origin in `ActivityLifecycleIntegration` on span options instead of after creating the span / transaction ([#3702](https://github.com/getsentry/sentry-java/pull/3702)) - - This allows spans to be filtered by span origin on creation + - This allows spans to be filtered by span origin on creation - Honor ignored span origins in `SentryTracer.startChild` ([#3704](https://github.com/getsentry/sentry-java/pull/3704)) - Add `enable-spotlight` and `spotlight-connection-url` to external options and check if spotlight is enabled when deciding whether to inspect an OpenTelemetry span for connecting to splotlight ([#3709](https://github.com/getsentry/sentry-java/pull/3709)) - Trace context on `Contexts.setTrace` has been marked `@NotNull` ([#3721](https://github.com/getsentry/sentry-java/pull/3721)) - - Setting it to `null` would cause an exception. - - Transactions are dropped if trace context is missing + - Setting it to `null` would cause an exception. + - Transactions are dropped if trace context is missing - Remove internal annotation on `SpanOptions` ([#3722](https://github.com/getsentry/sentry-java/pull/3722)) - `SentryLogbackInitializer` is now public ([#3723](https://github.com/getsentry/sentry-java/pull/3723)) - Fix order of calling `close` on previous Sentry instance when re-initializing ([#3750](https://github.com/getsentry/sentry-java/pull/3750)) - - Previously some parts of Sentry were immediately closed after re-init that should have stayed open and some parts of the previous init were never closed + - Previously some parts of Sentry were immediately closed after re-init that should have stayed open and some parts of the previous init were never closed ### Behavioural Changes @@ -91,9 +80,9 @@ - Removed user segment ([#3512](https://github.com/getsentry/sentry-java/pull/3512)) - Use span id of remote parent ([#3548](https://github.com/getsentry/sentry-java/pull/3548)) - - Traces were broken because on an incoming request, OtelSentrySpanProcessor did not set the parentSpanId on the span correctly. Traces were not referencing the actual parent span but some other (random) span ID which the server doesn't know. + - Traces were broken because on an incoming request, OtelSentrySpanProcessor did not set the parentSpanId on the span correctly. Traces were not referencing the actual parent span but some other (random) span ID which the server doesn't know. - Attach active span to scope when using OpenTelemetry ([#3549](https://github.com/getsentry/sentry-java/pull/3549)) - - Errors weren't linked to traces correctly due to parts of the SDK not knowing the current span + - Errors weren't linked to traces correctly due to parts of the SDK not knowing the current span - Record dropped spans in client report when sampling out OpenTelemetry spans ([#3552](https://github.com/getsentry/sentry-java/pull/3552)) - Retrieve the correct current span from `Scope`/`Scopes` when using OpenTelemetry ([#3554](https://github.com/getsentry/sentry-java/pull/3554)) @@ -106,43 +95,43 @@ ### Fixes - Support spans that are split into multiple batches ([#3539](https://github.com/getsentry/sentry-java/pull/3539)) - - When spans belonging to a single transaction were split into multiple batches for SpanExporter, we did not add all spans because the isSpanTooOld check wasn't inverted. + - When spans belonging to a single transaction were split into multiple batches for SpanExporter, we did not add all spans because the isSpanTooOld check wasn't inverted. - Parse and use `send-default-pii` and `max-request-body-size` from `sentry.properties` ([#3534](https://github.com/getsentry/sentry-java/pull/3534)) - `span.startChild` now uses `.makeCurrent()` by default ([#3544](https://github.com/getsentry/sentry-java/pull/3544)) - - This caused an issue where the span tree wasn't correct because some spans were not added to their direct parent + - This caused an issue where the span tree wasn't correct because some spans were not added to their direct parent - Partially fix bootstrap class loading ([#3543](https://github.com/getsentry/sentry-java/pull/3543)) - - There was a problem with two separate Sentry `Scopes` being active inside each OpenTelemetry `Context` due to using context keys from more than one class loader. + - There was a problem with two separate Sentry `Scopes` being active inside each OpenTelemetry `Context` due to using context keys from more than one class loader. ## 8.0.0-alpha.2 ### Behavioural Changes - (Android) The JNI layer for sentry-native has now been moved from sentry-java to sentry-native ([#3189](https://github.com/getsentry/sentry-java/pull/3189)) - - This now includes prefab support for sentry-native, allowing you to link and access the sentry-native API within your native app code - - Checkout the `sentry-samples/sentry-samples-android` example on how to configure CMake and consume `sentry.h` + - This now includes prefab support for sentry-native, allowing you to link and access the sentry-native API within your native app code + - Checkout the `sentry-samples/sentry-samples-android` example on how to configure CMake and consume `sentry.h` ### Features - Our `sentry-opentelemetry-agent` has been completely reworked and now plays nicely with the rest of the Java SDK - - You may also want to give this new agent a try even if you haven't used OpenTelemetry (with Sentry) before. It offers support for [many more libraries and frameworks](https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/supported-libraries.md), improving on our trace propagation, `Scopes` (used to be `Hub`) propagation as well as performance instrumentation (i.e. more spans). - - If you are using a framework we did not support before and currently resort to manual instrumentation, please give the agent a try. See [here for a list of supported libraries, frameworks and application servers](https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/supported-libraries.md). - - NOTE: Not all features have been implemented yet for the OpenTelemetry agent. Features of note that are not working yet: - - Metrics - - Measurements - - `forceFinish` on transaction - - `scheduleFinish` on transaction - - see [#3436](https://github.com/getsentry/sentry-java/issues/3436) for a more up-to-date list of features we have (not) implemented - - Please see "Installing `sentry-opentelemetry-agent`" for more details on how to set up the agent. - - What's new about the Agent - - When the OpenTelemetry Agent is used, Sentry API creates OpenTelemetry spans under the hood, handing back a wrapper object which bridges the gap between traditional Sentry API and OpenTelemetry. We might be replacing some of the Sentry performance API in the future. - - This is achieved by configuring the SDK to use `OtelSpanFactory` instead of `DefaultSpanFactory` which is done automatically by the auto init of the Java Agent. - - OpenTelemetry spans are now only turned into Sentry spans when they are finished so they can be sent to the Sentry server. - - Now registers an OpenTelemetry `Sampler` which uses Sentry sampling configuration - - Other Performance integrations automatically stop creating spans to avoid duplicate spans - - The Sentry SDK now makes use of OpenTelemetry `Context` for storing Sentry `Scopes` (which is similar to what used to be called `Hub`) and thus relies on OpenTelemetry for `Context` propagation. - - Classes used for the previous version of our OpenTelemetry support have been deprecated but can still be used manually. We're not planning to keep the old agent around in favor of less complexity in the SDK. + - You may also want to give this new agent a try even if you haven't used OpenTelemetry (with Sentry) before. It offers support for [many more libraries and frameworks](https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/supported-libraries.md), improving on our trace propagation, `Scopes` (used to be `Hub`) propagation as well as performance instrumentation (i.e. more spans). + - If you are using a framework we did not support before and currently resort to manual instrumentation, please give the agent a try. See [here for a list of supported libraries, frameworks and application servers](https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/supported-libraries.md). + - NOTE: Not all features have been implemented yet for the OpenTelemetry agent. Features of note that are not working yet: + - Metrics + - Measurements + - `forceFinish` on transaction + - `scheduleFinish` on transaction + - see [#3436](https://github.com/getsentry/sentry-java/issues/3436) for a more up-to-date list of features we have (not) implemented + - Please see "Installing `sentry-opentelemetry-agent`" for more details on how to set up the agent. + - What's new about the Agent + - When the OpenTelemetry Agent is used, Sentry API creates OpenTelemetry spans under the hood, handing back a wrapper object which bridges the gap between traditional Sentry API and OpenTelemetry. We might be replacing some of the Sentry performance API in the future. + - This is achieved by configuring the SDK to use `OtelSpanFactory` instead of `DefaultSpanFactory` which is done automatically by the auto init of the Java Agent. + - OpenTelemetry spans are now only turned into Sentry spans when they are finished so they can be sent to the Sentry server. + - Now registers an OpenTelemetry `Sampler` which uses Sentry sampling configuration + - Other Performance integrations automatically stop creating spans to avoid duplicate spans + - The Sentry SDK now makes use of OpenTelemetry `Context` for storing Sentry `Scopes` (which is similar to what used to be called `Hub`) and thus relies on OpenTelemetry for `Context` propagation. + - Classes used for the previous version of our OpenTelemetry support have been deprecated but can still be used manually. We're not planning to keep the old agent around in favor of less complexity in the SDK. - Add `ignoredSpanOrigins` option for ignoring spans coming from certain integrations - - We pre-configure this to ignore Performance instrumentation for Spring and other integrations when using our OpenTelemetry Agent to avoid duplicate spans + - We pre-configure this to ignore Performance instrumentation for Spring and other integrations when using our OpenTelemetry Agent to avoid duplicate spans - Add data fetching environment hint to breadcrumb for GraphQL (#3413) ([#3431](https://github.com/getsentry/sentry-java/pull/3431)) ### Fixes @@ -194,8 +183,8 @@ Sentry.OptionsConfiguration optionsConfiguration() { ### Dependencies - Bump Native SDK from v0.7.0 to v0.7.5 ([#3441](https://github.com/getsentry/sentry-java/pull/3189)) - - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#075) - - [diff](https://github.com/getsentry/sentry-native/compare/0.7.0...0.7.5) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#075) + - [diff](https://github.com/getsentry/sentry-native/compare/0.7.0...0.7.5) ## 8.0.0-alpha.1 @@ -253,6 +242,38 @@ You may also use `LifecycleHelper.close(token)`, e.g. in case you need to pass t - Report exceptions returned by Throwable.getSuppressed() to Sentry as exception groups ([#3396] https://github.com/getsentry/sentry-java/pull/3396) +## 7.15.0 + +### Features + +- Add support for `feedback` envelope header item type ([#3687](https://github.com/getsentry/sentry-java/pull/3687)) +- Add breadcrumb.origin field ([#3727](https://github.com/getsentry/sentry-java/pull/3727)) +- Session Replay: Add options to selectively mask/unmask views captured in replay. The following options are available: ([#3689](https://github.com/getsentry/sentry-java/pull/3689)) + - `android:tag="sentry-mask|sentry-unmask"` in XML or `view.setTag("sentry-mask|sentry-unmask")` in code tags + - if you already have a tag set for a view, you can set a tag by id: `` in XML or `view.setTag(io.sentry.android.replay.R.id.sentry_privacy, "mask|unmask")` in code + - `view.sentryReplayMask()` or `view.sentryReplayUnmask()` extension functions + - mask/unmask `View`s of a certain type by adding fully-qualified classname to one of the lists `options.experimental.sessionReplay.addMaskViewClass()` or `options.experimental.sessionReplay.addUnmaskViewClass()`. Note, that all of the view subclasses/subtypes will be masked/unmasked as well + - For example, (this is already a default behavior) to mask all `TextView`s and their subclasses (`RadioButton`, `EditText`, etc.): `options.experimental.sessionReplay.addMaskViewClass("android.widget.TextView")` + - If you're using code obfuscation, adjust your proguard-rules accordingly, so your custom view class name is not minified +- Session Replay: Support Jetpack Compose masking ([#3739](https://github.com/getsentry/sentry-java/pull/3739)) + - To selectively mask/unmask @Composables, use `Modifier.sentryReplayMask()` and `Modifier.sentryReplayUnmask()` modifiers +- Session Replay: Mask `WebView`, `VideoView` and `androidx.media3.ui.PlayerView` by default ([#3775](https://github.com/getsentry/sentry-java/pull/3775)) + +### Fixes + +- Avoid stopping appStartProfiler after application creation ([#3630](https://github.com/getsentry/sentry-java/pull/3630)) +- Session Replay: Correctly detect dominant color for `TextView`s with Spans ([#3682](https://github.com/getsentry/sentry-java/pull/3682)) +- Fix ensure Application Context is used even when SDK is initialized via Activity Context ([#3669](https://github.com/getsentry/sentry-java/pull/3669)) +- Fix potential ANRs due to `Calendar.getInstance` usage in Breadcrumbs constructor ([#3736](https://github.com/getsentry/sentry-java/pull/3736)) +- Fix potential ANRs due to default integrations ([#3778](https://github.com/getsentry/sentry-java/pull/3778)) +- Lazily initialize heavy `SentryOptions` members to avoid ANRs on app start ([#3749](https://github.com/getsentry/sentry-java/pull/3749)) + +*Breaking changes*: + +- `options.experimental.sessionReplay.errorSampleRate` was renamed to `options.experimental.sessionReplay.onErrorSampleRate` ([#3637](https://github.com/getsentry/sentry-java/pull/3637)) +- Manifest option `io.sentry.session-replay.error-sample-rate` was renamed to `io.sentry.session-replay.on-error-sample-rate` ([#3637](https://github.com/getsentry/sentry-java/pull/3637)) +- Change `redactAllText` and `redactAllImages` to `maskAllText` and `maskAllImages` ([#3741](https://github.com/getsentry/sentry-java/pull/3741)) + ## 7.14.0 ### Features diff --git a/README.md b/README.md index aaca8f78d56..90cdcc8588d 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,13 @@ Sentry SDK for Java and Android | sentry-opentelemetry-core | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-core/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-core) | | sentry-okhttp | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-okhttp/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-okhttp) | +# Releases +This repo uses the following ways to release SDK updates: + +- `Pre-release`: We create pre-releases (alpha, beta, RC,…) for larger and potentially more impactful changes, such as new features or major versions. +- `Latest`: We continuously release major/minor/hotfix versions from the `main` branch. These releases go through all our internal quality gates and are very safe to use and intended to be the default for most teams. +- `Stable`: We promote releases from `Latest` when they have been used in the field for some time and in scale, considering time since release, adoption, and other quality and stability metrics. These releases will be indicated on the releases page (https://github.com/getsentry/sentry-java/releases/) with the `Stable` suffix. # Useful links and docs diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index eac2e598779..8271d7dd7f3 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -53,7 +53,7 @@ object Config { val appCompat = "androidx.appcompat:appcompat:1.3.0" val timber = "com.jakewharton.timber:timber:4.7.1" val okhttp = "com.squareup.okhttp3:okhttp:$okHttpVersion" - val leakCanary = "com.squareup.leakcanary:leakcanary-android:2.8.1" + val leakCanary = "com.squareup.leakcanary:leakcanary-android:2.14" val constraintLayout = "androidx.constraintlayout:constraintlayout:2.1.3" private val lifecycleVersion = "2.2.0" @@ -148,8 +148,11 @@ object Config { val composeActivity = "androidx.activity:activity-compose:1.4.0" val composeFoundation = "androidx.compose.foundation:foundation:$composeVersion" val composeUi = "androidx.compose.ui:ui:$composeVersion" + + val composeUiReplay = "androidx.compose.ui:ui:1.5.0" // Note: don't change without testing forwards compatibility val composeFoundationLayout = "androidx.compose.foundation:foundation-layout:$composeVersion" val composeMaterial = "androidx.compose.material3:material3:1.0.0-alpha13" + val composeCoil = "io.coil-kt:coil-compose:2.0.0" val apolloKotlin = "com.apollographql.apollo3:apollo-runtime:3.8.2" @@ -199,6 +202,7 @@ object Config { val hsqldb = "org.hsqldb:hsqldb:2.6.1" val javaFaker = "com.github.javafaker:javafaker:1.0.2" val msgpack = "org.msgpack:msgpack-core:0.9.8" + val leakCanaryInstrumentation = "com.squareup.leakcanary:leakcanary-android-instrumentation:2.14" } object QualityPlugins { diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index a6e2e4c8ded..35c8408af2e 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -160,6 +160,7 @@ public final class io/sentry/android/core/BuildInfoProvider { } public final class io/sentry/android/core/ContextUtils { + public static fun getApplicationContext (Landroid/content/Context;)Landroid/content/Context; public static fun isForegroundImportance ()Z } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java index 7c26ed450e8..b37877c12e5 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ActivityLifecycleIntegration.java @@ -394,7 +394,7 @@ public void onActivityCreated( firstActivityCreated = true; - if (fullyDisplayedReporter != null) { + if (performanceEnabled && ttfdSpan != null && fullyDisplayedReporter != null) { fullyDisplayedReporter.registerFullyDrawnListener(() -> onFullFrameDrawn(ttfdSpan)); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 764e1b03fe6..8562f6ad2d4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -92,10 +92,7 @@ static void loadDefaultAndMetadataOptions( final @NotNull BuildInfoProvider buildInfoProvider) { Objects.requireNonNull(context, "The context is required."); - // it returns null if ContextImpl, so let's check for nullability - if (context.getApplicationContext() != null) { - context = context.getApplicationContext(); - } + context = ContextUtils.getApplicationContext(context); Objects.requireNonNull(options, "The options object is required."); Objects.requireNonNull(logger, "The ILogger object is required."); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java index ec80704f82d..a2d61e99219 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidTransactionProfiler.java @@ -75,7 +75,9 @@ public AndroidTransactionProfiler( final boolean isProfilingEnabled, final int profilingTracesHz, final @NotNull ISentryExecutorService executorService) { - this.context = Objects.requireNonNull(context, "The application context is required"); + this.context = + Objects.requireNonNull( + ContextUtils.getApplicationContext(context), "The application context is required"); this.logger = Objects.requireNonNull(logger, "ILogger is required"); this.frameMetricsCollector = Objects.requireNonNull(frameMetricsCollector, "SentryFrameMetricsCollector is required"); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java index 9ee6fcd23a5..df8de1a7de2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java @@ -35,7 +35,7 @@ public final class AnrIntegration implements Integration, Closeable { private final @NotNull AutoClosableReentrantLock startLock = new AutoClosableReentrantLock(); public AnrIntegration(final @NotNull Context context) { - this.context = context; + this.context = ContextUtils.getApplicationContext(context); } /** diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java index 0f70cbd109c..431e9dc4c0d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2EventProcessor.java @@ -97,7 +97,7 @@ public AnrV2EventProcessor( final @NotNull SentryAndroidOptions options, final @NotNull BuildInfoProvider buildInfoProvider, final @Nullable SecureRandom random) { - this.context = context; + this.context = ContextUtils.getApplicationContext(context); this.options = options; this.buildInfoProvider = buildInfoProvider; this.random = random; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java index 152ceed3224..d52c5328caf 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrV2Integration.java @@ -63,7 +63,7 @@ public AnrV2Integration(final @NotNull Context context) { AnrV2Integration( final @NotNull Context context, final @NotNull ICurrentDateProvider dateProvider) { - this.context = context; + this.context = ContextUtils.getApplicationContext(context); this.dateProvider = dateProvider; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java index 0d20dc7a90b..063334c021a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegration.java @@ -29,7 +29,8 @@ public final class AppComponentsBreadcrumbsIntegration private @Nullable SentryAndroidOptions options; public AppComponentsBreadcrumbsIntegration(final @NotNull Context context) { - this.context = Objects.requireNonNull(context, "Context is required"); + this.context = + Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); } @Override @@ -84,43 +85,25 @@ public void close() throws IOException { @SuppressWarnings("deprecation") @Override public void onConfigurationChanged(@NotNull Configuration newConfig) { - if (scopes != null) { - final Device.DeviceOrientation deviceOrientation = - DeviceOrientations.getOrientation(context.getResources().getConfiguration().orientation); - - String orientation; - if (deviceOrientation != null) { - orientation = deviceOrientation.name().toLowerCase(Locale.ROOT); - } else { - orientation = "undefined"; - } - - final Breadcrumb breadcrumb = new Breadcrumb(); - breadcrumb.setType("navigation"); - breadcrumb.setCategory("device.orientation"); - breadcrumb.setData("position", orientation); - breadcrumb.setLevel(SentryLevel.INFO); - - final Hint hint = new Hint(); - hint.set(ANDROID_CONFIGURATION, newConfig); - - scopes.addBreadcrumb(breadcrumb, hint); - } + final long now = System.currentTimeMillis(); + executeInBackground(() -> captureConfigurationChangedBreadcrumb(now, newConfig)); } @Override public void onLowMemory() { - createLowMemoryBreadcrumb(null); + final long now = System.currentTimeMillis(); + executeInBackground(() -> captureLowMemoryBreadcrumb(now, null)); } @Override public void onTrimMemory(final int level) { - createLowMemoryBreadcrumb(level); + final long now = System.currentTimeMillis(); + executeInBackground(() -> captureLowMemoryBreadcrumb(now, level)); } - private void createLowMemoryBreadcrumb(final @Nullable Integer level) { + private void captureLowMemoryBreadcrumb(final long timeMs, final @Nullable Integer level) { if (scopes != null) { - final Breadcrumb breadcrumb = new Breadcrumb(); + final Breadcrumb breadcrumb = new Breadcrumb(timeMs); if (level != null) { // only add breadcrumb if TRIM_MEMORY_BACKGROUND, TRIM_MEMORY_MODERATE or // TRIM_MEMORY_COMPLETE. @@ -146,4 +129,42 @@ private void createLowMemoryBreadcrumb(final @Nullable Integer level) { scopes.addBreadcrumb(breadcrumb); } } + + private void captureConfigurationChangedBreadcrumb( + final long timeMs, final @NotNull Configuration newConfig) { + if (scopes != null) { + final Device.DeviceOrientation deviceOrientation = + DeviceOrientations.getOrientation(context.getResources().getConfiguration().orientation); + + String orientation; + if (deviceOrientation != null) { + orientation = deviceOrientation.name().toLowerCase(Locale.ROOT); + } else { + orientation = "undefined"; + } + + final Breadcrumb breadcrumb = new Breadcrumb(timeMs); + breadcrumb.setType("navigation"); + breadcrumb.setCategory("device.orientation"); + breadcrumb.setData("position", orientation); + breadcrumb.setLevel(SentryLevel.INFO); + + final Hint hint = new Hint(); + hint.set(ANDROID_CONFIGURATION, newConfig); + + scopes.addBreadcrumb(breadcrumb, hint); + } + } + + private void executeInBackground(final @NotNull Runnable runnable) { + if (options != null) { + try { + options.getExecutorService().submit(runnable); + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.ERROR, t, "Failed to submit app components breadcrumb task"); + } + } + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java index 2e76de4d123..89fe856631b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ContextUtils.java @@ -383,4 +383,19 @@ static void setAppPackageInfo( } app.setPermissions(permissions); } + + /** + * Get the app context + * + * @return the app context, or if not available, the provided context + */ + @NotNull + public static Context getApplicationContext(final @NotNull Context context) { + // it returns null if ContextImpl, so let's check for nullability + final @Nullable Context appContext = context.getApplicationContext(); + if (appContext != null) { + return appContext; + } + return context; + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index 0b76324908c..ff85162a38f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -47,7 +47,9 @@ public DefaultAndroidEventProcessor( final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider, final @NotNull SentryAndroidOptions options) { - this.context = Objects.requireNonNull(context, "The application context is required."); + this.context = + Objects.requireNonNull( + ContextUtils.getApplicationContext(context), "The application context is required."); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "The BuildInfoProvider is required."); this.options = Objects.requireNonNull(options, "The options object is required."); @@ -57,7 +59,7 @@ public DefaultAndroidEventProcessor( // some device info performs disk I/O, but it's result is cached, let's pre-cache it final @NotNull ExecutorService executorService = Executors.newSingleThreadExecutor(); this.deviceInfoUtil = - executorService.submit(() -> DeviceInfoUtil.getInstance(context, options)); + executorService.submit(() -> DeviceInfoUtil.getInstance(this.context, options)); executorService.shutdown(); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index ccd0fa430dc..9c7b748c036 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -81,7 +81,7 @@ public static DeviceInfoUtil getInstance( if (instance == null) { try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { if (instance == null) { - instance = new DeviceInfoUtil(context.getApplicationContext(), options); + instance = new DeviceInfoUtil(ContextUtils.getApplicationContext(context), options); } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index 286f59f7c06..73c37dfe973 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -7,7 +7,6 @@ import io.sentry.ISentryLifecycleToken; import io.sentry.SentryLevel; import io.sentry.Session; -import io.sentry.android.core.internal.util.BreadcrumbFactory; import io.sentry.transport.CurrentDateProvider; import io.sentry.transport.ICurrentDateProvider; import io.sentry.util.AutoClosableReentrantLock; @@ -92,7 +91,6 @@ private void startSession() { if (lastUpdatedSession == 0L || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis) { if (enableSessionTracking) { - addSessionBreadcrumb("start"); scopes.startSession(); } scopes.getOptions().getReplayController().start(); @@ -127,7 +125,6 @@ private void scheduleEndSession() { @Override public void run() { if (enableSessionTracking) { - addSessionBreadcrumb("end"); scopes.endSession(); } scopes.getOptions().getReplayController().stop(); @@ -159,11 +156,6 @@ private void addAppBreadcrumb(final @NotNull String state) { } } - private void addSessionBreadcrumb(final @NotNull String state) { - final Breadcrumb breadcrumb = BreadcrumbFactory.forSession(state); - scopes.addBreadcrumb(breadcrumb); - } - @TestOnly @Nullable TimerTask getTimerTask() { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index b998cb33066..0edf0f55980 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -98,9 +98,9 @@ final class ManifestMetadataReader { static final String REPLAYS_ERROR_SAMPLE_RATE = "io.sentry.session-replay.on-error-sample-rate"; - static final String REPLAYS_REDACT_ALL_TEXT = "io.sentry.session-replay.redact-all-text"; + static final String REPLAYS_MASK_ALL_TEXT = "io.sentry.session-replay.mask-all-text"; - static final String REPLAYS_REDACT_ALL_IMAGES = "io.sentry.session-replay.redact-all-images"; + static final String REPLAYS_MASK_ALL_IMAGES = "io.sentry.session-replay.mask-all-images"; static final String FORCE_INIT = "io.sentry.force-init"; @@ -389,12 +389,12 @@ static void applyMetadata( options .getExperimental() .getSessionReplay() - .setRedactAllText(readBool(metadata, logger, REPLAYS_REDACT_ALL_TEXT, true)); + .setMaskAllText(readBool(metadata, logger, REPLAYS_MASK_ALL_TEXT, true)); options .getExperimental() .getSessionReplay() - .setRedactAllImages(readBool(metadata, logger, REPLAYS_REDACT_ALL_IMAGES, true)); + .setMaskAllImages(readBool(metadata, logger, REPLAYS_MASK_ALL_IMAGES, true)); } options diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java index 9f1dd3ecf67..dc715119c16 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NetworkBreadcrumbsIntegration.java @@ -15,12 +15,14 @@ import io.sentry.Hint; import io.sentry.ILogger; import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; import io.sentry.SentryDateProvider; import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.TypeCheckHint; import io.sentry.android.core.internal.util.AndroidConnectionStatusProvider; +import io.sentry.util.AutoClosableReentrantLock; import io.sentry.util.Objects; import java.io.Closeable; import java.io.IOException; @@ -31,18 +33,20 @@ public final class NetworkBreadcrumbsIntegration implements Integration, Closeable { private final @NotNull Context context; - private final @NotNull BuildInfoProvider buildInfoProvider; - private final @NotNull ILogger logger; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + private volatile boolean isClosed; + private @Nullable SentryOptions options; - @TestOnly @Nullable NetworkBreadcrumbsNetworkCallback networkCallback; + @TestOnly @Nullable volatile NetworkBreadcrumbsNetworkCallback networkCallback; public NetworkBreadcrumbsIntegration( final @NotNull Context context, final @NotNull BuildInfoProvider buildInfoProvider, final @NotNull ILogger logger) { - this.context = Objects.requireNonNull(context, "Context is required"); + this.context = + Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); this.logger = Objects.requireNonNull(logger, "ILogger is required"); @@ -62,41 +66,74 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions "NetworkBreadcrumbsIntegration enabled: %s", androidOptions.isEnableNetworkEventBreadcrumbs()); + this.options = options; + if (androidOptions.isEnableNetworkEventBreadcrumbs()) { // The specific error is logged in the ConnectivityChecker method - if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.LOLLIPOP) { - networkCallback = null; - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration requires Android 5+"); + if (buildInfoProvider.getSdkInfoVersion() < Build.VERSION_CODES.N) { + logger.log(SentryLevel.DEBUG, "NetworkCallbacks need Android N+."); return; } - networkCallback = - new NetworkBreadcrumbsNetworkCallback( - scopes, buildInfoProvider, options.getDateProvider()); - final boolean registered = - AndroidConnectionStatusProvider.registerNetworkCallback( - context, logger, buildInfoProvider, networkCallback); + try { + options + .getExecutorService() + .submit( + new Runnable() { + @Override + public void run() { + // in case integration is closed before the task is executed, simply return + if (isClosed) { + return; + } - // The specific error is logged in the ConnectivityChecker method - if (!registered) { - networkCallback = null; - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration not installed."); - return; + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + networkCallback = + new NetworkBreadcrumbsNetworkCallback( + scopes, buildInfoProvider, options.getDateProvider()); + + final boolean registered = + AndroidConnectionStatusProvider.registerNetworkCallback( + context, logger, buildInfoProvider, networkCallback); + if (registered) { + logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration installed."); + addIntegrationToSdkVersion(getClass()); + } else { + logger.log( + SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration not installed."); + // The specific error is logged by AndroidConnectionStatusProvider + } + } + } + }); + } catch (Throwable t) { + logger.log(SentryLevel.ERROR, "Error submitting NetworkBreadcrumbsIntegration task.", t); } - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration installed."); - addIntegrationToSdkVersion(getClass()); } } @Override public void close() throws IOException { - if (networkCallback != null) { - AndroidConnectionStatusProvider.unregisterNetworkCallback( - context, logger, buildInfoProvider, networkCallback); - logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration remove."); + isClosed = true; + + try { + Objects.requireNonNull(options, "Options is required") + .getExecutorService() + .submit( + () -> { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (networkCallback != null) { + AndroidConnectionStatusProvider.unregisterNetworkCallback( + context, logger, buildInfoProvider, networkCallback); + logger.log(SentryLevel.DEBUG, "NetworkBreadcrumbsIntegration removed."); + } + networkCallback = null; + } + }); + } catch (Throwable t) { + logger.log(SentryLevel.ERROR, "Error submitting NetworkBreadcrumbsIntegration task.", t); } - networkCallback = null; } @SuppressLint("ObsoleteSdkInt") diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java index 33ffd0be0a3..81f1a52262b 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PhoneStateBreadcrumbsIntegration.java @@ -30,7 +30,8 @@ public final class PhoneStateBreadcrumbsIntegration implements Integration, Clos private final @NotNull AutoClosableReentrantLock startLock = new AutoClosableReentrantLock(); public PhoneStateBreadcrumbsIntegration(final @NotNull Context context) { - this.context = Objects.requireNonNull(context, "Context is required"); + this.context = + Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index d3c222e2046..6a7745104d3 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -14,7 +14,6 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.Session; -import io.sentry.android.core.internal.util.BreadcrumbFactory; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.android.fragment.FragmentLifecycleIntegration; @@ -177,7 +176,6 @@ public static void init( } }); if (!sessionStarted.get()) { - scopes.addBreadcrumb(BreadcrumbFactory.forSession("session.start")); scopes.startSession(); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java index f647b7e747c..7334b0bb9e1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryPerformanceProvider.java @@ -162,10 +162,9 @@ private void launchAppStartProfiler(final @NotNull AppStartMetrics appStartMetri final @NotNull ITransactionProfiler appStartProfiler = new AndroidTransactionProfiler( - context.getApplicationContext(), + context, buildInfoProvider, - new SentryFrameMetricsCollector( - context.getApplicationContext(), logger, buildInfoProvider), + new SentryFrameMetricsCollector(context, logger, buildInfoProvider), logger, profilingOptions.getProfilingTracesDirPath(), profilingOptions.isProfilingEnabled(), diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java index 57fe9c96d59..00e0dde6463 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SystemEventsBreadcrumbsIntegration.java @@ -79,7 +79,8 @@ public SystemEventsBreadcrumbsIntegration(final @NotNull Context context) { public SystemEventsBreadcrumbsIntegration( final @NotNull Context context, final @NotNull List actions) { - this.context = Objects.requireNonNull(context, "Context is required"); + this.context = + Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); this.actions = Objects.requireNonNull(actions, "Actions list is required"); } @@ -212,7 +213,7 @@ static final class SystemEventsBroadcastReceiver extends BroadcastReceiver { private static final long DEBOUNCE_WAIT_TIME_MS = 60 * 1000; private final @NotNull IScopes scopes; private final @NotNull SentryAndroidOptions options; - private final @NotNull Debouncer debouncer = + private final @NotNull Debouncer batteryChangedDebouncer = new Debouncer(AndroidCurrentDateProvider.getInstance(), DEBOUNCE_WAIT_TIME_MS, 0); SystemEventsBroadcastReceiver( @@ -222,19 +223,43 @@ static final class SystemEventsBroadcastReceiver extends BroadcastReceiver { } @Override - public void onReceive(Context context, Intent intent) { - final boolean shouldDebounce = debouncer.checkForDebounce(); - final String action = intent.getAction(); + public void onReceive(final Context context, final @NotNull Intent intent) { + final @Nullable String action = intent.getAction(); final boolean isBatteryChanged = ACTION_BATTERY_CHANGED.equals(action); - if (isBatteryChanged && shouldDebounce) { - // aligning with iOS which only captures battery status changes every minute at maximum + + // aligning with iOS which only captures battery status changes every minute at maximum + if (isBatteryChanged && batteryChangedDebouncer.checkForDebounce()) { return; } - final Breadcrumb breadcrumb = new Breadcrumb(); + final long now = System.currentTimeMillis(); + try { + options + .getExecutorService() + .submit( + () -> { + final Breadcrumb breadcrumb = + createBreadcrumb(now, intent, action, isBatteryChanged); + final Hint hint = new Hint(); + hint.set(ANDROID_INTENT, intent); + scopes.addBreadcrumb(breadcrumb, hint); + }); + } catch (Throwable t) { + options + .getLogger() + .log(SentryLevel.ERROR, t, "Failed to submit system event breadcrumb action."); + } + } + + private @NotNull Breadcrumb createBreadcrumb( + final long timeMs, + final @NotNull Intent intent, + final @Nullable String action, + boolean isBatteryChanged) { + final Breadcrumb breadcrumb = new Breadcrumb(timeMs); breadcrumb.setType("system"); breadcrumb.setCategory("device.event"); - String shortAction = StringUtils.getStringAfterDot(action); + final String shortAction = StringUtils.getStringAfterDot(action); if (shortAction != null) { breadcrumb.setData("action", shortAction); } @@ -274,11 +299,7 @@ public void onReceive(Context context, Intent intent) { } } breadcrumb.setLevel(SentryLevel.INFO); - - final Hint hint = new Hint(); - hint.set(ANDROID_INTENT, intent); - - scopes.addBreadcrumb(breadcrumb, hint); + return breadcrumb; } } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java index 01d57819760..f835b3670bf 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/TempSensorBreadcrumbsIntegration.java @@ -36,7 +36,8 @@ public final class TempSensorBreadcrumbsIntegration private final @NotNull AutoClosableReentrantLock startLock = new AutoClosableReentrantLock(); public TempSensorBreadcrumbsIntegration(final @NotNull Context context) { - this.context = Objects.requireNonNull(context, "Context is required"); + this.context = + Objects.requireNonNull(ContextUtils.getApplicationContext(context), "Context is required"); } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoader.java index 6c5bed5ae2a..568b67f0b02 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/debugmeta/AssetsDebugMetaLoader.java @@ -6,6 +6,7 @@ import android.content.res.AssetManager; import io.sentry.ILogger; import io.sentry.SentryLevel; +import io.sentry.android.core.ContextUtils; import io.sentry.internal.debugmeta.IDebugMetaLoader; import java.io.BufferedInputStream; import java.io.FileNotFoundException; @@ -24,7 +25,7 @@ public final class AssetsDebugMetaLoader implements IDebugMetaLoader { private final @NotNull ILogger logger; public AssetsDebugMetaLoader(final @NotNull Context context, final @NotNull ILogger logger) { - this.context = context; + this.context = ContextUtils.getApplicationContext(context); this.logger = logger; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java index 6d6f3737cba..b6374a32e36 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/modules/AssetsModulesLoader.java @@ -3,6 +3,7 @@ import android.content.Context; import io.sentry.ILogger; import io.sentry.SentryLevel; +import io.sentry.android.core.ContextUtils; import io.sentry.internal.modules.ModulesLoader; import java.io.FileNotFoundException; import java.io.IOException; @@ -19,7 +20,7 @@ public final class AssetsModulesLoader extends ModulesLoader { public AssetsModulesLoader(final @NotNull Context context, final @NotNull ILogger logger) { super(logger); - this.context = context; + this.context = ContextUtils.getApplicationContext(context); } @Override diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java index b8279edcb1f..0afd2bce970 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/AndroidConnectionStatusProvider.java @@ -12,6 +12,7 @@ import io.sentry.ILogger; import io.sentry.SentryLevel; import io.sentry.android.core.BuildInfoProvider; +import io.sentry.android.core.ContextUtils; import java.util.HashMap; import java.util.Map; import org.jetbrains.annotations.ApiStatus; @@ -37,7 +38,7 @@ public AndroidConnectionStatusProvider( @NotNull Context context, @NotNull ILogger logger, @NotNull BuildInfoProvider buildInfoProvider) { - this.context = context; + this.context = ContextUtils.getApplicationContext(context); this.logger = logger; this.buildInfoProvider = buildInfoProvider; this.registeredCallbacks = new HashMap<>(); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java index 27731e48cff..25ff5da2bdb 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/internal/util/SentryFrameMetricsCollector.java @@ -17,6 +17,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryOptions; import io.sentry.android.core.BuildInfoProvider; +import io.sentry.android.core.ContextUtils; import io.sentry.util.Objects; import java.lang.ref.WeakReference; import java.lang.reflect.Field; @@ -84,7 +85,9 @@ public SentryFrameMetricsCollector( final @NotNull ILogger logger, final @NotNull BuildInfoProvider buildInfoProvider, final @NotNull WindowFrameMetricsManager windowFrameMetricsManager) { - Objects.requireNonNull(context, "The context is required"); + final @NotNull Context appContext = + Objects.requireNonNull( + ContextUtils.getApplicationContext(context), "The context is required"); this.logger = Objects.requireNonNull(logger, "Logger is required"); this.buildInfoProvider = Objects.requireNonNull(buildInfoProvider, "BuildInfoProvider is required"); @@ -92,7 +95,7 @@ public SentryFrameMetricsCollector( Objects.requireNonNull(windowFrameMetricsManager, "WindowFrameMetricsManager is required"); // registerActivityLifecycleCallbacks is only available if Context is an AppContext - if (!(context instanceof Application)) { + if (!(appContext instanceof Application)) { return; } // FrameMetrics api is only available since sdk version N @@ -110,7 +113,7 @@ public SentryFrameMetricsCollector( // We have to register the lifecycle callback, even if no profile is started, otherwise when we // start a profile, we wouldn't have the current activity and couldn't get the frameMetrics. - ((Application) context).registerActivityLifecycleCallbacks(this); + ((Application) appContext).registerActivityLifecycleCallbacks(this); // Most considerations regarding timestamps of frames are inspired from JankStats library: // https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:metrics/metrics-performance/src/main/java/androidx/metrics/performance/JankStatsApi24Impl.kt diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt index dfbddc3ca47..757bbd68ca7 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ActivityLifecycleIntegrationTest.kt @@ -78,7 +78,6 @@ class ActivityLifecycleIntegrationTest { } val bundle = mock() val activityFramesTracker = mock() - val fullyDisplayedReporter = FullyDisplayedReporter.getInstance() val transactionFinishedCallback = mock() lateinit var shadowActivityManager: ShadowActivityManager @@ -620,11 +619,30 @@ class ActivityLifecycleIntegrationTest { sut.onActivityCreated(activity, mock()) val ttfdSpan = sut.ttfdSpanMap[activity] sut.ttidSpanMap.values.first().finish() - fixture.fullyDisplayedReporter.reportFullyDrawn() + fixture.options.fullyDisplayedReporter.reportFullyDrawn() assertTrue(ttfdSpan!!.isFinished) assertNotEquals(SpanStatus.CANCELLED, ttfdSpan.status) } + @Test + fun `if ttfd is disabled, no listener is registered for FullyDisplayedReporter`() { + val ttfdReporter = mock() + + val sut = fixture.getSut() + fixture.options.apply { + tracesSampleRate = 1.0 + isEnableTimeToFullDisplayTracing = false + fullyDisplayedReporter = ttfdReporter + } + + sut.register(fixture.scopes, fixture.options) + + val activity = mock() + sut.onActivityCreated(activity, mock()) + + verify(ttfdReporter, never()).registerFullyDrawnListener(any()) + } + @Test fun `App start is Cold when savedInstanceState is null`() { val sut = fixture.getSut() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt index 5f8792c850c..9ae0c1c1c0f 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AppComponentsBreadcrumbsIntegrationTest.kt @@ -7,6 +7,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.Breadcrumb import io.sentry.IScopes import io.sentry.SentryLevel +import io.sentry.test.ImmediateExecutorService import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -36,7 +37,9 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When app components breadcrumb is enabled, it registers callback`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } val scopes = mock() sut.register(scopes, options) verify(fixture.context).registerComponentCallbacks(any()) @@ -45,7 +48,9 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When app components breadcrumb is enabled, but ComponentCallbacks is not ready, do not throw`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } val scopes = mock() sut.register(scopes, options) whenever(fixture.context.registerComponentCallbacks(any())).thenThrow(NullPointerException()) @@ -58,6 +63,7 @@ class AppComponentsBreadcrumbsIntegrationTest { val sut = fixture.getSut() val options = SentryAndroidOptions().apply { isEnableAppComponentBreadcrumbs = false + executorService = ImmediateExecutorService() } val scopes = mock() sut.register(scopes, options) @@ -67,7 +73,9 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When AppComponentsBreadcrumbsIntegrationTest is closed, it should unregister the callback`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } val scopes = mock() sut.register(scopes, options) sut.close() @@ -77,7 +85,9 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When app components breadcrumb is closed, but ComponentCallbacks is not ready, do not throw`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } val scopes = mock() whenever(fixture.context.registerComponentCallbacks(any())).thenThrow(NullPointerException()) whenever(fixture.context.unregisterComponentCallbacks(any())).thenThrow(NullPointerException()) @@ -88,7 +98,9 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When low memory event, a breadcrumb with type, category and level should be set`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } val scopes = mock() sut.register(scopes, options) sut.onLowMemory() @@ -104,7 +116,9 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When trim memory event with level, a breadcrumb with type, category and level should be set`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } val scopes = mock() sut.register(scopes, options) sut.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) @@ -120,7 +134,9 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When trim memory event with level not so high, do not add a breadcrumb`() { val sut = fixture.getSut() - val options = SentryAndroidOptions() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } val scopes = mock() sut.register(scopes, options) sut.onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) @@ -130,7 +146,9 @@ class AppComponentsBreadcrumbsIntegrationTest { @Test fun `When device orientation event, a breadcrumb with type, category and level should be set`() { val sut = AppComponentsBreadcrumbsIntegration(ApplicationProvider.getApplicationContext()) - val options = SentryAndroidOptions() + val options = SentryAndroidOptions().apply { + executorService = ImmediateExecutorService() + } val scopes = mock() sut.register(scopes, options) sut.onConfigurationChanged(mock()) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt index b758fae1f83..588a32a6569 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ContextUtilsTest.kt @@ -29,6 +29,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue @Config(sdk = [33]) @@ -213,4 +214,21 @@ class ContextUtilsTest { ) assertFalse(ContextUtils.isForegroundImportance()) } + + @Test + fun `getApplicationContext returns context if app context is null`() { + val contextMock = mock() + val appContext = ContextUtils.getApplicationContext(contextMock) + assertSame(contextMock, appContext) + } + + @Test + fun `getApplicationContext returns app context`() { + val contextMock = mock() + val appContextMock = mock() + whenever(contextMock.applicationContext).thenReturn(appContextMock) + + val appContext = ContextUtils.getApplicationContext(contextMock) + assertSame(appContextMock, appContext) + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index 61f65fa93bf..93a731481b1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -137,49 +137,6 @@ class LifecycleWatcherTest { verify(fixture.scopes, never()).endSession() } - @Test - fun `When session tracking is enabled, add breadcrumb on start`() { - val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) - verify(fixture.scopes).addBreadcrumb( - check { - assertEquals("app.lifecycle", it.category) - assertEquals("session", it.type) - assertEquals(SentryLevel.INFO, it.level) - // cant assert data, its not a public API - } - ) - } - - @Test - fun `When session tracking is enabled, add breadcrumb on stop`() { - val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) - watcher.onStop(fixture.ownerMock) - verify(fixture.scopes, timeout(10000)).endSession() - verify(fixture.scopes).addBreadcrumb( - check { - assertEquals("app.lifecycle", it.category) - assertEquals("session", it.type) - assertEquals(SentryLevel.INFO, it.level) - // cant assert data, its not a public API - } - ) - } - - @Test - fun `When session tracking is disabled, do not add breadcrumb on start`() { - val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) - watcher.onStart(fixture.ownerMock) - verify(fixture.scopes, never()).addBreadcrumb(any()) - } - - @Test - fun `When session tracking is disabled, do not add breadcrumb on stop`() { - val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) - watcher.onStop(fixture.ownerMock) - verify(fixture.scopes, never()).addBreadcrumb(any()) - } - @Test fun `When app lifecycle breadcrumbs is enabled, add breadcrumb on start`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 3705da44e66..f1440cd14fb 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1260,11 +1260,11 @@ class ManifestMetadataReaderTest { } @Test - fun `applyMetadata reads replays onErrorSampleRate from metadata`() { + fun `applyMetadata does not override replays onErrorSampleRate from options`() { // Arrange val expectedSampleRate = 0.99f - - val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to expectedSampleRate) + fixture.options.experimental.sessionReplay.onErrorSampleRate = expectedSampleRate.toDouble() + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to 0.1f) val context = fixture.getContext(metaData = bundle) // Act @@ -1275,22 +1275,20 @@ class ManifestMetadataReaderTest { } @Test - fun `applyMetadata does not override replays onErrorSampleRate from options`() { + fun `applyMetadata reads forceInit flag to options`() { // Arrange - val expectedSampleRate = 0.99f - fixture.options.experimental.sessionReplay.onErrorSampleRate = expectedSampleRate.toDouble() - val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to 0.1f) + val bundle = bundleOf(ManifestMetadataReader.FORCE_INIT to true) val context = fixture.getContext(metaData = bundle) // Act ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.onErrorSampleRate) + assertTrue(fixture.options.isForceInit) } @Test - fun `applyMetadata without specifying replays onErrorSampleRate, stays null`() { + fun `applyMetadata reads forceInit flag to options and keeps default if not found`() { // Arrange val context = fixture.getContext() @@ -1298,25 +1296,26 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertNull(fixture.options.experimental.sessionReplay.onErrorSampleRate) + assertFalse(fixture.options.isForceInit) } @Test - fun `applyMetadata reads session replay redact flags to options`() { + fun `applyMetadata reads replays onErrorSampleRate from metadata`() { // Arrange - val bundle = bundleOf(ManifestMetadataReader.REPLAYS_REDACT_ALL_TEXT to false, ManifestMetadataReader.REPLAYS_REDACT_ALL_IMAGES to false) + val expectedSampleRate = 0.99f + + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to expectedSampleRate) val context = fixture.getContext(metaData = bundle) // Act ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.experimental.sessionReplay.ignoreViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) - assertTrue(fixture.options.experimental.sessionReplay.ignoreViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.onErrorSampleRate) } @Test - fun `applyMetadata reads session replay redact flags to options and keeps default if not found`() { + fun `applyMetadata without specifying replays onErrorSampleRate, stays null`() { // Arrange val context = fixture.getContext() @@ -1324,25 +1323,25 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.experimental.sessionReplay.redactViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) - assertTrue(fixture.options.experimental.sessionReplay.redactViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) + assertNull(fixture.options.experimental.sessionReplay.onErrorSampleRate) } @Test - fun `applyMetadata reads forceInit flag to options`() { + fun `applyMetadata reads session replay mask flags to options`() { // Arrange - val bundle = bundleOf(ManifestMetadataReader.FORCE_INIT to true) + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_MASK_ALL_TEXT to false, ManifestMetadataReader.REPLAYS_MASK_ALL_IMAGES to false) val context = fixture.getContext(metaData = bundle) // Act ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertTrue(fixture.options.isForceInit) + assertTrue(fixture.options.experimental.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.unmaskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } @Test - fun `applyMetadata reads forceInit flag to options and keeps default if not found`() { + fun `applyMetadata reads session replay mask flags to options and keeps default if not found`() { // Arrange val context = fixture.getContext() @@ -1350,6 +1349,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertFalse(fixture.options.isForceInit) + assertTrue(fixture.options.experimental.sessionReplay.maskViewClasses.contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)) + assertTrue(fixture.options.experimental.sessionReplay.maskViewClasses.contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt index c28cf640856..fd3c5a4ddce 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/NetworkBreadcrumbsIntegrationTest.kt @@ -9,12 +9,15 @@ import android.os.Build import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.IScopes +import io.sentry.ISentryExecutorService import io.sentry.SentryDateProvider import io.sentry.SentryLevel import io.sentry.SentryNanotimeDate import io.sentry.TypeCheckHint import io.sentry.android.core.NetworkBreadcrumbsIntegration.NetworkBreadcrumbConnectionDetail import io.sentry.android.core.NetworkBreadcrumbsIntegration.NetworkBreadcrumbsNetworkCallback +import io.sentry.test.DeferredExecutorService +import io.sentry.test.ImmediateExecutorService import org.mockito.kotlin.KInOrder import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -47,14 +50,22 @@ class NetworkBreadcrumbsIntegrationTest { init { whenever(mockBuildInfoProvider.sdkInfoVersion).thenReturn(Build.VERSION_CODES.N) - whenever(context.getSystemService(eq(Context.CONNECTIVITY_SERVICE))).thenReturn(connectivityManager) + whenever(context.getSystemService(eq(Context.CONNECTIVITY_SERVICE))).thenReturn( + connectivityManager + ) } - fun getSut(enableNetworkEventBreadcrumbs: Boolean = true, buildInfo: BuildInfoProvider = mockBuildInfoProvider): NetworkBreadcrumbsIntegration { + fun getSut( + enableNetworkEventBreadcrumbs: Boolean = true, + buildInfo: BuildInfoProvider = mockBuildInfoProvider, + executor: ISentryExecutorService = ImmediateExecutorService() + ): NetworkBreadcrumbsIntegration { options = SentryAndroidOptions().apply { + executorService = executor isEnableNetworkEventBreadcrumbs = enableNetworkEventBreadcrumbs dateProvider = SentryDateProvider { - val nowNanos = TimeUnit.MILLISECONDS.toNanos(nowMs ?: System.currentTimeMillis()) + val nowNanos = + TimeUnit.MILLISECONDS.toNanos(nowMs ?: System.currentTimeMillis()) SentryNanotimeDate(DateUtils.nanosToDate(nowNanos), nowNanos) } } @@ -117,7 +128,10 @@ class NetworkBreadcrumbsIntegrationTest { sut.register(fixture.scopes, fixture.options) sut.close() - verify(fixture.connectivityManager, never()).unregisterNetworkCallback(any()) + verify( + fixture.connectivityManager, + never() + ).unregisterNetworkCallback(any()) assertNull(sut.networkCallback) } @@ -482,11 +496,27 @@ class NetworkBreadcrumbsIntegrationTest { } } + @Test + fun `If integration is opened and closed immediately it still properly unregisters`() { + val executor = DeferredExecutorService() + val sut = fixture.getSut(executor = executor) + + sut.register(fixture.scopes, fixture.options) + sut.close() + + executor.runAll() + + assertNull(sut.networkCallback) + verify(fixture.connectivityManager, never()).registerDefaultNetworkCallback(any()) + verify(fixture.connectivityManager, never()).unregisterNetworkCallback(any()) + } + private fun KInOrder.verifyBreadcrumbInOrder(check: (detail: NetworkBreadcrumbConnectionDetail) -> Unit) { verify(fixture.scopes, times(1)).addBreadcrumb( any(), check { - val connectionDetail = it[TypeCheckHint.ANDROID_NETWORK_CAPABILITIES] as NetworkBreadcrumbConnectionDetail + val connectionDetail = + it[TypeCheckHint.ANDROID_NETWORK_CAPABILITIES] as NetworkBreadcrumbConnectionDetail check(connectionDetail) } ) @@ -496,7 +526,8 @@ class NetworkBreadcrumbsIntegrationTest { verify(fixture.scopes).addBreadcrumb( any(), check { - val connectionDetail = it[TypeCheckHint.ANDROID_NETWORK_CAPABILITIES] as NetworkBreadcrumbConnectionDetail + val connectionDetail = + it[TypeCheckHint.ANDROID_NETWORK_CAPABILITIES] as NetworkBreadcrumbConnectionDetail check(connectionDetail) } ) @@ -516,9 +547,13 @@ class NetworkBreadcrumbsIntegrationTest { whenever(capabilities.linkUpstreamBandwidthKbps).thenReturn(upstreamBandwidthKbps) whenever(capabilities.signalStrength).thenReturn(signalStrength) whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)).thenReturn(isVpn) - whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)).thenReturn(isEthernet) + whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)).thenReturn( + isEthernet + ) whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)).thenReturn(isWifi) - whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)).thenReturn(isCellular) + whenever(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)).thenReturn( + isCellular + ) return capabilities } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 992bebaa03d..cf2369d5597 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -363,7 +363,7 @@ class SentryAndroidTest { optionsConfig: (SentryAndroidOptions) -> Unit = {}, callback: (session: Session?) -> Unit ) { - Mockito.mockStatic(ContextUtils::class.java).use { mockedContextUtils -> + Mockito.mockStatic(ContextUtils::class.java, Mockito.CALLS_REAL_METHODS).use { mockedContextUtils -> mockedContextUtils.`when` { ContextUtils.isForegroundImportance() } .thenReturn(inForeground) SentryAndroid.init(context) { options -> @@ -441,7 +441,7 @@ class SentryAndroidTest { .untilTrue(asserted) // assert that persisted values have changed - options.executorService.close(5000L) // finalizes all enqueued persisting tasks + options.executorService.close(10000L) // finalizes all enqueued persisting tasks assertEquals( "TestActivity", PersistingScopeObserver.read(options, TRANSACTION_FILENAME, String::class.java) diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index 98b00eb4f80..7755166a4e2 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -109,6 +109,7 @@ dependencies { implementation(Config.Libs.androidxRecylerView) implementation(Config.Libs.constraintLayout) implementation(Config.TestLibs.espressoIdlingResource) + implementation(Config.Libs.leakCanary) compileOnly(Config.CompileOnly.nopen) errorprone(Config.CompileOnly.nopenChecker) @@ -123,6 +124,7 @@ dependencies { androidTestImplementation(Config.TestLibs.androidxTestCoreKtx) androidTestImplementation(Config.TestLibs.mockWebserver) androidTestImplementation(Config.TestLibs.androidxJunit) + androidTestImplementation(Config.TestLibs.leakCanaryInstrumentation) androidTestUtil(Config.TestLibs.androidxTestOrchestrator) } diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt index d6d3d1b6662..bfac0c3b5f2 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt @@ -77,6 +77,7 @@ abstract class BaseUiTest { */ protected fun initSentry( relayWaitForRequests: Boolean = false, + context: Context = this.context, optionsConfiguration: ((options: SentryAndroidOptions) -> Unit)? = null ) { relay.waitForRequests = relayWaitForRequests diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt index b615406a3d7..6a5707f70ee 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/SdkInitTests.kt @@ -9,7 +9,12 @@ import io.sentry.android.core.AndroidLogger import io.sentry.android.core.SentryAndroidOptions import io.sentry.assertEnvelopeTransaction import io.sentry.protocol.SentryTransaction +import leakcanary.LeakAssertions +import leakcanary.LeakCanary import org.junit.runner.RunWith +import shark.AndroidReferenceMatchers +import shark.IgnoredReferenceMatcher +import shark.ReferencePattern import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -170,6 +175,42 @@ class SdkInitTests : BaseUiTest() { } val afterRestart = System.currentTimeMillis() val restartMs = afterRestart - beforeRestart - assertTrue(restartMs > 3000, "Expected more than 3000 ms for SDK close and restart. Got $restartMs ms") + assertTrue( + restartMs > 3000, + "Expected more than 3000 ms for SDK close and restart. Got $restartMs ms" + ) + } + + @Test + fun initViaActivityDoesNotLeak() { + LeakCanary.config = LeakCanary.config.copy( + referenceMatchers = AndroidReferenceMatchers.appDefaults + + listOf( + IgnoredReferenceMatcher( + ReferencePattern.InstanceFieldPattern( + "com.saucelabs.rdcinjector.testfairy.TestFairyEventQueue", + "context" + ) + ) + ) + ('a'..'z').map { char -> + IgnoredReferenceMatcher( + ReferencePattern.StaticFieldPattern( + "com.testfairy.modules.capture.TouchListener", + "$char" + ) + ) + } + ) + + val activityScenario = launchActivity() + activityScenario.moveToState(Lifecycle.State.RESUMED) + + activityScenario.onActivity { activity -> + initSentry(context = activity) + } + + activityScenario.moveToState(Lifecycle.State.DESTROYED) + + LeakAssertions.assertNoLeaks() } } diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/res/values/values.xml b/sentry-android-integration-tests/sentry-uitest-android/src/main/res/values/values.xml new file mode 100644 index 00000000000..7763c15cd1a --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/res/values/values.xml @@ -0,0 +1,4 @@ + + + true + diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 221a60f6986..888cf6a172e 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -7,11 +7,13 @@ public final class io/sentry/android/replay/BuildConfig { } public class io/sentry/android/replay/DefaultReplayBreadcrumbConverter : io/sentry/ReplayBreadcrumbConverter { + public static final field $stable I public fun ()V public fun convert (Lio/sentry/Breadcrumb;)Lio/sentry/rrweb/RRWebEvent; } public final class io/sentry/android/replay/GeneratedVideo { + public static final field $stable I public fun (Ljava/io/File;IJ)V public final fun component1 ()Ljava/io/File; public final fun component2 ()I @@ -26,6 +28,11 @@ public final class io/sentry/android/replay/GeneratedVideo { public fun toString ()Ljava/lang/String; } +public final class io/sentry/android/replay/ModifierExtensionsKt { + public static final fun sentryReplayMask (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; + public static final fun sentryReplayUnmask (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; +} + public abstract interface class io/sentry/android/replay/Recorder : java/io/Closeable { public abstract fun pause ()V public abstract fun resume ()V @@ -34,6 +41,7 @@ public abstract interface class io/sentry/android/replay/Recorder : java/io/Clos } public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { + public static final field $stable I public static final field Companion Lio/sentry/android/replay/ReplayCache$Companion; public fun (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;Lio/sentry/android/replay/ScreenshotRecorderConfig;)V public final fun addFrame (Ljava/io/File;JLjava/lang/String;)V @@ -50,6 +58,7 @@ public final class io/sentry/android/replay/ReplayCache$Companion { } public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, io/sentry/android/replay/gestures/TouchRecorderCallback, java/io/Closeable { + public static final field $stable I public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -78,6 +87,7 @@ public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallb } public final class io/sentry/android/replay/ScreenshotRecorderConfig { + public static final field $stable I public static final field Companion Lio/sentry/android/replay/ScreenshotRecorderConfig$Companion; public fun (IIFFII)V public final fun component1 ()I @@ -103,25 +113,33 @@ public final class io/sentry/android/replay/ScreenshotRecorderConfig$Companion { public final fun from (Landroid/content/Context;Lio/sentry/SentryReplayOptions;)Lio/sentry/android/replay/ScreenshotRecorderConfig; } +public final class io/sentry/android/replay/SentryReplayModifiers { + public static final field $stable I + public static final field INSTANCE Lio/sentry/android/replay/SentryReplayModifiers; + public final fun getSentryPrivacy ()Landroidx/compose/ui/semantics/SemanticsPropertyKey; +} + public final class io/sentry/android/replay/SessionReplayOptionsKt { - public static final fun getRedactAllImages (Lio/sentry/SentryReplayOptions;)Z - public static final fun getRedactAllText (Lio/sentry/SentryReplayOptions;)Z - public static final fun setRedactAllImages (Lio/sentry/SentryReplayOptions;Z)V - public static final fun setRedactAllText (Lio/sentry/SentryReplayOptions;Z)V + public static final fun getMaskAllImages (Lio/sentry/SentryReplayOptions;)Z + public static final fun getMaskAllText (Lio/sentry/SentryReplayOptions;)Z + public static final fun setMaskAllImages (Lio/sentry/SentryReplayOptions;Z)V + public static final fun setMaskAllText (Lio/sentry/SentryReplayOptions;Z)V } public final class io/sentry/android/replay/ViewExtensionsKt { - public static final fun sentryReplayIgnore (Landroid/view/View;)V - public static final fun sentryReplayRedact (Landroid/view/View;)V + public static final fun sentryReplayMask (Landroid/view/View;)V + public static final fun sentryReplayUnmask (Landroid/view/View;)V } public final class io/sentry/android/replay/gestures/GestureRecorder : io/sentry/android/replay/OnRootViewsChangedListener { + public static final field $stable I public fun (Lio/sentry/SentryOptions;Lio/sentry/android/replay/gestures/TouchRecorderCallback;)V public fun onRootViewsChanged (Landroid/view/View;Z)V public final fun stop ()V } public final class io/sentry/android/replay/gestures/ReplayGestureConverter { + public static final field $stable I public fun (Lio/sentry/transport/ICurrentDateProvider;)V public final fun convert (Landroid/view/MotionEvent;Lio/sentry/android/replay/ScreenshotRecorderConfig;)Ljava/util/List; } @@ -130,6 +148,19 @@ public abstract interface class io/sentry/android/replay/gestures/TouchRecorderC public abstract fun onTouchEvent (Landroid/view/MotionEvent;)V } +public final class io/sentry/android/replay/util/AndroidTextLayout : io/sentry/android/replay/util/TextLayout { + public static final field $stable I + public fun (Landroid/text/Layout;)V + public fun getDominantTextColor ()Ljava/lang/Integer; + public fun getEllipsisCount (I)I + public fun getLineBottom (I)I + public fun getLineCount ()I + public fun getLineStart (I)I + public fun getLineTop (I)I + public fun getLineVisibleEnd (I)I + public fun getPrimaryHorizontal (II)F +} + public class io/sentry/android/replay/util/FixedWindowCallback : android/view/Window$Callback { public final field delegate Landroid/view/Window$Callback; public fun (Landroid/view/Window$Callback;)V @@ -160,6 +191,17 @@ public class io/sentry/android/replay/util/FixedWindowCallback : android/view/Wi public fun onWindowStartingActionMode (Landroid/view/ActionMode$Callback;I)Landroid/view/ActionMode; } +public abstract interface class io/sentry/android/replay/util/TextLayout { + public abstract fun getDominantTextColor ()Ljava/lang/Integer; + public abstract fun getEllipsisCount (I)I + public abstract fun getLineBottom (I)I + public abstract fun getLineCount ()I + public abstract fun getLineStart (I)I + public abstract fun getLineTop (I)I + public abstract fun getLineVisibleEnd (I)I + public abstract fun getPrimaryHorizontal (II)F +} + public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { public abstract fun getVideoTime ()J public abstract fun isStarted ()Z @@ -169,6 +211,7 @@ public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer } public final class io/sentry/android/replay/video/SimpleMp4FrameMuxer : io/sentry/android/replay/video/SimpleFrameMuxer { + public static final field $stable I public fun (Ljava/lang/String;F)V public fun getVideoTime ()J public fun isStarted ()Z @@ -178,6 +221,7 @@ public final class io/sentry/android/replay/video/SimpleMp4FrameMuxer : io/sentr } public abstract class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field $stable I public static final field Companion Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion; public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;Lkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -186,7 +230,7 @@ public abstract class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { public final fun getElevation ()F public final fun getHeight ()I public final fun getParent ()Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; - public final fun getShouldRedact ()Z + public final fun getShouldMask ()Z public final fun getVisibleRect ()Landroid/graphics/Rect; public final fun getWidth ()I public final fun getX ()F @@ -195,6 +239,7 @@ public abstract class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { public final fun isObscured (Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;)Z public final fun isVisible ()Z public final fun setChildren (Ljava/util/List;)V + public final fun setImportantForCaptureToAncestors (Z)V public final fun setImportantForContentCapture (Z)V public final fun traverse (Lkotlin/jvm/functions/Function1;)V } @@ -204,20 +249,23 @@ public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Comp } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$GenericViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field $stable I public fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$ImageViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field $stable I public fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V public synthetic fun (FFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$TextViewHierarchyNode : io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { - public fun (Landroid/text/Layout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V - public synthetic fun (Landroid/text/Layout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final field $stable I + public fun (Lio/sentry/android/replay/util/TextLayout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;)V + public synthetic fun (Lio/sentry/android/replay/util/TextLayout;Ljava/lang/Integer;IIFFIIFILio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;ZZZLandroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getDominantColor ()Ljava/lang/Integer; - public final fun getLayout ()Landroid/text/Layout; + public final fun getLayout ()Lio/sentry/android/replay/util/TextLayout; public final fun getPaddingLeft ()I public final fun getPaddingTop ()I } diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 2e746412688..15713bb6f43 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -1,5 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask plugins { id("com.android.library") @@ -25,9 +26,20 @@ android { buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") } + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = Config.androidComposeCompilerVersion + useLiveLiterals = false + } + buildTypes { getByName("debug") - getByName("release") + getByName("release") { + consumerProguardFiles("proguard-rules.pro") + } } kotlinOptions { @@ -65,6 +77,7 @@ kotlin { dependencies { api(projects.sentry) + compileOnly(Config.Libs.composeUiReplay) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) // tests @@ -77,9 +90,19 @@ dependencies { testImplementation(Config.TestLibs.mockitoKotlin) testImplementation(Config.TestLibs.mockitoInline) testImplementation(Config.TestLibs.awaitility) + testImplementation(Config.Libs.composeActivity) + testImplementation(Config.Libs.composeUi) + testImplementation(Config.Libs.composeCoil) + testImplementation(Config.Libs.composeFoundation) + testImplementation(Config.Libs.composeFoundationLayout) + testImplementation(Config.Libs.composeMaterial) } tasks.withType { // Target version of the generated JVM bytecode. It is used for type resolution. jvmTarget = JavaVersion.VERSION_1_8.toString() } + +tasks.withType>().configureEach { + compilerOptions.freeCompilerArgs.add("-opt-in=androidx.compose.ui.ExperimentalComposeUiApi") +} diff --git a/sentry-android-replay/proguard-rules.pro b/sentry-android-replay/proguard-rules.pro index 738204b4c8b..378c0964f8c 100644 --- a/sentry-android-replay/proguard-rules.pro +++ b/sentry-android-replay/proguard-rules.pro @@ -1,3 +1,28 @@ # Uncomment this to preserve the line number information for # debugging stack traces. -keepattributes SourceFile,LineNumberTable + +# Rules to detect Images/Icons and mask them +-dontwarn androidx.compose.ui.graphics.painter.Painter +-keepnames class * extends androidx.compose.ui.graphics.painter.Painter +-keepclasseswithmembernames class * { + androidx.compose.ui.graphics.painter.Painter painter; +} +# Rules to detect Text colors and if they have Modifier.fillMaxWidth to later mask them +-dontwarn androidx.compose.ui.graphics.ColorProducer +-dontwarn androidx.compose.foundation.layout.FillElement +-keepnames class androidx.compose.foundation.layout.FillElement +-keepclasseswithmembernames class * { + androidx.compose.ui.graphics.ColorProducer color; +} +# Rules to detect a compose view to parse its hierarchy +-dontwarn androidx.compose.ui.platform.AndroidComposeView +-keepnames class androidx.compose.ui.platform.AndroidComposeView +# Rules to detect a media player view to later mask it +-dontwarn androidx.media3.ui.PlayerView +-keepnames class androidx.media3.ui.PlayerView +# Rules to detect a ExoPlayer view to later mask it +-dontwarn com.google.android.exoplayer2.ui.PlayerView +-keepnames class com.google.android.exoplayer2.ui.PlayerView +-dontwarn com.google.android.exoplayer2.ui.StyledPlayerView +-keepnames class com.google.android.exoplayer2.ui.StyledPlayerView diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt new file mode 100644 index 00000000000..b5d52223886 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ModifierExtensions.kt @@ -0,0 +1,29 @@ +package io.sentry.android.replay + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.semantics +import io.sentry.android.replay.SentryReplayModifiers.SentryPrivacy + +public object SentryReplayModifiers { + val SentryPrivacy = SemanticsPropertyKey( + name = "SentryPrivacy", + mergePolicy = { parentValue, _ -> parentValue } + ) +} + +public fun Modifier.sentryReplayMask(): Modifier { + return semantics( + properties = { + this[SentryPrivacy] = "mask" + } + ) +} + +public fun Modifier.sentryReplayUnmask(): Modifier { + return semantics( + properties = { + this[SentryPrivacy] = "unmask" + } + ) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index b2b78600c05..a449d3843ac 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -23,6 +23,7 @@ import io.sentry.android.replay.capture.SessionCaptureStrategy import io.sentry.android.replay.gestures.GestureRecorder import io.sentry.android.replay.gestures.TouchRecorderCallback import io.sentry.android.replay.util.MainLooperHandler +import io.sentry.android.replay.util.appContext import io.sentry.android.replay.util.sample import io.sentry.android.replay.util.submitSafely import io.sentry.cache.PersistingScopeObserver @@ -51,7 +52,7 @@ public class ReplayIntegration( // needed for the Java's call site constructor(context: Context, dateProvider: ICurrentDateProvider) : this( - context, + context.appContext(), dateProvider, null, null, @@ -67,7 +68,7 @@ public class ReplayIntegration( replayCaptureStrategyProvider: ((isFullSession: Boolean) -> CaptureStrategy)? = null, mainLooperHandler: MainLooperHandler? = null, gestureRecorderProvider: (() -> GestureRecorder)? = null - ) : this(context, dateProvider, recorderProvider, recorderConfigProvider, replayCacheProvider) { + ) : this(context.appContext(), dateProvider, recorderProvider, recorderConfigProvider, replayCacheProvider) { this.replayCaptureStrategyProvider = replayCaptureStrategyProvider this.mainLooperHandler = mainLooperHandler ?: MainLooperHandler() this.gestureRecorderProvider = gestureRecorderProvider diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index fdab9f442d3..4a229f85df6 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -24,10 +24,10 @@ import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.SentryReplayOptions import io.sentry.android.replay.util.MainLooperHandler -import io.sentry.android.replay.util.dominantTextColor import io.sentry.android.replay.util.getVisibleRects import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.submitSafely +import io.sentry.android.replay.util.traverse import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode @@ -115,6 +115,7 @@ internal class ScreenshotRecorder( return@request } + // TODO: handle animations with heuristics (e.g. if we fall under this condition 2 times in a row, we should capture) if (contentChanged.get()) { options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") bitmap.recycle() @@ -122,13 +123,13 @@ internal class ScreenshotRecorder( } val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) - root.traverse(viewHierarchy) + root.traverse(viewHierarchy, options) - recorder.submitSafely(options, "screenshot_recorder.redact") { + recorder.submitSafely(options, "screenshot_recorder.mask") { val canvas = Canvas(bitmap) canvas.setMatrix(prescaledMatrix) viewHierarchy.traverse { node -> - if (node.shouldRedact && (node.width > 0 && node.height > 0)) { + if (node.shouldMask && (node.width > 0 && node.height > 0)) { node.visibleRect ?: return@traverse false // TODO: investigate why it returns true on RN when it shouldn't @@ -143,7 +144,7 @@ internal class ScreenshotRecorder( } is TextViewHierarchyNode -> { - val textColor = node.layout.dominantTextColor + val textColor = node.layout?.dominantTextColor ?: node.dominantColor ?: Color.BLACK node.layout.getVisibleRects( @@ -202,6 +203,8 @@ internal class ScreenshotRecorder( // next bind the new root rootView = WeakReference(root) root.viewTreeObserver?.addOnDrawListener(this) + // invalidate the flag to capture the first frame after new window is attached + contentChanged.set(true) } fun unbind(root: View?) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt index e3e6605a968..fb5105565b6 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/SessionReplayOptions.kt @@ -2,30 +2,30 @@ package io.sentry.android.replay import io.sentry.SentryReplayOptions -// since we don't have getters for redactAllText and redactAllImages, they won't be accessible as +// since we don't have getters for maskAllText and maskAllimages, they won't be accessible as // properties in Kotlin, therefore we create these extensions where a getter is dummy, but a setter // delegates to the corresponding method in SentryReplayOptions /** - * Redact all text content. Draws a rectangle of text bounds with text color on top. By default - * only views extending TextView are redacted. + * Mask all text content. Draws a rectangle of text bounds with text color on top. By default + * only views extending TextView are masked. * *

Default is enabled. */ -var SentryReplayOptions.redactAllText: Boolean +var SentryReplayOptions.maskAllText: Boolean @Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR) get() = error("Getter not supported") - set(value) = setRedactAllText(value) + set(value) = setMaskAllText(value) /** - * Redact all image content. Draws a rectangle of image bounds with image's dominant color on top. + * Mask all image content. Draws a rectangle of image bounds with image's dominant color on top. * By default only views extending ImageView with BitmapDrawable or custom Drawable type are - * redacted. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come + * masked. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come * from the apk. * *

Default is enabled. */ -var SentryReplayOptions.redactAllImages: Boolean +var SentryReplayOptions.maskAllImages: Boolean @Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR) get() = error("Getter not supported") - set(value) = setRedactAllImages(value) + set(value) = setMaskAllImages(value) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt index 37061a5b77c..2625399c99a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ViewExtensions.kt @@ -3,16 +3,16 @@ package io.sentry.android.replay import android.view.View /** - * Marks this view to be redacted in session replay. + * Marks this view to be masked in session replay. */ -fun View.sentryReplayRedact() { - setTag(R.id.sentry_privacy, "redact") +fun View.sentryReplayMask() { + setTag(R.id.sentry_privacy, "mask") } /** - * Marks this view to be ignored from redaction in session. + * Marks this view to be unmasked in session replay. * All its content will be visible in the replay, use with caution. */ -fun View.sentryReplayIgnore() { - setTag(R.id.sentry_privacy, "ignore") +fun View.sentryReplayUnmask() { + setTag(R.id.sentry_privacy, "unmask") } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Context.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Context.kt new file mode 100644 index 00000000000..3c5be33115f --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Context.kt @@ -0,0 +1,5 @@ +package io.sentry.android.replay.util + +import android.content.Context + +internal fun Context.appContext() = this.applicationContext ?: this diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt new file mode 100644 index 00000000000..56083717221 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Nodes.kt @@ -0,0 +1,206 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals and classes + +package io.sentry.android.replay.util + +import android.graphics.Rect +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorProducer +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.text.TextLayoutResult +import kotlin.math.roundToInt + +internal class ComposeTextLayout(internal val layout: TextLayoutResult, private val hasFillModifier: Boolean) : TextLayout { + override val lineCount: Int get() = layout.lineCount + override val dominantTextColor: Int? get() = null + override fun getPrimaryHorizontal(line: Int, offset: Int): Float { + val horizontalPos = layout.getHorizontalPosition(offset, usePrimaryDirection = true) + // when there's no `fill` modifier on a Text composable, compose still thinks that there's + // one and wrongly calculates horizontal position relative to node's start, not text's start + // for some reason. This is only the case for single-line text (multiline works fien). + // So we subtract line's left to get the correct position + return if (!hasFillModifier && lineCount == 1) { + horizontalPos - layout.getLineLeft(line) + } else { + horizontalPos + } + } + override fun getEllipsisCount(line: Int): Int = if (layout.isLineEllipsized(line)) 1 else 0 + override fun getLineVisibleEnd(line: Int): Int = layout.getLineEnd(line, visibleEnd = true) + override fun getLineTop(line: Int): Int = layout.getLineTop(line).roundToInt() + override fun getLineBottom(line: Int): Int = layout.getLineBottom(line).roundToInt() + override fun getLineStart(line: Int): Int = layout.getLineStart(line) +} + +// TODO: probably most of the below we can do via bytecode instrumentation and speed up at runtime + +/** + * This method is necessary to mask images in Compose. + * + * We heuristically look up for classes that have a [Painter] modifier, usually they all have a + * `Painter` string in their name, e.g. PainterElement, PainterModifierNodeElement or + * ContentPainterModifier for Coil. + * + * That's not going to cover all cases, but probably 90%. + * + * We also add special proguard rules to keep the `Painter` class names and their `painter` member. + */ +internal fun LayoutNode.findPainter(): Painter? { + val modifierInfos = getModifierInfo() + for (index in modifierInfos.indices) { + val modifier = modifierInfos[index].modifier + if (modifier::class.java.name.contains("Painter")) { + return try { + modifier::class.java.getDeclaredField("painter") + .apply { isAccessible = true } + .get(modifier) as? Painter + } catch (e: Throwable) { + null + } + } + } + return null +} + +/** + * We heuristically check the known classes that are coming from local assets usually: + * [androidx.compose.ui.graphics.vector.VectorPainter] + * [androidx.compose.ui.graphics.painter.ColorPainter] + * [androidx.compose.ui.graphics.painter.BrushPainter] + * + * In theory, [androidx.compose.ui.graphics.painter.BitmapPainter] can also come from local assets, + * but it can as well come from a network resource, so we preemptively mask it. + */ +internal fun Painter.isMaskable(): Boolean { + val className = this::class.java.name + return !className.contains("Vector") && + !className.contains("Color") && + !className.contains("Brush") +} + +internal data class TextAttributes(val color: Color?, val hasFillModifier: Boolean) + +/** + * This method is necessary to mask text in Compose. + * + * We heuristically look up for classes that have a [Text] modifier, usually they all have a + * `Text` string in their name, e.g. TextStringSimpleElement or TextAnnotatedStringElement. We then + * get the color from the modifier, to be able to mask it with the correct color. + * + * We also look up for classes that have a [Fill] modifier, usually they all have a `Fill` string in + * their name, e.g. FillElement. This is necessary to workaround a Compose bug where single-line + * text composable without a `fill` modifier still thinks that there's one and wrongly calculates + * horizontal position. + * + * We also add special proguard rules to keep the `Text` class names and their `color` member. + */ +internal fun LayoutNode.findTextAttributes(): TextAttributes { + val modifierInfos = getModifierInfo() + var color: Color? = null + var hasFillModifier = false + for (index in modifierInfos.indices) { + val modifier = modifierInfos[index].modifier + val modifierClassName = modifier::class.java.name + if (modifierClassName.contains("Text")) { + color = try { + ( + modifier::class.java.getDeclaredField("color") + .apply { isAccessible = true } + .get(modifier) as? ColorProducer + ) + ?.invoke() + } catch (e: Throwable) { + null + } + } else if (modifierClassName.contains("Fill")) { + hasFillModifier = true + } + } + return TextAttributes(color, hasFillModifier) +} + +/** + * Returns the smaller of the given values. If any value is NaN, returns NaN. Preferred over + * `kotlin.comparisons.minOf()` for 4 arguments as it avoids allocating an array because of the + * varargs. + */ +private inline fun fastMinOf(a: Float, b: Float, c: Float, d: Float): Float { + return minOf(a, minOf(b, minOf(c, d))) +} + +/** + * Returns the largest of the given values. If any value is NaN, returns NaN. Preferred over + * `kotlin.comparisons.maxOf()` for 4 arguments as it avoids allocating an array because of the + * varargs. + */ +private inline fun fastMaxOf(a: Float, b: Float, c: Float, d: Float): Float { + return maxOf(a, maxOf(b, maxOf(c, d))) +} + +/** + * Returns this float value clamped in the inclusive range defined by [minimumValue] and + * [maximumValue]. Unlike [Float.coerceIn], the range is not validated: the caller must ensure that + * [minimumValue] is less than [maximumValue]. + */ +private inline fun Float.fastCoerceIn(minimumValue: Float, maximumValue: Float) = + this.fastCoerceAtLeast(minimumValue).fastCoerceAtMost(maximumValue) + +/** Ensures that this value is not less than the specified [minimumValue]. */ +private inline fun Float.fastCoerceAtLeast(minimumValue: Float): Float { + return if (this < minimumValue) minimumValue else this +} + +/** Ensures that this value is not greater than the specified [maximumValue]. */ +private inline fun Float.fastCoerceAtMost(maximumValue: Float): Float { + return if (this > maximumValue) maximumValue else this +} + +/** + * A faster copy of https://github.com/androidx/androidx/blob/fc7df0dd68466ac3bb16b1c79b7a73dd0bfdd4c1/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/LayoutCoordinates.kt#L187 + * + * Since we traverse the tree from the root, we don't need to find it again from the leaf node and + * just pass it as an argument. + * + * @return boundaries of this layout relative to the window's origin. + */ +internal fun LayoutCoordinates.boundsInWindow(root: LayoutCoordinates?): Rect { + root ?: return Rect() + + val rootWidth = root.size.width.toFloat() + val rootHeight = root.size.height.toFloat() + + val bounds = root.localBoundingBoxOf(this) + val boundsLeft = bounds.left.fastCoerceIn(0f, rootWidth) + val boundsTop = bounds.top.fastCoerceIn(0f, rootHeight) + val boundsRight = bounds.right.fastCoerceIn(0f, rootWidth) + val boundsBottom = bounds.bottom.fastCoerceIn(0f, rootHeight) + + if (boundsLeft == boundsRight || boundsTop == boundsBottom) { + return Rect() + } + + val topLeft = root.localToWindow(Offset(boundsLeft, boundsTop)) + val topRight = root.localToWindow(Offset(boundsRight, boundsTop)) + val bottomRight = root.localToWindow(Offset(boundsRight, boundsBottom)) + val bottomLeft = root.localToWindow(Offset(boundsLeft, boundsBottom)) + + val topLeftX = topLeft.x + val topRightX = topRight.x + val bottomLeftX = bottomLeft.x + val bottomRightX = bottomRight.x + + val left = fastMinOf(topLeftX, topRightX, bottomLeftX, bottomRightX) + val right = fastMaxOf(topLeftX, topRightX, bottomLeftX, bottomRightX) + + val topLeftY = topLeft.y + val topRightY = topRight.y + val bottomLeftY = bottomLeft.y + val bottomRightY = bottomRight.y + + val top = fastMinOf(topLeftY, topRightY, bottomLeftY, bottomRightY) + val bottom = fastMaxOf(topLeftY, topRightY, bottomLeftY, bottomRightY) + + return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt new file mode 100644 index 00000000000..cd07c6d1701 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/TextLayout.kt @@ -0,0 +1,21 @@ +package io.sentry.android.replay.util + +/** + * An abstraction over [android.text.Layout] with different implementations for Views and Compose. + */ +interface TextLayout { + val lineCount: Int + + /** + * Returns the dominant text color of the layout by looking at the [ForegroundColorSpan] spans if + * this text is a [Spanned] text. If the text is not a [Spanned] text or there are no spans, it + * returns null. + */ + val dominantTextColor: Int? + fun getPrimaryHorizontal(line: Int, offset: Int): Float + fun getEllipsisCount(line: Int): Int + fun getLineVisibleEnd(line: Int): Int + fun getLineTop(line: Int): Int + fun getLineBottom(line: Int): Int + fun getLineStart(line: Int): Int +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt index 86c75f2e9dc..0a0656de52e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Views.kt @@ -16,9 +16,45 @@ import android.text.Layout import android.text.Spanned import android.text.style.ForegroundColorSpan import android.view.View +import android.view.ViewGroup import android.widget.TextView +import io.sentry.SentryOptions +import io.sentry.android.replay.viewhierarchy.ComposeViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.lang.NullPointerException +/** + * Recursively traverses the view hierarchy and creates a [ViewHierarchyNode] for each view. + * Supports Compose view hierarchy as well. + */ +internal fun View.traverse(parentNode: ViewHierarchyNode, options: SentryOptions) { + if (this !is ViewGroup) { + return + } + + if (ComposeViewHierarchyNode.fromView(this, parentNode, options)) { + // if it's a compose view, we can skip the children as they are already traversed in + // the ComposeViewHierarchyNode.fromView method + return + } + + if (this.childCount == 0) { + return + } + + val childNodes = ArrayList(this.childCount) + for (i in 0 until childCount) { + val child = getChildAt(i) + if (child != null) { + val childNode = + ViewHierarchyNode.fromView(child, parentNode, indexOfChild(child), options) + childNodes.add(childNode) + child.traverse(childNode, options) + } + } + parentNode.children = childNodes +} + /** * Adapted copy of AccessibilityNodeInfo from https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/view/View.java;l=10718 */ @@ -52,9 +88,9 @@ internal fun View.isVisibleToUser(): Pair { @SuppressLint("ObsoleteSdkInt") @TargetApi(21) -internal fun Drawable?.isRedactable(): Boolean { +internal fun Drawable?.isMaskable(): Boolean { // TODO: maybe find a way how to check if the drawable is coming from the apk or loaded from network - // TODO: otherwise maybe check for the bitmap size and don't redact those that take a lot of height (e.g. a background of a whatsapp chat) + // TODO: otherwise maybe check for the bitmap size and don't mask those that take a lot of height (e.g. a background of a whatsapp chat) return when (this) { is InsetDrawable, is ColorDrawable, is VectorDrawable, is GradientDrawable -> false is BitmapDrawable -> { @@ -65,19 +101,20 @@ internal fun Drawable?.isRedactable(): Boolean { } } -internal fun Layout?.getVisibleRects(globalRect: Rect, paddingLeft: Int, paddingTop: Int): List { +internal fun TextLayout?.getVisibleRects(globalRect: Rect, paddingLeft: Int, paddingTop: Int): List { if (this == null) { return listOf(globalRect) } val rects = mutableListOf() for (i in 0 until lineCount) { - val lineStart = getPrimaryHorizontal(getLineStart(i)).toInt() + val lineStart = getPrimaryHorizontal(i, getLineStart(i)).toInt() val ellipsisCount = getEllipsisCount(i) - var lineEnd = getPrimaryHorizontal(getLineVisibleEnd(i) - ellipsisCount + if (ellipsisCount > 0) 1 else 0).toInt() - if (lineEnd == 0) { + val lineVisibleEnd = getLineVisibleEnd(i) + var lineEnd = getPrimaryHorizontal(i, lineVisibleEnd - ellipsisCount + if (ellipsisCount > 0) 1 else 0).toInt() + if (lineEnd == 0 && lineVisibleEnd > 0) { // looks like the case for when emojis are present in text - lineEnd = getPrimaryHorizontal(getLineVisibleEnd(i) - 1).toInt() + 1 + lineEnd = getPrimaryHorizontal(i, lineVisibleEnd - 1).toInt() + 1 } val lineTop = getLineTop(i) val lineBottom = getLineBottom(i) @@ -105,32 +142,39 @@ internal val TextView.totalPaddingTopSafe: Int } /** - * Returns the dominant text color of the layout by looking at the [ForegroundColorSpan] spans if - * this text is a [Spanned] text. If the text is not a [Spanned] text or there are no spans, it - * returns null. + * Converts an [Int] ARGB color to an opaque color by setting the alpha channel to 255. */ -internal val Layout?.dominantTextColor: Int? get() { - this ?: return null +internal fun Int.toOpaque() = this or 0xFF000000.toInt() - if (text !is Spanned) return null +class AndroidTextLayout(private val layout: Layout) : TextLayout { + override val lineCount: Int get() = layout.lineCount + override val dominantTextColor: Int? get() { + if (layout.text !is Spanned) return null - val spans = (text as Spanned).getSpans(0, text.length, ForegroundColorSpan::class.java) + val spans = (layout.text as Spanned).getSpans(0, layout.text.length, ForegroundColorSpan::class.java) - // determine the dominant color by the span with the longest range - var longestSpan = Int.MIN_VALUE - var dominantColor: Int? = null - for (span in spans) { - val spanStart = (text as Spanned).getSpanStart(span) - val spanEnd = (text as Spanned).getSpanEnd(span) - if (spanStart == -1 || spanEnd == -1) { - // the span is not attached - continue - } - val spanLength = spanEnd - spanStart - if (spanLength > longestSpan) { - longestSpan = spanLength - dominantColor = span.foregroundColor + // determine the dominant color by the span with the longest range + var longestSpan = Int.MIN_VALUE + var dominantColor: Int? = null + for (span in spans) { + val spanStart = (layout.text as Spanned).getSpanStart(span) + val spanEnd = (layout.text as Spanned).getSpanEnd(span) + if (spanStart == -1 || spanEnd == -1) { + // the span is not attached + continue + } + val spanLength = spanEnd - spanStart + if (spanLength > longestSpan) { + longestSpan = spanLength + dominantColor = span.foregroundColor + } } + return dominantColor?.toOpaque() } - return dominantColor + override fun getPrimaryHorizontal(line: Int, offset: Int): Float = layout.getPrimaryHorizontal(offset) + override fun getEllipsisCount(line: Int): Int = layout.getEllipsisCount(line) + override fun getLineVisibleEnd(line: Int): Int = layout.getLineVisibleEnd(line) + override fun getLineTop(line: Int): Int = layout.getLineTop(line) + override fun getLineBottom(line: Int): Int = layout.getLineBottom(line) + override fun getLineStart(line: Int): Int = layout.getLineStart(line) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index baf521a2e67..211decc098d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -136,7 +136,7 @@ internal class SimpleVideoEncoder( ) format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate) format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate.toFloat()) - format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, -1) // use -1 to force always non-key frames, meaning only partial updates to save the video size + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 6) // use 6 to force non-key frames, meaning only partial updates to save the video size. Every 6th second is a key frame, which is useful for buffer mode format } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt new file mode 100644 index 00000000000..888528f769b --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ComposeViewHierarchyNode.kt @@ -0,0 +1,213 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") // to access internal vals + +package io.sentry.android.replay.viewhierarchy + +import android.annotation.TargetApi +import android.view.View +import androidx.compose.ui.graphics.isUnspecified +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.findRootCoordinates +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.Owner +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.text.TextLayoutResult +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.SentryReplayOptions +import io.sentry.android.replay.SentryReplayModifiers +import io.sentry.android.replay.util.ComposeTextLayout +import io.sentry.android.replay.util.boundsInWindow +import io.sentry.android.replay.util.findPainter +import io.sentry.android.replay.util.findTextAttributes +import io.sentry.android.replay.util.isMaskable +import io.sentry.android.replay.util.toOpaque +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode + +@TargetApi(26) +internal object ComposeViewHierarchyNode { + + /** + * Since Compose doesn't have a concept of a View class (they are all composable functions), + * we need to map the semantics node to a corresponding old view system class. + */ + private fun LayoutNode.getProxyClassName(isImage: Boolean): String { + return when { + isImage -> SentryReplayOptions.IMAGE_VIEW_CLASS_NAME + collapsedSemantics?.contains(SemanticsProperties.Text) == true || + collapsedSemantics?.contains(SemanticsActions.SetText) == true -> SentryReplayOptions.TEXT_VIEW_CLASS_NAME + else -> "android.view.View" + } + } + + private fun LayoutNode.shouldMask(isImage: Boolean, options: SentryOptions): Boolean { + val sentryPrivacyModifier = collapsedSemantics?.getOrNull(SentryReplayModifiers.SentryPrivacy) + if (sentryPrivacyModifier == "unmask") { + return false + } + + if (sentryPrivacyModifier == "mask") { + return true + } + + val className = getProxyClassName(isImage) + if (options.experimental.sessionReplay.unmaskViewClasses.contains(className)) { + return false + } + + return options.experimental.sessionReplay.maskViewClasses.contains(className) + } + + private var _rootCoordinates: LayoutCoordinates? = null + + private fun fromComposeNode( + node: LayoutNode, + parent: ViewHierarchyNode?, + distance: Int, + isComposeRoot: Boolean, + options: SentryOptions + ): ViewHierarchyNode? { + val isInTree = node.isPlaced && node.isAttached + if (!isInTree) { + return null + } + + if (isComposeRoot) { + _rootCoordinates = node.coordinates.findRootCoordinates() + } + + val semantics = node.collapsedSemantics + val visibleRect = node.coordinates.boundsInWindow(_rootCoordinates) + val isVisible = !node.outerCoordinator.isTransparent() && + (semantics == null || !semantics.contains(SemanticsProperties.InvisibleToUser)) && + visibleRect.height() > 0 && visibleRect.width() > 0 + val isEditable = semantics?.contains(SemanticsActions.SetText) == true + val positionInWindow = node.coordinates.positionInWindow() + return when { + semantics?.contains(SemanticsProperties.Text) == true || isEditable -> { + val shouldMask = isVisible && node.shouldMask(isImage = false, options) + + parent?.setImportantForCaptureToAncestors(true) + val textLayoutResults = mutableListOf() + semantics?.getOrNull(SemanticsActions.GetTextLayoutResult) + ?.action + ?.invoke(textLayoutResults) + + val (color, hasFillModifier) = node.findTextAttributes() + var textColor = textLayoutResults.firstOrNull()?.layoutInput?.style?.color + if (textColor?.isUnspecified == true) { + textColor = color + } + // TODO: support multiple text layouts + // TODO: support editable text (currently there's a way to get @Composable's padding only via reflection, and we can't reliably mask input fields based on TextLayout, so we mask the whole view instead) + TextViewHierarchyNode( + layout = if (textLayoutResults.isNotEmpty() && !isEditable) ComposeTextLayout(textLayoutResults.first(), hasFillModifier) else null, + dominantColor = textColor?.toArgb()?.toOpaque(), + x = positionInWindow.x, + y = positionInWindow.y, + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + shouldMask = shouldMask, + isImportantForContentCapture = true, + isVisible = isVisible, + visibleRect = visibleRect + ) + } + else -> { + val painter = node.findPainter() + if (painter != null) { + val shouldMask = isVisible && node.shouldMask(isImage = true, options) + + parent?.setImportantForCaptureToAncestors(true) + ImageViewHierarchyNode( + x = positionInWindow.x, + y = positionInWindow.y, + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + isVisible = isVisible, + isImportantForContentCapture = true, + shouldMask = shouldMask && painter.isMaskable(), + visibleRect = visibleRect + ) + } else { + val shouldMask = isVisible && node.shouldMask(isImage = false, options) + + // TODO: this currently does not support embedded AndroidViews, we'd have to + // TODO: traverse the ViewHierarchyNode here again. For now we can recommend + // TODO: using custom modifiers to obscure the entire node if it's sensitive + GenericViewHierarchyNode( + x = positionInWindow.x, + y = positionInWindow.y, + width = node.width, + height = node.height, + elevation = (parent?.elevation ?: 0f), + distance = distance, + parent = parent, + shouldMask = shouldMask, + isImportantForContentCapture = false, /* will be set by children */ + isVisible = isVisible, + visibleRect = visibleRect + ) + } + } + } + } + + fun fromView(view: View, parent: ViewHierarchyNode?, options: SentryOptions): Boolean { + if (!view::class.java.name.contains("AndroidComposeView")) { + return false + } + + if (parent == null) { + return false + } + + try { + val rootNode = (view as? Owner)?.root ?: return false + rootNode.traverse(parent, isComposeRoot = true, options) + } catch (e: Throwable) { + options.logger.log( + SentryLevel.ERROR, + e, + """ + Error traversing Compose tree. Most likely you're using an unsupported version of + androidx.compose.ui:ui. The minimum supported version is 1.5.0. If it's a newer + version, please open a github issue with the version you're using, so we can add + support for it. + """.trimIndent() + ) + return false + } + + return true + } + + private fun LayoutNode.traverse(parentNode: ViewHierarchyNode, isComposeRoot: Boolean, options: SentryOptions) { + val children = this.children + if (children.isEmpty()) { + return + } + + val childNodes = ArrayList(children.size) + for (index in children.indices) { + val child = children[index] + val childNode = fromComposeNode(child, parentNode, index, isComposeRoot, options) + if (childNode != null) { + childNodes.add(childNode) + child.traverse(childNode, isComposeRoot = false, options) + } + } + parentNode.children = childNodes + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 90b96f134bb..ef05ecb0296 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -2,14 +2,16 @@ package io.sentry.android.replay.viewhierarchy import android.annotation.TargetApi import android.graphics.Rect -import android.text.Layout import android.view.View import android.widget.ImageView import android.widget.TextView import io.sentry.SentryOptions import io.sentry.android.replay.R -import io.sentry.android.replay.util.isRedactable +import io.sentry.android.replay.util.AndroidTextLayout +import io.sentry.android.replay.util.TextLayout +import io.sentry.android.replay.util.isMaskable import io.sentry.android.replay.util.isVisibleToUser +import io.sentry.android.replay.util.toOpaque import io.sentry.android.replay.util.totalPaddingTopSafe @TargetApi(26) @@ -23,7 +25,7 @@ sealed class ViewHierarchyNode( /* Distance to the parent (index) */ val distance: Int, val parent: ViewHierarchyNode? = null, - val shouldRedact: Boolean = false, + val shouldMask: Boolean = false, /* Whether the node is important for content capture (=non-empty container) */ var isImportantForContentCapture: Boolean = false, val isVisible: Boolean = false, @@ -39,14 +41,14 @@ sealed class ViewHierarchyNode( elevation: Float, distance: Int, parent: ViewHierarchyNode? = null, - shouldRedact: Boolean = false, + shouldMask: Boolean = false, isImportantForContentCapture: Boolean = false, isVisible: Boolean = false, visibleRect: Rect? = null - ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldMask, isImportantForContentCapture, isVisible, visibleRect) class TextViewHierarchyNode( - val layout: Layout? = null, + val layout: TextLayout? = null, val dominantColor: Int? = null, val paddingLeft: Int = 0, val paddingTop: Int = 0, @@ -57,11 +59,11 @@ sealed class ViewHierarchyNode( elevation: Float, distance: Int, parent: ViewHierarchyNode? = null, - shouldRedact: Boolean = false, + shouldMask: Boolean = false, isImportantForContentCapture: Boolean = false, isVisible: Boolean = false, visibleRect: Rect? = null - ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldMask, isImportantForContentCapture, isVisible, visibleRect) class ImageViewHierarchyNode( x: Float, @@ -71,11 +73,25 @@ sealed class ViewHierarchyNode( elevation: Float, distance: Int, parent: ViewHierarchyNode? = null, - shouldRedact: Boolean = false, + shouldMask: Boolean = false, isImportantForContentCapture: Boolean = false, isVisible: Boolean = false, visibleRect: Rect? = null - ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldRedact, isImportantForContentCapture, isVisible, visibleRect) + ) : ViewHierarchyNode(x, y, width, height, elevation, distance, parent, shouldMask, isImportantForContentCapture, isVisible, visibleRect) + + /** + * Basically replicating this: https://developer.android.com/reference/android/view/View#isImportantForContentCapture() + * but for lower APIs and with less overhead. If we take a look at how it's set in Android: + * https://cs.android.com/search?q=IMPORTANT_FOR_CONTENT_CAPTURE_YES&ss=android%2Fplatform%2Fsuperproject%2Fmain + * we see that they just set it as important for views containing TextViews, ImageViews and WebViews. + */ + fun setImportantForCaptureToAncestors(isImportant: Boolean) { + var parent = this.parent + while (parent != null) { + parent.isImportantForContentCapture = isImportant + parent = parent.parent + } + } /** * Traverses the view hierarchy starting from this node. The traversal is done in a depth-first @@ -217,25 +233,8 @@ sealed class ViewHierarchyNode( ) companion object { - - private fun Int.toOpaque() = this or 0xFF000000.toInt() - - /** - * Basically replicating this: https://developer.android.com/reference/android/view/View#isImportantForContentCapture() - * but for lower APIs and with less overhead. If we take a look at how it's set in Android: - * https://cs.android.com/search?q=IMPORTANT_FOR_CONTENT_CAPTURE_YES&ss=android%2Fplatform%2Fsuperproject%2Fmain - * we see that they just set it as important for views containing TextViews, ImageViews and WebViews. - */ - private fun ViewHierarchyNode?.setImportantForCaptureToAncestors(isImportant: Boolean) { - var parent = this?.parent - while (parent != null) { - parent.isImportantForContentCapture = isImportant - parent = parent.parent - } - } - - private const val SENTRY_IGNORE_TAG = "sentry-ignore" - private const val SENTRY_REDACT_TAG = "sentry-redact" + private const val SENTRY_UNMASK_TAG = "sentry-unmask" + private const val SENTRY_MASK_TAG = "sentry-mask" private fun Class<*>.isAssignableFrom(set: Set): Boolean { var cls: Class<*>? = this @@ -249,34 +248,34 @@ sealed class ViewHierarchyNode( return false } - private fun View.shouldRedact(options: SentryOptions): Boolean { - if ((tag as? String)?.lowercase()?.contains(SENTRY_IGNORE_TAG) == true || - getTag(R.id.sentry_privacy) == "ignore" + private fun View.shouldMask(options: SentryOptions): Boolean { + if ((tag as? String)?.lowercase()?.contains(SENTRY_UNMASK_TAG) == true || + getTag(R.id.sentry_privacy) == "unmask" ) { return false } - if ((tag as? String)?.lowercase()?.contains(SENTRY_REDACT_TAG) == true || - getTag(R.id.sentry_privacy) == "redact" + if ((tag as? String)?.lowercase()?.contains(SENTRY_MASK_TAG) == true || + getTag(R.id.sentry_privacy) == "mask" ) { return true } - if (this.javaClass.isAssignableFrom(options.experimental.sessionReplay.ignoreViewClasses)) { + if (this.javaClass.isAssignableFrom(options.experimental.sessionReplay.unmaskViewClasses)) { return false } - return this.javaClass.isAssignableFrom(options.experimental.sessionReplay.redactViewClasses) + return this.javaClass.isAssignableFrom(options.experimental.sessionReplay.maskViewClasses) } fun fromView(view: View, parent: ViewHierarchyNode?, distance: Int, options: SentryOptions): ViewHierarchyNode { val (isVisible, visibleRect) = view.isVisibleToUser() - val shouldRedact = isVisible && view.shouldRedact(options) + val shouldMask = isVisible && view.shouldMask(options) when (view) { is TextView -> { - parent.setImportantForCaptureToAncestors(true) + parent?.setImportantForCaptureToAncestors(true) return TextViewHierarchyNode( - layout = view.layout, + layout = view.layout?.let { AndroidTextLayout(it) }, dominantColor = view.currentTextColor.toOpaque(), paddingLeft = view.totalPaddingLeft, paddingTop = view.totalPaddingTopSafe, @@ -285,7 +284,7 @@ sealed class ViewHierarchyNode( width = view.width, height = view.height, elevation = (parent?.elevation ?: 0f) + view.elevation, - shouldRedact = shouldRedact, + shouldMask = shouldMask, distance = distance, parent = parent, isImportantForContentCapture = true, @@ -295,7 +294,7 @@ sealed class ViewHierarchyNode( } is ImageView -> { - parent.setImportantForCaptureToAncestors(true) + parent?.setImportantForCaptureToAncestors(true) return ImageViewHierarchyNode( x = view.x, y = view.y, @@ -306,7 +305,7 @@ sealed class ViewHierarchyNode( parent = parent, isVisible = isVisible, isImportantForContentCapture = true, - shouldRedact = shouldRedact && view.drawable?.isRedactable() == true, + shouldMask = shouldMask && view.drawable?.isMaskable() == true, visibleRect = visibleRect ) } @@ -320,7 +319,7 @@ sealed class ViewHierarchyNode( (parent?.elevation ?: 0f) + view.elevation, distance = distance, parent = parent, - shouldRedact = shouldRedact, + shouldMask = shouldMask, isImportantForContentCapture = false, /* will be set by children */ isVisible = isVisible, visibleRect = visibleRect diff --git a/sentry-android-replay/src/test/AndroidManifest.xml b/sentry-android-replay/src/test/AndroidManifest.xml new file mode 100644 index 00000000000..c8f45a53bbf --- /dev/null +++ b/sentry-android-replay/src/test/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt index ec545ed1091..9a5b805ad73 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/TextViewDominantColorTest.kt @@ -36,7 +36,7 @@ class TextViewDominantColorTest { val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) assertTrue(node is TextViewHierarchyNode) - assertNull(node.layout.dominantTextColor) + assertNull(node.layout?.dominantTextColor) } @Test @@ -55,7 +55,7 @@ class TextViewDominantColorTest { val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) assertTrue(node is TextViewHierarchyNode) - assertEquals(Color.RED, node.layout.dominantTextColor) + assertEquals(Color.RED, node.layout?.dominantTextColor) } @Test @@ -75,7 +75,7 @@ class TextViewDominantColorTest { val node = ViewHierarchyNode.fromView(TextViewActivity.textView!!, null, 0, SentryOptions()) assertTrue(node is TextViewHierarchyNode) - assertEquals(Color.BLACK, node.layout.dominantTextColor) + assertEquals(Color.BLACK, node.layout?.dominantTextColor) } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt new file mode 100644 index 00000000000..e5330fa8277 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/ComposeMaskingOptionsTest.kt @@ -0,0 +1,240 @@ +package io.sentry.android.replay.viewhierarchy + +import android.app.Activity +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.invisibleToUser +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import coil.compose.AsyncImage +import io.sentry.SentryOptions +import io.sentry.android.replay.maskAllImages +import io.sentry.android.replay.maskAllText +import io.sentry.android.replay.sentryReplayMask +import io.sentry.android.replay.sentryReplayUnmask +import io.sentry.android.replay.util.ComposeTextLayout +import io.sentry.android.replay.util.traverse +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.GenericViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.Robolectric.buildActivity +import org.robolectric.annotation.Config +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class ComposeMaskingOptionsTest { + + @Before + fun setup() { + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + ComposeMaskingOptionsActivity.textModifierApplier = null + ComposeMaskingOptionsActivity.containerModifierApplier = null + } + + @Test + fun `when maskAllText is set all Text nodes are masked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + assertTrue(textNodes.all { it.shouldMask }) + // just a sanity check for parsing the tree + assertEquals("Random repo", (textNodes[1].layout as ComposeTextLayout).layout.layoutInput.text.text) + } + + @Test + fun `when maskAllText is set to false all Text nodes are unmasked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = false + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + assertTrue(textNodes.none { it.shouldMask }) + } + + @Test + fun `when maskAllImages is set all Image nodes are masked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllImages = true + } + + val imageNodes = activity.get().collectNodesOfType(options) + assertEquals(1, imageNodes.size) // [AsyncImage] + assertTrue(imageNodes.all { it.shouldMask }) + } + + @Test + fun `when maskAllImages is set to false all Image nodes are unmasked`() { + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllImages = false + } + + val imageNodes = activity.get().collectNodesOfType(options) + assertEquals(1, imageNodes.size) // [AsyncImage] + assertTrue(imageNodes.none { it.shouldMask }) + } + + @Test + fun `when sentry-mask modifier is set masks the node`() { + ComposeMaskingOptionsActivity.textModifierApplier = { Modifier.sentryReplayMask() } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = false + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + textNodes.forEach { + if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { + assertTrue(it.shouldMask) + } else { + assertFalse(it.shouldMask) + } + } + } + + @Test + fun `when sentry-unmask modifier is set unmasks the node`() { + ComposeMaskingOptionsActivity.textModifierApplier = { Modifier.sentryReplayUnmask() } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + assertEquals(4, textNodes.size) // [TextField, Text, Button, Activity Title] + textNodes.forEach { + if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { + assertFalse(it.shouldMask) + } else { + assertTrue(it.shouldMask) + } + } + } + + @Test + fun `when view is not visible, does not mask the view`() { + ComposeMaskingOptionsActivity.textModifierApplier = { Modifier.semantics { invisibleToUser() } } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + val textNodes = activity.get().collectNodesOfType(options) + textNodes.forEach { + if ((it.layout as? ComposeTextLayout)?.layout?.layoutInput?.text?.text == "Make Request") { + assertFalse(it.shouldMask) + } else { + assertTrue(it.shouldMask) + } + } + } + + @Test + fun `when a container view is unmasked its children are not unmasked`() { + ComposeMaskingOptionsActivity.containerModifierApplier = { Modifier.sentryReplayUnmask() } + val activity = buildActivity(ComposeMaskingOptionsActivity::class.java).setup() + + val options = SentryOptions() + + val allNodes = activity.get().collectNodesOfType(options) + val imageNodes = allNodes.filterIsInstance() + val textNodes = allNodes.filterIsInstance() + val genericNodes = allNodes.filterIsInstance() + assertTrue(imageNodes.all { it.shouldMask }) + assertTrue(textNodes.all { it.shouldMask }) + assertTrue(genericNodes.none { it.shouldMask }) + } + + private inline fun Activity.collectNodesOfType(options: SentryOptions): List { + val root = window.decorView + val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options) + root.traverse(viewHierarchy, options) + + val nodes = mutableListOf() + viewHierarchy.traverse { + if (it is T) { + nodes += it + } + return@traverse true + } + return nodes + } +} + +private class ComposeMaskingOptionsActivity : ComponentActivity() { + + companion object { + var textModifierApplier: (() -> Modifier)? = null + var containerModifierApplier: (() -> Modifier)? = null + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! + + setContent { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .then(containerModifierApplier?.invoke() ?: Modifier) + ) { + AsyncImage( + model = Uri.fromFile(File(image.toURI())), + contentDescription = null, + modifier = Modifier.padding(vertical = 16.dp) + ) + TextField( + value = TextFieldValue("Placeholder"), + onValueChange = { _ -> } + ) + Text("Random repo") + Button( + onClick = {}, + modifier = Modifier + .testTag("button_list_repos_async") + .padding(top = 32.dp) + ) { + Text("Make Request", modifier = Modifier.then(textModifierApplier?.invoke() ?: Modifier)) + } + } + } + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt new file mode 100644 index 00000000000..4a40e0a9150 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/MaskingOptionsTest.kt @@ -0,0 +1,278 @@ +package io.sentry.android.replay.viewhierarchy + +import android.app.Activity +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.LinearLayout.LayoutParams +import android.widget.RadioButton +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryOptions +import io.sentry.android.replay.maskAllImages +import io.sentry.android.replay.maskAllText +import io.sentry.android.replay.sentryReplayMask +import io.sentry.android.replay.sentryReplayUnmask +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode +import org.junit.runner.RunWith +import org.robolectric.Robolectric.buildActivity +import org.robolectric.annotation.Config +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [30]) +class MaskingOptionsTest { + + @BeforeTest + fun setup() { + System.setProperty("robolectric.areWindowsMarkedVisible", "true") + } + + @Test + fun `when maskAllText is set all TextView nodes are masked`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.radioButton!!, null, 0, options) + + assertTrue(textNode is TextViewHierarchyNode) + assertTrue(textNode.shouldMask) + + assertTrue(radioButtonNode is TextViewHierarchyNode) + assertTrue(radioButtonNode.shouldMask) + } + + @Test + fun `when maskAllText is set to false all TextView nodes are unmasked`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = false + } + + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.radioButton!!, null, 0, options) + + assertTrue(textNode is TextViewHierarchyNode) + assertFalse(textNode.shouldMask) + + assertTrue(radioButtonNode is TextViewHierarchyNode) + assertFalse(radioButtonNode.shouldMask) + } + + @Test + fun `when maskAllImages is set all ImageView nodes are masked`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllImages = true + } + + val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) + + assertTrue(imageNode is ImageViewHierarchyNode) + assertTrue(imageNode.shouldMask) + } + + @Test + fun `when maskAllImages is set to false all ImageView nodes are unmasked`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllImages = false + } + + val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) + + assertTrue(imageNode is ImageViewHierarchyNode) + assertFalse(imageNode.shouldMask) + } + + @Test + fun `when sentry-mask tag is set mask the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = false + } + + MaskingOptionsActivity.textView!!.tag = "sentry-mask" + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertTrue(textNode.shouldMask) + } + + @Test + fun `when sentry-unmask tag is set unmasks the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + MaskingOptionsActivity.textView!!.tag = "sentry-unmask" + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertFalse(textNode.shouldMask) + } + + @Test + fun `when sentry-privacy tag is set to mask masks the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = false + } + + MaskingOptionsActivity.textView!!.sentryReplayMask() + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertTrue(textNode.shouldMask) + } + + @Test + fun `when sentry-privacy tag is set to unmask unmasks the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + MaskingOptionsActivity.textView!!.sentryReplayUnmask() + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertFalse(textNode.shouldMask) + } + + @Test + fun `when view is not visible, does not mask the view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true + } + + MaskingOptionsActivity.textView!!.visibility = View.GONE + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + + assertFalse(textNode.shouldMask) + } + + @Test + fun `when added to mask list masks custom view`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskViewClasses.add(CustomView::class.java.canonicalName) + } + + val customViewNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.customView!!, null, 0, options) + + assertTrue(customViewNode.shouldMask) + } + + @Test + fun `when subclass is added to ignored classes ignores all instances of that class`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.maskAllText = true // all TextView subclasses + experimental.sessionReplay.unmaskViewClasses.add(RadioButton::class.java.canonicalName) + } + + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val radioButtonNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.radioButton!!, null, 0, options) + + assertTrue(textNode.shouldMask) + assertFalse(radioButtonNode.shouldMask) + } + + @Test + fun `when a container view is ignored its children are not ignored`() { + buildActivity(MaskingOptionsActivity::class.java).setup() + + val options = SentryOptions().apply { + experimental.sessionReplay.unmaskViewClasses.add(LinearLayout::class.java.canonicalName) + } + + val linearLayoutNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!.parent as LinearLayout, null, 0, options) + val textNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.textView!!, null, 0, options) + val imageNode = ViewHierarchyNode.fromView(MaskingOptionsActivity.imageView!!, null, 0, options) + + assertFalse(linearLayoutNode.shouldMask) + assertTrue(textNode.shouldMask) + assertTrue(imageNode.shouldMask) + } +} + +private class CustomView(context: Context) : View(context) { + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawColor(Color.BLACK) + } +} + +private class MaskingOptionsActivity : Activity() { + + companion object { + var textView: TextView? = null + var radioButton: RadioButton? = null + var imageView: ImageView? = null + var customView: CustomView? = null + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val linearLayout = LinearLayout(this).apply { + setBackgroundColor(android.R.color.white) + orientation = LinearLayout.VERTICAL + layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + } + + textView = TextView(this).apply { + text = "Hello, World!" + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } + linearLayout.addView(textView) + + val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! + imageView = ImageView(this).apply { + setImageDrawable(Drawable.createFromPath(image.path)) + layoutParams = LayoutParams(50, 50).apply { + setMargins(0, 16, 0, 0) + } + } + linearLayout.addView(imageView) + + radioButton = RadioButton(this).apply { + text = "Radio Button" + layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { + setMargins(0, 16, 0, 0) + } + } + linearLayout.addView(radioButton) + + customView = CustomView(this).apply { + layoutParams = LayoutParams(50, 50).apply { + setMargins(0, 16, 0, 0) + } + } + linearLayout.addView(customView) + + setContentView(linearLayout) + } +} diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt deleted file mode 100644 index 8ffffd046da..00000000000 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/viewhierarchy/RedactionOptionsTest.kt +++ /dev/null @@ -1,278 +0,0 @@ -package io.sentry.android.replay.viewhierarchy - -import android.app.Activity -import android.content.Context -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.drawable.Drawable -import android.os.Bundle -import android.view.View -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.LinearLayout.LayoutParams -import android.widget.RadioButton -import android.widget.TextView -import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.sentry.SentryOptions -import io.sentry.android.replay.redactAllImages -import io.sentry.android.replay.redactAllText -import io.sentry.android.replay.sentryReplayIgnore -import io.sentry.android.replay.sentryReplayRedact -import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.ImageViewHierarchyNode -import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode.TextViewHierarchyNode -import org.junit.Before -import org.junit.runner.RunWith -import org.robolectric.Robolectric.buildActivity -import org.robolectric.annotation.Config -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -@RunWith(AndroidJUnit4::class) -@Config(sdk = [30]) -class RedactionOptionsTest { - - @Before - fun setup() { - System.setProperty("robolectric.areWindowsMarkedVisible", "true") - } - - @Test - fun `when redactAllText is set all TextView nodes are redacted`() { - buildActivity(ExampleActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true - } - - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - val radioButtonNode = ViewHierarchyNode.fromView(ExampleActivity.radioButton!!, null, 0, options) - - assertTrue(textNode is TextViewHierarchyNode) - assertTrue(textNode.shouldRedact) - - assertTrue(radioButtonNode is TextViewHierarchyNode) - assertTrue(radioButtonNode.shouldRedact) - } - - @Test - fun `when redactAllText is set to false all TextView nodes are ignored`() { - buildActivity(ExampleActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = false - } - - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - val radioButtonNode = ViewHierarchyNode.fromView(ExampleActivity.radioButton!!, null, 0, options) - - assertTrue(textNode is TextViewHierarchyNode) - assertFalse(textNode.shouldRedact) - - assertTrue(radioButtonNode is TextViewHierarchyNode) - assertFalse(radioButtonNode.shouldRedact) - } - - @Test - fun `when redactAllImages is set all ImageView nodes are redacted`() { - buildActivity(ExampleActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllImages = true - } - - val imageNode = ViewHierarchyNode.fromView(ExampleActivity.imageView!!, null, 0, options) - - assertTrue(imageNode is ImageViewHierarchyNode) - assertTrue(imageNode.shouldRedact) - } - - @Test - fun `when redactAllImages is set to false all ImageView nodes are ignored`() { - buildActivity(ExampleActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllImages = false - } - - val imageNode = ViewHierarchyNode.fromView(ExampleActivity.imageView!!, null, 0, options) - - assertTrue(imageNode is ImageViewHierarchyNode) - assertFalse(imageNode.shouldRedact) - } - - @Test - fun `when sentry-redact tag is set redacts the view`() { - buildActivity(ExampleActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = false - } - - ExampleActivity.textView!!.tag = "sentry-redact" - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - - assertTrue(textNode.shouldRedact) - } - - @Test - fun `when sentry-ignore tag is set ignores the view`() { - buildActivity(ExampleActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true - } - - ExampleActivity.textView!!.tag = "sentry-ignore" - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - - assertFalse(textNode.shouldRedact) - } - - @Test - fun `when sentry-privacy tag is set to redact redacts the view`() { - buildActivity(ExampleActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = false - } - - ExampleActivity.textView!!.sentryReplayRedact() - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - - assertTrue(textNode.shouldRedact) - } - - @Test - fun `when sentry-privacy tag is set to ignore ignores the view`() { - buildActivity(ExampleActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true - } - - ExampleActivity.textView!!.sentryReplayIgnore() - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - - assertFalse(textNode.shouldRedact) - } - - @Test - fun `when view is not visible, does not redact the view`() { - buildActivity(ExampleActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true - } - - ExampleActivity.textView!!.visibility = View.GONE - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - - assertFalse(textNode.shouldRedact) - } - - @Test - fun `when added to redact list redacts custom view`() { - buildActivity(ExampleActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactViewClasses.add(CustomView::class.java.canonicalName) - } - - val customViewNode = ViewHierarchyNode.fromView(ExampleActivity.customView!!, null, 0, options) - - assertTrue(customViewNode.shouldRedact) - } - - @Test - fun `when subclass is added to ignored classes ignores all instances of that class`() { - buildActivity(ExampleActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.redactAllText = true // all TextView subclasses - experimental.sessionReplay.ignoreViewClasses.add(RadioButton::class.java.canonicalName) - } - - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - val radioButtonNode = ViewHierarchyNode.fromView(ExampleActivity.radioButton!!, null, 0, options) - - assertTrue(textNode.shouldRedact) - assertFalse(radioButtonNode.shouldRedact) - } - - @Test - fun `when a container view is ignored its children are not ignored`() { - buildActivity(ExampleActivity::class.java).setup() - - val options = SentryOptions().apply { - experimental.sessionReplay.ignoreViewClasses.add(LinearLayout::class.java.canonicalName) - } - - val linearLayoutNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!.parent as LinearLayout, null, 0, options) - val textNode = ViewHierarchyNode.fromView(ExampleActivity.textView!!, null, 0, options) - val imageNode = ViewHierarchyNode.fromView(ExampleActivity.imageView!!, null, 0, options) - - assertFalse(linearLayoutNode.shouldRedact) - assertTrue(textNode.shouldRedact) - assertTrue(imageNode.shouldRedact) - } -} - -private class CustomView(context: Context) : View(context) { - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - canvas.drawColor(Color.BLACK) - } -} - -private class ExampleActivity : Activity() { - - companion object { - var textView: TextView? = null - var radioButton: RadioButton? = null - var imageView: ImageView? = null - var customView: CustomView? = null - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val linearLayout = LinearLayout(this).apply { - setBackgroundColor(android.R.color.white) - orientation = LinearLayout.VERTICAL - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) - } - - textView = TextView(this).apply { - text = "Hello, World!" - layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) - } - linearLayout.addView(textView) - - val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! - imageView = ImageView(this).apply { - setImageDrawable(Drawable.createFromPath(image.path)) - layoutParams = LayoutParams(50, 50).apply { - setMargins(0, 16, 0, 0) - } - } - linearLayout.addView(imageView) - - radioButton = RadioButton(this).apply { - text = "Radio Button" - layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { - setMargins(0, 16, 0, 0) - } - } - linearLayout.addView(radioButton) - - customView = CustomView(this).apply { - layoutParams = LayoutParams(50, 50).apply { - setMargins(0, 16, 0, 0) - } - } - linearLayout.addView(customView) - - setContentView(linearLayout) - } -} diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index e86dab253d4..0f3cffecc20 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -125,6 +125,7 @@ dependencies { implementation(Config.Libs.composeFoundationLayout) implementation(Config.Libs.composeNavigation) implementation(Config.Libs.composeMaterial) + implementation(Config.Libs.composeCoil) debugImplementation(Config.Libs.leakCanary) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index a17e11ef119..cc6e99cc605 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -161,7 +161,6 @@ - - + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt index 1a4929b0b7e..3d2e670495d 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/compose/ComposeActivity.kt @@ -5,6 +5,7 @@ package io.sentry.samples.android.compose import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -22,7 +23,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController @@ -31,10 +35,13 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import coil.compose.AsyncImage +import io.sentry.android.replay.sentryReplayUnmask import io.sentry.compose.SentryTraced import io.sentry.compose.withSentryObservableEffect import io.sentry.samples.android.GithubAPI import kotlinx.coroutines.launch +import io.sentry.samples.android.R as IR class ComposeActivity : ComponentActivity() { @@ -109,6 +116,17 @@ fun Github( modifier = Modifier .fillMaxSize() ) { + Image( + painter = painterResource(IR.drawable.sentry_glyph), + contentDescription = "LOGO", + colorFilter = ColorFilter.tint(Color.Black), + modifier = Modifier.padding(vertical = 16.dp) + ) + AsyncImage( + model = "https://i.imgur.com/tie6A3J.jpeg", + contentDescription = null, + modifier = Modifier.padding(vertical = 16.dp) + ) TextField( value = user, onValueChange = { newText -> @@ -127,7 +145,7 @@ fun Github( .testTag("button_list_repos_async") .padding(top = 32.dp) ) { - Text("Make Request") + Text("Make Request", modifier = Modifier.sentryReplayUnmask()) } } } diff --git a/sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml b/sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml new file mode 100644 index 00000000000..28a3442987b --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/res/drawable/sentry_glyph.xml @@ -0,0 +1,9 @@ + + + diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 81ce6b4989a..8a9c296b135 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -95,6 +95,7 @@ public final class io/sentry/BaggageHeader { public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/JsonUnknown, java/lang/Comparable { public fun ()V + public fun (J)V public fun (Ljava/lang/String;)V public fun (Ljava/util/Date;)V public fun compareTo (Lio/sentry/Breadcrumb;)I @@ -108,6 +109,7 @@ public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/ public fun getData (Ljava/lang/String;)Ljava/lang/Object; public fun getLevel ()Lio/sentry/SentryLevel; public fun getMessage ()Ljava/lang/String; + public fun getOrigin ()Ljava/lang/String; public fun getTimestamp ()Ljava/util/Date; public fun getType ()Ljava/lang/String; public fun getUnknown ()Ljava/util/Map; @@ -126,6 +128,7 @@ public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/ public fun setData (Ljava/lang/String;Ljava/lang/Object;)V public fun setLevel (Lio/sentry/SentryLevel;)V public fun setMessage (Ljava/lang/String;)V + public fun setOrigin (Ljava/lang/String;)V public fun setType (Ljava/lang/String;)V public fun setUnknown (Ljava/util/Map;)V public static fun transaction (Ljava/lang/String;)Lio/sentry/Breadcrumb; @@ -147,6 +150,7 @@ public final class io/sentry/Breadcrumb$JsonKeys { public static final field DATA Ljava/lang/String; public static final field LEVEL Ljava/lang/String; public static final field MESSAGE Ljava/lang/String; + public static final field ORIGIN Ljava/lang/String; public static final field TIMESTAMP Ljava/lang/String; public static final field TYPE Ljava/lang/String; public fun ()V @@ -424,7 +428,7 @@ public abstract interface class io/sentry/EventProcessor { } public final class io/sentry/ExperimentalOptions { - public fun ()V + public fun (Z)V public fun getSessionReplay ()Lio/sentry/SentryReplayOptions; public fun setSessionReplay (Lio/sentry/SentryReplayOptions;)V } @@ -2670,6 +2674,7 @@ public final class io/sentry/SentryItemType : java/lang/Enum, io/sentry/JsonSeri public static final field CheckIn Lio/sentry/SentryItemType; public static final field ClientReport Lio/sentry/SentryItemType; public static final field Event Lio/sentry/SentryItemType; + public static final field Feedback Lio/sentry/SentryItemType; public static final field Profile Lio/sentry/SentryItemType; public static final field ReplayEvent Lio/sentry/SentryItemType; public static final field ReplayRecording Lio/sentry/SentryItemType; @@ -2686,6 +2691,12 @@ public final class io/sentry/SentryItemType : java/lang/Enum, io/sentry/JsonSeri public static fun values ()[Lio/sentry/SentryItemType; } +public final class io/sentry/SentryItemType$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryItemType; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + public final class io/sentry/SentryLevel : java/lang/Enum, io/sentry/JsonSerializable { public static final field DEBUG Lio/sentry/SentryLevel; public static final field ERROR Lio/sentry/SentryLevel; @@ -2930,6 +2941,7 @@ public class io/sentry/SentryOptions { public fun setExecutorService (Lio/sentry/ISentryExecutorService;)V public fun setFlushTimeoutMillis (J)V public fun setForceInit (Z)V + public fun setFullyDisplayedReporter (Lio/sentry/FullyDisplayedReporter;)V public fun setGestureTargetLocators (Ljava/util/List;)V public fun setIdleTimeout (Ljava/lang/Long;)V public fun setIgnoredCheckIns (Ljava/util/List;)V @@ -3117,27 +3129,32 @@ public final class io/sentry/SentryReplayEvent$ReplayType$Deserializer : io/sent } public final class io/sentry/SentryReplayOptions { + public static final field ANDROIDX_MEDIA_VIEW_CLASS_NAME Ljava/lang/String; + public static final field EXOPLAYER_CLASS_NAME Ljava/lang/String; + public static final field EXOPLAYER_STYLED_CLASS_NAME Ljava/lang/String; public static final field IMAGE_VIEW_CLASS_NAME Ljava/lang/String; public static final field TEXT_VIEW_CLASS_NAME Ljava/lang/String; - public fun ()V + public static final field VIDEO_VIEW_CLASS_NAME Ljava/lang/String; + public static final field WEB_VIEW_CLASS_NAME Ljava/lang/String; public fun (Ljava/lang/Double;Ljava/lang/Double;)V - public fun addIgnoreViewClass (Ljava/lang/String;)V - public fun addRedactViewClass (Ljava/lang/String;)V + public fun (Z)V + public fun addMaskViewClass (Ljava/lang/String;)V + public fun addUnmaskViewClass (Ljava/lang/String;)V public fun getErrorReplayDuration ()J public fun getFrameRate ()I - public fun getIgnoreViewClasses ()Ljava/util/Set; + public fun getMaskViewClasses ()Ljava/util/Set; public fun getOnErrorSampleRate ()Ljava/lang/Double; public fun getQuality ()Lio/sentry/SentryReplayOptions$SentryReplayQuality; - public fun getRedactViewClasses ()Ljava/util/Set; public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J + public fun getUnmaskViewClasses ()Ljava/util/Set; public fun isSessionReplayEnabled ()Z public fun isSessionReplayForErrorsEnabled ()Z + public fun setMaskAllImages (Z)V + public fun setMaskAllText (Z)V public fun setOnErrorSampleRate (Ljava/lang/Double;)V public fun setQuality (Lio/sentry/SentryReplayOptions$SentryReplayQuality;)V - public fun setRedactAllImages (Z)V - public fun setRedactAllText (Z)V public fun setSessionSampleRate (Ljava/lang/Double;)V } @@ -5995,6 +6012,7 @@ public final class io/sentry/util/JsonSerializationUtils { public final class io/sentry/util/LazyEvaluator { public fun (Lio/sentry/util/LazyEvaluator$Evaluator;)V public fun getValue ()Ljava/lang/Object; + public fun setValue (Ljava/lang/Object;)V } public abstract interface class io/sentry/util/LazyEvaluator$Evaluator { diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index 96e3eb44120..d5096455108 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -20,8 +20,11 @@ /** Series of application events */ public final class Breadcrumb implements JsonUnknown, JsonSerializable, Comparable { - /** A timestamp representing when the breadcrumb occurred. */ - private final @NotNull Date timestamp; + /** A timestamp representing when the breadcrumb occurred in milliseconds. */ + private @Nullable final Long timestampMs; + + /** A timestamp representing when the breadcrumb occurred as java.util.Date. */ + private @Nullable Date timestamp; private final @NotNull Long nanos; @@ -37,6 +40,12 @@ public final class Breadcrumb implements JsonUnknown, JsonSerializable, Comparab /** Dotted strings that indicate what the crumb is or where it comes from. */ private @Nullable String category; + /** + * Origin of the breadcrumb that is used to identify source of the breadcrumb. For example hybrid + * SDKs can identify native breadcrumbs from JS or Flutter. + */ + private @Nullable String origin; + /** The level of the event. */ private @Nullable SentryLevel level; @@ -48,17 +57,27 @@ public final class Breadcrumb implements JsonUnknown, JsonSerializable, Comparab * * @param timestamp the timestamp */ + @SuppressWarnings("JavaUtilDate") public Breadcrumb(final @NotNull Date timestamp) { this.nanos = System.nanoTime(); this.timestamp = timestamp; + this.timestampMs = null; + } + + public Breadcrumb(final long timestamp) { + this.nanos = System.nanoTime(); + this.timestampMs = timestamp; + this.timestamp = null; } Breadcrumb(final @NotNull Breadcrumb breadcrumb) { this.nanos = System.nanoTime(); this.timestamp = breadcrumb.timestamp; + this.timestampMs = breadcrumb.timestampMs; this.message = breadcrumb.message; this.type = breadcrumb.type; this.category = breadcrumb.category; + this.origin = breadcrumb.origin; final Map dataClone = CollectionUtils.newConcurrentHashMap(breadcrumb.data); if (dataClone != null) { this.data = dataClone; @@ -83,6 +102,7 @@ public static Breadcrumb fromMap( String type = null; @NotNull Map data = new ConcurrentHashMap<>(); String category = null; + String origin = null; SentryLevel level = null; Map unknown = null; @@ -121,6 +141,9 @@ public static Breadcrumb fromMap( case JsonKeys.CATEGORY: category = (value instanceof String) ? (String) value : null; break; + case JsonKeys.ORIGIN: + origin = (value instanceof String) ? (String) value : null; + break; case JsonKeys.LEVEL: String levelString = (value instanceof String) ? (String) value : null; if (levelString != null) { @@ -145,6 +168,7 @@ public static Breadcrumb fromMap( breadcrumb.type = type; breadcrumb.data = data; breadcrumb.category = category; + breadcrumb.origin = origin; breadcrumb.level = level; breadcrumb.setUnknown(unknown); @@ -508,7 +532,7 @@ public static Breadcrumb fromMap( /** Breadcrumb ctor */ public Breadcrumb() { - this(DateUtils.getCurrentDateTime()); + this(System.currentTimeMillis()); } /** @@ -522,13 +546,20 @@ public Breadcrumb(@Nullable String message) { } /** - * Returns the Breadcrumb's timestamp + * Returns the Breadcrumb's timestamp as java.util.Date * * @return the timestamp */ - @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) + @SuppressWarnings("JavaUtilDate") public @NotNull Date getTimestamp() { - return (Date) timestamp.clone(); + if (timestamp != null) { + return (Date) timestamp.clone(); + } else if (timestampMs != null) { + // we memoize it here into timestamp to avoid instantiating Calendar again and again + timestamp = DateUtils.getDateTime(timestampMs); + return timestamp; + } + throw new IllegalStateException("No timestamp set for breadcrumb"); } /** @@ -626,6 +657,24 @@ public void setCategory(@Nullable String category) { this.category = category; } + /** + * Returns the origin + * + * @return the origin + */ + public @Nullable String getOrigin() { + return origin; + } + + /** + * Sets the origin + * + * @param origin the origin + */ + public void setOrigin(@Nullable String origin) { + this.origin = origin; + } + /** * Returns the SentryLevel * @@ -650,16 +699,17 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Breadcrumb that = (Breadcrumb) o; - return timestamp.getTime() == that.timestamp.getTime() + return getTimestamp().getTime() == that.getTimestamp().getTime() && Objects.equals(message, that.message) && Objects.equals(type, that.type) && Objects.equals(category, that.category) + && Objects.equals(origin, that.origin) && level == that.level; } @Override public int hashCode() { - return Objects.hash(timestamp, message, type, category, level); + return Objects.hash(timestamp, message, type, category, origin, level); } // region json @@ -687,6 +737,7 @@ public static final class JsonKeys { public static final String TYPE = "type"; public static final String DATA = "data"; public static final String CATEGORY = "category"; + public static final String ORIGIN = "origin"; public static final String LEVEL = "level"; } @@ -694,7 +745,7 @@ public static final class JsonKeys { public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) throws IOException { writer.beginObject(); - writer.name(JsonKeys.TIMESTAMP).value(logger, timestamp); + writer.name(JsonKeys.TIMESTAMP).value(logger, getTimestamp()); if (message != null) { writer.name(JsonKeys.MESSAGE).value(message); } @@ -705,6 +756,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (category != null) { writer.name(JsonKeys.CATEGORY).value(category); } + if (origin != null) { + writer.name(JsonKeys.ORIGIN).value(origin); + } if (level != null) { writer.name(JsonKeys.LEVEL).value(logger, level); } @@ -729,6 +783,7 @@ public static final class Deserializer implements JsonDeserializer { String type = null; @NotNull Map data = new ConcurrentHashMap<>(); String category = null; + String origin = null; SentryLevel level = null; Map unknown = null; @@ -758,6 +813,9 @@ public static final class Deserializer implements JsonDeserializer { case JsonKeys.CATEGORY: category = reader.nextStringOrNull(); break; + case JsonKeys.ORIGIN: + origin = reader.nextStringOrNull(); + break; case JsonKeys.LEVEL: try { level = new SentryLevel.Deserializer().deserialize(reader, logger); @@ -779,6 +837,7 @@ public static final class Deserializer implements JsonDeserializer { breadcrumb.type = type; breadcrumb.data = data; breadcrumb.category = category; + breadcrumb.origin = origin; breadcrumb.level = level; breadcrumb.setUnknown(unknown); diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index f587996bd8c..4a0e7de78d1 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -9,7 +9,11 @@ *

Beware that experimental options can change at any time. */ public final class ExperimentalOptions { - private @NotNull SentryReplayOptions sessionReplay = new SentryReplayOptions(); + private @NotNull SentryReplayOptions sessionReplay; + + public ExperimentalOptions(final boolean empty) { + this.sessionReplay = new SentryReplayOptions(empty); + } @NotNull public SentryReplayOptions getSessionReplay() { diff --git a/sentry/src/main/java/io/sentry/SentryItemType.java b/sentry/src/main/java/io/sentry/SentryItemType.java index b0d9c62e905..85acc0aaddd 100644 --- a/sentry/src/main/java/io/sentry/SentryItemType.java +++ b/sentry/src/main/java/io/sentry/SentryItemType.java @@ -20,6 +20,7 @@ public enum SentryItemType implements JsonSerializable { ReplayRecording("replay_recording"), ReplayVideo("replay_video"), CheckIn("check_in"), + Feedback("feedback"), Unknown("__unknown__"); // DataCategory.Unknown private final String itemType; @@ -61,7 +62,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger writer.value(itemType); } - static final class Deserializer implements JsonDeserializer { + public static final class Deserializer implements JsonDeserializer { @Override public @NotNull SentryItemType deserialize( diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index fc47b10d715..48557127424 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -20,6 +20,7 @@ import io.sentry.transport.NoOpEnvelopeCache; import io.sentry.transport.NoOpTransportGate; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.LazyEvaluator; import io.sentry.util.Platform; import io.sentry.util.SampleRateUtils; import io.sentry.util.StringUtils; @@ -118,11 +119,13 @@ public class SentryOptions { /** minimum LogLevel to be used if debug is enabled */ private @NotNull SentryLevel diagnosticLevel = DEFAULT_DIAGNOSTIC_LEVEL; - /** Envelope reader interface */ - private @NotNull IEnvelopeReader envelopeReader = new EnvelopeReader(new JsonSerializer(this)); - /** Serializer interface to serialize/deserialize json events */ - private @NotNull ISerializer serializer = new JsonSerializer(this); + private final @NotNull LazyEvaluator serializer = + new LazyEvaluator<>(() -> new JsonSerializer(this)); + + /** Envelope reader interface */ + private final @NotNull LazyEvaluator envelopeReader = + new LazyEvaluator<>(() -> new EnvelopeReader(serializer.getValue())); /** Max depth when serializing object graphs with reflection. * */ private int maxDepth = 100; @@ -415,7 +418,8 @@ public class SentryOptions { /** Date provider to retrieve the current date from. */ @ApiStatus.Internal - private @NotNull SentryDateProvider dateProvider = new SentryAutoDateProvider(); + private final @NotNull LazyEvaluator dateProvider = + new LazyEvaluator<>(() -> new SentryAutoDateProvider()); private final @NotNull List performanceCollectors = new ArrayList<>(); @@ -427,7 +431,7 @@ public class SentryOptions { private boolean enableTimeToFullDisplayTracing = false; /** Screen fully displayed reporter, used for time-to-full-display spans. */ - private final @NotNull FullyDisplayedReporter fullyDisplayedReporter = + private @NotNull FullyDisplayedReporter fullyDisplayedReporter = FullyDisplayedReporter.getInstance(); private @NotNull IConnectionStatusProvider connectionStatusProvider = @@ -476,7 +480,7 @@ public class SentryOptions { @ApiStatus.Experimental private @Nullable Cron cron = null; - private final @NotNull ExperimentalOptions experimental = new ExperimentalOptions(); + private final @NotNull ExperimentalOptions experimental; private @NotNull ReplayController replayController = NoOpReplayController.getInstance(); @@ -610,7 +614,7 @@ public void setDiagnosticLevel(@Nullable final SentryLevel diagnosticLevel) { * @return the serializer */ public @NotNull ISerializer getSerializer() { - return serializer; + return serializer.getValue(); } /** @@ -619,7 +623,7 @@ public void setDiagnosticLevel(@Nullable final SentryLevel diagnosticLevel) { * @param serializer the serializer */ public void setSerializer(@Nullable ISerializer serializer) { - this.serializer = serializer != null ? serializer : NoOpSerializer.getInstance(); + this.serializer.setValue(serializer != null ? serializer : NoOpSerializer.getInstance()); } /** @@ -641,12 +645,12 @@ public void setMaxDepth(int maxDepth) { } public @NotNull IEnvelopeReader getEnvelopeReader() { - return envelopeReader; + return envelopeReader.getValue(); } public void setEnvelopeReader(final @Nullable IEnvelopeReader envelopeReader) { - this.envelopeReader = - envelopeReader != null ? envelopeReader : NoOpEnvelopeReader.getInstance(); + this.envelopeReader.setValue( + envelopeReader != null ? envelopeReader : NoOpEnvelopeReader.getInstance()); } /** @@ -2023,6 +2027,13 @@ public void setEnableTimeToFullDisplayTracing(final boolean enableTimeToFullDisp return fullyDisplayedReporter; } + @ApiStatus.Internal + @TestOnly + public void setFullyDisplayedReporter( + final @NotNull FullyDisplayedReporter fullyDisplayedReporter) { + this.fullyDisplayedReporter = fullyDisplayedReporter; + } + /** * Whether OPTIONS requests should be traced. * @@ -2159,7 +2170,7 @@ public void setIgnoredSpanOrigins(final @Nullable List ignoredSpanOrigin /** Returns the current {@link SentryDateProvider} that is used to retrieve the current date. */ @ApiStatus.Internal public @NotNull SentryDateProvider getDateProvider() { - return dateProvider; + return dateProvider.getValue(); } /** @@ -2170,7 +2181,7 @@ public void setIgnoredSpanOrigins(final @Nullable List ignoredSpanOrigin */ @ApiStatus.Internal public void setDateProvider(final @NotNull SentryDateProvider dateProvider) { - this.dateProvider = dateProvider; + this.dateProvider.setValue(dateProvider); } /** @@ -2480,6 +2491,7 @@ public SentryOptions() { * @param empty if options should be empty. */ private SentryOptions(final boolean empty) { + experimental = new ExperimentalOptions(empty); if (!empty) { setSpanFactory(new DefaultSpanFactory()); // SentryExecutorService should be initialized before any diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 7656b088a15..0c99085726a 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -11,6 +11,12 @@ public final class SentryReplayOptions { public static final String TEXT_VIEW_CLASS_NAME = "android.widget.TextView"; public static final String IMAGE_VIEW_CLASS_NAME = "android.widget.ImageView"; + public static final String WEB_VIEW_CLASS_NAME = "android.webkit.WebView"; + public static final String VIDEO_VIEW_CLASS_NAME = "android.widget.VideoView"; + public static final String ANDROIDX_MEDIA_VIEW_CLASS_NAME = "androidx.media3.ui.PlayerView"; + public static final String EXOPLAYER_CLASS_NAME = "com.google.android.exoplayer2.ui.PlayerView"; + public static final String EXOPLAYER_STYLED_CLASS_NAME = + "com.google.android.exoplayer2.ui.StyledPlayerView"; public enum SentryReplayQuality { /** Video Scale: 80% Bit Rate: 50.000 */ @@ -52,19 +58,19 @@ public enum SentryReplayQuality { private @Nullable Double onErrorSampleRate; /** - * Redact all views with the specified class names. The class name is the fully qualified class - * name of the view, e.g. android.widget.TextView. The subclasses of the specified classes will be - * redacted as well. + * Mask all views with the specified class names. The class name is the fully qualified class name + * of the view, e.g. android.widget.TextView. The subclasses of the specified classes will be + * masked as well. * *

If you're using an obfuscation tool, make sure to add the respective proguard rules to keep * the class names. * *

Default is empty. */ - private Set redactViewClasses = new CopyOnWriteArraySet<>(); + private Set maskViewClasses = new CopyOnWriteArraySet<>(); /** - * Ignore all views with the specified class names from redaction. The class name is the fully + * Ignore all views with the specified class names from masking. The class name is the fully * qualified class name of the view, e.g. android.widget.TextView. The subclasses of the specified * classes will be ignored as well. * @@ -73,7 +79,7 @@ public enum SentryReplayQuality { * *

Default is empty. */ - private Set ignoreViewClasses = new CopyOnWriteArraySet<>(); + private Set unmaskViewClasses = new CopyOnWriteArraySet<>(); /** * Defines the quality of the session replay. The higher the quality, the more accurate the replay @@ -96,14 +102,21 @@ public enum SentryReplayQuality { /** The maximum duration of a full session replay, defaults to 1h. */ private long sessionDuration = 60 * 60 * 1000L; - public SentryReplayOptions() { - setRedactAllText(true); - setRedactAllImages(true); + public SentryReplayOptions(final boolean empty) { + if (!empty) { + setMaskAllText(true); + setMaskAllImages(true); + maskViewClasses.add(WEB_VIEW_CLASS_NAME); + maskViewClasses.add(VIDEO_VIEW_CLASS_NAME); + maskViewClasses.add(ANDROIDX_MEDIA_VIEW_CLASS_NAME); + maskViewClasses.add(EXOPLAYER_CLASS_NAME); + maskViewClasses.add(EXOPLAYER_STYLED_CLASS_NAME); + } } public SentryReplayOptions( final @Nullable Double sessionSampleRate, final @Nullable Double onErrorSampleRate) { - this(); + this(false); this.sessionSampleRate = sessionSampleRate; this.onErrorSampleRate = onErrorSampleRate; } @@ -147,55 +160,55 @@ public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { } /** - * Redact all text content. Draws a rectangle of text bounds with text color on top. By default - * only views extending TextView are redacted. + * Mask all text content. Draws a rectangle of text bounds with text color on top. By default only + * views extending TextView are masked. * *

Default is enabled. */ - public void setRedactAllText(final boolean redactAllText) { - if (redactAllText) { - addRedactViewClass(TEXT_VIEW_CLASS_NAME); - ignoreViewClasses.remove(TEXT_VIEW_CLASS_NAME); + public void setMaskAllText(final boolean maskAllText) { + if (maskAllText) { + addMaskViewClass(TEXT_VIEW_CLASS_NAME); + unmaskViewClasses.remove(TEXT_VIEW_CLASS_NAME); } else { - addIgnoreViewClass(TEXT_VIEW_CLASS_NAME); - redactViewClasses.remove(TEXT_VIEW_CLASS_NAME); + addUnmaskViewClass(TEXT_VIEW_CLASS_NAME); + maskViewClasses.remove(TEXT_VIEW_CLASS_NAME); } } /** - * Redact all image content. Draws a rectangle of image bounds with image's dominant color on top. + * Mask all image content. Draws a rectangle of image bounds with image's dominant color on top. * By default only views extending ImageView with BitmapDrawable or custom Drawable type are - * redacted. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come + * masked. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come * from the apk. * *

Default is enabled. */ - public void setRedactAllImages(final boolean redactAllImages) { - if (redactAllImages) { - addRedactViewClass(IMAGE_VIEW_CLASS_NAME); - ignoreViewClasses.remove(IMAGE_VIEW_CLASS_NAME); + public void setMaskAllImages(final boolean maskAllImages) { + if (maskAllImages) { + addMaskViewClass(IMAGE_VIEW_CLASS_NAME); + unmaskViewClasses.remove(IMAGE_VIEW_CLASS_NAME); } else { - addIgnoreViewClass(IMAGE_VIEW_CLASS_NAME); - redactViewClasses.remove(IMAGE_VIEW_CLASS_NAME); + addUnmaskViewClass(IMAGE_VIEW_CLASS_NAME); + maskViewClasses.remove(IMAGE_VIEW_CLASS_NAME); } } @NotNull - public Set getRedactViewClasses() { - return this.redactViewClasses; + public Set getMaskViewClasses() { + return this.maskViewClasses; } - public void addRedactViewClass(final @NotNull String className) { - this.redactViewClasses.add(className); + public void addMaskViewClass(final @NotNull String className) { + this.maskViewClasses.add(className); } @NotNull - public Set getIgnoreViewClasses() { - return this.ignoreViewClasses; + public Set getUnmaskViewClasses() { + return this.unmaskViewClasses; } - public void addIgnoreViewClass(final @NotNull String className) { - this.ignoreViewClasses.add(className); + public void addUnmaskViewClass(final @NotNull String className) { + this.unmaskViewClasses.add(className); } @ApiStatus.Internal diff --git a/sentry/src/main/java/io/sentry/TracesSampler.java b/sentry/src/main/java/io/sentry/TracesSampler.java index 5138e685776..9b215c96c22 100644 --- a/sentry/src/main/java/io/sentry/TracesSampler.java +++ b/sentry/src/main/java/io/sentry/TracesSampler.java @@ -22,6 +22,7 @@ public TracesSampler(final @NotNull SentryOptions options) { this.random = random; } + @SuppressWarnings("deprecation") @NotNull public TracesSamplingDecision sample(final @NotNull SamplingContext samplingContext) { final TracesSamplingDecision samplingContextSamplingDecision = diff --git a/sentry/src/main/java/io/sentry/cache/CacheStrategy.java b/sentry/src/main/java/io/sentry/cache/CacheStrategy.java index dbb6a49c19d..d48cc3108dd 100644 --- a/sentry/src/main/java/io/sentry/cache/CacheStrategy.java +++ b/sentry/src/main/java/io/sentry/cache/CacheStrategy.java @@ -10,6 +10,7 @@ import io.sentry.SentryOptions; import io.sentry.Session; import io.sentry.clientreport.DiscardReason; +import io.sentry.util.LazyEvaluator; import io.sentry.util.Objects; import java.io.BufferedInputStream; import java.io.BufferedReader; @@ -36,8 +37,9 @@ abstract class CacheStrategy { @SuppressWarnings("CharsetObjectCanBeUsed") protected static final Charset UTF_8 = Charset.forName("UTF-8"); - protected final @NotNull SentryOptions options; - protected final @NotNull ISerializer serializer; + protected @NotNull SentryOptions options; + protected final @NotNull LazyEvaluator serializer = + new LazyEvaluator<>(() -> options.getSerializer()); protected final @NotNull File directory; private final int maxSize; @@ -48,7 +50,6 @@ abstract class CacheStrategy { Objects.requireNonNull(directoryPath, "Directory is required."); this.options = Objects.requireNonNull(options, "SentryOptions is required."); - this.serializer = options.getSerializer(); this.directory = new File(directoryPath); this.maxSize = maxSize; @@ -177,7 +178,7 @@ private void moveInitFlagIfNecessary( && currentSession.getSessionId().equals(session.getSessionId())) { session.setInitAsTrue(); try { - newSessionItem = SentryEnvelopeItem.fromSession(serializer, session); + newSessionItem = SentryEnvelopeItem.fromSession(serializer.getValue(), session); // remove item from envelope items so we can replace with the new one that has the // init flag true itemsIterator.remove(); @@ -216,7 +217,7 @@ private void moveInitFlagIfNecessary( private @Nullable SentryEnvelope readEnvelope(final @NotNull File file) { try (final InputStream inputStream = new BufferedInputStream(new FileInputStream(file))) { - return serializer.deserializeEnvelope(inputStream); + return serializer.getValue().deserializeEnvelope(inputStream); } catch (IOException e) { options.getLogger().log(ERROR, "Failed to deserialize the envelope.", e); } @@ -258,7 +259,7 @@ private boolean isSessionType(final @Nullable SentryEnvelopeItem item) { try (final Reader reader = new BufferedReader( new InputStreamReader(new ByteArrayInputStream(item.getData()), UTF_8))) { - return serializer.deserialize(reader, Session.class); + return serializer.getValue().deserialize(reader, Session.class); } catch (Throwable e) { options.getLogger().log(ERROR, "Failed to deserialize the session.", e); } @@ -268,7 +269,7 @@ private boolean isSessionType(final @Nullable SentryEnvelopeItem item) { private void saveNewEnvelope( final @NotNull SentryEnvelope envelope, final @NotNull File file, final long timestamp) { try (final OutputStream outputStream = new FileOutputStream(file)) { - serializer.serialize(envelope, outputStream); + serializer.getValue().serialize(envelope, outputStream); // we need to set the same timestamp so the sorting from oldest to newest wont break. file.setLastModified(timestamp); } catch (Throwable e) { diff --git a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java index 3a85fb6eb2b..0255d05a18e 100644 --- a/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java +++ b/sentry/src/main/java/io/sentry/cache/EnvelopeCache.java @@ -119,7 +119,7 @@ public void store(final @NotNull SentryEnvelope envelope, final @NotNull Hint hi try (final Reader reader = new BufferedReader( new InputStreamReader(new FileInputStream(currentSessionFile), UTF_8))) { - final Session session = serializer.deserialize(reader, Session.class); + final Session session = serializer.getValue().deserialize(reader, Session.class); if (session != null) { writeSessionToDisk(previousSessionFile, session); } @@ -207,7 +207,7 @@ private void tryEndPreviousSession(final @NotNull Hint hint) { try (final Reader reader = new BufferedReader( new InputStreamReader(new FileInputStream(previousSessionFile), UTF_8))) { - final Session session = serializer.deserialize(reader, Session.class); + final Session session = serializer.getValue().deserialize(reader, Session.class); if (session != null) { final AbnormalExit abnormalHint = (AbnormalExit) sdkHint; final @Nullable Long abnormalExitTimestamp = abnormalHint.timestamp(); @@ -266,7 +266,7 @@ private void updateCurrentSession( try (final Reader reader = new BufferedReader( new InputStreamReader(new ByteArrayInputStream(item.getData()), UTF_8))) { - final Session session = serializer.deserialize(reader, Session.class); + final Session session = serializer.getValue().deserialize(reader, Session.class); if (session == null) { options .getLogger() @@ -307,7 +307,7 @@ private void writeEnvelopeToDisk( } try (final OutputStream outputStream = new FileOutputStream(file)) { - serializer.serialize(envelope, outputStream); + serializer.getValue().serialize(envelope, outputStream); } catch (Throwable e) { options .getLogger() @@ -327,7 +327,7 @@ private void writeSessionToDisk(final @NotNull File file, final @NotNull Session try (final OutputStream outputStream = new FileOutputStream(file); final Writer writer = new BufferedWriter(new OutputStreamWriter(outputStream, UTF_8))) { - serializer.serialize(session, writer); + serializer.getValue().serialize(session, writer); } catch (Throwable e) { options .getLogger() @@ -393,7 +393,7 @@ public void discard(final @NotNull SentryEnvelope envelope) { for (final File file : allCachedEnvelopes) { try (final InputStream is = new BufferedInputStream(new FileInputStream(file))) { - ret.add(serializer.deserializeEnvelope(is)); + ret.add(serializer.getValue().deserializeEnvelope(is)); } catch (FileNotFoundException e) { options .getLogger() diff --git a/sentry/src/main/java/io/sentry/clientreport/AtomicClientReportStorage.java b/sentry/src/main/java/io/sentry/clientreport/AtomicClientReportStorage.java index 2e7f0c27a7b..fd1f9a9de99 100644 --- a/sentry/src/main/java/io/sentry/clientreport/AtomicClientReportStorage.java +++ b/sentry/src/main/java/io/sentry/clientreport/AtomicClientReportStorage.java @@ -1,10 +1,12 @@ package io.sentry.clientreport; import io.sentry.DataCategory; +import io.sentry.util.LazyEvaluator; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; import org.jetbrains.annotations.ApiStatus; @@ -14,25 +16,28 @@ @ApiStatus.Internal final class AtomicClientReportStorage implements IClientReportStorage { - private final @NotNull Map lostEventCounts; + private final @NotNull LazyEvaluator> lostEventCounts = + new LazyEvaluator<>( + () -> { + final Map modifyableEventCountsForInit = + new ConcurrentHashMap<>(); - public AtomicClientReportStorage() { - final Map modifyableEventCountsForInit = new ConcurrentHashMap<>(); + for (final DiscardReason discardReason : DiscardReason.values()) { + for (final DataCategory category : DataCategory.values()) { + modifyableEventCountsForInit.put( + new ClientReportKey(discardReason.getReason(), category.getCategory()), + new AtomicLong(0)); + } + } - for (final DiscardReason discardReason : DiscardReason.values()) { - for (final DataCategory category : DataCategory.values()) { - modifyableEventCountsForInit.put( - new ClientReportKey(discardReason.getReason(), category.getCategory()), - new AtomicLong(0)); - } - } + return Collections.unmodifiableMap(modifyableEventCountsForInit); + }); - lostEventCounts = Collections.unmodifiableMap(modifyableEventCountsForInit); - } + public AtomicClientReportStorage() {} @Override public void addCount(ClientReportKey key, Long count) { - final @Nullable AtomicLong quantity = lostEventCounts.get(key); + final @Nullable AtomicLong quantity = lostEventCounts.getValue().get(key); if (quantity != null) { quantity.addAndGet(count); @@ -43,7 +48,8 @@ public void addCount(ClientReportKey key, Long count) { public List resetCountsAndGet() { final List discardedEvents = new ArrayList<>(); - for (final Map.Entry entry : lostEventCounts.entrySet()) { + Set> entrySet = lostEventCounts.getValue().entrySet(); + for (final Map.Entry entry : entrySet) { final Long quantity = entry.getValue().getAndSet(0); if (quantity > 0) { discardedEvents.add( diff --git a/sentry/src/main/java/io/sentry/protocol/MetricSummary.java b/sentry/src/main/java/io/sentry/protocol/MetricSummary.java new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/sentry/src/main/java/io/sentry/protocol/MetricSummary.java @@ -0,0 +1 @@ + diff --git a/sentry/src/main/java/io/sentry/util/LazyEvaluator.java b/sentry/src/main/java/io/sentry/util/LazyEvaluator.java index bd9ea813217..0f8653c411a 100644 --- a/sentry/src/main/java/io/sentry/util/LazyEvaluator.java +++ b/sentry/src/main/java/io/sentry/util/LazyEvaluator.java @@ -11,7 +11,8 @@ */ @ApiStatus.Internal public final class LazyEvaluator { - private @Nullable T value = null; + + private volatile @Nullable T value = null; private final @NotNull Evaluator evaluator; private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); @@ -31,11 +32,21 @@ public LazyEvaluator(final @NotNull Evaluator evaluator) { * @return The result of the evaluator function. */ public @NotNull T getValue() { - try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { - if (value == null) { - value = evaluator.evaluate(); + if (value == null) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (value == null) { + value = evaluator.evaluate(); + } } - return value; + } + + //noinspection DataFlowIssue + return value; + } + + public void setValue(final @Nullable T value) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + this.value = value; } } diff --git a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt index c15fa1dc38b..658e41149bc 100644 --- a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt +++ b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt @@ -1,5 +1,6 @@ package io.sentry +import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -21,6 +22,7 @@ class BreadcrumbTest { val level = SentryLevel.DEBUG breadcrumb.level = level breadcrumb.category = "category" + breadcrumb.origin = "origin" val clone = Breadcrumb(breadcrumb) @@ -44,6 +46,7 @@ class BreadcrumbTest { val level = SentryLevel.DEBUG breadcrumb.level = level breadcrumb.category = "category" + breadcrumb.origin = "origin" val clone = Breadcrumb(breadcrumb) @@ -53,6 +56,7 @@ class BreadcrumbTest { assertEquals("type", clone.type) assertEquals(SentryLevel.DEBUG, clone.level) assertEquals("category", clone.category) + assertEquals("origin", clone.origin) } @Test @@ -67,6 +71,7 @@ class BreadcrumbTest { val level = SentryLevel.DEBUG breadcrumb.level = level breadcrumb.category = "category" + breadcrumb.origin = "origin" val clone = Breadcrumb(breadcrumb) @@ -77,6 +82,7 @@ class BreadcrumbTest { breadcrumb.type = "newType" breadcrumb.level = SentryLevel.FATAL breadcrumb.category = "newCategory" + breadcrumb.origin = "newOrigin" assertEquals("message", clone.message) assertEquals("data", clone.data["data"]) @@ -86,6 +92,7 @@ class BreadcrumbTest { assertEquals("type", clone.type) assertEquals(SentryLevel.DEBUG, clone.level) assertEquals("category", clone.category) + assertEquals("origin", clone.origin) } @Test @@ -94,6 +101,12 @@ class BreadcrumbTest { assertNotNull(breadcrumb.timestamp) } + @Test + fun `breadcrumb can be created with Date timestamp`() { + val breadcrumb = Breadcrumb(Date(123L)) + assertEquals(123L, breadcrumb.timestamp.time) + } + @Test fun `breadcrumb takes message on ctor`() { val breadcrumb = Breadcrumb("this is a test") diff --git a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt index 01843dfc90a..794a3dac092 100644 --- a/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryReplayOptionsTest.kt @@ -7,7 +7,7 @@ class SentryReplayOptionsTest { @Test fun `uses medium quality as default`() { - val replayOptions = SentryReplayOptions() + val replayOptions = SentryReplayOptions(true) assertEquals(SentryReplayOptions.SentryReplayQuality.MEDIUM, replayOptions.quality) assertEquals(75_000, replayOptions.quality.bitRate) @@ -16,7 +16,7 @@ class SentryReplayOptionsTest { @Test fun `low quality`() { - val replayOptions = SentryReplayOptions().apply { quality = SentryReplayOptions.SentryReplayQuality.LOW } + val replayOptions = SentryReplayOptions(true).apply { quality = SentryReplayOptions.SentryReplayQuality.LOW } assertEquals(50_000, replayOptions.quality.bitRate) assertEquals(0.8f, replayOptions.quality.sizeScale) @@ -24,7 +24,7 @@ class SentryReplayOptionsTest { @Test fun `high quality`() { - val replayOptions = SentryReplayOptions().apply { quality = SentryReplayOptions.SentryReplayQuality.HIGH } + val replayOptions = SentryReplayOptions(true).apply { quality = SentryReplayOptions.SentryReplayQuality.HIGH } assertEquals(100_000, replayOptions.quality.bitRate) assertEquals(1.0f, replayOptions.quality.sizeScale) diff --git a/sentry/src/test/java/io/sentry/cache/CacheStrategyTest.kt b/sentry/src/test/java/io/sentry/cache/CacheStrategyTest.kt index e67a6616ce5..3c3e6d18d06 100644 --- a/sentry/src/test/java/io/sentry/cache/CacheStrategyTest.kt +++ b/sentry/src/test/java/io/sentry/cache/CacheStrategyTest.kt @@ -108,13 +108,13 @@ class CacheStrategyTest { val files = createTempFilesSortByOldestToNewest() val okSession = createSessionMockData(Session.State.Ok, true) - val okEnvelope = SentryEnvelope.from(sut.serializer, okSession, null) - sut.serializer.serialize(okEnvelope, files[0].outputStream()) + val okEnvelope = SentryEnvelope.from(sut.serializer.value, okSession, null) + sut.serializer.value.serialize(okEnvelope, files[0].outputStream()) val updatedOkSession = okSession.clone() updatedOkSession.update(null, null, true) - val updatedOkEnvelope = SentryEnvelope.from(sut.serializer, updatedOkSession, null) - sut.serializer.serialize(updatedOkEnvelope, files[1].outputStream()) + val updatedOkEnvelope = SentryEnvelope.from(sut.serializer.value, updatedOkSession, null) + sut.serializer.value.serialize(updatedOkEnvelope, files[1].outputStream()) saveSessionToFile(files[2], sut, Session.State.Exited, null) @@ -178,17 +178,17 @@ class CacheStrategyTest { ) private fun getSessionFromFile(file: File, sut: CacheStrategy): Session { - val envelope = sut.serializer.deserializeEnvelope(file.inputStream()) + val envelope = sut.serializer.value.deserializeEnvelope(file.inputStream()) val item = envelope!!.items.first() val reader = InputStreamReader(ByteArrayInputStream(item.data), Charsets.UTF_8) - return sut.serializer.deserialize(reader, Session::class.java)!! + return sut.serializer.value.deserialize(reader, Session::class.java)!! } private fun saveSessionToFile(file: File, sut: CacheStrategy, state: Session.State = Session.State.Ok, init: Boolean? = true) { val okSession = createSessionMockData(state, init) - val okEnvelope = SentryEnvelope.from(sut.serializer, okSession, null) - sut.serializer.serialize(okEnvelope, file.outputStream()) + val okEnvelope = SentryEnvelope.from(sut.serializer.value, okSession, null) + sut.serializer.value.serialize(okEnvelope, file.outputStream()) } private fun getOptionsWithRealSerializer(): SentryOptions { diff --git a/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt index fed8c4649da..0e43168897a 100644 --- a/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt @@ -28,6 +28,7 @@ class BreadcrumbSerializationTest { type = "ace57e2e-305e-4048-abf0-6c8538ea7bf4" setData("6607d106-d426-462b-af74-f29fce978e48", "149bb94a-1387-4484-90be-2df15d1322ab") category = "b6eea851-5ae5-40ed-8fdd-5e1a655a879c" + origin = "4d8085ef-22fc-49d5-801e-55d509fd1a1c" level = SentryLevel.DEBUG } } @@ -59,6 +60,7 @@ class BreadcrumbSerializationTest { "6607d106-d426-462b-af74-f29fce978e48" to "149bb94a-1387-4484-90be-2df15d1322ab" ), "category" to "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "origin" to "4d8085ef-22fc-49d5-801e-55d509fd1a1c", "level" to "debug" ) val actual = Breadcrumb.fromMap(map, SentryOptions()) @@ -69,6 +71,7 @@ class BreadcrumbSerializationTest { assertEquals(expected.type, actual?.type) assertEquals(expected.data, actual?.data) assertEquals(expected.category, actual?.category) + assertEquals(expected.origin, actual?.origin) assertEquals(expected.level, actual?.level) } diff --git a/sentry/src/test/java/io/sentry/protocol/SentryItemTypeSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryItemTypeSerializationTest.kt new file mode 100644 index 00000000000..6e7a8191b84 --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/SentryItemTypeSerializationTest.kt @@ -0,0 +1,72 @@ +package io.sentry.protocol + +import io.sentry.ILogger +import io.sentry.JsonObjectReader +import io.sentry.JsonObjectWriter +import io.sentry.SentryItemType +import org.junit.Test +import org.mockito.kotlin.mock +import java.io.StringReader +import java.io.StringWriter +import kotlin.test.assertEquals + +class SentryItemTypeSerializationTest { + + class Fixture { + val logger = mock() + } + private val fixture = Fixture() + + @Test + fun serialize() { + assertEquals(serialize(SentryItemType.Session), json("session")) + assertEquals(serialize(SentryItemType.Event), json("event")) + assertEquals(serialize(SentryItemType.UserFeedback), json("user_report")) + assertEquals(serialize(SentryItemType.Attachment), json("attachment")) + assertEquals(serialize(SentryItemType.Transaction), json("transaction")) + assertEquals(serialize(SentryItemType.Profile), json("profile")) + assertEquals(serialize(SentryItemType.ClientReport), json("client_report")) + assertEquals(serialize(SentryItemType.ReplayEvent), json("replay_event")) + assertEquals(serialize(SentryItemType.ReplayRecording), json("replay_recording")) + assertEquals(serialize(SentryItemType.ReplayVideo), json("replay_video")) + assertEquals(serialize(SentryItemType.CheckIn), json("check_in")) + assertEquals(serialize(SentryItemType.Feedback), json("feedback")) + } + + @Test + fun deserialize() { + assertEquals(deserialize(json("session")), SentryItemType.Session) + assertEquals(deserialize(json("event")), SentryItemType.Event) + assertEquals(deserialize(json("user_report")), SentryItemType.UserFeedback) + assertEquals(deserialize(json("attachment")), SentryItemType.Attachment) + assertEquals(deserialize(json("transaction")), SentryItemType.Transaction) + assertEquals(deserialize(json("profile")), SentryItemType.Profile) + assertEquals(deserialize(json("client_report")), SentryItemType.ClientReport) + assertEquals(deserialize(json("replay_event")), SentryItemType.ReplayEvent) + assertEquals(deserialize(json("replay_recording")), SentryItemType.ReplayRecording) + assertEquals(deserialize(json("replay_video")), SentryItemType.ReplayVideo) + assertEquals(deserialize(json("check_in")), SentryItemType.CheckIn) + assertEquals(deserialize(json("feedback")), SentryItemType.Feedback) + } + + private fun json(type: String): String { + return "{\"type\":\"${type}\"}" + } + + private fun serialize(src: SentryItemType): String { + val wrt = StringWriter() + val jsonWrt = JsonObjectWriter(wrt, 100) + jsonWrt.beginObject() + jsonWrt.name("type") + src.serialize(jsonWrt, fixture.logger) + jsonWrt.endObject() + return wrt.toString() + } + + private fun deserialize(json: String): SentryItemType { + val reader = JsonObjectReader(StringReader(json)) + reader.beginObject() + reader.nextName() + return SentryItemType.Deserializer().deserialize(reader, fixture.logger) + } +} diff --git a/sentry/src/test/resources/json/breadcrumb.json b/sentry/src/test/resources/json/breadcrumb.json index 053d538fbee..a8873b1a2d7 100644 --- a/sentry/src/test/resources/json/breadcrumb.json +++ b/sentry/src/test/resources/json/breadcrumb.json @@ -7,5 +7,6 @@ "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" }, "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "origin": "4d8085ef-22fc-49d5-801e-55d509fd1a1c", "level": "debug" } diff --git a/sentry/src/test/resources/json/sentry_base_event.json b/sentry/src/test/resources/json/sentry_base_event.json index fad001fa599..cdd93ff32a9 100644 --- a/sentry/src/test/resources/json/sentry_base_event.json +++ b/sentry/src/test/resources/json/sentry_base_event.json @@ -206,6 +206,7 @@ "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" }, "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "origin": "4d8085ef-22fc-49d5-801e-55d509fd1a1c", "level": "debug" } ], diff --git a/sentry/src/test/resources/json/sentry_base_event_with_null_extra.json b/sentry/src/test/resources/json/sentry_base_event_with_null_extra.json index 74fbe8779e8..2904aa56979 100644 --- a/sentry/src/test/resources/json/sentry_base_event_with_null_extra.json +++ b/sentry/src/test/resources/json/sentry_base_event_with_null_extra.json @@ -206,6 +206,7 @@ "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" }, "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "origin": "4d8085ef-22fc-49d5-801e-55d509fd1a1c", "level": "debug" } ], diff --git a/sentry/src/test/resources/json/sentry_event.json b/sentry/src/test/resources/json/sentry_event.json index 4e260e0d459..8046c51a4ac 100644 --- a/sentry/src/test/resources/json/sentry_event.json +++ b/sentry/src/test/resources/json/sentry_event.json @@ -341,6 +341,7 @@ "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" }, "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "origin": "4d8085ef-22fc-49d5-801e-55d509fd1a1c", "level": "debug" } ], diff --git a/sentry/src/test/resources/json/sentry_transaction.json b/sentry/src/test/resources/json/sentry_transaction.json index d570555e444..8bca3484f72 100644 --- a/sentry/src/test/resources/json/sentry_transaction.json +++ b/sentry/src/test/resources/json/sentry_transaction.json @@ -263,6 +263,7 @@ "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" }, "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "origin": "4d8085ef-22fc-49d5-801e-55d509fd1a1c", "level": "debug" } ], diff --git a/sentry/src/test/resources/json/sentry_transaction_legacy_date_format.json b/sentry/src/test/resources/json/sentry_transaction_legacy_date_format.json index 09100e5c8bd..8964302f728 100644 --- a/sentry/src/test/resources/json/sentry_transaction_legacy_date_format.json +++ b/sentry/src/test/resources/json/sentry_transaction_legacy_date_format.json @@ -263,6 +263,7 @@ "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" }, "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "origin": "4d8085ef-22fc-49d5-801e-55d509fd1a1c", "level": "debug" } ], diff --git a/sentry/src/test/resources/json/sentry_transaction_no_measurement_unit.json b/sentry/src/test/resources/json/sentry_transaction_no_measurement_unit.json index f9774cf6e50..326e9cb904a 100644 --- a/sentry/src/test/resources/json/sentry_transaction_no_measurement_unit.json +++ b/sentry/src/test/resources/json/sentry_transaction_no_measurement_unit.json @@ -230,6 +230,7 @@ "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" }, "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "origin": "4d8085ef-22fc-49d5-801e-55d509fd1a1c", "level": "debug" } ],