Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Build distribution add initial API #279

Merged
merged 2 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
12 changes: 11 additions & 1 deletion distribution/distribution/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<application>
</application>
<meta-data
android:name="com.emergetools.distribution.API_KEY"
android:value="${emerge.distribution.apiKey}" />

<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,77 @@
package com.emergetools.distribution

import android.content.Context
import android.content.Intent
import android.net.Uri
import com.emergetools.distribution.internal.DistributionInternal
import kotlinx.serialization.Serializable
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())
}

/**
*
*/
fun isEnabled(context: Context): Boolean {
return DistributionInternal.isEnabled(context)
}

/**
* Check to see if an updated version of the current app exists.
*/
suspend fun checkForUpdate(context: Context): UpdateStatus {
return DistributionInternal.checkForUpdate(context)
}

/**
* Download the provided update.
*/
fun downloadUpdate(context: Context, info: UpdateInfo) {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(info.downloadUrl))
context.startActivity(browserIntent)
}
}

/**
* 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,

/**
* Emerge Tools build tag used to find the desired next build.
*/
val tag: String? = null,
)

@Serializable
data class UpdateInfo(
val id: String,
val tag: String,
val version: String,
val appId: String,
val downloadUrl: String,
)

sealed class UpdateStatus {
class Error(val message: String) : UpdateStatus()
class NewRelease(val info: UpdateInfo) : 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,223 @@
// 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.content.pm.PackageManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.emergetools.distribution.DistributionOptions
import com.emergetools.distribution.UpdateInfo
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

private const val MANIFEST_TAG_API_KEY = "com.emergetools.distribution.API_KEY"

internal fun getApiKey(metadata: Bundle): String? {
val apiKey = metadata.getString(MANIFEST_TAG_API_KEY, null)
if (apiKey == "") {
return null
}
return apiKey
}

@Serializable
internal data class CheckForUpdatesMessageResult(
val message: String
)

@Serializable
internal data class CheckForUpdatesSuccessResult(
val updateInfo: UpdateInfo
)

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 success = tryDecode<CheckForUpdatesSuccessResult>(body)
if (success !== null) {
return UpdateStatus.NewRelease(success.updateInfo)
}

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,
val tag: String,
val apiKey: String?,
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
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)

// apiKey may be null if
val metaData = context.packageManager.getApplicationInfo(
context.packageName,
PackageManager.GET_META_DATA
).metaData
val apiKey = getApiKey(metaData)

val tag = options.tag ?: "release"

state = State(handler, tag, apiKey, options.okHttpClient)
}

private fun getState(): State? {
var theState: State?
synchronized(this) {
theState = state
}
return theState
}

suspend fun checkForUpdate(context: Context): UpdateStatus {
try {
val state = getState()
if (state == null) {
Log.e(TAG, "Build distribution not initialized")
return UpdateStatus.Error("Build distribution not initialized")
}
return doCheckForUpdate(context, state)
} catch (e: Exception) {
Log.e(TAG, "Error: $e")
return UpdateStatus.Error("Error: $e")
}
}

@Suppress("unused")
fun isEnabled(context: Context): Boolean {
try {
val state = getState()
return state?.apiKey != null
} catch (e: Exception) {
Log.e(TAG, "Error: $e")
return false
}
}
}

@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun doCheckForUpdate(context: Context, state: State): UpdateStatus {
// Despite the name context.packageName is the actually the application id.
val applicationId = context.packageName
val apiKey = state.apiKey
if (apiKey == null) {
Log.e(TAG, "No API key available")
return UpdateStatus.Error("No API key available")
}

val url = HttpUrl.Builder().apply {
scheme("https")
host("api.emergetools.com")
addPathSegment("distribution")
addPathSegment("checkForUpdates")
addQueryParameter("apiKey", apiKey)
addQueryParameter("tag", state.tag)
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()
}
}
},
)
}
Loading
Loading