diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dfacd19115..72431094bca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ - Add `installGroupsOverride` parameter and `installGroups` property to Build Distribution SDK ([#5062](https://github.com/getsentry/sentry-java/pull/5062)) - Update Android targetSdk to API 36 (Android 16) ([#5016](https://github.com/getsentry/sentry-java/pull/5016)) +- Add AndroidManifest support for Spotlight configuration via `io.sentry.spotlight.enable` and `io.sentry.spotlight.url` ([#5064](https://github.com/getsentry/sentry-java/pull/5064)) + +### Fixes + +- Extract `SpotlightIntegration` to separate `sentry-spotlight` module to prevent insecure HTTP URLs from appearing in release APKs ([#5064](https://github.com/getsentry/sentry-java/pull/5064)) + - **Breaking:** Users who enable Spotlight must now add the `io.sentry:sentry-spotlight` dependency: + ```kotlin + dependencies { + debugImplementation("io.sentry:sentry-spotlight:") + } + ``` ### Fixes diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index e293fd76ee9..8d5f73fbf44 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -102,6 +102,7 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(projects.sentryTestSupport) + testImplementation(projects.sentrySpotlight) testImplementation(projects.sentryAndroidFragment) testImplementation(projects.sentryAndroidTimber) testImplementation(projects.sentryAndroidReplay) diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index 5ebad5ac0c8..d5815518e15 100644 --- a/sentry-android-core/proguard-rules.pro +++ b/sentry-android-core/proguard-rules.pro @@ -83,3 +83,8 @@ -dontwarn io.sentry.android.distribution.DistributionIntegration -keepnames class io.sentry.android.distribution.DistributionIntegration ##---------------End: proguard configuration for sentry-android-distribution ---------- + +##---------------Begin: proguard configuration for sentry-spotlight ---------- +-dontwarn io.sentry.spotlight.SpotlightIntegration +-keepnames class io.sentry.spotlight.SpotlightIntegration +##---------------End: proguard configuration for sentry-spotlight ---------- 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 7fed68da6d9..f816c74f744 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 @@ -164,6 +164,10 @@ final class ManifestMetadataReader { static final String FEEDBACK_SHOW_BRANDING = "io.sentry.feedback.show-branding"; + static final String SPOTLIGHT_ENABLE = "io.sentry.spotlight.enable"; + + static final String SPOTLIGHT_CONNECTION_URL = "io.sentry.spotlight.url"; + /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -642,6 +646,15 @@ static void applyMetadata( metadata, logger, FEEDBACK_USE_SENTRY_USER, feedbackOptions.isUseSentryUser())); feedbackOptions.setShowBranding( readBool(metadata, logger, FEEDBACK_SHOW_BRANDING, feedbackOptions.isShowBranding())); + + options.setEnableSpotlight( + readBool(metadata, logger, SPOTLIGHT_ENABLE, options.isEnableSpotlight())); + + final @Nullable String spotlightUrl = + readString(metadata, logger, SPOTLIGHT_CONNECTION_URL, null); + if (spotlightUrl != null) { + options.setSpotlightConnectionUrl(spotlightUrl); + } } options .getLogger() 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 1c4a4d3f334..36a0a531a64 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 @@ -2217,4 +2217,76 @@ class ManifestMetadataReaderTest { assertTrue(headers.contains("Authorization")) assertTrue(headers.contains("X-Custom-Header")) } + + // Spotlight Configuration Tests + + @Test + fun `applyMetadata reads spotlight enabled and keeps default value if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.isEnableSpotlight) + } + + @Test + fun `applyMetadata reads spotlight enabled to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.SPOTLIGHT_ENABLE to true) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isEnableSpotlight) + } + + @Test + fun `applyMetadata reads spotlight url and keeps null if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertNull(fixture.options.spotlightConnectionUrl) + } + + @Test + fun `applyMetadata reads spotlight url to options`() { + // Arrange + val expectedUrl = "http://10.0.2.2:8969/stream" + val bundle = bundleOf(ManifestMetadataReader.SPOTLIGHT_CONNECTION_URL to expectedUrl) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(expectedUrl, fixture.options.spotlightConnectionUrl) + } + + @Test + fun `applyMetadata reads both spotlight enabled and url to options`() { + // Arrange + val expectedUrl = "http://localhost:8969/stream" + val bundle = + bundleOf( + ManifestMetadataReader.SPOTLIGHT_ENABLE to true, + ManifestMetadataReader.SPOTLIGHT_CONNECTION_URL to expectedUrl, + ) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.isEnableSpotlight) + assertEquals(expectedUrl, fixture.options.spotlightConnectionUrl) + } } 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 a9ab18098f3..716d03d8d7a 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 @@ -26,7 +26,6 @@ import io.sentry.SentryOptions import io.sentry.SentryOptions.BeforeSendCallback import io.sentry.Session import io.sentry.ShutdownHookIntegration -import io.sentry.SpotlightIntegration import io.sentry.SystemOutLogger import io.sentry.UncaughtExceptionHandlerIntegration import io.sentry.android.core.cache.AndroidEnvelopeCache @@ -46,6 +45,7 @@ import io.sentry.cache.PersistingScopeObserver.TRANSACTION_FILENAME import io.sentry.cache.tape.QueueFile import io.sentry.protocol.Contexts import io.sentry.protocol.SentryId +import io.sentry.spotlight.SpotlightIntegration import io.sentry.test.applyTestOptions import io.sentry.transport.NoOpEnvelopeCache import io.sentry.util.StringUtils diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 2360350a035..bb2c3954ca6 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -127,6 +127,7 @@ dependencies { implementation(projects.sentryCompose) implementation(projects.sentryKotlinExtensions) implementation(projects.sentryOkhttp) + implementation(projects.sentrySpotlight) // how to exclude androidx if release health feature is disabled // implementation(projects.sentryAndroid) { diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 102ae34a064..17cef556dde 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -253,5 +253,8 @@ + diff --git a/sentry-spotlight/api/sentry-spotlight.api b/sentry-spotlight/api/sentry-spotlight.api new file mode 100644 index 00000000000..0c46549d4a2 --- /dev/null +++ b/sentry-spotlight/api/sentry-spotlight.api @@ -0,0 +1,11 @@ +public final class io/sentry/spotlight/BuildConfig { + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/spotlight/SpotlightIntegration : io/sentry/Integration, io/sentry/SentryOptions$BeforeEnvelopeCallback, java/io/Closeable { + public fun ()V + public fun close ()V + public fun execute (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V +} + diff --git a/sentry-spotlight/build.gradle.kts b/sentry-spotlight/build.gradle.kts new file mode 100644 index 00000000000..dbab6237b12 --- /dev/null +++ b/sentry-spotlight/build.gradle.kts @@ -0,0 +1,86 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) + jacoco + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.animalsniffer) + alias(libs.plugins.buildconfig) +} + +tasks.withType().configureEach { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +} + +dependencies { + api(projects.sentry) + + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + + // tests + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) + testImplementation(projects.sentryTestSupport) + + val gummyBearsModule = libs.gummy.bears.api21.get().module + signature("${gummyBearsModule}:${libs.versions.gummyBears.get()}@signature") +} + +configure { test { java.srcDir("src/test/java") } } + +jacoco { toolVersion = libs.versions.jacoco.get() } + +tasks.jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(false) + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + dependsOn(animalsnifferMain) + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.spotlight") + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} + +tasks.withType().configureEach { + dependsOn(tasks.generateBuildConfig) + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_JAVA_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-spotlight", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} diff --git a/sentry-spotlight/proguard-rules.pro b/sentry-spotlight/proguard-rules.pro new file mode 100644 index 00000000000..9fc0ff5c7b9 --- /dev/null +++ b/sentry-spotlight/proguard-rules.pro @@ -0,0 +1,10 @@ +##---------------Begin: proguard configuration for sentry-spotlight ---------- + +# The SDK checks at runtime if this class is available via Class.forName +-keep class io.sentry.spotlight.SpotlightIntegration { (...); } + +# To ensure that stack traces is unambiguous +# https://developer.android.com/studio/build/shrink-code#decode-stack-trace +-keepattributes LineNumberTable,SourceFile + +##---------------End: proguard configuration for sentry-spotlight ---------- diff --git a/sentry/src/main/java/io/sentry/SpotlightIntegration.java b/sentry-spotlight/src/main/java/io/sentry/spotlight/SpotlightIntegration.java similarity index 89% rename from sentry/src/main/java/io/sentry/SpotlightIntegration.java rename to sentry-spotlight/src/main/java/io/sentry/spotlight/SpotlightIntegration.java index 1eb9ed83ae0..ac8916cd08c 100644 --- a/sentry/src/main/java/io/sentry/SpotlightIntegration.java +++ b/sentry-spotlight/src/main/java/io/sentry/spotlight/SpotlightIntegration.java @@ -1,10 +1,21 @@ -package io.sentry; +package io.sentry.spotlight; import static io.sentry.SentryLevel.DEBUG; import static io.sentry.SentryLevel.ERROR; import static io.sentry.SentryLevel.WARNING; import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; +import io.sentry.Hint; +import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.ISentryExecutorService; +import io.sentry.Integration; +import io.sentry.NoOpLogger; +import io.sentry.NoOpSentryExecutorService; +import io.sentry.SentryEnvelope; +import io.sentry.SentryExecutorService; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.SentryOptions; import io.sentry.util.Platform; import java.io.Closeable; import java.io.IOException; @@ -16,12 +27,16 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.TestOnly; @ApiStatus.Internal public final class SpotlightIntegration implements Integration, SentryOptions.BeforeEnvelopeCallback, Closeable { + static { + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-spotlight", BuildConfig.VERSION_NAME); + } + private @Nullable SentryOptions options; private @NotNull ILogger logger = NoOpLogger.getInstance(); private @NotNull ISentryExecutorService executorService = NoOpSentryExecutorService.getInstance(); @@ -78,8 +93,7 @@ private void sendEnvelope(final @NotNull SentryEnvelope envelope) { } } - @TestOnly - public String getSpotlightConnectionUrl() { + String getSpotlightConnectionUrl() { if (options != null && options.getSpotlightConnectionUrl() != null) { return options.getSpotlightConnectionUrl(); } diff --git a/sentry/src/test/java/io/sentry/internal/SpotlightIntegrationTest.kt b/sentry-spotlight/src/test/java/io/sentry/spotlight/SpotlightIntegrationTest.kt similarity index 90% rename from sentry/src/test/java/io/sentry/internal/SpotlightIntegrationTest.kt rename to sentry-spotlight/src/test/java/io/sentry/spotlight/SpotlightIntegrationTest.kt index be480d749bf..19fda229e95 100644 --- a/sentry/src/test/java/io/sentry/internal/SpotlightIntegrationTest.kt +++ b/sentry-spotlight/src/test/java/io/sentry/spotlight/SpotlightIntegrationTest.kt @@ -1,10 +1,9 @@ -package io.sentry.internal +package io.sentry.spotlight import io.sentry.IScopes import io.sentry.SentryOptions import io.sentry.SentryOptions.BeforeEnvelopeCallback -import io.sentry.SpotlightIntegration -import io.sentry.util.PlatformTestManipulator +import io.sentry.util.SpotlightPlatformTestManipulator import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -52,10 +51,10 @@ class SpotlightIntegrationTest { fun `spotlight connection url falls back to platform defaults`() { val spotlight = SpotlightIntegration() - PlatformTestManipulator.pretendIsAndroid(true) + SpotlightPlatformTestManipulator.pretendIsAndroid(true) assertEquals("http://10.0.2.2:8969/stream", spotlight.spotlightConnectionUrl) - PlatformTestManipulator.pretendIsAndroid(false) + SpotlightPlatformTestManipulator.pretendIsAndroid(false) assertEquals("http://localhost:8969/stream", spotlight.spotlightConnectionUrl) } diff --git a/sentry-spotlight/src/test/java/io/sentry/util/SpotlightPlatformTestManipulator.kt b/sentry-spotlight/src/test/java/io/sentry/util/SpotlightPlatformTestManipulator.kt new file mode 100644 index 00000000000..331a70a0b72 --- /dev/null +++ b/sentry-spotlight/src/test/java/io/sentry/util/SpotlightPlatformTestManipulator.kt @@ -0,0 +1,7 @@ +package io.sentry.util + +object SpotlightPlatformTestManipulator { + fun pretendIsAndroid(isAndroid: Boolean) { + Platform.isAndroid = isAndroid + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index befd2e1adbd..7020c8e216d 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1786,6 +1786,16 @@ public final class io/sentry/NoOpScopesStorage : io/sentry/IScopesStorage { public fun set (Lio/sentry/IScopes;)Lio/sentry/ISentryLifecycleToken; } +public final class io/sentry/NoOpSentryExecutorService : io/sentry/ISentryExecutorService { + public fun close (J)V + public static fun getInstance ()Lio/sentry/ISentryExecutorService; + public fun isClosed ()Z + public fun prewarm ()V + public fun schedule (Ljava/lang/Runnable;J)Ljava/util/concurrent/Future; + public fun submit (Ljava/lang/Runnable;)Ljava/util/concurrent/Future; + public fun submit (Ljava/util/concurrent/Callable;)Ljava/util/concurrent/Future; +} + public final class io/sentry/NoOpSocketTagger : io/sentry/ISocketTagger { public static fun getInstance ()Lio/sentry/ISocketTagger; public fun tagSockets ()V @@ -4342,14 +4352,6 @@ public final class io/sentry/SpanStatus$Deserializer : io/sentry/JsonDeserialize public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } -public final class io/sentry/SpotlightIntegration : io/sentry/Integration, io/sentry/SentryOptions$BeforeEnvelopeCallback, java/io/Closeable { - public fun ()V - public fun close ()V - public fun execute (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)V - public fun getSpotlightConnectionUrl ()Ljava/lang/String; - public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V -} - public final class io/sentry/SystemOutLogger : io/sentry/ILogger { public fun ()V public fun isEnabled (Lio/sentry/SentryLevel;)Z diff --git a/sentry/src/main/java/io/sentry/NoOpSentryExecutorService.java b/sentry/src/main/java/io/sentry/NoOpSentryExecutorService.java index d1dc6b207e5..c2ce81f6577 100644 --- a/sentry/src/main/java/io/sentry/NoOpSentryExecutorService.java +++ b/sentry/src/main/java/io/sentry/NoOpSentryExecutorService.java @@ -3,9 +3,11 @@ import java.util.concurrent.Callable; import java.util.concurrent.Future; import java.util.concurrent.FutureTask; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; -final class NoOpSentryExecutorService implements ISentryExecutorService { +@ApiStatus.Internal +public final class NoOpSentryExecutorService implements ISentryExecutorService { private static final NoOpSentryExecutorService instance = new NoOpSentryExecutorService(); private NoOpSentryExecutorService() {} diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index f24f649092d..c370672dd97 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -3308,7 +3308,16 @@ private SentryOptions(final boolean empty) { integrations.add(new UncaughtExceptionHandlerIntegration()); integrations.add(new ShutdownHookIntegration()); - integrations.add(new SpotlightIntegration()); + + // SpotlightIntegration is loaded via reflection to allow the sentry-spotlight module + // to be excluded from release builds, preventing insecure HTTP URLs from appearing in APKs + try { + final Class clazz = Class.forName("io.sentry.spotlight.SpotlightIntegration"); + final Integration spotlight = (Integration) clazz.getConstructor().newInstance(); + integrations.add(spotlight); + } catch (Throwable ignored) { + // SpotlightIntegration not available + } eventProcessors.add(new MainEventProcessor(this)); eventProcessors.add(new DuplicateEventDetectionEventProcessor(this)); diff --git a/settings.gradle.kts b/settings.gradle.kts index e9297876f24..fcff35af112 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,6 +20,7 @@ rootProject.buildFileName = "build.gradle.kts" includeBuild("build-logic") include( "sentry", + "sentry-spotlight", "sentry-kotlin-extensions", "sentry-android-distribution", "sentry-android-core",