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)) }