From fcdbe4bfa30101f41701c9c7e56a7469f0415e8c Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Thu, 18 Jun 2026 06:24:22 +0200 Subject: [PATCH] chore(util): Add CompileOnlyCompat for compile-only LinkageError fallbacks Introduces a small internal helper for calling compileOnly APIs that may be missing or binary-incompatible at runtime. Adopt it in SentrySQLiteDriver and SentryLayoutNodeHelper to replace ad-hoc try/catch blocks. --- .../viewhierarchy/SentryLayoutNodeHelper.kt | 39 +++++---- .../io/sentry/sqlite/SentrySQLiteDriver.kt | 9 +-- sentry/api/sentry.api | 13 +++ .../io/sentry/util/CompileOnlyCompat.java | 72 +++++++++++++++++ .../io/sentry/util/CompileOnlyCompatTest.kt | 79 +++++++++++++++++++ 5 files changed, 184 insertions(+), 28 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/util/CompileOnlyCompat.java create mode 100644 sentry/src/test/java/io/sentry/util/CompileOnlyCompatTest.kt diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/SentryLayoutNodeHelper.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/SentryLayoutNodeHelper.kt index 6cfe5ec6fc0..334f18f7561 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/SentryLayoutNodeHelper.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/SentryLayoutNodeHelper.kt @@ -10,6 +10,7 @@ package io.sentry.android.replay.viewhierarchy import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.node.NodeCoordinator +import io.sentry.util.CompileOnlyCompat.CompileOnlyCall import java.lang.reflect.Method /** @@ -41,17 +42,13 @@ internal object SentryLayoutNodeHelper { fun getChildren(node: LayoutNode): List { when (useFallback) { false -> return node.children - true -> { - return getFallback().getChildren!!.invoke(node) as List - } - null -> { - try { - return node.children.also { useFallback = false } - } catch (_: NoSuchMethodError) { - useFallback = true - return getFallback().getChildren!!.invoke(node) as List - } - } + true -> return getFallback().getChildren!!.invoke(node) as List + null -> + return CompileOnlyCall { node.children.also { useFallback = false } } + .ifAbsent { + useFallback = true + getFallback().getChildren!!.invoke(node) as List + } } } @@ -63,16 +60,16 @@ internal object SentryLayoutNodeHelper { val coordinator = fb.getOuterCoordinator!!.invoke(node) as NodeCoordinator return coordinator.isTransparent() } - null -> { - try { - return node.outerCoordinator.isTransparent().also { useFallback = false } - } catch (_: NoSuchMethodError) { - useFallback = true - val fb = getFallback() - val coordinator = fb.getOuterCoordinator!!.invoke(node) as NodeCoordinator - return coordinator.isTransparent() - } - } + null -> + return CompileOnlyCall { + node.outerCoordinator.isTransparent().also { useFallback = false } + } + .ifAbsent { + useFallback = true + val fb = getFallback() + val coordinator = fb.getOuterCoordinator!!.invoke(node) as NodeCoordinator + coordinator.isTransparent() + } } } diff --git a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt index 9a619c418a5..e76403898fd 100644 --- a/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt +++ b/sentry-android-sqlite/src/main/java/io/sentry/sqlite/SentrySQLiteDriver.kt @@ -5,6 +5,7 @@ import androidx.sqlite.SQLiteDriver import io.sentry.ScopesAdapter import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel +import io.sentry.util.CompileOnlyCompat.CompileOnlyCall /** * Wraps a [SQLiteDriver] and automatically adds spans for each SQL statement it executes. @@ -36,13 +37,7 @@ internal class SentrySQLiteDriver private constructor(private val delegate: SQLi } override val hasConnectionPool: Boolean - get() = - try { - delegate.hasConnectionPool - } catch (_: LinkageError) { - // Delegates on androidx.sqlite < 2.6.0 won't have a hasConnectionPool property. - false - } + get() = CompileOnlyCall { delegate.hasConnectionPool }.ifAbsent(false) @Suppress("TooGenericExceptionCaught") override fun open(fileName: String): SQLiteConnection { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index e9083350349..3c0d15bedc6 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7628,6 +7628,19 @@ public abstract interface class io/sentry/util/CollectionUtils$Predicate { public abstract fun test (Ljava/lang/Object;)Z } +public final class io/sentry/util/CompileOnlyCompat { +} + +public abstract interface class io/sentry/util/CompileOnlyCompat$CompileOnlyCall { + public abstract fun call ()Ljava/lang/Object; + public fun ifAbsent (Lio/sentry/util/CompileOnlyCompat$Fallback;)Ljava/lang/Object; + public fun ifAbsent (Ljava/lang/Object;)Ljava/lang/Object; +} + +public abstract interface class io/sentry/util/CompileOnlyCompat$Fallback { + public abstract fun call (Ljava/lang/LinkageError;)Ljava/lang/Object; +} + public final class io/sentry/util/DebugMetaPropertiesApplier { public static field DEBUG_META_PROPERTIES_FILENAME Ljava/lang/String; public fun ()V diff --git a/sentry/src/main/java/io/sentry/util/CompileOnlyCompat.java b/sentry/src/main/java/io/sentry/util/CompileOnlyCompat.java new file mode 100644 index 00000000000..a7717ec8d41 --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/CompileOnlyCompat.java @@ -0,0 +1,72 @@ +package io.sentry.util; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Helpers for safely calling methods from {@code compileOnly} dependencies that may not exist at + * runtime. + * + *

When a dependency is {@code compileOnly}, the app supplies the actual version. That version + * may predate the API we compiled against, which means it may not have methods we call from our + * source code if the API has added methods over time. Calling a missing method throws a {@link + * LinkageError} (e.g., {@link NoSuchMethodError}, {@link AbstractMethodError}, etc.). This class + * lets us centralize the try/catch-with-fallback pattern. + * + *

This is not for {@code implementation} dependencies with transitive version conflicts on the + * classpath; use local {@code try/catch} handling for those cases instead. + */ +@ApiStatus.Internal +public final class CompileOnlyCompat { + + /** + * Functional interface for a call that returns a non-null value and may throw a {@link + * LinkageError}. + * + *

Use it when calling from Kotlin: + * + *

{@code
+   * CompileOnlyCall { delegate.hasConnectionPool }.ifAbsent(false)
+   * }
+ */ + @FunctionalInterface + public interface CompileOnlyCall { + + @NotNull + T call(); + + /** + * Invokes this callable and returns its result. If the call throws a {@link LinkageError}, + * returns {@code fallback} instead. + */ + default @NotNull T ifAbsent(final @NotNull T fallback) { + return run(this, error -> fallback); + } + + /** + * Invokes this callable and returns its result. If the call throws a {@link LinkageError}, + * invokes {@code fallback} and returns its result instead. + */ + default @NotNull T ifAbsent(final @NotNull Fallback fallback) { + return run(this, fallback); + } + } + + /** Fallback that receives the caught {@link LinkageError} and returns a non-null value. */ + @FunctionalInterface + public interface Fallback { + @NotNull + T call(@NotNull LinkageError error); + } + + private CompileOnlyCompat() {} + + private static @NotNull T run( + final @NotNull CompileOnlyCall call, final @NotNull Fallback fallback) { + try { + return call.call(); + } catch (LinkageError e) { + return fallback.call(e); + } + } +} diff --git a/sentry/src/test/java/io/sentry/util/CompileOnlyCompatTest.kt b/sentry/src/test/java/io/sentry/util/CompileOnlyCompatTest.kt new file mode 100644 index 00000000000..084920eca25 --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/CompileOnlyCompatTest.kt @@ -0,0 +1,79 @@ +package io.sentry.util + +import io.sentry.util.CompileOnlyCompat.CompileOnlyCall +import io.sentry.util.CompileOnlyCompat.Fallback +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class CompileOnlyCompatTest { + + @Test + fun `ifAbsent returns call result when method exists`() { + val result = CompileOnlyCall { "hello" }.ifAbsent("fallback") + assertEquals("hello", result) + } + + @Test + fun `ifAbsent returns constant fallback on LinkageError`() { + val result = CompileOnlyCall { throw NoSuchMethodError() }.ifAbsent("fallback") + assertEquals("fallback", result) + } + + @Test + fun `ifAbsent does not catch non-LinkageErrors`() { + assertFailsWith { + CompileOnlyCall { throw IllegalStateException() }.ifAbsent("fallback") + } + } + + @Test + fun `ifAbsent with Fallback returns call result when method exists`() { + val result = CompileOnlyCall { "hello" }.ifAbsent(Fallback { _ -> "fallback" }) + assertEquals("hello", result) + } + + @Test + fun `ifAbsent with Fallback invokes fallback on LinkageError`() { + var fallbackInvoked = false + val result = + CompileOnlyCall { throw NoSuchMethodError() } + .ifAbsent { _ -> + fallbackInvoked = true + "fallback" + } + assertEquals("fallback", result) + assertTrue(fallbackInvoked) + } + + @Test + fun `ifAbsent with Fallback passes the LinkageError`() { + var captured: LinkageError? = null + CompileOnlyCall { throw NoSuchMethodError("test") } + .ifAbsent { error -> + captured = error + "fallback" + } + assertTrue(captured is NoSuchMethodError) + assertEquals("test", captured!!.message) + } + + @Test + fun `ifAbsent with Fallback does not invoke fallback on success`() { + var fallbackInvoked = false + CompileOnlyCall { "hello" } + .ifAbsent { _ -> + fallbackInvoked = true + "fallback" + } + assertTrue(!fallbackInvoked) + } + + @Test + fun `ifAbsent with Fallback does not catch non-LinkageErrors`() { + assertFailsWith { + CompileOnlyCall { throw IllegalStateException() }.ifAbsent { _ -> "fallback" } + } + } +}