diff --git a/distribution/distribution/build.gradle.kts b/distribution/distribution/build.gradle.kts
index 18018f4c..e215ff0d 100644
--- a/distribution/distribution/build.gradle.kts
+++ b/distribution/distribution/build.gradle.kts
@@ -47,6 +47,9 @@ dependencies {
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.kotlin.coroutines.android)
implementation(libs.androidx.startup.runtime)
+ implementation(libs.androidx.runtime.android)
+ implementation(libs.androidx.foundation.layout.android)
+ implementation(libs.androidx.material3.android)
testImplementation(libs.google.truth)
diff --git a/distribution/distribution/src/main/AndroidManifest.xml b/distribution/distribution/src/main/AndroidManifest.xml
index ae88f3fa..db0eb73b 100644
--- a/distribution/distribution/src/main/AndroidManifest.xml
+++ b/distribution/distribution/src/main/AndroidManifest.xml
@@ -9,6 +9,12 @@
+
+
+
-
diff --git a/distribution/distribution/src/main/kotlin/com/emergetools/distribution/Distribution.kt b/distribution/distribution/src/main/kotlin/com/emergetools/distribution/Distribution.kt
index 06c09c02..23514bf1 100644
--- a/distribution/distribution/src/main/kotlin/com/emergetools/distribution/Distribution.kt
+++ b/distribution/distribution/src/main/kotlin/com/emergetools/distribution/Distribution.kt
@@ -1,5 +1,51 @@
package com.emergetools.distribution
+import android.content.Context
+import com.emergetools.distribution.internal.DistributionInternal
+import okhttp3.OkHttpClient
+
+/**
+ * The public Android SDK for Emerge Tools Build Distribution.
+ */
object Distribution {
- // Left intentionally blank.
+ /**
+ * Initialize build distribution. This should be called once in each process.
+ * This method may be called from any thread with a Looper. It is safe to
+ * call this from the main thread. Options may be passed if you want to override the default values.
+ * @param context Android context
+ * @param options Override distribution settings
+ */
+ fun init(context: Context, options: DistributionOptions? = null) {
+ DistributionInternal.init(context, options ?: DistributionOptions())
+ }
+
+ /**
+ *
+ */
+ suspend fun checkForUpdate(context: Context, apiKey: String? = null): UpdateStatus {
+ return DistributionInternal.checkForUpdate(context, apiKey)
+ }
+}
+
+/**
+ * Optional settings for build distribution.
+ */
+data class DistributionOptions(
+ /**
+ * Pass an existing OkHttpClient. If null Distribution will create it's own OkHttpClient.
+ * This allows reuse of existing OkHttpClient thread pools etc.
+ */
+ val okHttpClient: OkHttpClient? = null,
+)
+
+sealed class UpdateStatus {
+ class Error(val message: String) : UpdateStatus()
+ class NewRelease(
+ val id: String,
+ val tag: String,
+ val version: String,
+ val appId: String,
+ val downloadUrl: String
+ ) : UpdateStatus()
+ object UpToDate : UpdateStatus()
}
diff --git a/distribution/distribution/src/main/kotlin/com/emergetools/distribution/DistributionInitializer.kt b/distribution/distribution/src/main/kotlin/com/emergetools/distribution/DistributionInitializer.kt
new file mode 100644
index 00000000..39718eff
--- /dev/null
+++ b/distribution/distribution/src/main/kotlin/com/emergetools/distribution/DistributionInitializer.kt
@@ -0,0 +1,16 @@
+package com.emergetools.distribution
+
+import android.content.Context
+import androidx.startup.Initializer
+import androidx.work.WorkManagerInitializer
+
+class DistributionInitializer : Initializer {
+ override fun create(context: Context): Distribution {
+ Distribution.init(context)
+ return Distribution
+ }
+
+ override fun dependencies(): List>> {
+ return listOf(WorkManagerInitializer::class.java)
+ }
+}
diff --git a/distribution/distribution/src/main/kotlin/com/emergetools/distribution/internal/Constants.kt b/distribution/distribution/src/main/kotlin/com/emergetools/distribution/internal/Constants.kt
new file mode 100644
index 00000000..496be638
--- /dev/null
+++ b/distribution/distribution/src/main/kotlin/com/emergetools/distribution/internal/Constants.kt
@@ -0,0 +1,3 @@
+package com.emergetools.distribution.internal
+
+internal const val TAG = "Distribution"
diff --git a/distribution/distribution/src/main/kotlin/com/emergetools/distribution/internal/DistributionInternal.kt b/distribution/distribution/src/main/kotlin/com/emergetools/distribution/internal/DistributionInternal.kt
new file mode 100644
index 00000000..28525ebc
--- /dev/null
+++ b/distribution/distribution/src/main/kotlin/com/emergetools/distribution/internal/DistributionInternal.kt
@@ -0,0 +1,174 @@
+// We catch very generic exceptions on purpose. We need to avoid crashing the app.
+@file:Suppress("TooGenericExceptionCaught")
+
+package com.emergetools.distribution.internal
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import com.emergetools.distribution.DistributionOptions
+import com.emergetools.distribution.UpdateStatus
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.json.Json
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.HttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.internal.closeQuietly
+import java.io.IOException
+import kotlin.coroutines.resumeWithException
+
+@Serializable
+internal data class CheckForUpdatesMessageResult(
+ val message: String
+)
+
+private inline fun tryDecode(s: String): T? {
+ return try {
+ Json.decodeFromString(s)
+ } catch (_: SerializationException) {
+ null
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+}
+
+private fun decodeResult(body: String?): UpdateStatus {
+ if (body == null) {
+ return UpdateStatus.Error("Empty response from server")
+ }
+
+ val message = tryDecode(body)
+ if (message !== null) {
+ return UpdateStatus.Error(message.message)
+ }
+
+ return UpdateStatus.Error("Unexpected response $body")
+}
+
+private class State(val handler: Handler, private var okHttpClient: OkHttpClient?) {
+
+ @Synchronized
+ fun getOkHttpClient(): OkHttpClient {
+ var client = okHttpClient
+ if (client == null) {
+ client = OkHttpClient()
+ okHttpClient = client
+ }
+ return client
+ }
+}
+
+object DistributionInternal {
+ private var state: State? = null
+
+ @Synchronized
+ @Suppress("unused")
+ fun init(context: Context, options: DistributionOptions) {
+ if (state != null) {
+ Log.e(TAG, "Distribution already initialized, ignoring Distribution.init().")
+ return
+ }
+
+ val looper = Looper.myLooper()
+ if (looper == null) {
+ Log.e(TAG, "Distribution.init() must be called from a thread with a Looper.")
+ return
+ }
+
+ val handler = Handler(looper)
+ state = State(handler, options.okHttpClient)
+ }
+
+ private fun getState(): State? {
+ var theState: State?
+ synchronized(this) {
+ theState = state
+ }
+ return theState
+ }
+
+ suspend fun checkForUpdate(context: Context, apiKey: String?): UpdateStatus {
+ try {
+ val state = getState()
+ if (state == null) {
+ Log.e(TAG, "Build distribution not initialized")
+ return UpdateStatus.Error("Build distribution not initialized")
+ }
+ if (apiKey == null) {
+ Log.e(TAG, "No API key available")
+ return UpdateStatus.Error("No API key available")
+ }
+ return doCheckForUpdate(context, state, apiKey)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error: $e")
+ return UpdateStatus.Error("Error: $e")
+ }
+ }
+}
+
+@OptIn(ExperimentalCoroutinesApi::class)
+private suspend fun doCheckForUpdate(context: Context, state: State, apiKey: String): UpdateStatus {
+ // Despite the name context.packageName is the actually the application id.
+ val applicationId = context.packageName
+
+ val url = HttpUrl.Builder().apply {
+ scheme("https")
+ host("api.emergetools.com")
+ addPathSegment("distribution")
+ addPathSegment("checkForUpdates")
+ addQueryParameter("apiKey", apiKey)
+ addQueryParameter("binaryIdentifier", "polar bears")
+ addQueryParameter("appId", applicationId)
+ addQueryParameter("platform", "android")
+ }.build()
+
+ val request = Request.Builder().apply {
+ url(url)
+ }.build()
+
+ val client = state.getOkHttpClient()
+ val call = client.newCall(request)
+ executeAsync(call).use { response ->
+ return withContext(Dispatchers.IO) {
+ val body = response.body?.string()
+ println(body)
+ return@withContext decodeResult(body)
+ }
+ }
+}
+
+@ExperimentalCoroutinesApi // resume with a resource cleanup.
+suspend fun executeAsync(call: Call): Response =
+ suspendCancellableCoroutine { continuation ->
+ continuation.invokeOnCancellation {
+ call.cancel()
+ }
+ call.enqueue(
+ object : Callback {
+ override fun onFailure(
+ call: Call,
+ e: IOException,
+ ) {
+ continuation.resumeWithException(e)
+ }
+
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ continuation.resume(response) {
+ response.closeQuietly()
+ }
+ }
+ },
+ )
+ }
diff --git a/distribution/sample/app/build.gradle.kts b/distribution/sample/app/build.gradle.kts
index ce58fb46..e68295a6 100644
--- a/distribution/sample/app/build.gradle.kts
+++ b/distribution/sample/app/build.gradle.kts
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.buildconfig)
id("com.emergetools.android")
}
@@ -26,12 +27,18 @@ android {
targetSdk = 33
versionCode = 1
versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
}
buildTypes {
release {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
isMinifyEnabled = true
+ signingConfig = signingConfigs.getByName("debug")
}
debug {
applicationIdSuffix = ".debug"
@@ -46,6 +53,14 @@ android {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
+
+ buildFeatures {
+ compose = true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = libs.versions.compose.compiler.extension.get()
+ }
}
buildConfig {
@@ -54,6 +69,27 @@ buildConfig {
}
dependencies {
+ implementation(libs.androidx.activity)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.navigation.ui.ktx)
+ implementation(libs.kotlinx.serialization)
+
// Distribution SDK
implementation(projects.distribution.distribution)
+
+ implementation(platform(libs.compose.bom))
+ implementation(libs.compose.ui)
+ implementation(libs.compose.ui.tooling)
+ implementation(libs.compose.ui.tooling.preview)
+ implementation(libs.compose.material)
+ implementation(libs.androidx.test.core.ktx)
+
+ androidTestImplementation(libs.compose.runtime)
+ androidTestImplementation(libs.compose.ui)
+ androidTestImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.test.core)
+ androidTestImplementation(libs.androidx.test.runner)
+ androidTestImplementation(libs.androidx.test.uiautomator)
}
diff --git a/distribution/sample/app/proguard-rules.pro b/distribution/sample/app/proguard-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/distribution/sample/app/src/main/kotlin/com/emergetools/distribution/sample/MainActivity.kt b/distribution/sample/app/src/main/kotlin/com/emergetools/distribution/sample/MainActivity.kt
index 2507c29e..99cdc039 100644
--- a/distribution/sample/app/src/main/kotlin/com/emergetools/distribution/sample/MainActivity.kt
+++ b/distribution/sample/app/src/main/kotlin/com/emergetools/distribution/sample/MainActivity.kt
@@ -1,16 +1,71 @@
package com.emergetools.distribution.sample
-import android.app.Activity
import android.os.Bundle
-import android.widget.TextView
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import com.emergetools.distribution.Distribution
+import com.emergetools.distribution.UpdateStatus
+import kotlinx.coroutines.launch
-class MainActivity : Activity() {
- public override fun onCreate(savedInstanceState: Bundle?) {
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ setContent {
+ MyApp()
+ }
+ }
+}
- val label = TextView(this)
- label.text = "Hello world!"
+@Composable
+fun MyApp() {
+ MaterialTheme {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ MyCustomUpdaterButton()
+ }
+ }
+}
- setContentView(label)
+@Composable
+fun MyCustomUpdaterButton() {
+ val context = LocalContext.current
+ Box {
+ val scope = rememberCoroutineScope()
+ var isLoading by remember { mutableStateOf(false) }
+ var status by remember { mutableStateOf(null) }
+ Button(onClick = {
+ scope.launch {
+ isLoading = true
+ status = null
+ status = Distribution.checkForUpdate(context)
+ isLoading = false
+ }
+ }) {
+ when (isLoading) {
+ true -> Text(text = "Loading...")
+ false -> when (status) {
+ is UpdateStatus.UpToDate -> Text(text = "Up to date!")
+ is UpdateStatus.Error -> Text(text = (status as UpdateStatus.Error).message)
+ is UpdateStatus.NewRelease -> Text(text = "New release!")
+ else -> Text(text = "Check for updates")
+ }
+ }
+ }
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 877716b9..a84db492 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -24,6 +24,11 @@ emerge-performance = "2.1.2"
emerge-reaper = "1.0.0-rc02"
emerge-snapshots = "1.3.0-rc01"
emerge-distribution = "0.0.1"
+activity-ktx = "1.9.2"
+navigation-compose = "2.8.2"
+material3-android = "1.3.0"
+runtime-android = "1.7.3"
+foundation-layout-android = "1.7.3"
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
@@ -108,3 +113,8 @@ okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
tree-printer = "hu.webarticum:tree-printer:3.0.0"
+androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity-ktx" }
+navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" }
+androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3-android" }
+androidx-runtime-android = { group = "androidx.compose.runtime", name = "runtime-android", version.ref = "runtime-android" }
+androidx-foundation-layout-android = { group = "androidx.compose.foundation", name = "foundation-layout-android", version.ref = "foundation-layout-android" }
diff --git a/reaper/reaper/src/main/kotlin/com/emergetools/reaper/ReaperReportErrorWorker.kt b/reaper/reaper/src/main/kotlin/com/emergetools/reaper/ReaperReportErrorWorker.kt
index bd91b273..00e87fee 100644
--- a/reaper/reaper/src/main/kotlin/com/emergetools/reaper/ReaperReportErrorWorker.kt
+++ b/reaper/reaper/src/main/kotlin/com/emergetools/reaper/ReaperReportErrorWorker.kt
@@ -155,3 +155,30 @@ fun sendError(ctx: Context, message: String) {
WorkManager.getInstance(ctx).enqueue(workRequest)
}
+
+@ExperimentalCoroutinesApi // resume with a resource cleanup.
+suspend fun Call.executeAsync(): Response =
+ suspendCancellableCoroutine { continuation ->
+ continuation.invokeOnCancellation {
+ this.cancel()
+ }
+ this.enqueue(
+ object : Callback {
+ override fun onFailure(
+ call: Call,
+ e: IOException,
+ ) {
+ continuation.resumeWithException(e)
+ }
+
+ override fun onResponse(
+ call: Call,
+ response: Response,
+ ) {
+ continuation.resume(response) {
+ response.closeQuietly()
+ }
+ }
+ },
+ )
+ }