-
-
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 all commits
6524e88
4b092f5
b1f9aa1
bcefa15
2959cdb
b7da7e4
0cae425
b238c29
ff0acf5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) { | ||
|
|
||
| /** 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")}") | ||
| } | ||
runningcode marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 |
|---|---|---|
| @@ -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) { | ||
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 (optString returns "null" for null values) | ||
runningcode marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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) | ||
|
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", | ||
| 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) | ||
| } | ||
| } | ||
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!