Skip to content
Merged
11 changes: 11 additions & 0 deletions sentry-android-distribution/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ android {

defaultConfig { minSdk = libs.versions.minSdk.get().toInt() }
buildFeatures { buildConfig = false }

testOptions {
unitTests.apply {
isReturnDefaultValues = true
isIncludeAndroidResources = true
}
}
}

kotlin {
Expand All @@ -29,4 +36,8 @@ dependencies {
libs.jetbrains.annotations
) // Use implementation instead of compileOnly to override kotlin stdlib's version
implementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid))
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.roboelectric)
testImplementation(libs.kotlin.test.junit)
testImplementation(libs.androidx.test.core)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package io.sentry.android.distribution

import io.sentry.SentryLevel
import io.sentry.SentryOptions
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import javax.net.ssl.HttpsURLConnection

/** HTTP client for making requests to Sentry's distribution API. */
internal class DistributionHttpClient(private val options: SentryOptions) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had claude generate an HttpClient for me. It works. I'm just not sure if it is a good idea to develop another client.

The other alternative is to expand the existing HttpConnection to support our use case OR shadow oktthp in to our library. Open to thoughts!


/** Represents the result of an HTTP request. */
data class HttpResponse(
val statusCode: Int,
val body: String,
val isSuccessful: Boolean = statusCode in 200..299,
)

/** Parameters for checking updates. */
data class UpdateCheckParams(
val mainBinaryIdentifier: String,
val appId: String,
val platform: String = "android",
val versionCode: Long,
val versionName: String,
)

/**
* Makes a GET request to the distribution API to check for updates.
*
* @param params Update check parameters
* @return HttpResponse containing the response details
*/
fun checkForUpdates(params: UpdateCheckParams): HttpResponse {
val distributionOptions = options.distribution
val orgSlug = distributionOptions.orgSlug
val projectSlug = distributionOptions.projectSlug
val authToken = distributionOptions.orgAuthToken
val baseUrl = distributionOptions.sentryBaseUrl

if (orgSlug.isNullOrEmpty() || projectSlug.isNullOrEmpty() || authToken.isNullOrEmpty()) {
throw IllegalStateException(
"Missing required distribution configuration: orgSlug, projectSlug, or orgAuthToken"
)
}

val urlString = buildString {
append(baseUrl.trimEnd('/'))
append(
"/api/0/projects/${URLEncoder.encode(orgSlug, "UTF-8")}/${URLEncoder.encode(projectSlug, "UTF-8")}/preprodartifacts/check-for-updates/"
)
append("?main_binary_identifier=${URLEncoder.encode(params.mainBinaryIdentifier, "UTF-8")}")
append("&app_id=${URLEncoder.encode(params.appId, "UTF-8")}")
append("&platform=${URLEncoder.encode(params.platform, "UTF-8")}")
append("&build_number=${URLEncoder.encode(params.versionCode.toString(), "UTF-8")}")
append("&build_version=${URLEncoder.encode(params.versionName, "UTF-8")}")
}
val url = URL(urlString)

return try {
makeRequest(url, authToken)
} catch (e: IOException) {
options.logger.log(SentryLevel.ERROR, e, "Network error while checking for updates")
throw e
}
}

private fun makeRequest(url: URL, authToken: String): HttpResponse {
val connection = url.openConnection() as HttpURLConnection

try {
connection.requestMethod = "GET"
connection.setRequestProperty("Authorization", "Bearer $authToken")
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty(
"User-Agent",
options.sentryClientName ?: throw IllegalStateException("sentryClientName must be set"),
)
connection.connectTimeout = options.connectionTimeoutMillis
connection.readTimeout = options.readTimeoutMillis

if (connection is HttpsURLConnection && options.sslSocketFactory != null) {
connection.sslSocketFactory = options.sslSocketFactory
}

val responseCode = connection.responseCode
val responseBody = readResponse(connection)

options.logger.log(
SentryLevel.DEBUG,
"Distribution API request completed with status: $responseCode",
)

return HttpResponse(responseCode, responseBody)
} finally {
connection.disconnect()
}
}

private fun readResponse(connection: HttpURLConnection): String {
val inputStream =
if (connection.responseCode in 200..299) {
connection.inputStream
} else {
connection.errorStream ?: connection.inputStream
}

return inputStream?.use { stream ->
BufferedReader(InputStreamReader(stream, "UTF-8")).use { reader -> reader.readText() }
} ?: ""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ package io.sentry.android.distribution

import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import io.sentry.IDistributionApi
import io.sentry.IScopes
import io.sentry.Integration
import io.sentry.SentryLevel
import io.sentry.SentryOptions
import io.sentry.UpdateInfo
import io.sentry.UpdateStatus
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import org.jetbrains.annotations.ApiStatus

/**
Expand All @@ -24,6 +29,9 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut
private lateinit var sentryOptions: SentryOptions
private val context: Context = context.applicationContext

private lateinit var httpClient: DistributionHttpClient
private lateinit var responseParser: UpdateResponseParser

/**
* Registers the Distribution integration with Sentry.
*
Expand All @@ -34,6 +42,10 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut
// Store scopes and options for use by distribution functionality
this.scopes = scopes
this.sentryOptions = options

// Initialize HTTP client and response parser
this.httpClient = DistributionHttpClient(options)
this.responseParser = UpdateResponseParser(options)
}

/**
Expand All @@ -44,7 +56,31 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut
* @return UpdateStatus indicating if an update is available, up to date, or error
*/
public override fun checkForUpdateBlocking(): UpdateStatus {
throw NotImplementedError()
return try {
sentryOptions.logger.log(SentryLevel.DEBUG, "Checking for distribution updates")

val params = createUpdateCheckParams()
val response = httpClient.checkForUpdates(params)
responseParser.parseResponse(response.statusCode, response.body)
} catch (e: IllegalStateException) {
sentryOptions.logger.log(SentryLevel.WARNING, e.message ?: "Configuration error")
UpdateStatus.UpdateError(e.message ?: "Configuration error")
} catch (e: UnknownHostException) {
// UnknownHostException typically indicates no internet connection available
sentryOptions.logger.log(
SentryLevel.ERROR,
e,
"DNS lookup failed - check internet connection",
)
UpdateStatus.NoNetwork("No internet connection or invalid server URL")
} catch (e: SocketTimeoutException) {
// SocketTimeoutException could indicate either slow network or server issues
sentryOptions.logger.log(SentryLevel.ERROR, e, "Network request timed out")
UpdateStatus.NoNetwork("Request timed out - check network connection")
} catch (e: Exception) {
sentryOptions.logger.log(SentryLevel.ERROR, e, "Unexpected error checking for updates")
UpdateStatus.UpdateError("Unexpected error: ${e.message}")
}
}

/**
Expand Down Expand Up @@ -75,4 +111,37 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut
// Silently fail as this is expected behavior in some environments
}
}

private fun createUpdateCheckParams(): DistributionHttpClient.UpdateCheckParams {
return try {
val packageManager = context.packageManager
val packageName = context.packageName
val packageInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0))
} else {
@Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, 0)
}

val versionName = packageInfo.versionName ?: "unknown"
val versionCode =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode
} else {
@Suppress("DEPRECATION") packageInfo.versionCode.toLong()
}
val appId = context.applicationInfo.packageName

DistributionHttpClient.UpdateCheckParams(
mainBinaryIdentifier = appId,
appId = appId,
platform = "android",
versionCode = versionCode,
versionName = versionName,
)
} catch (e: PackageManager.NameNotFoundException) {
sentryOptions.logger.log(SentryLevel.ERROR, e, "Failed to get package info")
throw IllegalStateException("Unable to get app package information", e)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package io.sentry.android.distribution

import io.sentry.SentryLevel
import io.sentry.SentryOptions
import io.sentry.UpdateInfo
import io.sentry.UpdateStatus
import org.json.JSONException
import org.json.JSONObject

/** Parser for distribution API responses. */
internal class UpdateResponseParser(private val options: SentryOptions) {

/**
* Parses the API response and returns the appropriate UpdateStatus.
*
* @param statusCode HTTP status code
* @param responseBody Response body as string
* @return UpdateStatus indicating the result
*/
fun parseResponse(statusCode: Int, responseBody: String): UpdateStatus {
return when (statusCode) {
200 -> parseSuccessResponse(responseBody)
in 400..499 -> UpdateStatus.UpdateError("Client error: $statusCode")
in 500..599 -> UpdateStatus.UpdateError("Server error: $statusCode")
else -> UpdateStatus.UpdateError("Unexpected response code: $statusCode")
}
}

private fun parseSuccessResponse(responseBody: String): UpdateStatus {
return try {
val json = JSONObject(responseBody)

options.logger.log(SentryLevel.DEBUG, "Parsing distribution API response")

// Check if there's a new release available
val updateAvailable = json.optBoolean("updateAvailable", false)

if (updateAvailable) {
val updateInfo = parseUpdateInfo(json)
UpdateStatus.NewRelease(updateInfo)
} else {
UpdateStatus.UpToDate.getInstance()
}
} catch (e: JSONException) {
options.logger.log(SentryLevel.ERROR, e, "Failed to parse API response")
UpdateStatus.UpdateError("Invalid response format: ${e.message}")
} catch (e: Exception) {
options.logger.log(SentryLevel.ERROR, e, "Unexpected error parsing response")
UpdateStatus.UpdateError("Failed to parse response: ${e.message}")
}
}

private fun parseUpdateInfo(json: JSONObject): UpdateInfo {
val id = json.optString("id", "")
val buildVersion = json.optString("buildVersion", "")
val buildNumber = json.optInt("buildNumber", 0)
val downloadUrl = json.optString("downloadUrl", "")
val appName = json.optString("appName", "")
val createdDate = json.optString("createdDate", "")

// Validate required fields (optString returns "null" for null values)
val missingFields = mutableListOf<String>()

if (id.isEmpty() || id == "null") {
missingFields.add("id")
}
if (buildVersion.isEmpty() || buildVersion == "null") {
missingFields.add("buildVersion")
}
if (downloadUrl.isEmpty() || downloadUrl == "null") {
missingFields.add("downloadUrl")
}

if (missingFields.isNotEmpty()) {
throw IllegalArgumentException(
"Missing required fields in API response: ${missingFields.joinToString(", ")}"
)
}

return UpdateInfo(id, buildVersion, buildNumber, downloadUrl, appName, createdDate)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Empty String Passed for Nullable Field

The UpdateResponseParser passes an empty string for createdDate when it's missing from the API response. This contradicts the UpdateInfo constructor's createdDate parameter being nullable, as a missing optional field is represented by "" instead of null.

Fix in Cursor Fix in Web

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package io.sentry.android.distribution

import io.sentry.SentryOptions
import org.junit.Assert.*
import org.junit.Before
import org.junit.Ignore
import org.junit.Test

class DistributionHttpClientTest {
Copy link
Contributor Author

@runningcode runningcode Sep 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a test class I used to test the integration. In a future, SAGP will inject the authToken and other things so we won't need to specify this manually. I'll also integrate distribution in to the sample app.


private lateinit var options: SentryOptions
private lateinit var httpClient: DistributionHttpClient

@Before
fun setUp() {
options =
SentryOptions().apply {
connectionTimeoutMillis = 10000
readTimeoutMillis = 10000
}

options.distribution.apply {
orgSlug = "sentry"
projectSlug = "launchpad-test"
orgAuthToken = "DONT_CHECK_THIS_IN"
sentryBaseUrl = "https://sentry.io"
}

httpClient = DistributionHttpClient(options)
}

@Test
@Ignore("This is just used for testing against the real API.")
fun `test checkForUpdates with real API`() {
val params =
DistributionHttpClient.UpdateCheckParams(
mainBinaryIdentifier = "com.emergetools.hackernews",
appId = "com.emergetools.hackernews",
versionName = "1.0.0",
versionCode = 5L,
)

val response = httpClient.checkForUpdates(params)

// Print response for debugging
println("HTTP Status: ${response.statusCode}")
println("Response Body: ${response.body}")
println("Is Successful: ${response.isSuccessful}")

// Basic assertions
assertTrue("Response should have a status code", response.statusCode > 0)
assertNotNull("Response body should not be null", response.body)
}
}
Loading
Loading