From 054ca70fc6ffa80a44dc08c006dc789b4c543046 Mon Sep 17 00:00:00 2001 From: MrSluffy Date: Mon, 17 Feb 2025 15:49:37 +0800 Subject: [PATCH] feat : implementation nightly OTA - Checks for available nightly builds from the GitHub repository --- lawnchair/AndroidManifest.xml | 1 + lawnchair/res/values/strings.xml | 4 + .../src/app/lawnchair/api/gh/GitHubApi.kt | 57 +++++ .../lawnchair/ui/preferences/about/About.kt | 12 ++ .../ui/preferences/about/ContributorRow.kt | 35 +--- .../ui/preferences/components/CheckUpdate.kt | 197 ++++++++++++++++++ 6 files changed, 272 insertions(+), 34 deletions(-) create mode 100644 lawnchair/src/app/lawnchair/api/gh/GitHubApi.kt create mode 100644 lawnchair/src/app/lawnchair/ui/preferences/components/CheckUpdate.kt diff --git a/lawnchair/AndroidManifest.xml b/lawnchair/AndroidManifest.xml index 904818d411a..3e8cb152c5d 100644 --- a/lawnchair/AndroidManifest.xml +++ b/lawnchair/AndroidManifest.xml @@ -35,6 +35,7 @@ + diff --git a/lawnchair/res/values/strings.xml b/lawnchair/res/values/strings.xml index ea28a890480..f1d74cbaf0d 100644 --- a/lawnchair/res/values/strings.xml +++ b/lawnchair/res/values/strings.xml @@ -47,6 +47,10 @@ Loading… + Download update + Install update + You\'re up-to-date! + Managed by Lawnchair diff --git a/lawnchair/src/app/lawnchair/api/gh/GitHubApi.kt b/lawnchair/src/app/lawnchair/api/gh/GitHubApi.kt new file mode 100644 index 00000000000..05aa2939b75 --- /dev/null +++ b/lawnchair/src/app/lawnchair/api/gh/GitHubApi.kt @@ -0,0 +1,57 @@ +package app.lawnchair.api.gh + +import app.lawnchair.util.kotlinxJson +import kotlinx.serialization.Serializable +import okhttp3.MediaType.Companion.toMediaType +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import retrofit2.http.GET +import retrofit2.http.Path + +interface GitHubApi { + @GET("repos/LawnchairLauncher/lawnchair/releases") + suspend fun getReleases(): List + + @GET("repos/{owner}/{repo}/events") + suspend fun getRepositoryEvents( + @Path("owner") owner: String, + @Path("repo") repo: String, + ): List +} + +const val BASE_URL = "https://api.github.com/" + +val retrofit: Retrofit by lazy { + Retrofit.Builder() + .baseUrl(BASE_URL) + .addConverterFactory(kotlinxJson.asConverterFactory("application/json".toMediaType())) + .build() +} + +val api: GitHubApi by lazy { + retrofit.create(GitHubApi::class.java) +} + +@Serializable +data class GitHubRelease( + val tag_name: String, + val assets: List, +) + +@Serializable +data class GitHubAsset( + val name: String, + val browser_download_url: String, +) + +@Serializable +data class GitHubEvent( + val type: String, + val actor: Actor, + val created_at: String, +) + +@Serializable +data class Actor( + val login: String, +) diff --git a/lawnchair/src/app/lawnchair/ui/preferences/about/About.kt b/lawnchair/src/app/lawnchair/ui/preferences/about/About.kt index 5e20e04f152..6287fc87e0b 100644 --- a/lawnchair/src/app/lawnchair/ui/preferences/about/About.kt +++ b/lawnchair/src/app/lawnchair/ui/preferences/about/About.kt @@ -43,11 +43,14 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.net.toUri +import app.lawnchair.preferences.preferenceManager import app.lawnchair.ui.preferences.LocalIsExpandedScreen +import app.lawnchair.ui.preferences.components.CheckUpdate import app.lawnchair.ui.preferences.components.NavigationActionPreference import app.lawnchair.ui.preferences.components.controls.ClickablePreference import app.lawnchair.ui.preferences.components.layout.PreferenceGroup import app.lawnchair.ui.preferences.components.layout.PreferenceLayout +import app.lawnchair.util.checkAndRequestFilesPermission import com.android.launcher3.BuildConfig import com.android.launcher3.R @@ -252,6 +255,15 @@ fun About( }, ), ) + if (BuildConfig.APPLICATION_ID.contains("nightly") && + checkAndRequestFilesPermission( + context, + preferenceManager(), + ) + ) { + Spacer(modifier = Modifier.height(8.dp)) + CheckUpdate() + } Spacer(modifier = Modifier.requiredHeight(16.dp)) Row( modifier = Modifier diff --git a/lawnchair/src/app/lawnchair/ui/preferences/about/ContributorRow.kt b/lawnchair/src/app/lawnchair/ui/preferences/about/ContributorRow.kt index ca2f28ad7d2..fcdad3b5685 100644 --- a/lawnchair/src/app/lawnchair/ui/preferences/about/ContributorRow.kt +++ b/lawnchair/src/app/lawnchair/ui/preferences/about/ContributorRow.kt @@ -37,30 +37,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import app.lawnchair.api.gh.api import app.lawnchair.ui.placeholder.PlaceholderHighlight import app.lawnchair.ui.placeholder.fade import app.lawnchair.ui.placeholder.placeholder import app.lawnchair.ui.preferences.components.layout.PreferenceTemplate -import app.lawnchair.util.kotlinxJson import coil.compose.SubcomposeAsyncImage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import okhttp3.MediaType.Companion.toMediaType -import retrofit2.Retrofit -import retrofit2.converter.kotlinx.serialization.asConverterFactory -import retrofit2.http.GET -import retrofit2.http.Path suspend fun checkUserContribution(userName: String): String { - val retrofit = Retrofit.Builder() - .baseUrl("https://api.github.com/") - .addConverterFactory(kotlinxJson.asConverterFactory("application/json".toMediaType())) - .build() - - val api = retrofit.create(GitHubApi::class.java) - return withContext(Dispatchers.IO) { try { val events = api.getRepositoryEvents("LawnchairLauncher", "lawnchair") @@ -130,23 +117,3 @@ fun ContributorRow( }, ) } - -interface GitHubApi { - @GET("repos/{owner}/{repo}/events") - suspend fun getRepositoryEvents( - @Path("owner") owner: String, - @Path("repo") repo: String, - ): List -} - -@Serializable -data class GitHubEvent( - val type: String, - val actor: Actor, - val created_at: String, -) - -@Serializable -data class Actor( - val login: String, -) diff --git a/lawnchair/src/app/lawnchair/ui/preferences/components/CheckUpdate.kt b/lawnchair/src/app/lawnchair/ui/preferences/components/CheckUpdate.kt new file mode 100644 index 00000000000..f653e0fc09b --- /dev/null +++ b/lawnchair/src/app/lawnchair/ui/preferences/components/CheckUpdate.kt @@ -0,0 +1,197 @@ +package app.lawnchair.ui.preferences.components + +import android.content.Context +import android.content.Intent +import android.os.Environment +import android.provider.Settings +import android.util.Log +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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 androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import app.lawnchair.api.gh.api +import com.android.launcher3.BuildConfig +import com.android.launcher3.R +import com.android.launcher3.Utilities +import java.io.File +import java.io.FileOutputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request + +@Composable +fun CheckUpdate( + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + var latestDownloadUrl by remember { mutableStateOf(null) } + var updateAvailable by remember { mutableStateOf(false) } + var downloadProgress by remember { mutableFloatStateOf(0f) } + var isDownloading by remember { mutableStateOf(false) } + var downloadedFile by remember { mutableStateOf(null) } + + val currentVersionNumber = "15.Dev.(#2033)" // Hardcoded for testing + .substringAfterLast("#") + .toIntOrNull() ?: 0 + + LaunchedEffect(Unit) { + coroutineScope.launch { + try { + val releases = api.getReleases() + releases.forEach { release -> + if (release.tag_name == "nightly") { + release.assets.forEach { asset -> + val releaseNumber = asset.name + .substringAfter("_") + .substringBefore("-") + .toIntOrNull() ?: 0 + if (releaseNumber > currentVersionNumber) { + latestDownloadUrl = asset.browser_download_url + updateAvailable = true + } + } + } + } + } catch (e: Exception) { + Log.e("OTA", "Error fetching latest nightly release", e) + } + } + } + + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (updateAvailable) { + latestDownloadUrl?.let { url -> + if (!isDownloading && downloadedFile == null) { + Button( + onClick = { + coroutineScope.launch { + isDownloading = true + downloadedFile = downloadApk(url) { progress -> downloadProgress = progress } + isDownloading = false + } + }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text(text = stringResource(R.string.download_update)) + } + } + + if (isDownloading) { + LinearProgressIndicator( + progress = { downloadProgress }, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) + Text( + text = "${(downloadProgress * 100).toInt()}%", + modifier = Modifier.padding(top = 4.dp), + ) + } + + downloadedFile?.let { file -> + Button( + onClick = { + if (!hasInstallPermission(context)) { + requestInstallPermission(context) + return@Button + } + val apkUri = FileProvider.getUriForFile( + context, + "${BuildConfig.APPLICATION_ID}.fileprovider", + file, + ) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(apkUri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + context.startActivity(intent) + }, + modifier = Modifier.padding(top = 8.dp), + ) { + Text(text = stringResource(R.string.install_update)) + } + } + } + } else { + Text( + text = stringResource(R.string.pro_updated), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} + +fun hasInstallPermission(context: Context): Boolean { + return if (Utilities.ATLEAST_O) { + context.packageManager.canRequestPackageInstalls() + } else { + true + } +} + +fun requestInstallPermission(activity: Context) { + if (Utilities.ATLEAST_O) { + val intent = Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + "package:${activity.packageName}".toUri(), + ) + activity.startActivity(intent) + } +} + +suspend fun downloadApk(url: String, onProgress: (Float) -> Unit): File? = withContext(Dispatchers.IO) { + try { + val apkFile = File(Environment.getExternalStorageDirectory(), "Lawnchair/update.apk").apply { parentFile?.mkdirs() } + val response = OkHttpClient().newCall(Request.Builder().url(url).build()).execute() + val body = response.body ?: return@withContext null + + val totalBytes = body.contentLength().toFloat() + if (totalBytes <= 0) return@withContext null + + body.byteStream().use { input -> + FileOutputStream(apkFile).use { output -> + val buffer = ByteArray(8192) + var bytesDownloaded = 0L + var bytesRead: Int + + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + bytesDownloaded += bytesRead + onProgress(bytesDownloaded / totalBytes) + } + } + } + apkFile + } catch (e: Exception) { + Log.e("OTA", "Download failed", e) + null + } +}