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() + } + } + }, + ) + }