diff --git a/lawnchair/res/values/strings.xml b/lawnchair/res/values/strings.xml
index 530a79fd5d..8e8f6d7151 100644
--- a/lawnchair/res/values/strings.xml
+++ b/lawnchair/res/values/strings.xml
@@ -54,8 +54,25 @@
Download update
Install update
+ View changes
You\'re up-to-date!
+
+ Updates
+ Failed to load changes
+ No changes found
+ Build #%1$d → #%2$d
+ by %1$s • %2$s • %3$s
+
+
+ just now
+ %dm
+ %dh
+ %dd
+ %dw
+ %dmo
+ %dy
+
Managed by Lawnchair
diff --git a/lawnchair/src/app/lawnchair/ui/preferences/about/About.kt b/lawnchair/src/app/lawnchair/ui/preferences/about/About.kt
index db6550efca..7344472240 100644
--- a/lawnchair/src/app/lawnchair/ui/preferences/about/About.kt
+++ b/lawnchair/src/app/lawnchair/ui/preferences/about/About.kt
@@ -28,10 +28,16 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
@@ -53,7 +59,9 @@ import app.lawnchair.ui.preferences.components.layout.preferenceGroupItems
import app.lawnchair.ui.preferences.navigation.AboutLicenses
import com.android.launcher3.BuildConfig
import com.android.launcher3.R
+import kotlinx.coroutines.launch
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun About(
modifier: Modifier = Modifier,
@@ -62,6 +70,30 @@ fun About(
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
+ val sheetState = rememberModalBottomSheetState(true)
+ var openBottomSheet by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+
+ if (openBottomSheet) {
+ val updateState = uiState.updateState
+ if (updateState is UpdateState.Available) {
+ ChangesDialog(
+ changelogState = updateState.changelogState,
+ onDismiss = {
+ scope.launch {
+ sheetState.hide()
+ }.invokeOnCompletion {
+ openBottomSheet = false
+ }
+ },
+ onDownload = {
+ viewModel.downloadUpdate()
+ },
+ sheetState = sheetState,
+ )
+ }
+ }
+
PreferenceLayoutLazyColumn(
label = stringResource(id = R.string.about_label),
modifier = modifier,
@@ -125,7 +157,15 @@ fun About(
item {
UpdateSection(
updateState = uiState.updateState,
- onEvent = viewModel::onEvent,
+ onInstall = {
+ viewModel.installUpdate(it)
+ },
+ onViewChanges = {
+ openBottomSheet = true
+ scope.launch {
+ sheetState.show()
+ }
+ },
)
}
item {
diff --git a/lawnchair/src/app/lawnchair/ui/preferences/about/AboutModels.kt b/lawnchair/src/app/lawnchair/ui/preferences/about/AboutModels.kt
index 2c0a3648b9..7dbad00231 100644
--- a/lawnchair/src/app/lawnchair/ui/preferences/about/AboutModels.kt
+++ b/lawnchair/src/app/lawnchair/ui/preferences/about/AboutModels.kt
@@ -104,7 +104,7 @@ sealed interface UpdateState {
* @param name The name of the available update (used in `Available` state).
* @param url The URL to download the update from (used in `Available` state).
*/
- data class Available(val name: String, val url: String) : UpdateState
+ data class Available(val name: String, val url: String, val changelogState: ChangelogState?) : UpdateState
/**
* An update is currently being downloaded. Contains the download progress.
@@ -122,16 +122,11 @@ sealed interface UpdateState {
data object Failed : UpdateState
}
-/**
- * Sealed interface representing user actions that can be triggered from the "About" screen.
- *
- * This interface defines the different types of events related to user interactions,
- * such as initiating a download or an installation.
- */
-sealed interface AboutEvent {
- data object OnDownloadClicked : AboutEvent
- data class OnInstallClicked(val file: File) : AboutEvent
-}
+data class ChangelogState(
+ val commits: List = emptyList(),
+ val currentBuildNumber: Int = 0,
+ val latestBuildNumber: Int = 0,
+)
/**
* Represents the status of a contributor.
diff --git a/lawnchair/src/app/lawnchair/ui/preferences/about/AboutViewModel.kt b/lawnchair/src/app/lawnchair/ui/preferences/about/AboutViewModel.kt
index 412a5977ae..b52f9eb0ae 100644
--- a/lawnchair/src/app/lawnchair/ui/preferences/about/AboutViewModel.kt
+++ b/lawnchair/src/app/lawnchair/ui/preferences/about/AboutViewModel.kt
@@ -5,6 +5,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.android.launcher3.BuildConfig
import com.android.launcher3.R
+import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -16,9 +17,11 @@ class AboutViewModel(
application: Application,
) : AndroidViewModel(application) {
+ private val api: GitHubService = gitHubApiRetrofit.create()
+
private val nightlyBuildsRepository = NightlyBuildsRepository(
applicationContext = application,
- api = gitHubApiRetrofit.create(),
+ api = api,
)
private val _uiState = MutableStateFlow(AboutUiState())
@@ -56,11 +59,12 @@ class AboutViewModel(
}
}
- fun onEvent(event: AboutEvent) {
- when (event) {
- is AboutEvent.OnDownloadClicked -> nightlyBuildsRepository.downloadUpdate()
- is AboutEvent.OnInstallClicked -> nightlyBuildsRepository.installUpdate(event.file)
- }
+ fun downloadUpdate() {
+ nightlyBuildsRepository.downloadUpdate()
+ }
+
+ fun installUpdate(file: File) {
+ nightlyBuildsRepository.installUpdate(file)
}
private suspend fun fetchActiveContributors(): Set {
@@ -70,6 +74,7 @@ class AboutViewModel(
.toSet()
}.getOrDefault(emptySet())
}
+
companion object {
private val team = listOf(
TeamMember(
diff --git a/lawnchair/src/app/lawnchair/ui/preferences/about/ChangesDialog.kt b/lawnchair/src/app/lawnchair/ui/preferences/about/ChangesDialog.kt
new file mode 100644
index 0000000000..da978c72c7
--- /dev/null
+++ b/lawnchair/src/app/lawnchair/ui/preferences/about/ChangesDialog.kt
@@ -0,0 +1,219 @@
+package app.lawnchair.ui.preferences.about
+
+import android.content.Context
+import android.content.Intent
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.SheetState
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import app.lawnchair.ui.preferences.components.layout.PreferenceGroupItem
+import app.lawnchair.ui.preferences.components.layout.PreferenceTemplate
+import com.android.launcher3.R
+import java.time.Instant
+import java.util.concurrent.TimeUnit
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ChangesDialog(
+ changelogState: ChangelogState?,
+ sheetState: SheetState,
+ onDismiss: () -> Unit,
+ onDownload: () -> Unit,
+) {
+ val commits = changelogState?.commits
+ val currentBuild = changelogState?.currentBuildNumber ?: 0
+ val latestBuild = changelogState?.latestBuildNumber ?: 0
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)) {
+ Text(
+ text = stringResource(R.string.changes_dialog_title),
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ Text(
+ text = stringResource(
+ R.string.changes_dialog_build_format,
+ currentBuild,
+ latestBuild,
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
+ )
+ }
+ LazyColumn(
+ modifier = Modifier
+ .heightIn(max = 600.dp)
+ .fillMaxWidth(),
+ ) {
+ if (commits != null) {
+ itemsIndexed(commits) { index, commit ->
+ PreferenceGroupItem(
+ cutTop = index != 0,
+ cutBottom = index != commits.lastIndex,
+ ) {
+ CommitItem(commit = commit)
+ }
+ Spacer(Modifier.height(3.dp))
+ }
+ } else {
+ item {
+ Text(
+ text = stringResource(R.string.changes_dialog_error),
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+ }
+ Row(
+ horizontalArrangement = Arrangement.End,
+ modifier = Modifier
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .fillMaxWidth(),
+ ) {
+ OutlinedButton(
+ onClick = onDismiss,
+ ) {
+ Text(text = stringResource(android.R.string.cancel))
+ }
+ Spacer(Modifier.width(8.dp))
+ Button(
+ onClick = {
+ onDownload()
+ onDismiss()
+ },
+ ) {
+ Text(text = stringResource(R.string.download_update))
+ }
+ }
+ }
+}
+
+@Composable
+private fun CommitItem(
+ commit: GitHubCommit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+
+ val message = commit.commit.message
+ val title = message.substringBefore("\n").take(100)
+ val description = message.substringAfter("\n", "").take(200)
+
+ PreferenceTemplate(
+ title = {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ description = {
+ if (description.isNotEmpty()) {
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ val timeAgo = getTimeAgo(context, commit.commit.author.date)
+ Text(
+ text = stringResource(
+ R.string.changes_dialog_commit_info,
+ commit.commit.author.name,
+ commit.sha.take(7),
+ timeAgo,
+ ),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+ )
+ },
+ modifier = modifier.clickable {
+ openCommitInBrowser(context, commit.sha)
+ },
+ )
+}
+
+private fun openCommitInBrowser(context: Context, commitSha: String) {
+ val commitUrl = "https://github.com/LawnchairLauncher/lawnchair/commit/$commitSha"
+ val intent = Intent(Intent.ACTION_VIEW, commitUrl.toUri())
+ context.startActivity(intent)
+}
+
+private fun getTimeAgo(context: Context, dateString: String): String {
+ return try {
+ val commitDate = Instant.parse(dateString)
+ val currentTime = Instant.now()
+ val diffMillis = currentTime.toEpochMilli() - commitDate.toEpochMilli()
+
+ when {
+ diffMillis < TimeUnit.MINUTES.toMillis(1) -> {
+ context.getString(R.string.time_just_now)
+ }
+
+ diffMillis < TimeUnit.HOURS.toMillis(1) -> {
+ val minutes = TimeUnit.MILLISECONDS.toMinutes(diffMillis)
+ context.getString(R.string.time_minutes_ago, minutes)
+ }
+
+ diffMillis < TimeUnit.DAYS.toMillis(1) -> {
+ val hours = TimeUnit.MILLISECONDS.toHours(diffMillis)
+ context.getString(R.string.time_hours_ago, hours)
+ }
+
+ diffMillis < TimeUnit.DAYS.toMillis(7) -> {
+ val days = TimeUnit.MILLISECONDS.toDays(diffMillis)
+ context.getString(R.string.time_days_ago, days)
+ }
+
+ diffMillis < TimeUnit.DAYS.toMillis(30) -> {
+ val weeks = TimeUnit.MILLISECONDS.toDays(diffMillis) / 7
+ context.getString(R.string.time_weeks_ago, weeks)
+ }
+
+ diffMillis < TimeUnit.DAYS.toMillis(365) -> {
+ val months = TimeUnit.MILLISECONDS.toDays(diffMillis) / 30
+ context.getString(R.string.time_months_ago, months)
+ }
+
+ else -> {
+ val years = TimeUnit.MILLISECONDS.toDays(diffMillis) / 365
+ context.getString(R.string.time_years_ago, years)
+ }
+ }
+ } catch (e: Exception) {
+ // If date parsing fails, fallback to date prefix
+ dateString.substringBefore("T")
+ }
+}
diff --git a/lawnchair/src/app/lawnchair/ui/preferences/about/GithubService.kt b/lawnchair/src/app/lawnchair/ui/preferences/about/GithubService.kt
index d9d1ed558c..953650a0a6 100644
--- a/lawnchair/src/app/lawnchair/ui/preferences/about/GithubService.kt
+++ b/lawnchair/src/app/lawnchair/ui/preferences/about/GithubService.kt
@@ -28,6 +28,20 @@ interface GitHubService {
@Path("repo") repo: String,
): List
+ @GET("repos/{owner}/{repo}/commits")
+ suspend fun getRepositoryCommits(
+ @Path("owner") owner: String,
+ @Path("repo") repo: String,
+ ): List
+
+ @GET("repos/{owner}/{repo}/compare/{base}...{head}")
+ suspend fun compareCommits(
+ @Path("owner") owner: String,
+ @Path("repo") repo: String,
+ @Path("base") base: String,
+ @Path("head") head: String,
+ ): GitHubCompareResponse
+
@Streaming
@GET
suspend fun downloadFile(@Url fileUrl: String): ResponseBody
@@ -84,6 +98,53 @@ data class GitHubEvent(
)
}
+/**
+ * Represents a GitHub commit.
+ *
+ * @property sha The SHA hash of the commit.
+ * @property commit The commit details.
+ * @property author The author of the commit.
+ */
+@Serializable
+data class GitHubCommit(
+ val sha: String,
+ val commit: CommitDetails,
+ val author: Author? = null,
+) {
+ @Serializable
+ data class CommitDetails(
+ val message: String,
+ val author: CommitAuthor,
+ )
+
+ @Serializable
+ data class CommitAuthor(
+ val name: String,
+ val date: String,
+ )
+
+ @Serializable
+ data class Author(
+ val login: String,
+ )
+}
+
+/**
+ * Represents a GitHub comparison response between two commits.
+ *
+ * @property commits List of commits between the base and head.
+ * @property aheadBy Number of commits ahead.
+ * @property behindBy Number of commits behind.
+ */
+@Serializable
+data class GitHubCompareResponse(
+ val commits: List,
+ @SerialName("ahead_by")
+ val aheadBy: Int,
+ @SerialName("behind_by")
+ val behindBy: Int,
+)
+
internal val gitHubApiRetrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
diff --git a/lawnchair/src/app/lawnchair/ui/preferences/about/NightlyBuildsRepository.kt b/lawnchair/src/app/lawnchair/ui/preferences/about/NightlyBuildsRepository.kt
index 85e48275c0..4403ebe981 100644
--- a/lawnchair/src/app/lawnchair/ui/preferences/about/NightlyBuildsRepository.kt
+++ b/lawnchair/src/app/lawnchair/ui/preferences/about/NightlyBuildsRepository.kt
@@ -30,6 +30,10 @@ class NightlyBuildsRepository(
private val _updateState = MutableStateFlow(UpdateState.UpToDate)
val updateState = _updateState.asStateFlow()
+ private var currentBuildNumber: Int = 0
+ private var latestBuildNumber: Int = 0
+ private var currentCommitHash: String = BuildConfig.COMMIT_HASH
+
fun checkForUpdate() {
coroutineScope.launch(Dispatchers.Default) {
_updateState.update { UpdateState.Checking }
@@ -42,18 +46,29 @@ class NightlyBuildsRepository(
// ..(#)
// This is done inside build.gradle in the source root. Reflect
// changes from there if needed.
- val currentVersion = BuildConfig.VERSION_DISPLAY_NAME
+ currentBuildNumber = BuildConfig.VERSION_DISPLAY_NAME
.substringAfterLast("#")
.removeSuffix(")")
.toIntOrNull() ?: 0
- val latestVersion =
+ latestBuildNumber =
asset?.name?.substringAfter("_")?.substringBefore("-")?.toIntOrNull() ?: 0
- if (asset != null && latestVersion > currentVersion) {
+ if (asset != null && latestBuildNumber > currentBuildNumber) {
+ val commitList = getCommitsSinceCurrentVersion()
+
_updateState.update {
UpdateState.Available(
asset.name,
asset.browserDownloadUrl,
+ changelogState = if (commitList != null) {
+ ChangelogState(
+ commits = commitList,
+ currentBuildNumber = currentBuildNumber,
+ latestBuildNumber = latestBuildNumber,
+ )
+ } else {
+ null
+ },
)
}
} else {
@@ -114,6 +129,27 @@ class NightlyBuildsRepository(
applicationContext.startActivity(intent)
}
+ private suspend fun getCommitsSinceCurrentVersion(): List? {
+ return try {
+ // Get the latest commits (last 100)
+ val commits = api.getRepositoryCommits("LawnchairLauncher", "lawnchair")
+
+ // Find the index of current commit
+ val currentIndex = commits.indexOfFirst { it.sha.startsWith(currentCommitHash) }
+
+ if (currentIndex > 0) {
+ // Return all commits newer than current version
+ commits.take(currentIndex)
+ } else {
+ // If current commit not found, show last N commits
+ commits.take(MAX_FALLBACK_COMMITS)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to get commits", e)
+ null
+ }
+ }
+
private suspend fun downloadApk(url: String, onProgress: (Float) -> Unit): File? {
return try {
val cacheDir = applicationContext.cacheDir
@@ -170,3 +206,5 @@ private fun Context.requestInstallPermission() {
startActivity(intent)
}
}
+
+private const val MAX_FALLBACK_COMMITS = 30
diff --git a/lawnchair/src/app/lawnchair/ui/preferences/about/UpdateSection.kt b/lawnchair/src/app/lawnchair/ui/preferences/about/UpdateSection.kt
index 323da5488b..247cf14305 100644
--- a/lawnchair/src/app/lawnchair/ui/preferences/about/UpdateSection.kt
+++ b/lawnchair/src/app/lawnchair/ui/preferences/about/UpdateSection.kt
@@ -14,11 +14,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.android.launcher3.R
+import java.io.File
@Composable
fun UpdateSection(
updateState: UpdateState,
- onEvent: (AboutEvent) -> Unit,
+ onViewChanges: () -> Unit,
+ onInstall: (File) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@@ -40,8 +42,7 @@ fun UpdateSection(
}
is UpdateState.Available -> {
Button(
- onClick = { onEvent(AboutEvent.OnDownloadClicked) },
- modifier = Modifier.padding(top = 8.dp),
+ onClick = onViewChanges,
) {
Text(text = stringResource(R.string.download_update))
}
@@ -49,7 +50,9 @@ fun UpdateSection(
is UpdateState.Downloading -> {
LinearProgressIndicator(
progress = { updateState.progress },
- modifier = Modifier.fillMaxWidth().padding(16.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
)
Text(
text = "${(updateState.progress * 100).toInt()}%",
@@ -58,8 +61,9 @@ fun UpdateSection(
}
is UpdateState.Downloaded -> {
Button(
- onClick = { onEvent(AboutEvent.OnInstallClicked(updateState.file)) },
- modifier = Modifier.padding(top = 8.dp),
+ onClick = {
+ onInstall(updateState.file)
+ },
) {
Text(text = stringResource(R.string.install_update))
}