diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3b185217b4c..759bf5843b4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,6 +43,20 @@ jobs: with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + # TODO: remove once sentry-native publishes sentry-native-ndk-outproc + - name: Checkout sentry-native + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: getsentry/sentry-native + ref: jpnurmi/feat/native-android + path: sentry-native + submodules: 'recursive' + + # TODO: remove once sentry-native publishes sentry-native-ndk-outproc + - name: Publish sentry-native-ndk-outproc to Maven Local + working-directory: sentry-native/ndk + run: ./gradlew --no-daemon :sentry-native-ndk-outproc:publishToMavenLocal + - name: Run Tests and Lint run: make preMerge diff --git a/CHANGELOG.md b/CHANGELOG.md index acca00fc3c3..4e27502b69c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Add experimental, opt-in out-of-process crash reporting on Android ([#6347](https://github.com/getsentry/sentry-java/pull/6347)) + ### Fixes - Report the number of dropped spans in client reports when a batched span envelope item is discarded, instead of a quantity of 1 (e.g. for Flutter span streaming) ([#6265](https://github.com/getsentry/sentry-java/pull/6265)) diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 05786658ef3..647986ecdd1 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -97,6 +97,7 @@ object Config { val androidLibs = setOf( "sentry-android-core", "sentry-android-ndk", + "sentry-android-ndk-outproc", "sentry-android-fragment", "sentry-android-navigation", "sentry-android-navigation3", diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e5c2f9e2171..f353794eae3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -185,6 +185,7 @@ reactor-core = { module = "io.projectreactor:reactor-core", version = "3.5.3" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.17.4" } +sentry-native-ndk-outproc = { module = "io.sentry:sentry-native-ndk-outproc", version = "0.17.4" } servlet-api = { module = "javax.servlet:javax.servlet-api", version = "3.1.0" } servlet-jakarta-api = { module = "jakarta.servlet:jakarta.servlet-api", version = "6.1.0" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } diff --git a/scripts/update-sentry-native-ndk.sh b/scripts/update-sentry-native-ndk.sh index 0aef9ebb257..fa5aa12a636 100755 --- a/scripts/update-sentry-native-ndk.sh +++ b/scripts/update-sentry-native-ndk.sh @@ -16,7 +16,7 @@ set-version) echo "Setting sentry-native-ndk version to '$version'" - PATTERN='(module = "io\.sentry:sentry-native-ndk", version = ")[0-9.]+(")' + PATTERN='(module = "io\.sentry:sentry-native-ndk(?:-outproc)?", version = ")[0-9.]+(")' perl -pi -e "s/$PATTERN/\${1}$version\${2}/" "$GRADLE_NDK_FILEPATH" ;; *) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index e8b95794ea0..e6b08aeae4d 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -664,6 +664,7 @@ public class io/sentry/android/core/TombstoneIntegration$TombstonePolicy : io/se public fun getLastReportedTimestamp ()Ljava/lang/Long; public fun markReported (J)V public fun matches (Landroid/app/ApplicationExitInfo;)Z + public static fun mergeNativeCrashes (Lio/sentry/SentryEvent;Lio/sentry/SentryEvent;)V public fun shouldReportHistorical ()Z } 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 7626acfb1ba..18735929337 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 @@ -436,7 +436,7 @@ static void installDefaultIntegrations( // Integrations are registered in the same order. NDK before adding Watch outbox, // because sentry-native move files around and we don't want to watch that. final Class sentryNdkClass = loadClass.loadClass(SENTRY_NDK_CLASS_NAME, options.getLogger()); - options.addIntegration(new NdkIntegration(sentryNdkClass)); + options.addIntegration(new NdkIntegration(context, sentryNdkClass)); if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.S) { options.addIntegration(new TombstoneIntegration(context)); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java index 8353a32d65d..b486bae64fa 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java @@ -2,6 +2,7 @@ import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; +import android.content.Context; import io.sentry.IScopes; import io.sentry.Integration; import io.sentry.SentryLevel; @@ -19,11 +20,17 @@ public final class NdkIntegration implements Integration, Closeable { public static final String SENTRY_NDK_CLASS_NAME = "io.sentry.android.ndk.SentryNdk"; + private final @Nullable Context context; private final @Nullable Class sentryNdkClass; private @Nullable SentryAndroidOptions options; public NdkIntegration(final @Nullable Class sentryNdkClass) { + this(null, sentryNdkClass); + } + + NdkIntegration(final @Nullable Context context, final @Nullable Class sentryNdkClass) { + this.context = context != null ? ContextUtils.getApplicationContext(context) : null; this.sentryNdkClass = sentryNdkClass; } @@ -50,9 +57,25 @@ public final void register(final @NotNull IScopes scopes, final @NotNull SentryO } try { - final Method method = sentryNdkClass.getMethod("init", SentryAndroidOptions.class); - final Object[] args = new Object[1]; - args[0] = this.options; + Method method = null; + Object[] args = null; + if (context != null) { + try { + method = sentryNdkClass.getMethod("init", Context.class, SentryAndroidOptions.class); + args = new Object[] {context, this.options}; + } catch (NoSuchMethodException e) { + // an older sentry-android-ndk + this.options + .getLogger() + .log( + SentryLevel.WARNING, + "SentryNdk.init(Context, SentryAndroidOptions) not found, using SentryNdk.init(SentryAndroidOptions)."); + } + } + if (method == null) { + method = sentryNdkClass.getMethod("init", SentryAndroidOptions.class); + args = new Object[] {this.options}; + } method.invoke(null, args); this.options.getLogger().log(SentryLevel.DEBUG, "NdkIntegration installed."); 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 82916a248e6..64fd0a042b1 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 @@ -3,6 +3,7 @@ import android.annotation.SuppressLint; import android.app.Application; import android.content.Context; +import android.os.Build; import android.os.Process; import android.os.SystemClock; import android.os.Trace; @@ -48,6 +49,9 @@ public final class SentryAndroid { private static final String FRAGMENT_CLASS_NAME = "androidx.fragment.app.FragmentManager$FragmentLifecycleCallbacks"; + // the process of SentryNdkCrashService in sentry-android-ndk-outproc + private static final String NDK_CRASH_SERVICE_PROCESS_SUFFIX = ".sentry_ndk_crash"; + protected static final @NotNull AutoClosableReentrantLock staticLock = new AutoClosableReentrantLock(); @@ -96,6 +100,12 @@ public static void init( @NotNull final Context context, @NotNull ILogger logger, @NotNull Sentry.OptionsConfiguration configuration) { + if (isNdkCrashServiceProcess()) { + logger.log( + SentryLevel.DEBUG, "Skipping SentryAndroid.init in the NDK crash service process."); + return; + } + // Started before acquiring the lock so it stays balanced with the endSection() in the finally // even if acquire() throws. Trace.beginSection("SentryAndroid.init"); @@ -281,4 +291,14 @@ private static void deduplicateIntegrations( } } } + + // the crash service only runs on API 30+ + @SuppressLint("NewApi") + private static boolean isNdkCrashServiceProcess() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return false; + } + final @Nullable String processName = Application.getProcessName(); + return processName != null && processName.endsWith(NDK_CRASH_SERVICE_PROCESS_SUFFIX); + } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java index 43de4034ec5..832d1bb08a1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java @@ -289,7 +289,7 @@ private void addNativeAttachmentsToTombstoneHint( } } - private void mergeNativeCrashes( + public static void mergeNativeCrashes( final @NotNull SentryEvent nativeEvent, final @NotNull SentryEvent tombstoneEvent) { // we take the event data verbatim from the Native SDK and only apply tombstone data where we // are sure that it will improve the outcome: diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/NdkIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NdkIntegrationTest.kt index ae73a394050..29bdf7ddd88 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/NdkIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/NdkIntegrationTest.kt @@ -1,5 +1,6 @@ package io.sentry.android.core +import android.content.Context import io.sentry.ILogger import io.sentry.IScopes import io.sentry.SentryLevel @@ -18,6 +19,8 @@ class NdkIntegrationTest { val logger = mock() fun getSut(clazz: Class<*>? = SentryNdk::class.java): NdkIntegration = NdkIntegration(clazz) + + fun getSut(context: Context, clazz: Class<*>): NdkIntegration = NdkIntegration(context, clazz) } private val fixture = Fixture() @@ -95,6 +98,20 @@ class NdkIntegrationTest { assertFalse(options.isEnableScopeSync) } + @Test + fun `NdkIntegration falls back to init without context`() { + val integration = fixture.getSut(mock(), SentryNdk::class.java) + + val options = getOptions() + + integration.register(fixture.scopes, options) + + verify(fixture.logger).log(eq(SentryLevel.WARNING), any()) + verify(fixture.logger, never()).log(eq(SentryLevel.ERROR), any(), any()) + assertTrue(options.isEnableNdk) + assertTrue(options.isEnableScopeSync) + } + @Test fun `NdkIntegration won't close if close method is not available`() { val integration = fixture.getSut(SentryNdkNoClose::class.java) 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 725a5a61122..b5559582610 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 @@ -84,6 +84,7 @@ import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder +import org.robolectric.shadows.ShadowApplication @RunWith(AndroidJUnit4::class) @Config(sdk = [Build.VERSION_CODES.N], shadows = [SentryShadowProcess::class]) @@ -573,6 +574,18 @@ class SentryAndroidTest { assertTrue(optionsRef.eventProcessors.any { it is ApplicationExitInfoEventProcessor }) } + @Test + @Config(sdk = [30]) + fun `init does nothing in the NDK crash service process`() { + ShadowApplication.setProcessName("${context.packageName}.sentry_ndk_crash") + var configured = false + + fixture.initSut { configured = true } + + assertFalse(configured) + assertFalse(Sentry.isEnabled()) + } + private fun prefillScopeCache(options: SentryOptions, cacheDir: String) { val scopeDir = File(cacheDir, SCOPE_CACHE).also { it.mkdirs() } val queueFile = QueueFile.Builder(File(scopeDir, BREADCRUMBS_FILENAME)).build() diff --git a/sentry-android-ndk-outproc/README.md b/sentry-android-ndk-outproc/README.md new file mode 100644 index 00000000000..9a21f46dc7f --- /dev/null +++ b/sentry-android-ndk-outproc/README.md @@ -0,0 +1,16 @@ +# sentry-android-ndk-outproc + +Experimental opt-in alternative to `sentry-android-ndk`, with an Android service +that runs the native crash daemon. Depends on `sentry-native-ndk-outproc`. Select +it through the Sentry Gradle plugin: + +```kotlin +sentry { + ndkBackend.set("native") +} +``` + +The default is the inproc backend. + +The crash service needs API 30+. On older versions, native crash reporting is +disabled. diff --git a/sentry-android-ndk-outproc/api/sentry-android-ndk-outproc.api b/sentry-android-ndk-outproc/api/sentry-android-ndk-outproc.api new file mode 100644 index 00000000000..ea482e03037 --- /dev/null +++ b/sentry-android-ndk-outproc/api/sentry-android-ndk-outproc.api @@ -0,0 +1,40 @@ +public final class io/sentry/android/ndk/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public static final field SENTRY_PACKAGE_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/android/ndk/DebugImagesLoader : io/sentry/android/core/IDebugImagesLoader { + public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/ndk/NativeModuleListLoader;)V + public fun clearDebugImages ()V + public fun loadDebugImages ()Ljava/util/List; + public fun loadDebugImagesForAddresses (Ljava/util/Set;)Ljava/util/Set; +} + +public final class io/sentry/android/ndk/NdkScopeObserver : io/sentry/ScopeObserverAdapter { + public fun (Lio/sentry/SentryOptions;)V + public fun addAttachment (Lio/sentry/Attachment;)V + public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V + public fun clearAttachments ()V + public fun removeExtra (Ljava/lang/String;)V + public fun removeTag (Ljava/lang/String;)V + public fun setExtra (Ljava/lang/String;Ljava/lang/String;)V + public fun setTag (Ljava/lang/String;Ljava/lang/String;)V + public fun setTrace (Lio/sentry/SpanContext;Lio/sentry/IScope;)V + public fun setUser (Lio/sentry/protocol/User;)V +} + +public final class io/sentry/android/ndk/SentryNdk { + public static fun close ()V + public static fun init (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;)V + public static fun init (Lio/sentry/android/core/SentryAndroidOptions;)V +} + +public final class io/sentry/android/ndk/SentryNdkCrashService : android/app/Service { + public fun ()V + public fun onBind (Landroid/content/Intent;)Landroid/os/IBinder; +} + diff --git a/sentry-android-ndk-outproc/build.gradle.kts b/sentry-android-ndk-outproc/build.gradle.kts new file mode 100644 index 00000000000..75c7a2e75d5 --- /dev/null +++ b/sentry-android-ndk-outproc/build.gradle.kts @@ -0,0 +1,100 @@ +import org.jetbrains.kotlin.config.KotlinCompilerVersion + +plugins { + id("com.android.library") + alias(libs.plugins.kotlin.android) + alias(libs.plugins.gradle.versions) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "io.sentry.android.ndk" + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + ndk { abiFilters.addAll(Config.Android.abiFilters) } + + // for AGP 4.1 + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") + buildConfigField("String", "SENTRY_PACKAGE_NAME", "\"${project.name}\"") + } + + buildTypes { + getByName("debug") { consumerProguardFiles("../sentry-android-ndk/proguard-rules.pro") } + getByName("release") { consumerProguardFiles("../sentry-android-ndk/proguard-rules.pro") } + } + + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + + testOptions { + animationsDisabled = true + unitTests.apply { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } + + lint { + warningsAsErrors = true + checkDependencies = true + + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. + checkReleaseBuilds = false + } + + // needed because of Kotlin 1.4.x + configurations.all { resolutionStrategy.force(libs.jetbrains.annotations.get()) } + + buildFeatures { buildConfig = true } + + androidComponents.beforeVariants { + it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) + } + + // the default AGP version is too high, we keep this low for compatibility reasons + // see https://developer.android.com/build/releases/past-releases/ to find the AGP-NDK mapping + ndkVersion = "23.1.7779620" + + @Suppress("UnstableApiUsage") packagingOptions { jniLibs { useLegacyPackaging = true } } +} + +dependencies { + api(projects.sentry) + api(projects.sentryAndroidCore) + + compileOnly(libs.jetbrains.annotations) + + implementation(libs.sentry.native.ndk.outproc) + + testImplementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(projects.sentryTestSupport) + testImplementation(libs.google.truth) + testImplementation(libs.roboelectric) +} + +configurations + .matching { + it.name == "releaseApiElements" || + it.name == "releaseRuntimeElements" || + it.name == "releaseVariantReleaseApiPublication" || + it.name == "releaseVariantReleaseRuntimePublication" + } + .configureEach { + outgoing.capability("${project.group}:${project.name}:${project.version}") + outgoing.capability("${project.group}:sentry-android-ndk:${project.version}") + } + +extensions.configure { + sourceSets.getByName("main").java.srcDir("../sentry-android-ndk/src/main/java") + sourceSets.getByName("test").java.srcDir("../sentry-android-ndk/src/test/java") + sourceSets.getByName("test").resources.srcDir("../sentry-android-ndk/src/test/resources") +} diff --git a/sentry-android-ndk-outproc/src/main/AndroidManifest.xml b/sentry-android-ndk-outproc/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..360057efb94 --- /dev/null +++ b/sentry-android-ndk-outproc/src/main/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java new file mode 100644 index 00000000000..c245b7ff959 --- /dev/null +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java @@ -0,0 +1,161 @@ +package io.sentry.android.ndk; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.ServiceConnection; +import android.os.Build; +import io.sentry.android.core.NdkHandlerStrategy; +import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.ndk.NativeModuleListLoader; +import io.sentry.ndk.NdkOptions; +import io.sentry.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class SentryNdk { + + private static final @NotNull CountDownLatch loadLibraryLatch = new CountDownLatch(1); + + @SuppressLint("StaticFieldLeak") // the application context from NdkIntegration + private static @Nullable Context serviceContext; + + private static @Nullable ServiceConnection serviceConnection; + + private SentryNdk() {} + + static { + new Thread( + () -> { + try { + //noinspection UnstableApiUsage + io.sentry.ndk.SentryNdk.loadNativeLibraries(); + } catch (Throwable t) { + // ignored + // if loadLibrary() fails, the later init() will throw an exception anyway + } finally { + loadLibraryLatch.countDown(); + } + }, + "SentryNdkLoadLibs") + .start(); + } + + /** + * Init the NDK integration + * + * @param options the SentryAndroidOptions + */ + public static void init(@NotNull final SentryAndroidOptions options) { + init(null, options); + } + + /** + * Init the NDK integration + * + * @param context the Android context + * @param options the SentryAndroidOptions + */ + public static void init( + @Nullable final Context context, @NotNull final SentryAndroidOptions options) { + // no fork/exec fallback, the native crash daemon only runs in the crash service + if (context == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + throw new IllegalStateException("The NDK crash service needs a context and API 30+"); + } + + SentryNdkUtil.addPackage(options.getSdkVersion()); + try { + if (loadLibraryLatch.await(2000, TimeUnit.MILLISECONDS)) { + final @NotNull NdkOptions ndkOptions = + new NdkOptions( + Objects.requireNonNull(options.getDsn(), "DSN is required for sentry-ndk"), + options.isDebug(), + Objects.requireNonNull( + options.getOutboxPath(), "outbox path is required for sentry-ndk"), + options.getRelease(), + options.getEnvironment(), + options.getDist(), + options.getMaxBreadcrumbs(), + options.getNativeSdkName()); + + final int handlerStrategy = options.getNdkHandlerStrategy(); + if (handlerStrategy == NdkHandlerStrategy.SENTRY_HANDLER_STRATEGY_DEFAULT.getValue()) { + ndkOptions.setNdkHandlerStrategy( + io.sentry.ndk.NdkHandlerStrategy.SENTRY_HANDLER_STRATEGY_DEFAULT); + } else if (handlerStrategy + == NdkHandlerStrategy.SENTRY_HANDLER_STRATEGY_CHAIN_AT_START.getValue()) { + ndkOptions.setNdkHandlerStrategy( + io.sentry.ndk.NdkHandlerStrategy.SENTRY_HANDLER_STRATEGY_CHAIN_AT_START); + } + + final @Nullable Double tracesSampleRate = options.getTracesSampleRate(); + if (tracesSampleRate == null) { + ndkOptions.setTracesSampleRate(0.0f); + } else { + ndkOptions.setTracesSampleRate(tracesSampleRate.floatValue()); + } + + ndkOptions.setEnableAppHangTracking(options.isEnableNdkAppHangTracking()); + ndkOptions.setAppHangTimeoutMillis(options.getNdkAppHangTimeoutIntervalMillis()); + + final @Nullable ServiceConnection connection = + SentryNdkCrashService.start(context, options); + if (connection == null) { + throw new IllegalStateException("Failed to start the NDK crash service"); + } + serviceContext = context; + serviceConnection = connection; + + try { + //noinspection UnstableApiUsage + io.sentry.ndk.SentryNdk.init(ndkOptions); + } catch (IllegalStateException e) { + unbindCrashService(); + throw e; + } + + // only add scope sync observer if the scope sync is enabled. + if (options.isEnableScopeSync()) { + options.addScopeObserver(new NdkScopeObserver(options)); + } + + options.setDebugImagesLoader(new DebugImagesLoader(options, new NativeModuleListLoader())); + } else { + throw new IllegalStateException("Timeout waiting for Sentry NDK library to load"); + } + } catch (InterruptedException e) { + throw new IllegalStateException( + "Thread interrupted while waiting for NDK libs to be loaded", e); + } + } + + /** Closes the NDK integration */ + public static void close() { + try { + if (loadLibraryLatch.await(2000, TimeUnit.MILLISECONDS)) { + //noinspection UnstableApiUsage + io.sentry.ndk.SentryNdk.close(); + } else { + throw new IllegalStateException("Timeout waiting for Sentry NDK library to load"); + } + } catch (InterruptedException e) { + throw new IllegalStateException( + "Thread interrupted while waiting for NDK libs to be loaded", e); + } finally { + unbindCrashService(); + } + } + + private static void unbindCrashService() { + final @Nullable Context context = serviceContext; + final @Nullable ServiceConnection connection = serviceConnection; + serviceContext = null; + serviceConnection = null; + if (context != null && connection != null) { + context.unbindService(connection); + } + } +} diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java new file mode 100644 index 00000000000..2918070b8f6 --- /dev/null +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java @@ -0,0 +1,714 @@ +package io.sentry.android.ndk; + +import android.annotation.SuppressLint; +import android.app.ActivityManager; +import android.app.ApplicationExitInfo; +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; +import android.os.Parcel; +import android.os.ParcelFileDescriptor; +import android.os.Process; +import android.os.RemoteException; +import io.sentry.DateUtils; +import io.sentry.ILogger; +import io.sentry.SentryEnvelope; +import io.sentry.SentryEnvelopeItem; +import io.sentry.SentryEvent; +import io.sentry.SentryItemType; +import io.sentry.SentryLevel; +import io.sentry.android.core.AndroidLogger; +import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.android.core.TombstoneIntegration; +import io.sentry.android.core.cache.AndroidEnvelopeCache; +import io.sentry.android.core.internal.tombstone.TombstoneParser; +import io.sentry.ndk.SentryNdk.CrashDaemon; +import io.sentry.util.FileUtils; +import java.io.BufferedInputStream; +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; + +@ApiStatus.Internal +public final class SentryNdkCrashService extends Service { + + private static final @NotNull String BINDER_DESCRIPTOR = "io.sentry.android.ndk.CrashService"; + private static final int TRANSACTION_CONFIGURE = IBinder.FIRST_CALL_TRANSACTION; + + private static final @NotNull String SHM_PREFIX = ".sentry-shm-service-"; + private static final @NotNull String MERGED_SUFFIX = ".merged"; + private static final long POLL_INTERVAL_MILLIS = 250; + private static final long MATCH_TOLERANCE_MILLIS = 30000; + + private final @NotNull IBinder binder = + new Binder() { + @Override + protected boolean onTransact( + final int code, + final @NotNull Parcel data, + final @Nullable Parcel reply, + final int flags) + throws RemoteException { + if (code != TRANSACTION_CONFIGURE) { + return super.onTransact(code, data, reply, flags); + } + + data.enforceInterface(BINDER_DESCRIPTOR); + final @Nullable String nativeDatabasePath = data.readString(); + final @Nullable String nativeLibraryDir = data.readString(); + final @Nullable String shmPath = data.readString(); + final int appPid = data.readInt(); + final long appTid = data.readLong(); + final boolean tombstoneEnabled = data.readInt() == 1; + final boolean debug = data.readInt() == 1; + final long shutdownTimeoutMillis = data.readLong(); + final @Nullable ArrayList includes = data.createStringArrayList(); + final @Nullable ArrayList excludes = data.createStringArrayList(); + final @Nullable ParcelFileDescriptor notifyRead = data.readFileDescriptor(); + final @Nullable ParcelFileDescriptor readyWrite = data.readFileDescriptor(); + final @Nullable ParcelFileDescriptor messageRead = data.readFileDescriptor(); + + final boolean configured = + configureDaemon( + nativeDatabasePath, + nativeLibraryDir, + shmPath, + appPid, + appTid, + tombstoneEnabled, + debug, + shutdownTimeoutMillis, + includes, + excludes, + notifyRead, + readyWrite, + messageRead); + if (reply != null) { + reply.writeNoException(); + reply.writeInt(configured ? 1 : 0); + } + return true; + } + }; + + static @Nullable ServiceConnection start( + final @NotNull Context context, final @NotNull SentryAndroidOptions options) { + final @Nullable String outboxPath = options.getOutboxPath(); + if (outboxPath == null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Outbox path is null, not starting NDK crash service."); + return null; + } + + final @Nullable File cacheDir = new File(outboxPath).getParentFile(); + if (cacheDir == null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Cache dir is null, not starting NDK crash service."); + return null; + } + + final File nativeDatabaseDir = new File(cacheDir, ".sentry-native"); + if (!nativeDatabaseDir.exists() && !nativeDatabaseDir.mkdirs()) { + options.getLogger().log(SentryLevel.DEBUG, "Failed to create NDK database directory."); + return null; + } + + deleteStaleShmFiles(context, nativeDatabaseDir); + + final long ipcId = System.nanoTime(); + final File shmFile = + new File(nativeDatabaseDir, SHM_PREFIX + Process.myPid() + "-" + Long.toHexString(ipcId)); + + ParcelFileDescriptor notifyRead = null; + ParcelFileDescriptor notifyWrite = null; + ParcelFileDescriptor readyRead = null; + ParcelFileDescriptor readyWrite = null; + ParcelFileDescriptor messageWrite = null; + ParcelFileDescriptor messageRead = null; + try { + final ParcelFileDescriptor[] notifyPipe = ParcelFileDescriptor.createPipe(); + final ParcelFileDescriptor[] readyPipe = ParcelFileDescriptor.createPipe(); + notifyRead = notifyPipe[0]; + notifyWrite = notifyPipe[1]; + readyRead = readyPipe[0]; + readyWrite = readyPipe[1]; + final ParcelFileDescriptor[] messageSocket = ParcelFileDescriptor.createSocketPair(); + messageWrite = messageSocket[0]; + messageRead = messageSocket[1]; + + final Intent intent = new Intent(context, SentryNdkCrashService.class); + final @Nullable ServiceConnection connection = + configureService( + context, + intent, + options.getLogger(), + nativeDatabaseDir.getPath(), + context.getApplicationInfo().nativeLibraryDir, + shmFile.getPath(), + Process.myPid(), + ipcId, + options.isTombstoneEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S, + options.isDebug(), + options.getShutdownTimeoutMillis(), + new ArrayList<>(options.getInAppIncludes()), + new ArrayList<>(options.getInAppExcludes()), + notifyRead, + readyWrite, + messageRead); + if (connection == null) { + options.getLogger().log(SentryLevel.DEBUG, "NDK crash service did not configure."); + return null; + } + + io.sentry.ndk.SentryNdk.initCrashDaemon( + shmFile.getPath(), notifyWrite.detachFd(), readyRead.detachFd(), messageWrite.detachFd()); + return connection; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + options.getLogger().log(SentryLevel.DEBUG, e, "Failed to start NDK crash service."); + return null; + } catch (IOException | SecurityException e) { + options.getLogger().log(SentryLevel.DEBUG, e, "Failed to start NDK crash service."); + return null; + } finally { + closeQuietly(notifyRead); + closeQuietly(notifyWrite); + closeQuietly(readyRead); + closeQuietly(readyWrite); + closeQuietly(messageWrite); + closeQuietly(messageRead); + } + } + + @Override + public @Nullable IBinder onBind(final @NotNull Intent intent) { + return binder; + } + + private static void closeQuietly(final @Nullable Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (IOException ignored) { + // ignored + } + } + } + + // a setup that failed before the daemon attached leaves its file behind + private static void deleteStaleShmFiles( + final @NotNull Context context, final @NotNull File nativeDatabaseDir) { + final @Nullable ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + final @Nullable List processes = + activityManager != null ? activityManager.getRunningAppProcesses() : null; + final @Nullable File[] files = nativeDatabaseDir.listFiles(); + if (processes == null || files == null) { + return; + } + + final Set pids = new HashSet<>(); + for (ActivityManager.RunningAppProcessInfo process : processes) { + pids.add(String.valueOf(process.pid)); + } + for (File file : files) { + final String name = file.getName(); + if (!name.startsWith(SHM_PREFIX)) { + continue; + } + final int end = name.indexOf('-', SHM_PREFIX.length()); + if (end < 0 || !pids.contains(name.substring(SHM_PREFIX.length(), end))) { + file.delete(); + } + } + } + + private static @Nullable ServiceConnection configureService( + final @NotNull Context context, + final @NotNull Intent intent, + final @NotNull ILogger logger, + final @NotNull String nativeDatabasePath, + final @Nullable String nativeLibraryDir, + final @NotNull String shmPath, + final int appPid, + final long appTid, + final boolean tombstoneEnabled, + final boolean debug, + final long shutdownTimeoutMillis, + final @NotNull ArrayList includes, + final @NotNull ArrayList excludes, + final @NotNull ParcelFileDescriptor notifyRead, + final @NotNull ParcelFileDescriptor readyWrite, + final @NotNull ParcelFileDescriptor messageRead) + throws InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final boolean[] configured = new boolean[] {false}; + final boolean[] bound = new boolean[] {false}; + + final ServiceConnection connection = + new ServiceConnection() { + @Override + public void onServiceConnected( + final @NotNull ComponentName name, final @NotNull IBinder service) { + configured[0] = + transactConfigure( + service, + nativeDatabasePath, + nativeLibraryDir, + shmPath, + appPid, + appTid, + tombstoneEnabled, + debug, + shutdownTimeoutMillis, + includes, + excludes, + notifyRead, + readyWrite, + messageRead); + latch.countDown(); + } + + @Override + public void onServiceDisconnected(final @NotNull ComponentName name) { + logger.log(SentryLevel.WARNING, "NDK crash service disconnected."); + latch.countDown(); + } + + @Override + public void onBindingDied(final @NotNull ComponentName name) { + logger.log(SentryLevel.WARNING, "NDK crash service binding died."); + latch.countDown(); + } + }; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // deliver callbacks on the binder thread, the caller blocks on the latch + bound[0] = context.bindService(intent, Context.BIND_AUTO_CREATE, Runnable::run, connection); + } else { + bound[0] = context.bindService(intent, connection, Context.BIND_AUTO_CREATE); + } + if (!bound[0]) { + return null; + } + boolean ready = false; + try { + ready = latch.await(10000, TimeUnit.MILLISECONDS) && configured[0]; + // stay bound, so the service process keeps the app's importance and is not frozen + return ready ? connection : null; + } finally { + if (!ready) { + context.unbindService(connection); + } + } + } + + private static boolean transactConfigure( + final @NotNull IBinder service, + final @NotNull String nativeDatabasePath, + final @Nullable String nativeLibraryDir, + final @NotNull String shmPath, + final int appPid, + final long appTid, + final boolean tombstoneEnabled, + final boolean debug, + final long shutdownTimeoutMillis, + final @NotNull ArrayList includes, + final @NotNull ArrayList excludes, + final @NotNull ParcelFileDescriptor notifyRead, + final @NotNull ParcelFileDescriptor readyWrite, + final @NotNull ParcelFileDescriptor messageRead) { + final Parcel data = Parcel.obtain(); + final Parcel reply = Parcel.obtain(); + try { + data.writeInterfaceToken(BINDER_DESCRIPTOR); + data.writeString(nativeDatabasePath); + data.writeString(nativeLibraryDir); + data.writeString(shmPath); + data.writeInt(appPid); + data.writeLong(appTid); + data.writeInt(tombstoneEnabled ? 1 : 0); + data.writeInt(debug ? 1 : 0); + data.writeLong(shutdownTimeoutMillis); + data.writeStringList(includes); + data.writeStringList(excludes); + data.writeFileDescriptor(notifyRead.getFileDescriptor()); + data.writeFileDescriptor(readyWrite.getFileDescriptor()); + data.writeFileDescriptor(messageRead.getFileDescriptor()); + if (!service.transact(TRANSACTION_CONFIGURE, data, reply, 0)) { + return false; + } + reply.readException(); + return reply.readInt() == 1; + } catch (RemoteException | RuntimeException ignored) { + return false; + } finally { + data.recycle(); + reply.recycle(); + } + } + + @VisibleForTesting + boolean configureDaemon( + final @Nullable String nativeDatabasePath, + final @Nullable String nativeLibraryDir, + final @Nullable String shmPath, + final int appPid, + final long appTid, + final boolean tombstoneEnabled, + final boolean debug, + final long shutdownTimeoutMillis, + final @Nullable ArrayList includes, + final @Nullable ArrayList excludes, + final @Nullable ParcelFileDescriptor notifyRead, + final @Nullable ParcelFileDescriptor readyWrite, + final @Nullable ParcelFileDescriptor messageRead) { + if (nativeDatabasePath == null + || shmPath == null + || notifyRead == null + || readyWrite == null + || messageRead == null + || appPid < 0) { + return false; + } + + final DaemonConfig config = + new DaemonConfig( + nativeLibraryDir, + shmPath, + notifyRead.detachFd(), + readyWrite.detachFd(), + messageRead.detachFd(), + appPid, + appTid, + shutdownTimeoutMillis, + includes != null ? includes : new ArrayList<>(), + excludes != null ? excludes : new ArrayList<>(), + tombstoneEnabled, + debug); + // a relaunched app configures while the previous daemon may still be finishing + new Thread(() -> runDaemonWorker(config), "SentryNdkCrashService").start(); + return true; + } + + private void runDaemonWorker(final @NotNull DaemonConfig config) { + final SentryAndroidOptions options = new SentryAndroidOptions(); + options.setLogger(new AndroidLogger("SentryNdkCrash")); + options.setDebug(config.debug); + + try { + if (config.notifyReadFd < 0 || config.readyWriteFd < 0 || config.messageReadFd < 0) { + return; + } + + // keeps going after onDestroy, a crashed app's binding goes away mid-crash + try (final @Nullable CrashDaemon daemon = + io.sentry.ndk.SentryNdk.runCrashDaemon( + config.appPid, + config.appTid, + config.notifyReadFd, + config.readyWriteFd, + config.messageReadFd, + config.shmPath)) { + if (daemon == null) { + return; + } + final @Nullable String envelopePath = daemon.getEnvelopePath(); + if (envelopePath == null) { + return; + } + + final File envelope = new File(envelopePath); + final @Nullable MergedEnvelope mergedEnvelope = + config.tombstoneEnabled ? waitForMergedEnvelope(options, envelope, config) : null; + if (config.tombstoneEnabled && mergedEnvelope == null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Sending NDK crash without tombstone enrichment."); + } + + final CrashDaemon.SendResult result = + daemon.sendEnvelope( + mergedEnvelope != null ? mergedEnvelope.file.getPath() : envelope.getPath(), + config.shutdownTimeoutMillis); + if (result == CrashDaemon.SendResult.FAILED) { + options.getLogger().log(SentryLevel.DEBUG, "Failed to send NDK crash envelope."); + } else if (mergedEnvelope != null) { + writeLastTombstoneMarker(envelope, mergedEnvelope.tombstoneTimestamp); + } + } + } catch (InterruptedException | IOException e) { + options.getLogger().log(SentryLevel.DEBUG, e, "Error running NDK crash service."); + } + } + + @SuppressLint("NewApi") + private @Nullable MergedEnvelope waitForMergedEnvelope( + final @NotNull SentryAndroidOptions options, + final @NotNull File envelope, + final @NotNull DaemonConfig config) + throws InterruptedException { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return null; + } + + final @Nullable NativeEnvelope nativeEnvelope = readNativeEnvelope(options, envelope); + final @Nullable Date nativeTimestamp = + nativeEnvelope != null ? nativeEnvelope.event.getTimestamp() : null; + if (nativeTimestamp == null) { + return null; + } + + // the app exits once its tombstone is written, a later exit would not match + final long deadlineMillis = System.currentTimeMillis() + MATCH_TOLERANCE_MILLIS; + while (true) { + final @Nullable ApplicationExitInfo exitInfo = + findMatchingExitInfo(options, nativeTimestamp, config.appPid); + if (exitInfo != null) { + if (exitInfo.getReason() != ApplicationExitInfo.REASON_CRASH_NATIVE) { + return null; + } + try { + final @Nullable MergedEnvelope mergedEnvelope = + processEnvelope(options, envelope, nativeEnvelope, exitInfo, config); + if (mergedEnvelope != null) { + return mergedEnvelope; + } + } catch (Exception e) { + options.getLogger().log(SentryLevel.DEBUG, e, "Failed to merge NDK tombstone envelope."); + return null; + } + } + + final long remainingMillis = deadlineMillis - System.currentTimeMillis(); + if (remainingMillis <= 0) { + return null; + } + Thread.sleep(Math.min(POLL_INTERVAL_MILLIS, remainingMillis)); + } + } + + @SuppressLint("NewApi") + private @Nullable MergedEnvelope processEnvelope( + final @NotNull SentryAndroidOptions options, + final @NotNull File file, + final @NotNull NativeEnvelope nativeEnvelope, + final @NotNull ApplicationExitInfo exitInfo, + final @NotNull DaemonConfig config) + throws Exception { + try (final @Nullable InputStream tombstoneInputStream = exitInfo.getTraceInputStream()) { + if (tombstoneInputStream == null) { + return null; + } + + final SentryEvent tombstoneEvent; + try (final TombstoneParser parser = + new TombstoneParser( + tombstoneInputStream, + config.inAppIncludes, + config.inAppExcludes, + config.nativeLibraryDir)) { + tombstoneEvent = parser.parse(); + } + tombstoneEvent.setTimestamp(DateUtils.getDateTime(exitInfo.getTimestamp())); + + TombstoneIntegration.TombstonePolicy.mergeNativeCrashes(nativeEnvelope.event, tombstoneEvent); + final File mergedEnvelope = writeMergedEnvelope(options, file, nativeEnvelope); + return new MergedEnvelope(mergedEnvelope, exitInfo.getTimestamp()); + } + } + + @VisibleForTesting + @SuppressLint("NewApi") + @Nullable + ApplicationExitInfo findMatchingExitInfo( + final @NotNull SentryAndroidOptions options, + final @NotNull Date nativeTimestamp, + final int pid) { + final @Nullable ActivityManager activityManager = + (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager == null) { + return null; + } + + final List exitInfos = + activityManager.getHistoricalProcessExitReasons(null, 0, 10); + ApplicationExitInfo bestMatch = null; + long bestDiff = Long.MAX_VALUE; + for (ApplicationExitInfo exitInfo : exitInfos) { + if (exitInfo.getPid() != pid) { + continue; + } + final long diff = Math.abs(exitInfo.getTimestamp() - nativeTimestamp.getTime()); + if (diff < bestDiff) { + bestDiff = diff; + bestMatch = exitInfo; + } + } + + if (bestMatch != null && bestDiff <= MATCH_TOLERANCE_MILLIS) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Matched NDK tombstone with diff %d ms.", bestDiff); + return bestMatch; + } + return null; + } + + private @Nullable NativeEnvelope readNativeEnvelope( + final @NotNull SentryAndroidOptions options, final @NotNull File file) { + try (final InputStream stream = new BufferedInputStream(new FileInputStream(file))) { + final @Nullable SentryEnvelope envelope = options.getEnvelopeReader().read(stream); + if (envelope == null) { + return null; + } + for (SentryEnvelopeItem item : envelope.getItems()) { + if (item.getHeader().getType() != SentryItemType.Event) { + continue; + } + final @Nullable SentryEvent event = item.getEvent(options.getSerializer()); + if (event != null && "native".equals(event.getPlatform())) { + return new NativeEnvelope(envelope, event); + } + } + } catch (Exception e) { + options.getLogger().log(SentryLevel.DEBUG, e, "Failed to read NDK envelope."); + } + return null; + } + + private @NotNull File writeMergedEnvelope( + final @NotNull SentryAndroidOptions options, + final @NotNull File sourceFile, + final @NotNull NativeEnvelope nativeEnvelope) + throws Exception { + final List items = new ArrayList<>(); + items.add(SentryEnvelopeItem.fromEvent(options.getSerializer(), nativeEnvelope.event)); + for (SentryEnvelopeItem item : nativeEnvelope.envelope.getItems()) { + if (item.getHeader().getType() != SentryItemType.Event) { + items.add(item); + } + } + + final SentryEnvelope mergedEnvelope = + new SentryEnvelope(nativeEnvelope.envelope.getHeader(), items); + final File mergedFile = new File(sourceFile.getPath() + MERGED_SUFFIX); + final File tmpFile = new File(mergedFile.getPath() + ".tmp"); + try (final OutputStream outputStream = new FileOutputStream(tmpFile)) { + options.getSerializer().serialize(mergedEnvelope, outputStream); + } + if (!tmpFile.renameTo(mergedFile)) { + throw new IllegalStateException("Failed to publish merged envelope"); + } + options.getLogger().log(SentryLevel.DEBUG, "Wrote merged NDK tombstone envelope."); + return mergedFile; + } + + // workers for concurrent crashes must not move the marker back + private static synchronized void writeLastTombstoneMarker( + final @NotNull File envelopeFile, final long timestamp) throws IOException { + final @Nullable File runDir = envelopeFile.getParentFile(); + final @Nullable File nativeDir = runDir != null ? runDir.getParentFile() : null; + final @Nullable File cacheDir = nativeDir != null ? nativeDir.getParentFile() : null; + if (cacheDir == null) { + return; + } + final File marker = new File(cacheDir, AndroidEnvelopeCache.LAST_TOMBSTONE_REPORT); + final @Nullable String lastTimestamp = FileUtils.readText(marker); + try { + if (lastTimestamp != null && Long.parseLong(lastTimestamp.trim()) >= timestamp) { + return; + } + } catch (NumberFormatException ignored) { + // overwrite an invalid marker + } + try (final OutputStream outputStream = new FileOutputStream(marker)) { + outputStream.write(String.valueOf(timestamp).getBytes("UTF-8")); + } + } + + private static final class NativeEnvelope { + private final @NotNull SentryEnvelope envelope; + private final @NotNull SentryEvent event; + + private NativeEnvelope( + final @NotNull SentryEnvelope envelope, final @NotNull SentryEvent event) { + this.envelope = envelope; + this.event = event; + } + } + + private static final class MergedEnvelope { + private final @NotNull File file; + private final long tombstoneTimestamp; + + private MergedEnvelope(final @NotNull File file, final long tombstoneTimestamp) { + this.file = file; + this.tombstoneTimestamp = tombstoneTimestamp; + } + } + + private static final class DaemonConfig { + private final @Nullable String nativeLibraryDir; + private final @NotNull String shmPath; + private final int notifyReadFd; + private final int readyWriteFd; + private final int messageReadFd; + private final int appPid; + private final long appTid; + private final long shutdownTimeoutMillis; + private final @NotNull List inAppIncludes; + private final @NotNull List inAppExcludes; + private final boolean tombstoneEnabled; + private final boolean debug; + + private DaemonConfig( + final @Nullable String nativeLibraryDir, + final @NotNull String shmPath, + final int notifyReadFd, + final int readyWriteFd, + final int messageReadFd, + final int appPid, + final long appTid, + final long shutdownTimeoutMillis, + final @NotNull List inAppIncludes, + final @NotNull List inAppExcludes, + final boolean tombstoneEnabled, + final boolean debug) { + this.nativeLibraryDir = nativeLibraryDir; + this.shmPath = shmPath; + this.notifyReadFd = notifyReadFd; + this.readyWriteFd = readyWriteFd; + this.messageReadFd = messageReadFd; + this.appPid = appPid; + this.appTid = appTid; + this.shutdownTimeoutMillis = shutdownTimeoutMillis; + this.inAppIncludes = inAppIncludes; + this.inAppExcludes = inAppExcludes; + this.tombstoneEnabled = tombstoneEnabled; + this.debug = debug; + } + } +} diff --git a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/NativeBackendTest.kt b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/NativeBackendTest.kt new file mode 100644 index 00000000000..47d39738649 --- /dev/null +++ b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/NativeBackendTest.kt @@ -0,0 +1,91 @@ +package io.sentry.android.ndk + +import android.content.Context +import android.content.ServiceConnection +import com.google.common.truth.Truth.assertThat +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.ndk.NdkOptions +import kotlin.test.assertFailsWith +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [31]) +class NativeBackendTest { + @Test + fun `defaults missing traces sample rate to zero`() { + initialize({ tracesSampleRate = null }) { assertThat(it.tracesSampleRate).isEqualTo(0.0f) } + } + + @Test + fun `propagates traces sample rate`() { + initialize({ tracesSampleRate = 0.75 }) { assertThat(it.tracesSampleRate).isEqualTo(0.75f) } + } + + @Test + fun `does not enable app hang tracking by default`() { + initialize { + assertThat(it.isEnableAppHangTracking).isFalse() + assertThat(it.appHangTimeoutMillis).isEqualTo(5000L) + } + } + + @Test + fun `propagates app hang tracking options`() { + initialize({ + isEnableNdkAppHangTracking = true + ndkAppHangTimeoutIntervalMillis = 2000 + }) { + assertThat(it.isEnableAppHangTracking).isTrue() + assertThat(it.appHangTimeoutMillis).isEqualTo(2000L) + } + } + + @Test + fun `fails without a context`() { + assertFailsWith { SentryNdk.init(options()) } + } + + @Test + @Config(sdk = [29]) + fun `fails below API 30`() { + assertFailsWith { SentryNdk.init(mock(), options()) } + } + + private fun options() = + SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + cacheDirPath = "/cache" + } + + private fun initialize( + configure: SentryAndroidOptions.() -> Unit = {}, + verify: (NdkOptions) -> Unit, + ) { + val context = mock() + val options = options().apply(configure) + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { native -> + Mockito.mockStatic(SentryNdkCrashService::class.java).use { service -> + var captured: NdkOptions? = null + native + .`when` { io.sentry.ndk.SentryNdk.init(any()) } + .doAnswer { captured = it.arguments[0] as NdkOptions } + service + .`when` { SentryNdkCrashService.start(context, options) } + .thenReturn(mock()) + + SentryNdk.init(context, options) + + service.verify { SentryNdkCrashService.start(context, options) } + assertThat(captured).isNotNull() + verify(captured!!) + } + } + } +} diff --git a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt new file mode 100644 index 00000000000..732d7cfce26 --- /dev/null +++ b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt @@ -0,0 +1,403 @@ +package io.sentry.android.ndk + +import android.app.ActivityManager +import android.app.ApplicationExitInfo +import android.content.ComponentName +import android.content.Context +import android.os.Binder +import android.os.Parcel +import android.os.ParcelFileDescriptor +import com.google.common.truth.Truth.assertThat +import io.sentry.DateUtils +import io.sentry.SentryEnvelope +import io.sentry.SentryEvent +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.android.core.cache.AndroidEnvelopeCache +import io.sentry.ndk.NdkOptions +import io.sentry.ndk.SentryNdk.CrashDaemon +import java.io.File +import java.io.InputStream +import java.util.Date +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.zip.GZIPInputStream +import kotlin.test.assertFailsWith +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [31], shadows = [SentryShadowParcelFileDescriptor::class]) +class SentryNdkCrashServiceTest { + private val app = RuntimeEnvironment.getApplication() + private val component = ComponentName(app, SentryNdkCrashService::class.java) + private val options = + SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + cacheDirPath = app.cacheDir.absolutePath + } + + @Test + fun `binds without starting the service`() { + configure(true) + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { + val connection = SentryNdkCrashService.start(app, options) + + assertThat(connection).isNotNull() + assertThat(shadowOf(app).boundServiceConnections).containsExactly(connection) + assertThat(shadowOf(app).unboundServiceConnections).isEmpty() + // robolectric records the bind intent as started, a startService call would add another + assertThat(shadowOf(app).allStartedServices).hasSize(1) + } + } + + @Test + fun `unbinds when the service does not configure`() { + configure(false) + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { + assertThat(SentryNdkCrashService.start(app, options)).isNull() + + assertThat(shadowOf(app).boundServiceConnections).isEmpty() + assertThat(shadowOf(app).unboundServiceConnections).hasSize(1) + } + } + + @Test + fun `close unbinds the service`() { + configure(true) + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { + SentryNdk.init(app, options) + val connection = shadowOf(app).boundServiceConnections.single() + + SentryNdk.close() + + assertThat(shadowOf(app).boundServiceConnections).isEmpty() + assertThat(shadowOf(app).unboundServiceConnections).containsExactly(connection) + } + } + + @Test + fun `init unbinds when native init fails`() { + configure(true) + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { native -> + native + .`when` { io.sentry.ndk.SentryNdk.init(any()) } + .thenThrow(IllegalStateException()) + + assertFailsWith { SentryNdk.init(app, options) } + + assertThat(shadowOf(app).boundServiceConnections).isEmpty() + assertThat(shadowOf(app).unboundServiceConnections).hasSize(1) + } + } + + @Test + fun `init fails when the service does not bind`() { + shadowOf(app).declareComponentUnbindable(component) + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { native -> + assertFailsWith { SentryNdk.init(app, options) } + + native.verify({ io.sentry.ndk.SentryNdk.init(any()) }, never()) + } + } + + @Test + fun `deletes the shared memory files of exited processes`() { + configure(true) + val dir = File(options.cacheDirPath, ".sentry-native").apply { mkdirs() } + val running = File(dir, ".sentry-shm-service-1-a").apply { createNewFile() } + val exited = File(dir, ".sentry-shm-service-2-b").apply { createNewFile() } + val activityManager = app.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + shadowOf(activityManager) + .setProcesses(listOf(ActivityManager.RunningAppProcessInfo().apply { pid = 1 })) + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { + SentryNdkCrashService.start(app, options) + } + + assertThat(running.exists()).isTrue() + assertThat(exited.exists()).isFalse() + } + + @Test + fun `matches the exit info of the app pid`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + addExitInfo(service, pid = 1, timestamp = 2000) + addExitInfo(service, pid = 2, timestamp = 1000) + + assertThat(service.findMatchingExitInfo(options, Date(2000), 2)?.pid).isEqualTo(2) + } + + @Test + fun `ignores exit infos of other pids`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + addExitInfo(service, pid = 1, timestamp = 1000) + + assertThat(service.findMatchingExitInfo(options, Date(1000), 2)).isNull() + } + + @Test + @Config(instrumentedPackages = ["io.sentry.ndk"], shadows = [BlockingCrashDaemonShadow::class]) + fun `starts a daemon for a relaunched app while the previous one finishes`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + try { + configureDaemon(service, pid = 1, fd = 10) + assertThat(BlockingCrashDaemonShadow.daemons.poll(5, TimeUnit.SECONDS)) + .isEqualTo(listOf(1, 10, 11, 12, "shm-1")) + + configureDaemon(service, pid = 2, fd = 20) + assertThat(BlockingCrashDaemonShadow.daemons.poll(5, TimeUnit.SECONDS)) + .isEqualTo(listOf(2, 20, 21, 22, "shm-2")) + } finally { + BlockingCrashDaemonShadow.release.countDown() + } + } + + @Test + @Config(instrumentedPackages = ["io.sentry.ndk"], shadows = [CrashDaemonShadow::class]) + fun `sends the merged envelope and writes the marker`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + val envelope = writeNativeEnvelope(timestamp = 1000) + addExitInfo(service, pid = 1, timestamp = 2000, trace = tombstone()) + crashDaemon(envelope, CrashDaemon.SendResult.SENT) + + configureDaemon(service, pid = 1, fd = 10, tombstoneEnabled = true) + awaitWorker() + + assertThat(CrashDaemonShadow.sends).containsExactly(listOf("${envelope.path}.merged", 2000L)) + assertThat(marker().readText()).isEqualTo("2000") + } + + @Test + @Config(instrumentedPackages = ["io.sentry.ndk"], shadows = [CrashDaemonShadow::class]) + fun `writes the marker when the merged envelope is kept`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + val envelope = writeNativeEnvelope(timestamp = 1000) + addExitInfo(service, pid = 1, timestamp = 2000, trace = tombstone()) + crashDaemon(envelope, CrashDaemon.SendResult.KEPT) + + configureDaemon(service, pid = 1, fd = 10, tombstoneEnabled = true) + awaitWorker() + + assertThat(marker().readText()).isEqualTo("2000") + } + + @Test + @Config(instrumentedPackages = ["io.sentry.ndk"], shadows = [CrashDaemonShadow::class]) + fun `keeps a newer marker`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + val envelope = writeNativeEnvelope(timestamp = 1000) + addExitInfo(service, pid = 1, timestamp = 2000, trace = tombstone()) + crashDaemon(envelope, CrashDaemon.SendResult.SENT) + marker().writeText("3000") + + configureDaemon(service, pid = 1, fd = 10, tombstoneEnabled = true) + awaitWorker() + + assertThat(CrashDaemonShadow.sends).containsExactly(listOf("${envelope.path}.merged", 2000L)) + assertThat(marker().readText()).isEqualTo("3000") + } + + @Test + @Config(instrumentedPackages = ["io.sentry.ndk"], shadows = [CrashDaemonShadow::class]) + fun `writes no marker when the send fails`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + val envelope = writeNativeEnvelope(timestamp = 1000) + addExitInfo(service, pid = 1, timestamp = 2000, trace = tombstone()) + crashDaemon(envelope, CrashDaemon.SendResult.FAILED) + + configureDaemon(service, pid = 1, fd = 10, tombstoneEnabled = true) + awaitWorker() + + assertThat(CrashDaemonShadow.sends).containsExactly(listOf("${envelope.path}.merged", 2000L)) + assertThat(marker().exists()).isFalse() + } + + @Test + @Config(instrumentedPackages = ["io.sentry.ndk"], shadows = [CrashDaemonShadow::class]) + fun `sends without a tombstone when the app did not crash natively`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + val envelope = writeNativeEnvelope(timestamp = 1000) + addExitInfo(service, pid = 1, timestamp = 2000, reason = ApplicationExitInfo.REASON_SIGNALED) + crashDaemon(envelope, CrashDaemon.SendResult.SENT) + + configureDaemon(service, pid = 1, fd = 10, tombstoneEnabled = true) + awaitWorker() + + assertThat(CrashDaemonShadow.sends).containsExactly(listOf(envelope.path, 2000L)) + assertThat(marker().exists()).isFalse() + } + + @Test + @Config(instrumentedPackages = ["io.sentry.ndk"], shadows = [CrashDaemonShadow::class]) + fun `sends without a tombstone when it does not parse`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + val envelope = writeNativeEnvelope(timestamp = 1000) + addExitInfo(service, pid = 1, timestamp = 2000, trace = "invalid".byteInputStream()) + crashDaemon(envelope, CrashDaemon.SendResult.SENT) + + configureDaemon(service, pid = 1, fd = 10, tombstoneEnabled = true) + awaitWorker() + + assertThat(CrashDaemonShadow.sends).containsExactly(listOf(envelope.path, 2000L)) + assertThat(marker().exists()).isFalse() + } + + private fun addExitInfo( + context: Context, + pid: Int, + timestamp: Long, + reason: Int = ApplicationExitInfo.REASON_CRASH_NATIVE, + trace: InputStream? = null, + ) { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + shadowOf(activityManager) + .addApplicationExitInfo( + ApplicationExitInfoBuilder.newBuilder() + .setPid(pid) + .setReason(reason) + .setTimestamp(timestamp) + .setTraceInputStream(trace) + .build() + ) + } + + private fun configureDaemon( + service: SentryNdkCrashService, + pid: Int, + fd: Int, + tombstoneEnabled: Boolean = false, + ) { + val configured = + service.configureDaemon( + "db", + null, + "shm-$pid", + pid, + 0L, + tombstoneEnabled, + false, + 2000L, + null, + null, + descriptor(fd), + descriptor(fd + 1), + descriptor(fd + 2), + ) + assertThat(configured).isTrue() + } + + private fun writeNativeEnvelope(timestamp: Long): File { + val event = SentryEvent(DateUtils.getDateTime(timestamp)).apply { platform = "native" } + val file = File(app.cacheDir, ".sentry-native/test.run/${event.eventId}.envelope") + file.parentFile!!.mkdirs() + file.outputStream().use { + options.serializer.serialize(SentryEnvelope.from(options.serializer, event, null), it) + } + return file + } + + private fun tombstone(): InputStream = + GZIPInputStream(File("../sentry-android-core/src/test/resources/tombstone.pb.gz").inputStream()) + + private fun crashDaemon(envelope: File, result: CrashDaemon.SendResult) { + CrashDaemonShadow.envelopePath = envelope.path + CrashDaemonShadow.result = result + CrashDaemonShadow.sends.clear() + } + + private fun awaitWorker() { + Thread.getAllStackTraces() + .keys + .filter { it.name == "SentryNdkCrashService" } + .forEach { it.join(5000) } + } + + private fun marker(): File = File(app.cacheDir, AndroidEnvelopeCache.LAST_TOMBSTONE_REPORT) + + private fun descriptor(fd: Int): ParcelFileDescriptor = mock { on { detachFd() } doReturn fd } + + private fun configure(configured: Boolean) { + val binder = + object : Binder() { + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { + reply?.writeNoException() + reply?.writeInt(if (configured) 1 else 0) + return true + } + } + shadowOf(app).setComponentNameAndServiceForBindService(component, binder) + } +} + +@Implements(io.sentry.ndk.SentryNdk::class) +class BlockingCrashDaemonShadow { + companion object { + val daemons = LinkedBlockingQueue>() + val release = CountDownLatch(1) + + @Suppress("unused") + @Implementation + @JvmStatic + fun runCrashDaemon( + appPid: Int, + appTid: Long, + notifyFd: Int, + readyFd: Int, + messageFd: Int, + shmPath: String, + ): CrashDaemon? { + daemons.add(listOf(appPid, notifyFd, readyFd, messageFd, shmPath)) + release.await(10, TimeUnit.SECONDS) + return null + } + } +} + +@Implements(io.sentry.ndk.SentryNdk::class) +class CrashDaemonShadow { + companion object { + var envelopePath = "" + var result = CrashDaemon.SendResult.SENT + val sends = mutableListOf>() + + @Suppress("unused") @Implementation @JvmStatic fun loadNativeLibraries() {} + + @Suppress("unused", "UNUSED_PARAMETER") + @Implementation + @JvmStatic + fun nativeRunCrashDaemon( + appPid: Int, + appTid: Long, + notifyFd: Int, + readyFd: Int, + messageFd: Int, + shmPath: String, + ): Long = 1L + + @Suppress("unused", "UNUSED_PARAMETER") + @Implementation + @JvmStatic + fun nativeGetCrashEnvelopePath(handle: Long): String = envelopePath + + @Suppress("unused", "UNUSED_PARAMETER") + @Implementation + @JvmStatic + fun nativeSendEnvelope(handle: Long, path: String, timeout: Long): Int { + sends.add(listOf(path, timeout)) + return result.ordinal + } + } +} diff --git a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryShadowParcelFileDescriptor.kt b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryShadowParcelFileDescriptor.kt new file mode 100644 index 00000000000..229a4e70031 --- /dev/null +++ b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryShadowParcelFileDescriptor.kt @@ -0,0 +1,12 @@ +package io.sentry.android.ndk + +import android.os.ParcelFileDescriptor +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowParcelFileDescriptor + +// the real detachFd needs Android's FileDescriptor ownership API, missing on the JVM +@Implements(ParcelFileDescriptor::class) +class SentryShadowParcelFileDescriptor : ShadowParcelFileDescriptor() { + @Suppress("unused") @Implementation fun detachFd(): Int = -1 +} diff --git a/sentry-android-ndk/api/sentry-android-ndk.api b/sentry-android-ndk/api/sentry-android-ndk.api index a7c5571d0bb..3de4c0802d6 100644 --- a/sentry-android-ndk/api/sentry-android-ndk.api +++ b/sentry-android-ndk/api/sentry-android-ndk.api @@ -2,6 +2,7 @@ public final class io/sentry/android/ndk/BuildConfig { public static final field BUILD_TYPE Ljava/lang/String; public static final field DEBUG Z public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public static final field SENTRY_PACKAGE_NAME Ljava/lang/String; public static final field VERSION_NAME Ljava/lang/String; public fun ()V } @@ -28,6 +29,7 @@ public final class io/sentry/android/ndk/NdkScopeObserver : io/sentry/ScopeObser public final class io/sentry/android/ndk/SentryNdk { public static fun close ()V + public static fun init (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;)V public static fun init (Lio/sentry/android/core/SentryAndroidOptions;)V } diff --git a/sentry-android-ndk/build.gradle.kts b/sentry-android-ndk/build.gradle.kts index dff85906ca1..5f736bf662f 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -20,6 +20,7 @@ android { // for AGP 4.1 buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") + buildConfigField("String", "SENTRY_PACKAGE_NAME", "\"${project.name}\"") } buildTypes { @@ -78,3 +79,8 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(projects.sentryTestSupport) } + +extensions.configure { + sourceSets.getByName("main").java.srcDir("src/inproc/java") + sourceSets.getByName("test").java.srcDir("src/inprocTest/java") +} diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java b/sentry-android-ndk/src/inproc/java/io/sentry/android/ndk/SentryNdk.java similarity index 93% rename from sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java rename to sentry-android-ndk/src/inproc/java/io/sentry/android/ndk/SentryNdk.java index fae652f7a64..e2339ca911d 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java +++ b/sentry-android-ndk/src/inproc/java/io/sentry/android/ndk/SentryNdk.java @@ -1,5 +1,6 @@ package io.sentry.android.ndk; +import android.content.Context; import io.sentry.android.core.NdkHandlerStrategy; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.ndk.NativeModuleListLoader; @@ -41,6 +42,17 @@ private SentryNdk() {} * @param options the SentryAndroidOptions */ public static void init(@NotNull final SentryAndroidOptions options) { + init(null, options); + } + + /** + * Init the NDK integration + * + * @param context the Android context + * @param options the SentryAndroidOptions + */ + public static void init( + @Nullable final Context context, @NotNull final SentryAndroidOptions options) { SentryNdkUtil.addPackage(options.getSdkVersion()); try { if (loadLibraryLatch.await(2000, TimeUnit.MILLISECONDS)) { diff --git a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt b/sentry-android-ndk/src/inprocTest/java/io/sentry/android/ndk/SentryNdkTest.kt similarity index 100% rename from sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt rename to sentry-android-ndk/src/inprocTest/java/io/sentry/android/ndk/SentryNdkTest.kt diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdkUtil.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdkUtil.java index 9f92d2d4d79..182c5ceb867 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdkUtil.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdkUtil.java @@ -11,7 +11,7 @@ final class SentryNdkUtil { static { SentryIntegrationPackageStorage.getInstance() - .addPackage("maven:io.sentry:sentry-android-ndk", BuildConfig.VERSION_NAME); + .addPackage("maven:io.sentry:" + BuildConfig.SENTRY_PACKAGE_NAME, BuildConfig.VERSION_NAME); } private SentryNdkUtil() {} @@ -25,6 +25,7 @@ static void addPackage(@Nullable final SdkVersion sdkVersion) { if (sdkVersion == null) { return; } - sdkVersion.addPackage("maven:io.sentry:sentry-android-ndk", BuildConfig.VERSION_NAME); + sdkVersion.addPackage( + "maven:io.sentry:" + BuildConfig.SENTRY_PACKAGE_NAME, BuildConfig.VERSION_NAME); } } diff --git a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkUtilTest.kt b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkUtilTest.kt index a3f5e9a8f93..c4ca0203a67 100644 --- a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkUtilTest.kt +++ b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkUtilTest.kt @@ -12,8 +12,8 @@ class SentryNdkUtilTest { SentryNdkUtil.addPackage(options.sdkVersion) assertTrue( options.sdkVersion!!.packageSet.any { - it.name == "maven:io.sentry:sentry-android-ndk" - it.version == BuildConfig.VERSION_NAME + it.name == "maven:io.sentry:${BuildConfig.SENTRY_PACKAGE_NAME}" && + it.version == BuildConfig.VERSION_NAME } ) } diff --git a/settings.gradle.kts b/settings.gradle.kts index 413ab5fd59b..059761eec34 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -47,6 +47,7 @@ include( "sentry-android-distribution", "sentry-android-core", "sentry-android-ndk", + "sentry-android-ndk-outproc", "sentry-android", "sentry-android-timber", "sentry-android-fragment",