-
-
Notifications
You must be signed in to change notification settings - Fork 465
feat(android-distribution): add httpclient for checking for build distribution updates #4734
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
6524e88
4b092f5
b1f9aa1
bcefa15
2959cdb
b7da7e4
0cae425
b238c29
ff0acf5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
…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
There are no files selected for viewing
| 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) { | ||
|
|
||
| /** 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" | ||
runningcode marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ) | ||
|
|
||
| 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 | ||
runningcode marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| connection.requestMethod = "GET" | ||
| connection.setRequestProperty("Authorization", "Bearer $authToken") | ||
| connection.setRequestProperty("Accept", "application/json") | ||
| connection.setRequestProperty( | ||
| "User-Agent", | ||
| options.sentryClientName ?: "sentry-android-distribution", | ||
runningcode marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ) | ||
| connection.connectTimeout = options.connectionTimeoutMillis | ||
| connection.readTimeout = options.readTimeoutMillis | ||
|
|
||
| // Set SSL socket factory if available | ||
runningcode marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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 |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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. | ||
| * | ||
|
|
@@ -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) | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -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}") | ||
|
||
| } | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -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 | ||
runningcode marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| appId = appId, | ||
| platform = "android", | ||
| version = versionName, | ||
runningcode marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ) | ||
| } 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) { | ||
chromy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * 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", "") | ||
runningcode marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // Validate required fields | ||
| if (id.isEmpty() || buildVersion.isEmpty() || downloadUrl.isEmpty()) { | ||
| throw IllegalArgumentException("Missing required update information in API response") | ||
runningcode marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| return UpdateInfo(id, buildVersion, buildNumber, downloadUrl, appName, createdDate) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
| } | ||
| 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 { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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
HttpConnectionto support our use case OR shadow oktthp in to our library. Open to thoughts!