Skip to content
Merged
Next Next commit
feat(android-distribution): implement checkForUpdateBlocking function…
…ality

Implements the checkForUpdateBlocking method in DistributionIntegration to check for app updates via Sentry's distribution API.

## Why not reuse existing HttpConnection?

The existing `HttpConnection` class is designed specifically for Sentry event transport and is not suitable for distribution API calls:
- Hardcoded for POST requests (we need GET)
- Expects Sentry envelopes with gzip encoding (we need simple JSON)
- Only considers status 200 successful (REST APIs use 200-299 range)
- Includes Sentry-specific rate limiting logic

## Changes

- **DistributionHttpClient**: New HTTP client for distribution API requests
  - Supports GET requests with query parameters (main_binary_identifier, app_id, platform, version)
  - Uses SentryOptions.DistributionOptions for configuration (orgSlug, projectSlug, orgAuthToken)
  - Handles SSL configuration, timeouts, and proper error handling

- **UpdateResponseParser**: JSON response parser for API responses
  - Parses API responses into UpdateStatus objects (UpToDate, NewRelease, UpdateError)
  - Handles various HTTP status codes with appropriate error messages
  - Validates required fields in update information

- **DistributionIntegration**: Updated to use new classes
  - Automatically extracts app information (package name, version) from Android context
  - Clean separation of concerns with HTTP client and response parser
  - Comprehensive error handling and logging

- **Tests**: Added unit test for DistributionHttpClient with real API integration

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
  • Loading branch information
runningcode and claude committed Sep 29, 2025
commit 6524e8814fdd4f6062147de86c1826b6984c6a24
1 change: 1 addition & 0 deletions sentry-android-distribution/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ 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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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 version: 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.isEmpty() || projectSlug.isEmpty() || authToken.isEmpty()) {
throw IllegalStateException(
"Missing required distribution configuration: orgSlug, projectSlug, or orgAuthToken"
)
}

val queryParams = buildQueryParams(params)
val url =
URL(
"$baseUrl/api/0/projects/$orgSlug/$projectSlug/preprodartifacts/check-for-updates/?$queryParams"
)

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 {
// Configure connection
connection.requestMethod = "GET"
connection.setRequestProperty("Authorization", "Bearer $authToken")
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty(
"User-Agent",
options.sentryClientName ?: "sentry-android-distribution",
)
connection.connectTimeout = options.connectionTimeoutMillis
connection.readTimeout = options.readTimeoutMillis

// Set SSL socket factory if available
if (connection is HttpsURLConnection && options.sslSocketFactory != null) {
connection.sslSocketFactory = options.sslSocketFactory
}

// Get response
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() }
} ?: ""
}

private fun buildQueryParams(params: UpdateCheckParams): String {
val queryParams = mutableListOf<String>()

queryParams.add(
"main_binary_identifier=${URLEncoder.encode(params.mainBinaryIdentifier, "UTF-8")}"
)
queryParams.add("app_id=${URLEncoder.encode(params.appId, "UTF-8")}")
queryParams.add("platform=${URLEncoder.encode(params.platform, "UTF-8")}")
queryParams.add("version=${URLEncoder.encode(params.version, "UTF-8")}")

return queryParams.joinToString("&")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ 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
Expand All @@ -24,6 +27,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 +40,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 +54,19 @@ 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: Exception) {
sentryOptions.logger.log(SentryLevel.ERROR, e, "Failed to check for updates")
UpdateStatus.UpdateError("Network error: ${e.message}")
Copy link
Contributor

Choose a reason for hiding this comment

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

It might be nice to be able to distinguish between 'not connected' and 'network error' in the return type if that is possible and easy.

Copy link
Contributor Author

@runningcode runningcode Sep 22, 2025

Choose a reason for hiding this comment

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

UnknownHostException would mean there is possibly no network and SocketTimeoutException could mean a bad network. I added those to distinguish here.

}
}

/**
Expand Down Expand Up @@ -75,4 +97,30 @@ 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 appId = context.applicationInfo.packageName

DistributionHttpClient.UpdateCheckParams(
mainBinaryIdentifier = appId, // Using package name as binary identifier
appId = appId,
platform = "android",
version = 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,68 @@
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
if (id.isEmpty() || buildVersion.isEmpty() || downloadUrl.isEmpty()) {
throw IllegalArgumentException("Missing required update information in API response")
}

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",
platform = "android",
version = "1.0.0",
)

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)
}
}