diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 802a8bfb118..99d6b5115c8 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -77,6 +77,7 @@ dependencies { compileOnly(projects.sentryAndroidTimber) compileOnly(projects.sentryAndroidReplay) compileOnly(projects.sentryCompose) + compileOnly(projects.sentryAndroidDistribution) // lifecycle processor, session tracking implementation(libs.androidx.lifecycle.common.java8) diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index 0c6d47e5ecb..b22a4574320 100644 --- a/sentry-android-core/proguard-rules.pro +++ b/sentry-android-core/proguard-rules.pro @@ -78,3 +78,8 @@ -dontwarn io.sentry.android.replay.DefaultReplayBreadcrumbConverter -keepnames class io.sentry.android.replay.ReplayIntegration ##---------------End: proguard configuration for sentry-android-replay ---------- + +##---------------Begin: proguard configuration for sentry-android-distribution ---------- +-dontwarn io.sentry.android.distribution.internal.DistributionIntegration +-keepnames class io.sentry.android.distribution.internal.DistributionIntegration +##---------------End: proguard configuration for sentry-android-distribution ---------- 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 21dde74d3ee..0feed1ca71d 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 @@ -32,6 +32,7 @@ import io.sentry.android.core.internal.util.AndroidThreadChecker; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.android.distribution.internal.DistributionIntegration; import io.sentry.android.fragment.FragmentLifecycleIntegration; import io.sentry.android.replay.DefaultReplayBreadcrumbConverter; import io.sentry.android.replay.ReplayIntegration; @@ -321,7 +322,8 @@ static void installDefaultIntegrations( final @NotNull ActivityFramesTracker activityFramesTracker, final boolean isFragmentAvailable, final boolean isTimberAvailable, - final boolean isReplayAvailable) { + final boolean isReplayAvailable, + final boolean isDistributionAvailable) { // Integration MUST NOT cache option values in ctor, as they will be configured later by the // user @@ -391,6 +393,9 @@ static void installDefaultIntegrations( options.addIntegration(replay); options.setReplayController(replay); } + if (isDistributionAvailable) { + options.addIntegration(new DistributionIntegration()); + } options .getFeedbackOptions() .setDialogHandler(new SentryAndroidOptions.AndroidUserFeedbackIDialogHandler()); 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 d183d4c45be..ac025653037 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 @@ -41,6 +41,9 @@ public final class SentryAndroid { static final String SENTRY_REPLAY_INTEGRATION_CLASS_NAME = "io.sentry.android.replay.ReplayIntegration"; + static final String SENTRY_DISTRIBUTION_INTEGRATION_CLASS_NAME = + "io.sentry.android.distribution.internal.DistributionIntegration"; + private static final String TIMBER_CLASS_NAME = "timber.log.Timber"; private static final String FRAGMENT_CLASS_NAME = "androidx.fragment.app.FragmentManager$FragmentLifecycleCallbacks"; @@ -111,6 +114,8 @@ public static void init( && classLoader.isClassAvailable(SENTRY_TIMBER_INTEGRATION_CLASS_NAME, options)); final boolean isReplayAvailable = classLoader.isClassAvailable(SENTRY_REPLAY_INTEGRATION_CLASS_NAME, options); + final boolean isDistributionAvailable = + classLoader.isClassAvailable(SENTRY_DISTRIBUTION_INTEGRATION_CLASS_NAME, options); final BuildInfoProvider buildInfoProvider = new BuildInfoProvider(logger); final io.sentry.util.LoadClass loadClass = new io.sentry.util.LoadClass(); @@ -131,7 +136,8 @@ public static void init( activityFramesTracker, isFragmentAvailable, isTimberAvailable, - isReplayAvailable); + isReplayAvailable, + isDistributionAvailable); try { configuration.configure(options); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt index 8539d271430..34fc60d3634 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt @@ -124,6 +124,7 @@ class AndroidContinuousProfilerTest { false, false, false, + false, ) AndroidOptionsInitializer.initializeIntegrationsAndProcessors( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index cd1a7cc26de..79b5ce39be1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -105,6 +105,7 @@ class AndroidOptionsInitializerTest { false, false, false, + false, ) sentryOptions.configureOptions() @@ -149,6 +150,7 @@ class AndroidOptionsInitializerTest { isFragmentAvailable, isTimberAvailable, isReplayAvailable, + false, ) AndroidOptionsInitializer.initializeIntegrationsAndProcessors( @@ -820,6 +822,7 @@ class AndroidOptionsInitializerTest { false, false, false, + false, ) verify(mockOptions, never()).outboxPath verify(mockOptions, never()).cacheDirPath diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt index f4b4da814b8..dce66ae2b5e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt @@ -127,6 +127,7 @@ class AndroidProfilerTest { false, false, false, + false, ) AndroidOptionsInitializer.initializeIntegrationsAndProcessors( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt index ec6ba18d65e..50c7ba3c7d3 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt @@ -143,6 +143,7 @@ class AndroidTransactionProfilerTest { false, false, false, + false, ) AndroidOptionsInitializer.initializeIntegrationsAndProcessors( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt index fec0649f2fc..63a6ff8cb60 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt @@ -160,6 +160,7 @@ class SentryInitProviderTest { false, false, false, + false, ) AndroidOptionsInitializer.initializeIntegrationsAndProcessors( diff --git a/sentry-android-distribution/api/sentry-android-distribution.api b/sentry-android-distribution/api/sentry-android-distribution.api index 020a99620bd..a225fa8c287 100644 --- a/sentry-android-distribution/api/sentry-android-distribution.api +++ b/sentry-android-distribution/api/sentry-android-distribution.api @@ -1,4 +1,79 @@ public final class io/sentry/android/distribution/Distribution { + public static final field INSTANCE Lio/sentry/android/distribution/Distribution; + public final fun checkForUpdate (Landroid/content/Context;Lkotlin/jvm/functions/Function1;)V + public final fun checkForUpdateBlocking (Landroid/content/Context;)Lio/sentry/android/distribution/UpdateStatus; + public final fun downloadUpdate (Landroid/content/Context;Lio/sentry/android/distribution/UpdateInfo;)V + public final fun init (Landroid/content/Context;)V + public final fun init (Landroid/content/Context;Lkotlin/jvm/functions/Function1;)V + public final fun isEnabled ()Z +} + +public final class io/sentry/android/distribution/DistributionOptions { + public fun ()V + public final fun getBuildConfiguration ()Ljava/lang/String; + public final fun getOrgAuthToken ()Ljava/lang/String; + public final fun getOrganizationSlug ()Ljava/lang/String; + public final fun getProjectSlug ()Ljava/lang/String; + public final fun getSentryBaseUrl ()Ljava/lang/String; + public final fun setBuildConfiguration (Ljava/lang/String;)V + public final fun setOrgAuthToken (Ljava/lang/String;)V + public final fun setOrganizationSlug (Ljava/lang/String;)V + public final fun setProjectSlug (Ljava/lang/String;)V + public final fun setSentryBaseUrl (Ljava/lang/String;)V +} + +public final class io/sentry/android/distribution/UpdateInfo { + public fun (Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()I + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/sentry/android/distribution/UpdateInfo; + public static synthetic fun copy$default (Lio/sentry/android/distribution/UpdateInfo;Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/sentry/android/distribution/UpdateInfo; + public fun equals (Ljava/lang/Object;)Z + public final fun getAppName ()Ljava/lang/String; + public final fun getBuildNumber ()I + public final fun getBuildVersion ()Ljava/lang/String; + public final fun getCreatedDate ()Ljava/lang/String; + public final fun getDownloadUrl ()Ljava/lang/String; + public final fun getId ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract class io/sentry/android/distribution/UpdateStatus { +} + +public final class io/sentry/android/distribution/UpdateStatus$Error : io/sentry/android/distribution/UpdateStatus { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lio/sentry/android/distribution/UpdateStatus$Error; + public static synthetic fun copy$default (Lio/sentry/android/distribution/UpdateStatus$Error;Ljava/lang/String;ILjava/lang/Object;)Lio/sentry/android/distribution/UpdateStatus$Error; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessage ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/android/distribution/UpdateStatus$NewRelease : io/sentry/android/distribution/UpdateStatus { + public fun (Lio/sentry/android/distribution/UpdateInfo;)V + public final fun component1 ()Lio/sentry/android/distribution/UpdateInfo; + public final fun copy (Lio/sentry/android/distribution/UpdateInfo;)Lio/sentry/android/distribution/UpdateStatus$NewRelease; + public static synthetic fun copy$default (Lio/sentry/android/distribution/UpdateStatus$NewRelease;Lio/sentry/android/distribution/UpdateInfo;ILjava/lang/Object;)Lio/sentry/android/distribution/UpdateStatus$NewRelease; + public fun equals (Ljava/lang/Object;)Z + public final fun getInfo ()Lio/sentry/android/distribution/UpdateInfo; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/android/distribution/UpdateStatus$UpToDate : io/sentry/android/distribution/UpdateStatus { + public static final field INSTANCE Lio/sentry/android/distribution/UpdateStatus$UpToDate; +} + +public final class io/sentry/android/distribution/internal/DistributionIntegration : io/sentry/Integration { public fun ()V + public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V } diff --git a/sentry-android-distribution/src/main/AndroidManifest.xml b/sentry-android-distribution/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..9a40236b947 --- /dev/null +++ b/sentry-android-distribution/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/Distribution.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/Distribution.kt index 396cb02e131..85ddcecc96a 100644 --- a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/Distribution.kt +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/Distribution.kt @@ -1,3 +1,87 @@ package io.sentry.android.distribution -public class Distribution {} +import android.content.Context +import android.content.Intent +import android.net.Uri +import io.sentry.android.distribution.internal.DistributionInternal + +/** + * The public Android SDK for Sentry Build Distribution. + * + * Provides functionality to check for app updates and download new versions from Sentry's preprod + * artifacts system. + */ +public object Distribution { + /** + * Initialize build distribution with default options. This should be called once per process, + * typically in Application.onCreate(). + * + * @param context Android context + */ + public fun init(context: Context) { + init(context) {} + } + + /** + * Initialize build distribution with the provided configuration. This should be called once per + * process, typically in Application.onCreate(). + * + * @param context Android context + * @param configuration Configuration handler for build distribution options + */ + public fun init(context: Context, configuration: (DistributionOptions) -> Unit) { + val options = DistributionOptions() + configuration(options) + DistributionInternal.init(context, options) + } + + /** + * Check if build distribution is enabled and properly configured. + * + * @return true if build distribution is enabled + */ + public fun isEnabled(): Boolean { + return DistributionInternal.isEnabled() + } + + /** + * Check for available updates synchronously (blocking call). This method will block the calling + * thread while making the network request. Consider using checkForUpdate with callback for + * non-blocking behavior. + * + * @param context Android context + * @return UpdateStatus indicating if an update is available, up to date, or error + */ + public fun checkForUpdateBlocking(context: Context): UpdateStatus { + return DistributionInternal.checkForUpdateBlocking(context) + } + + /** + * Check for available updates asynchronously using a callback. + * + * @param context Android context + * @param onResult Callback that will be called with the UpdateStatus result + */ + public fun checkForUpdate(context: Context, onResult: (UpdateStatus) -> Unit) { + DistributionInternal.checkForUpdateAsync(context, onResult) + } + + /** + * Download and install the provided update by opening the download URL in the default browser or + * appropriate application. + * + * @param context Android context + * @param info Information about the update to download + */ + public fun downloadUpdate(context: Context, info: UpdateInfo) { + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(info.downloadUrl)) + browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + try { + context.startActivity(browserIntent) + } catch (e: android.content.ActivityNotFoundException) { + // No application can handle the HTTP/HTTPS URL, typically no browser installed + // Silently fail as this is expected behavior in some environments + } + } +} diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionOptions.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionOptions.kt new file mode 100644 index 00000000000..06fa10c7839 --- /dev/null +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionOptions.kt @@ -0,0 +1,19 @@ +package io.sentry.android.distribution + +/** Configuration options for Sentry Build Distribution. */ +public class DistributionOptions { + /** Organization authentication token for API access */ + public var orgAuthToken: String = "" + + /** Sentry organization slug */ + public var organizationSlug: String = "" + + /** Sentry project slug */ + public var projectSlug: String = "" + + /** Base URL for Sentry API (defaults to https://sentry.io) */ + public var sentryBaseUrl: String = "https://sentry.io" + + /** Optional build configuration name for filtering (e.g., "debug", "release", "staging") */ + public var buildConfiguration: String? = null +} diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateInfo.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateInfo.kt new file mode 100644 index 00000000000..28277e2d629 --- /dev/null +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateInfo.kt @@ -0,0 +1,20 @@ +package io.sentry.android.distribution + +/** + * Information about an available app update. + * + * @param id Unique identifier for this build artifact + * @param buildVersion Version string (e.g., "1.2.0") + * @param buildNumber Build number for this version + * @param downloadUrl URL where the update can be downloaded + * @param appName Application name + * @param createdDate ISO timestamp when this build was created + */ +public data class UpdateInfo( + val id: String, + val buildVersion: String, + val buildNumber: Int, + val downloadUrl: String, + val appName: String, + val createdDate: String, +) diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateStatus.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateStatus.kt new file mode 100644 index 00000000000..8549315b7c9 --- /dev/null +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateStatus.kt @@ -0,0 +1,13 @@ +package io.sentry.android.distribution + +/** Represents the result of checking for app updates. */ +public sealed class UpdateStatus { + /** Current app version is up to date, no update available. */ + public object UpToDate : UpdateStatus() + + /** A new release is available for download. */ + public data class NewRelease(val info: UpdateInfo) : UpdateStatus() + + /** An error occurred during the update check. */ + public data class Error(val message: String) : UpdateStatus() +} diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/internal/DistributionIntegration.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/internal/DistributionIntegration.kt new file mode 100644 index 00000000000..5f3633264c3 --- /dev/null +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/internal/DistributionIntegration.kt @@ -0,0 +1,18 @@ +package io.sentry.android.distribution.internal + +import io.sentry.IScopes +import io.sentry.Integration +import io.sentry.SentryOptions + +/** + * Integration that automatically enables distribution functionality when the module is included. + */ +public class DistributionIntegration : Integration { + public override fun register(scopes: IScopes, options: SentryOptions) { + // Distribution integration automatically enables when module is present + // No configuration needed - just having this class on the classpath enables the feature + + // If needed, we could initialize DistributionInternal here in the future + // For now, Distribution.init() still needs to be called manually + } +} diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/internal/DistributionInternal.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/internal/DistributionInternal.kt new file mode 100644 index 00000000000..648d7c70d07 --- /dev/null +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/internal/DistributionInternal.kt @@ -0,0 +1,28 @@ +package io.sentry.android.distribution.internal + +import android.content.Context +import io.sentry.android.distribution.DistributionOptions +import io.sentry.android.distribution.UpdateStatus + +/** Internal implementation for build distribution functionality. */ +internal object DistributionInternal { + private var isInitialized = false + + @Synchronized + fun init(context: Context, distributionOptions: DistributionOptions) { + // TODO: Implementation will be added in future PR + isInitialized = true + } + + fun isEnabled(): Boolean { + return isInitialized + } + + fun checkForUpdateBlocking(context: Context): UpdateStatus { + return UpdateStatus.Error("Implementation coming in future PR") + } + + fun checkForUpdateAsync(context: Context, onResult: (UpdateStatus) -> Unit) { + throw NotImplementedError() + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 17ccb16829c..fa03326cc26 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2551,6 +2551,7 @@ public final class io/sentry/Sentry { public static fun configureScope (Lio/sentry/ScopeCallback;)V public static fun configureScope (Lio/sentry/ScopeType;Lio/sentry/ScopeCallback;)V public static fun continueTrace (Ljava/lang/String;Ljava/util/List;)Lio/sentry/TransactionContext; + public static fun distribution ()Ljava/lang/Object; public static fun endSession ()V public static fun flush (J)V public static fun forkedCurrentScope (Ljava/lang/String;)Lio/sentry/IScopes; diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 3c04a6aaa96..4097cba9342 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -1301,6 +1301,23 @@ public static IReplayApi replay() { return getCurrentScopes().getScope().getOptions().getReplayController(); } + /** + * Returns the distribution API. This feature is only available when the + * sentry-android-distribution module is included in the build. + * + * @return The distribution API object that provides update checking functionality + */ + public static @Nullable Object distribution() { + try { + // Try to get the Distribution object via reflection + Class distributionClass = Class.forName("io.sentry.android.distribution.Distribution"); + return distributionClass.getField("INSTANCE").get(null); + } catch (Exception e) { + // Distribution module not available, return null + return null; + } + } + public static void showUserFeedbackDialog() { showUserFeedbackDialog(null); }