Skip to content

Commit

Permalink
Build distribution add initial API
Browse files Browse the repository at this point in the history
  • Loading branch information
chromy committed Oct 16, 2024
1 parent c8d6466 commit 92ef4ac
Show file tree
Hide file tree
Showing 10 changed files with 358 additions and 9 deletions.
3 changes: 3 additions & 0 deletions distribution/distribution/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 7 additions & 1 deletion distribution/distribution/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<application>
<provider android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data android:name="com.emergetools.distribution.DistributionInitializer"
android:value="androidx.startup" />
</provider>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.emergetools.distribution

import android.content.Context
import androidx.startup.Initializer
import androidx.work.WorkManagerInitializer

class DistributionInitializer : Initializer<Distribution> {
override fun create(context: Context): Distribution {
Distribution.init(context)
return Distribution
}

override fun dependencies(): List<Class<out Initializer<*>>> {
return listOf(WorkManagerInitializer::class.java)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.emergetools.distribution.internal

internal const val TAG = "Distribution"
Original file line number Diff line number Diff line change
@@ -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 <reified T> tryDecode(s: String): T? {
return try {
Json.decodeFromString<T>(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<CheckForUpdatesMessageResult>(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()
}
}
},
)
}
36 changes: 36 additions & 0 deletions distribution/sample/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
}
Expand All @@ -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"
Expand All @@ -46,6 +53,14 @@ android {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}

buildFeatures {
compose = true
}

composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.extension.get()
}
}

buildConfig {
Expand All @@ -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)
}
Empty file.
Loading

0 comments on commit 92ef4ac

Please sign in to comment.