Skip to content

Commit

Permalink
feat : implementation nightly OTA
Browse files Browse the repository at this point in the history
- Checks for available nightly builds from the GitHub repository
  • Loading branch information
MrSluffy committed Feb 17, 2025
1 parent 2623e42 commit 054ca70
Show file tree
Hide file tree
Showing 6 changed files with 272 additions and 34 deletions.
1 change: 1 addition & 0 deletions lawnchair/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
<uses-permission android:name="android.permission.FORCE_STOP_PACKAGES" tools:ignore="ProtectedPermissions"/>
<uses-permission android:name="android.permission.STATUS_BAR_SERVICE" tools:ignore="ProtectedPermissions"/>
<uses-permission android:name="android.permission.SUSPEND_APPS" tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>

<!--override minSdk declared in it-->
<uses-sdk tools:overrideLibrary="com.kieronquinn.app.smartspacer.sdk" />
Expand Down
4 changes: 4 additions & 0 deletions lawnchair/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@

<string name="loading">Loading…</string>

<string name="download_update">Download update</string>
<string name="install_update">Install update</string>
<string name="pro_updated">You\'re up-to-date!</string>

<string name="managed_by_lawnchair">Managed by Lawnchair</string>

<!-- When mentioning settings UI -->
Expand Down
57 changes: 57 additions & 0 deletions lawnchair/src/app/lawnchair/api/gh/GitHubApi.kt
Original file line number Diff line number Diff line change
@@ -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<GitHubRelease>

@GET("repos/{owner}/{repo}/events")
suspend fun getRepositoryEvents(
@Path("owner") owner: String,
@Path("repo") repo: String,
): List<GitHubEvent>
}

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<GitHubAsset>,
)

@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,
)
12 changes: 12 additions & 0 deletions lawnchair/src/app/lawnchair/ui/preferences/about/About.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<GitHubEvent>
}

@Serializable
data class GitHubEvent(
val type: String,
val actor: Actor,
val created_at: String,
)

@Serializable
data class Actor(
val login: String,
)
197 changes: 197 additions & 0 deletions lawnchair/src/app/lawnchair/ui/preferences/components/CheckUpdate.kt
Original file line number Diff line number Diff line change
@@ -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<String?>(null) }
var updateAvailable by remember { mutableStateOf(false) }
var downloadProgress by remember { mutableFloatStateOf(0f) }
var isDownloading by remember { mutableStateOf(false) }
var downloadedFile by remember { mutableStateOf<File?>(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
}
}

0 comments on commit 054ca70

Please sign in to comment.