Skip to content

Commit 57a565d

Browse files
runningcodeclaude
andcommitted
feat(android-distribution): implement checkForUpdateBlocking functionality
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]>
1 parent 323a9e9 commit 57a565d

File tree

5 files changed

+296
-1
lines changed

5 files changed

+296
-1
lines changed

sentry-android-distribution/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ androidComponents.beforeVariants {
2626
dependencies {
2727
implementation(projects.sentry)
2828
implementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid))
29+
testImplementation(libs.androidx.test.ext.junit)
2930
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package io.sentry.android.distribution
2+
3+
import io.sentry.SentryLevel
4+
import io.sentry.SentryOptions
5+
import java.io.BufferedReader
6+
import java.io.IOException
7+
import java.io.InputStreamReader
8+
import java.net.HttpURLConnection
9+
import java.net.URL
10+
import java.net.URLEncoder
11+
import javax.net.ssl.HttpsURLConnection
12+
13+
/** HTTP client for making requests to Sentry's distribution API. */
14+
internal class DistributionHttpClient(private val options: SentryOptions) {
15+
16+
/** Represents the result of an HTTP request. */
17+
data class HttpResponse(
18+
val statusCode: Int,
19+
val body: String,
20+
val isSuccessful: Boolean = statusCode in 200..299,
21+
)
22+
23+
/** Parameters for checking updates. */
24+
data class UpdateCheckParams(
25+
val mainBinaryIdentifier: String,
26+
val appId: String,
27+
val platform: String = "android",
28+
val version: String,
29+
)
30+
31+
/**
32+
* Makes a GET request to the distribution API to check for updates.
33+
*
34+
* @param params Update check parameters
35+
* @return HttpResponse containing the response details
36+
*/
37+
fun checkForUpdates(params: UpdateCheckParams): HttpResponse {
38+
val distributionOptions = options.distribution
39+
val orgSlug = distributionOptions.orgSlug
40+
val projectSlug = distributionOptions.projectSlug
41+
val authToken = distributionOptions.orgAuthToken
42+
val baseUrl = distributionOptions.sentryBaseUrl
43+
44+
if (orgSlug.isEmpty() || projectSlug.isEmpty() || authToken.isEmpty()) {
45+
throw IllegalStateException(
46+
"Missing required distribution configuration: orgSlug, projectSlug, or orgAuthToken"
47+
)
48+
}
49+
50+
val queryParams = buildQueryParams(params)
51+
val url =
52+
URL(
53+
"$baseUrl/api/0/projects/$orgSlug/$projectSlug/preprodartifacts/check-for-updates/?$queryParams"
54+
)
55+
56+
return try {
57+
makeRequest(url, authToken)
58+
} catch (e: IOException) {
59+
options.logger.log(SentryLevel.ERROR, e, "Network error while checking for updates")
60+
throw e
61+
}
62+
}
63+
64+
private fun makeRequest(url: URL, authToken: String): HttpResponse {
65+
val connection = url.openConnection() as HttpURLConnection
66+
67+
try {
68+
// Configure connection
69+
connection.requestMethod = "GET"
70+
connection.setRequestProperty("Authorization", "Bearer $authToken")
71+
connection.setRequestProperty("Accept", "application/json")
72+
connection.setRequestProperty(
73+
"User-Agent",
74+
options.sentryClientName ?: "sentry-android-distribution",
75+
)
76+
connection.connectTimeout = options.connectionTimeoutMillis
77+
connection.readTimeout = options.readTimeoutMillis
78+
79+
// Set SSL socket factory if available
80+
if (connection is HttpsURLConnection && options.sslSocketFactory != null) {
81+
connection.sslSocketFactory = options.sslSocketFactory
82+
}
83+
84+
// Get response
85+
val responseCode = connection.responseCode
86+
val responseBody = readResponse(connection)
87+
88+
options.logger.log(
89+
SentryLevel.DEBUG,
90+
"Distribution API request completed with status: $responseCode",
91+
)
92+
93+
return HttpResponse(responseCode, responseBody)
94+
} finally {
95+
connection.disconnect()
96+
}
97+
}
98+
99+
private fun readResponse(connection: HttpURLConnection): String {
100+
val inputStream =
101+
if (connection.responseCode in 200..299) {
102+
connection.inputStream
103+
} else {
104+
connection.errorStream ?: connection.inputStream
105+
}
106+
107+
return inputStream?.use { stream ->
108+
BufferedReader(InputStreamReader(stream, "UTF-8")).use { reader -> reader.readText() }
109+
} ?: ""
110+
}
111+
112+
private fun buildQueryParams(params: UpdateCheckParams): String {
113+
val queryParams = mutableListOf<String>()
114+
115+
queryParams.add(
116+
"main_binary_identifier=${URLEncoder.encode(params.mainBinaryIdentifier, "UTF-8")}"
117+
)
118+
queryParams.add("app_id=${URLEncoder.encode(params.appId, "UTF-8")}")
119+
queryParams.add("platform=${URLEncoder.encode(params.platform, "UTF-8")}")
120+
queryParams.add("version=${URLEncoder.encode(params.version, "UTF-8")}")
121+
122+
return queryParams.joinToString("&")
123+
}
124+
}

sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionIntegration.kt

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ package io.sentry.android.distribution
22

33
import android.content.Context
44
import android.content.Intent
5+
import android.content.pm.PackageManager
56
import android.net.Uri
7+
import android.os.Build
68
import io.sentry.IDistributionApi
79
import io.sentry.IScopes
810
import io.sentry.Integration
11+
import io.sentry.SentryLevel
912
import io.sentry.SentryOptions
1013
import io.sentry.UpdateInfo
1114
import io.sentry.UpdateStatus
@@ -22,6 +25,9 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut
2225
private lateinit var sentryOptions: SentryOptions
2326
private val context: Context = context.applicationContext
2427

28+
private lateinit var httpClient: DistributionHttpClient
29+
private lateinit var responseParser: UpdateResponseParser
30+
2531
/**
2632
* Registers the Distribution integration with Sentry.
2733
*
@@ -32,6 +38,10 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut
3238
// Store scopes and options for use by distribution functionality
3339
this.scopes = scopes
3440
this.sentryOptions = options
41+
42+
// Initialize HTTP client and response parser
43+
this.httpClient = DistributionHttpClient(options)
44+
this.responseParser = UpdateResponseParser(options)
3545
}
3646

3747
/**
@@ -42,7 +52,19 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut
4252
* @return UpdateStatus indicating if an update is available, up to date, or error
4353
*/
4454
public override fun checkForUpdateBlocking(): UpdateStatus {
45-
throw NotImplementedError()
55+
return try {
56+
sentryOptions.logger.log(SentryLevel.DEBUG, "Checking for distribution updates")
57+
58+
val params = createUpdateCheckParams()
59+
val response = httpClient.checkForUpdates(params)
60+
responseParser.parseResponse(response.statusCode, response.body)
61+
} catch (e: IllegalStateException) {
62+
sentryOptions.logger.log(SentryLevel.WARNING, e.message ?: "Configuration error")
63+
UpdateStatus.UpdateError(e.message ?: "Configuration error")
64+
} catch (e: Exception) {
65+
sentryOptions.logger.log(SentryLevel.ERROR, e, "Failed to check for updates")
66+
UpdateStatus.UpdateError("Network error: ${e.message}")
67+
}
4668
}
4769

4870
/**
@@ -73,4 +95,30 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut
7395
// Silently fail as this is expected behavior in some environments
7496
}
7597
}
98+
99+
private fun createUpdateCheckParams(): DistributionHttpClient.UpdateCheckParams {
100+
return try {
101+
val packageManager = context.packageManager
102+
val packageName = context.packageName
103+
val packageInfo =
104+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
105+
packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0))
106+
} else {
107+
@Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, 0)
108+
}
109+
110+
val versionName = packageInfo.versionName ?: "unknown"
111+
val appId = context.applicationInfo.packageName
112+
113+
DistributionHttpClient.UpdateCheckParams(
114+
mainBinaryIdentifier = appId, // Using package name as binary identifier
115+
appId = appId,
116+
platform = "android",
117+
version = versionName,
118+
)
119+
} catch (e: PackageManager.NameNotFoundException) {
120+
sentryOptions.logger.log(SentryLevel.ERROR, e, "Failed to get package info")
121+
throw IllegalStateException("Unable to get app package information", e)
122+
}
123+
}
76124
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package io.sentry.android.distribution
2+
3+
import io.sentry.SentryLevel
4+
import io.sentry.SentryOptions
5+
import io.sentry.UpdateInfo
6+
import io.sentry.UpdateStatus
7+
import org.json.JSONException
8+
import org.json.JSONObject
9+
10+
/** Parser for distribution API responses. */
11+
internal class UpdateResponseParser(private val options: SentryOptions) {
12+
13+
/**
14+
* Parses the API response and returns the appropriate UpdateStatus.
15+
*
16+
* @param statusCode HTTP status code
17+
* @param responseBody Response body as string
18+
* @return UpdateStatus indicating the result
19+
*/
20+
fun parseResponse(statusCode: Int, responseBody: String): UpdateStatus {
21+
return when (statusCode) {
22+
200 -> parseSuccessResponse(responseBody)
23+
in 400..499 -> UpdateStatus.UpdateError("Client error: $statusCode")
24+
in 500..599 -> UpdateStatus.UpdateError("Server error: $statusCode")
25+
else -> UpdateStatus.UpdateError("Unexpected response code: $statusCode")
26+
}
27+
}
28+
29+
private fun parseSuccessResponse(responseBody: String): UpdateStatus {
30+
return try {
31+
val json = JSONObject(responseBody)
32+
33+
options.logger.log(SentryLevel.DEBUG, "Parsing distribution API response")
34+
35+
// Check if there's a new release available
36+
val updateAvailable = json.optBoolean("updateAvailable", false)
37+
38+
if (updateAvailable) {
39+
val updateInfo = parseUpdateInfo(json)
40+
UpdateStatus.NewRelease(updateInfo)
41+
} else {
42+
UpdateStatus.UpToDate.getInstance()
43+
}
44+
} catch (e: JSONException) {
45+
options.logger.log(SentryLevel.ERROR, e, "Failed to parse API response")
46+
UpdateStatus.UpdateError("Invalid response format: ${e.message}")
47+
} catch (e: Exception) {
48+
options.logger.log(SentryLevel.ERROR, e, "Unexpected error parsing response")
49+
UpdateStatus.UpdateError("Failed to parse response: ${e.message}")
50+
}
51+
}
52+
53+
private fun parseUpdateInfo(json: JSONObject): UpdateInfo {
54+
val id = json.optString("id", "")
55+
val buildVersion = json.optString("buildVersion", "")
56+
val buildNumber = json.optInt("buildNumber", 0)
57+
val downloadUrl = json.optString("downloadUrl", "")
58+
val appName = json.optString("appName", "")
59+
val createdDate = json.optString("createdDate", "")
60+
61+
// Validate required fields
62+
if (id.isEmpty() || buildVersion.isEmpty() || downloadUrl.isEmpty()) {
63+
throw IllegalArgumentException("Missing required update information in API response")
64+
}
65+
66+
return UpdateInfo(id, buildVersion, buildNumber, downloadUrl, appName, createdDate)
67+
}
68+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package io.sentry.android.distribution
2+
3+
import io.sentry.SentryOptions
4+
import org.junit.Assert.*
5+
import org.junit.Before
6+
import org.junit.Ignore
7+
import org.junit.Test
8+
9+
class DistributionHttpClientTest {
10+
11+
private lateinit var options: SentryOptions
12+
private lateinit var httpClient: DistributionHttpClient
13+
14+
@Before
15+
fun setUp() {
16+
options =
17+
SentryOptions().apply {
18+
connectionTimeoutMillis = 10000
19+
readTimeoutMillis = 10000
20+
}
21+
22+
options.distribution.apply {
23+
orgSlug = "sentry"
24+
projectSlug = "launchpad-test"
25+
orgAuthToken = "DONT_CHECK_THIS_IN"
26+
sentryBaseUrl = "https://sentry.io"
27+
}
28+
29+
httpClient = DistributionHttpClient(options)
30+
}
31+
32+
@Test
33+
@Ignore("This is just used for testing against the real API.")
34+
fun `test checkForUpdates with real API`() {
35+
val params =
36+
DistributionHttpClient.UpdateCheckParams(
37+
mainBinaryIdentifier = "com.emergetools.hackernews",
38+
appId = "com.emergetools.hackernews",
39+
platform = "android",
40+
version = "1.0.0",
41+
)
42+
43+
val response = httpClient.checkForUpdates(params)
44+
45+
// Print response for debugging
46+
println("HTTP Status: ${response.statusCode}")
47+
println("Response Body: ${response.body}")
48+
println("Is Successful: ${response.isSuccessful}")
49+
50+
// Basic assertions
51+
assertTrue("Response should have a status code", response.statusCode > 0)
52+
assertNotNull("Response body should not be null", response.body)
53+
}
54+
}

0 commit comments

Comments
 (0)