diff --git a/sentry-android-distribution/build.gradle.kts b/sentry-android-distribution/build.gradle.kts index dd9a278aa3c..2d23bf3ab74 100644 --- a/sentry-android-distribution/build.gradle.kts +++ b/sentry-android-distribution/build.gradle.kts @@ -11,6 +11,13 @@ android { defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } buildFeatures { buildConfig = false } + + testOptions { + unitTests.apply { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } } kotlin { @@ -29,4 +36,8 @@ 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) + testImplementation(libs.roboelectric) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.androidx.test.core) } diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt new file mode 100644 index 00000000000..5cb7724908a --- /dev/null +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt @@ -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")}") + } + 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() } + } ?: "" + } +} diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionIntegration.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionIntegration.kt index 7490e8d627f..f08522bb643 100644 --- a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionIntegration.kt +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionIntegration.kt @@ -2,13 +2,18 @@ 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 +import java.net.SocketTimeoutException +import java.net.UnknownHostException import org.jetbrains.annotations.ApiStatus /** @@ -24,6 +29,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 +42,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 +56,31 @@ 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: UnknownHostException) { + // UnknownHostException typically indicates no internet connection available + sentryOptions.logger.log( + SentryLevel.ERROR, + e, + "DNS lookup failed - check internet connection", + ) + UpdateStatus.NoNetwork("No internet connection or invalid server URL") + } catch (e: SocketTimeoutException) { + // SocketTimeoutException could indicate either slow network or server issues + sentryOptions.logger.log(SentryLevel.ERROR, e, "Network request timed out") + UpdateStatus.NoNetwork("Request timed out - check network connection") + } catch (e: Exception) { + sentryOptions.logger.log(SentryLevel.ERROR, e, "Unexpected error checking for updates") + UpdateStatus.UpdateError("Unexpected error: ${e.message}") + } } /** @@ -75,4 +111,37 @@ 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 versionCode = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") packageInfo.versionCode.toLong() + } + val appId = context.applicationInfo.packageName + + DistributionHttpClient.UpdateCheckParams( + mainBinaryIdentifier = appId, + appId = appId, + platform = "android", + versionCode = versionCode, + versionName = 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) + } + } } diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateResponseParser.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateResponseParser.kt new file mode 100644 index 00000000000..a1396cae462 --- /dev/null +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/UpdateResponseParser.kt @@ -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) { + + /** + * 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 (optString returns "null" for null values) + val missingFields = mutableListOf() + + 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) + } +} diff --git a/sentry-android-distribution/src/test/java/io/sentry/android/distribution/DistributionHttpClientTest.kt b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/DistributionHttpClientTest.kt new file mode 100644 index 00000000000..9d456ae6f7c --- /dev/null +++ b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/DistributionHttpClientTest.kt @@ -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 { + + 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) + } +} diff --git a/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt new file mode 100644 index 00000000000..1cefdfa7ac2 --- /dev/null +++ b/sentry-android-distribution/src/test/java/io/sentry/android/distribution/UpdateResponseParserTest.kt @@ -0,0 +1,313 @@ +package io.sentry.android.distribution + +import io.sentry.SentryOptions +import io.sentry.UpdateStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UpdateResponseParserTest { + + private lateinit var options: SentryOptions + private lateinit var parser: UpdateResponseParser + + @Before + fun setUp() { + options = SentryOptions() + parser = UpdateResponseParser(options) + } + + @Test + fun `parseResponse returns NewRelease when update is available`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": "update-123", + "buildVersion": "2.0.0", + "buildNumber": 42, + "downloadUrl": "https://example.com/download", + "appName": "Test App", + "createdDate": "2023-10-01T00:00:00Z" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return NewRelease", result is UpdateStatus.NewRelease) + val updateInfo = (result as UpdateStatus.NewRelease).info + assertEquals("update-123", updateInfo.id) + assertEquals("2.0.0", updateInfo.buildVersion) + assertEquals(42, updateInfo.buildNumber) + assertEquals("https://example.com/download", updateInfo.downloadUrl) + assertEquals("Test App", updateInfo.appName) + assertEquals("2023-10-01T00:00:00Z", updateInfo.createdDate) + } + + @Test + fun `parseResponse returns UpToDate when no update is available`() { + val responseBody = + """ + { + "updateAvailable": false + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpToDate", result is UpdateStatus.UpToDate) + } + + @Test + fun `parseResponse returns UpToDate when updateAvailable is missing`() { + val responseBody = + """ + { + "someOtherField": "value" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpToDate", result is UpdateStatus.UpToDate) + } + + @Test + fun `parseResponse returns UpdateError for 4xx status codes`() { + val result = parser.parseResponse(404, "Not found") + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertEquals("Client error: 404", error.message) + } + + @Test + fun `parseResponse returns UpdateError for 5xx status codes`() { + val result = parser.parseResponse(500, "Internal server error") + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertEquals("Server error: 500", error.message) + } + + @Test + fun `parseResponse returns UpdateError for unexpected status codes`() { + val result = parser.parseResponse(999, "Unknown status") + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertEquals("Unexpected response code: 999", error.message) + } + + @Test + fun `parseResponse returns UpdateError for invalid JSON`() { + val result = parser.parseResponse(200, "invalid json {") + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention invalid format", + error.message.startsWith("Invalid response format:"), + ) + } + + @Test + fun `parseResponse returns UpdateError when required fields are missing`() { + val responseBody = + """ + { + "updateAvailable": true, + "buildVersion": "2.0.0" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention failed to parse", + error.message.startsWith("Failed to parse response:"), + ) + } + + @Test + fun `parseResponse handles minimal valid update response`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": "update-123", + "buildVersion": "2.0.0", + "downloadUrl": "https://example.com/download" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return NewRelease", result is UpdateStatus.NewRelease) + val updateInfo = (result as UpdateStatus.NewRelease).info + assertEquals("update-123", updateInfo.id) + assertEquals("2.0.0", updateInfo.buildVersion) + assertEquals(0, updateInfo.buildNumber) // Default value + assertEquals("https://example.com/download", updateInfo.downloadUrl) + assertEquals("", updateInfo.appName) // Default value + assertEquals("", updateInfo.createdDate) // Default value + } + + @Test + fun `parseResponse handles empty response body`() { + val result = parser.parseResponse(200, "") + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention invalid format", + error.message.startsWith("Invalid response format:"), + ) + } + + @Test + fun `parseResponse handles null values in JSON`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": null, + "buildVersion": "2.0.0", + "downloadUrl": "https://example.com/download" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention failed to parse", + error.message.startsWith("Failed to parse response:"), + ) + } + + @Test + fun `parseResponse returns specific error message when id is missing`() { + val responseBody = + """ + { + "updateAvailable": true, + "buildVersion": "2.0.0", + "downloadUrl": "https://example.com/download" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention missing id field", + error.message.contains("Missing required fields in API response: id"), + ) + } + + @Test + fun `parseResponse returns specific error message when buildVersion is missing`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": "update-123", + "downloadUrl": "https://example.com/download" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention missing buildVersion field", + error.message.contains("Missing required fields in API response: buildVersion"), + ) + } + + @Test + fun `parseResponse returns specific error message when downloadUrl is missing`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": "update-123", + "buildVersion": "2.0.0" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention missing downloadUrl field", + error.message.contains("Missing required fields in API response: downloadUrl"), + ) + } + + @Test + fun `parseResponse returns specific error message when multiple fields are missing`() { + val responseBody = + """ + { + "updateAvailable": true, + "buildNumber": 42 + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention all missing required fields", + error.message.contains( + "Missing required fields in API response: id, buildVersion, downloadUrl" + ), + ) + } + + @Test + fun `parseResponse returns specific error message when field is null string`() { + val responseBody = + """ + { + "updateAvailable": true, + "id": "null", + "buildVersion": "2.0.0", + "downloadUrl": "https://example.com/download" + } + """ + .trimIndent() + + val result = parser.parseResponse(200, responseBody) + + assertTrue("Should return UpdateError", result is UpdateStatus.UpdateError) + val error = result as UpdateStatus.UpdateError + assertTrue( + "Error message should mention missing id field when value is 'null' string", + error.message.contains("Missing required fields in API response: id"), + ) + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 527461870aa..0f478c95d57 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4344,6 +4344,11 @@ public final class io/sentry/UpdateStatus$NewRelease : io/sentry/UpdateStatus { public fun getInfo ()Lio/sentry/UpdateInfo; } +public final class io/sentry/UpdateStatus$NoNetwork : io/sentry/UpdateStatus { + public fun (Ljava/lang/String;)V + public fun getMessage ()Ljava/lang/String; +} + public final class io/sentry/UpdateStatus$UpToDate : io/sentry/UpdateStatus { public static fun getInstance ()Lio/sentry/UpdateStatus$UpToDate; } diff --git a/sentry/src/main/java/io/sentry/UpdateInfo.java b/sentry/src/main/java/io/sentry/UpdateInfo.java index 84edd7c4c59..66a5452191d 100644 --- a/sentry/src/main/java/io/sentry/UpdateInfo.java +++ b/sentry/src/main/java/io/sentry/UpdateInfo.java @@ -2,6 +2,7 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** Information about an available app update. */ @ApiStatus.Experimental @@ -11,7 +12,7 @@ public final class UpdateInfo { private final int buildNumber; private final @NotNull String downloadUrl; private final @NotNull String appName; - private final @NotNull String createdDate; + private final @Nullable String createdDate; public UpdateInfo( final @NotNull String id, @@ -19,7 +20,7 @@ public UpdateInfo( final int buildNumber, final @NotNull String downloadUrl, final @NotNull String appName, - final @NotNull String createdDate) { + final @Nullable String createdDate) { this.id = id; this.buildVersion = buildVersion; this.buildNumber = buildNumber; @@ -48,7 +49,7 @@ public int getBuildNumber() { return appName; } - public @NotNull String getCreatedDate() { + public @Nullable String getCreatedDate() { return createdDate; } } diff --git a/sentry/src/main/java/io/sentry/UpdateStatus.java b/sentry/src/main/java/io/sentry/UpdateStatus.java index 3de15680a0c..c7c14ca68cd 100644 --- a/sentry/src/main/java/io/sentry/UpdateStatus.java +++ b/sentry/src/main/java/io/sentry/UpdateStatus.java @@ -43,4 +43,17 @@ public UpdateError(final @NotNull String message) { return message; } } + + /** No network connection is available to check for updates. */ + public static final class NoNetwork extends UpdateStatus { + private final @NotNull String message; + + public NoNetwork(final @NotNull String message) { + this.message = message; + } + + public @NotNull String getMessage() { + return message; + } + } }