Skip to content

Commit 8e6d732

Browse files
runningcodeclaude
andauthored
feat(android-distribution): add httpclient for checking for build distribution updates (#4734)
* 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]> * Address PR review feedback - Remove unnecessary Claude-style comments from DistributionHttpClient - Replace manual URL building with Android Uri.Builder for safer parameter encoding - Add comprehensive tests for UpdateResponseParser with 11 test cases - Improve error handling to distinguish between network connection vs server issues - Add clarifying comments about which exceptions indicate network connectivity problems - Fix null value handling in JSON parsing to properly validate "null" strings - Remove unclear comment about package name usage 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * Fix User-Agent header to follow codebase conventions Replace custom fallback "sentry-android-distribution" with error throw when sentryClientName is null, following the pattern used throughout the codebase where sentryClientName is expected to always be set. Addresses PR review feedback about reusing consistent user agent. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * Update SocketTimeoutException message to mention network connection Change "check connection speed" to "check network connection" to be more general and align with the goal of distinguishing network connectivity issues. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * Add NoNetwork status and improve error messages - Add UpdateStatus.NoNetwork subclass for network-specific errors - Update DistributionIntegration to use NoNetwork for UnknownHostException and SocketTimeoutException - Improve UpdateResponseParser error messages to specify which required fields are missing - Add comprehensive tests for specific missing field error messages 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> * Fix CI compilation errors in DistributionHttpClient - Update UpdateCheckParams constructor to use separate versionCode and versionName parameters - Replace Android Uri with string building for better compatibility - Remove unused Android Uri import - Update URL construction to use build_number and build_version query parameters This fixes the CI compilation errors where the old constructor expected a single 'version' parameter. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Claude <[email protected]>
1 parent f634d01 commit 8e6d732

File tree

9 files changed

+668
-4
lines changed

9 files changed

+668
-4
lines changed

sentry-android-distribution/build.gradle.kts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ android {
1111

1212
defaultConfig { minSdk = libs.versions.minSdk.get().toInt() }
1313
buildFeatures { buildConfig = false }
14+
15+
testOptions {
16+
unitTests.apply {
17+
isReturnDefaultValues = true
18+
isIncludeAndroidResources = true
19+
}
20+
}
1421
}
1522

1623
kotlin {
@@ -29,4 +36,8 @@ dependencies {
2936
libs.jetbrains.annotations
3037
) // Use implementation instead of compileOnly to override kotlin stdlib's version
3138
implementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid))
39+
testImplementation(libs.androidx.test.ext.junit)
40+
testImplementation(libs.roboelectric)
41+
testImplementation(libs.kotlin.test.junit)
42+
testImplementation(libs.androidx.test.core)
3243
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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 versionCode: Long,
29+
val versionName: String,
30+
)
31+
32+
/**
33+
* Makes a GET request to the distribution API to check for updates.
34+
*
35+
* @param params Update check parameters
36+
* @return HttpResponse containing the response details
37+
*/
38+
fun checkForUpdates(params: UpdateCheckParams): HttpResponse {
39+
val distributionOptions = options.distribution
40+
val orgSlug = distributionOptions.orgSlug
41+
val projectSlug = distributionOptions.projectSlug
42+
val authToken = distributionOptions.orgAuthToken
43+
val baseUrl = distributionOptions.sentryBaseUrl
44+
45+
if (orgSlug.isNullOrEmpty() || projectSlug.isNullOrEmpty() || authToken.isNullOrEmpty()) {
46+
throw IllegalStateException(
47+
"Missing required distribution configuration: orgSlug, projectSlug, or orgAuthToken"
48+
)
49+
}
50+
51+
val urlString = buildString {
52+
append(baseUrl.trimEnd('/'))
53+
append(
54+
"/api/0/projects/${URLEncoder.encode(orgSlug, "UTF-8")}/${URLEncoder.encode(projectSlug, "UTF-8")}/preprodartifacts/check-for-updates/"
55+
)
56+
append("?main_binary_identifier=${URLEncoder.encode(params.mainBinaryIdentifier, "UTF-8")}")
57+
append("&app_id=${URLEncoder.encode(params.appId, "UTF-8")}")
58+
append("&platform=${URLEncoder.encode(params.platform, "UTF-8")}")
59+
append("&build_number=${URLEncoder.encode(params.versionCode.toString(), "UTF-8")}")
60+
append("&build_version=${URLEncoder.encode(params.versionName, "UTF-8")}")
61+
}
62+
val url = URL(urlString)
63+
64+
return try {
65+
makeRequest(url, authToken)
66+
} catch (e: IOException) {
67+
options.logger.log(SentryLevel.ERROR, e, "Network error while checking for updates")
68+
throw e
69+
}
70+
}
71+
72+
private fun makeRequest(url: URL, authToken: String): HttpResponse {
73+
val connection = url.openConnection() as HttpURLConnection
74+
75+
try {
76+
connection.requestMethod = "GET"
77+
connection.setRequestProperty("Authorization", "Bearer $authToken")
78+
connection.setRequestProperty("Accept", "application/json")
79+
connection.setRequestProperty(
80+
"User-Agent",
81+
options.sentryClientName ?: throw IllegalStateException("sentryClientName must be set"),
82+
)
83+
connection.connectTimeout = options.connectionTimeoutMillis
84+
connection.readTimeout = options.readTimeoutMillis
85+
86+
if (connection is HttpsURLConnection && options.sslSocketFactory != null) {
87+
connection.sslSocketFactory = options.sslSocketFactory
88+
}
89+
90+
val responseCode = connection.responseCode
91+
val responseBody = readResponse(connection)
92+
93+
options.logger.log(
94+
SentryLevel.DEBUG,
95+
"Distribution API request completed with status: $responseCode",
96+
)
97+
98+
return HttpResponse(responseCode, responseBody)
99+
} finally {
100+
connection.disconnect()
101+
}
102+
}
103+
104+
private fun readResponse(connection: HttpURLConnection): String {
105+
val inputStream =
106+
if (connection.responseCode in 200..299) {
107+
connection.inputStream
108+
} else {
109+
connection.errorStream ?: connection.inputStream
110+
}
111+
112+
return inputStream?.use { stream ->
113+
BufferedReader(InputStreamReader(stream, "UTF-8")).use { reader -> reader.readText() }
114+
} ?: ""
115+
}
116+
}

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

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@ 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
15+
import java.net.SocketTimeoutException
16+
import java.net.UnknownHostException
1217
import org.jetbrains.annotations.ApiStatus
1318

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

32+
private lateinit var httpClient: DistributionHttpClient
33+
private lateinit var responseParser: UpdateResponseParser
34+
2735
/**
2836
* Registers the Distribution integration with Sentry.
2937
*
@@ -34,6 +42,10 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut
3442
// Store scopes and options for use by distribution functionality
3543
this.scopes = scopes
3644
this.sentryOptions = options
45+
46+
// Initialize HTTP client and response parser
47+
this.httpClient = DistributionHttpClient(options)
48+
this.responseParser = UpdateResponseParser(options)
3749
}
3850

3951
/**
@@ -44,7 +56,31 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut
4456
* @return UpdateStatus indicating if an update is available, up to date, or error
4557
*/
4658
public override fun checkForUpdateBlocking(): UpdateStatus {
47-
throw NotImplementedError()
59+
return try {
60+
sentryOptions.logger.log(SentryLevel.DEBUG, "Checking for distribution updates")
61+
62+
val params = createUpdateCheckParams()
63+
val response = httpClient.checkForUpdates(params)
64+
responseParser.parseResponse(response.statusCode, response.body)
65+
} catch (e: IllegalStateException) {
66+
sentryOptions.logger.log(SentryLevel.WARNING, e.message ?: "Configuration error")
67+
UpdateStatus.UpdateError(e.message ?: "Configuration error")
68+
} catch (e: UnknownHostException) {
69+
// UnknownHostException typically indicates no internet connection available
70+
sentryOptions.logger.log(
71+
SentryLevel.ERROR,
72+
e,
73+
"DNS lookup failed - check internet connection",
74+
)
75+
UpdateStatus.NoNetwork("No internet connection or invalid server URL")
76+
} catch (e: SocketTimeoutException) {
77+
// SocketTimeoutException could indicate either slow network or server issues
78+
sentryOptions.logger.log(SentryLevel.ERROR, e, "Network request timed out")
79+
UpdateStatus.NoNetwork("Request timed out - check network connection")
80+
} catch (e: Exception) {
81+
sentryOptions.logger.log(SentryLevel.ERROR, e, "Unexpected error checking for updates")
82+
UpdateStatus.UpdateError("Unexpected error: ${e.message}")
83+
}
4884
}
4985

5086
/**
@@ -75,4 +111,37 @@ public class DistributionIntegration(context: Context) : Integration, IDistribut
75111
// Silently fail as this is expected behavior in some environments
76112
}
77113
}
114+
115+
private fun createUpdateCheckParams(): DistributionHttpClient.UpdateCheckParams {
116+
return try {
117+
val packageManager = context.packageManager
118+
val packageName = context.packageName
119+
val packageInfo =
120+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
121+
packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0))
122+
} else {
123+
@Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, 0)
124+
}
125+
126+
val versionName = packageInfo.versionName ?: "unknown"
127+
val versionCode =
128+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
129+
packageInfo.longVersionCode
130+
} else {
131+
@Suppress("DEPRECATION") packageInfo.versionCode.toLong()
132+
}
133+
val appId = context.applicationInfo.packageName
134+
135+
DistributionHttpClient.UpdateCheckParams(
136+
mainBinaryIdentifier = appId,
137+
appId = appId,
138+
platform = "android",
139+
versionCode = versionCode,
140+
versionName = versionName,
141+
)
142+
} catch (e: PackageManager.NameNotFoundException) {
143+
sentryOptions.logger.log(SentryLevel.ERROR, e, "Failed to get package info")
144+
throw IllegalStateException("Unable to get app package information", e)
145+
}
146+
}
78147
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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 (optString returns "null" for null values)
62+
val missingFields = mutableListOf<String>()
63+
64+
if (id.isEmpty() || id == "null") {
65+
missingFields.add("id")
66+
}
67+
if (buildVersion.isEmpty() || buildVersion == "null") {
68+
missingFields.add("buildVersion")
69+
}
70+
if (downloadUrl.isEmpty() || downloadUrl == "null") {
71+
missingFields.add("downloadUrl")
72+
}
73+
74+
if (missingFields.isNotEmpty()) {
75+
throw IllegalArgumentException(
76+
"Missing required fields in API response: ${missingFields.joinToString(", ")}"
77+
)
78+
}
79+
80+
return UpdateInfo(id, buildVersion, buildNumber, downloadUrl, appName, createdDate)
81+
}
82+
}
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+
versionName = "1.0.0",
40+
versionCode = 5L,
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)