refactor(about): migrate screen to ViewModel and UDF (#5698)

* feat(about): add viewmodel and UDF state models

* feat(about): add new repository and service

* feat(about): refactor About screen and related components

* refactor(about): remove legacy update checker file

* refactor(about): improve robustness of NightlyBuildsRepository

This change also tweaks the code to use application storage instead of external storage.

* refactor(acknowledgements): simplify Acknowledgements implementation

* fix(about): add missing strings

* refactor(acknowledgements): apply suggestions from review

* refactor(about): simplify nightly builds repository

* refactor(about): apply changes from review

* refactor(acknowledgements): apply changes from review

* fixup: apply review suggestions from Goooler
This commit is contained in:
SuperDragonXD
2025-08-02 18:01:14 +08:00
committed by GitHub
parent 3c3cf45940
commit df3632daae
17 changed files with 997 additions and 760 deletions
+4
View File
@@ -457,6 +457,10 @@
<string name="translate">Translate</string>
<string name="donate">Donate</string>
<string name="contributor_status_active">Active</string>
<string name="update_check_failed">Failed to check for updates</string>
<!--
Backup and restore
+1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="log_files" path="logs"/>
<cache-path name="nightly_updates" path="updates" />
<external-path name="external_files" path="." />
<external-path name="external_storage" path="/"/>
</paths>
@@ -1,57 +0,0 @@
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,
)
@@ -16,12 +16,10 @@
package app.lawnchair.ui.preferences
import app.lawnchair.ui.preferences.about.acknowledgements.OssLibrary
import app.lawnchair.ui.preferences.destinations.IconPackInfo
import kotlinx.coroutines.flow.StateFlow
sealed interface PreferenceInteractor {
val ossLibraries: StateFlow<List<OssLibrary>>
val iconPacks: StateFlow<List<IconPackInfo>>
val themedIconPacks: StateFlow<List<IconPackInfo>>
}
@@ -22,15 +22,12 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import app.lawnchair.icons.CustomAdaptiveIconDrawable
import app.lawnchair.ui.preferences.about.acknowledgements.OssLibrary
import app.lawnchair.ui.preferences.destinations.IconPackInfo
import app.lawnchair.util.Constants.LAWNICONS_PACKAGE_NAME
import app.lawnchair.util.getPackageVersionCode
import app.lawnchair.util.kotlinxJson
import com.android.launcher3.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
@@ -110,18 +107,4 @@ class PreferenceViewModel(private val app: Application) :
}
.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.Lazily, listOf())
override val ossLibraries: StateFlow<List<OssLibrary>> = flow {
val jsonString = app.resources.assets.open("app/cash/licensee/artifacts.json")
.bufferedReader().use { it.readText() }
val ossLibraries = kotlinxJson.decodeFromString<List<OssLibrary>>(jsonString)
.asSequence()
.filter { it.name != OssLibrary.UNKNOWN_NAME }
.distinctBy { "${it.groupId}:${it.artifactId}" }
.sortedBy { it.name }
.toList()
emit(ossLibraries)
}
.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
}
@@ -17,12 +17,9 @@
package app.lawnchair.ui.preferences.about
import android.content.Intent
import android.net.Uri
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -34,244 +31,112 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
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.ui.preferences.components.layout.PreferenceDivider
import app.lawnchair.ui.preferences.components.layout.PreferenceGroupItem
import app.lawnchair.ui.preferences.components.layout.PreferenceLayoutLazyColumn
import app.lawnchair.ui.preferences.components.layout.preferenceGroupItems
import app.lawnchair.ui.preferences.navigation.AboutLicenses
import app.lawnchair.util.FileAccessManager
import app.lawnchair.util.FileAccessState
import com.android.launcher3.BuildConfig
import com.android.launcher3.R
private enum class Role(val descriptionResId: Int) {
Development(descriptionResId = R.string.development),
DevOps(descriptionResId = R.string.devops),
QuickSwitchMaintenance(descriptionResId = R.string.quickswitch_maintenance),
Support(descriptionResId = R.string.support),
SupportAndPr(descriptionResId = R.string.support_and_pr),
}
private data class TeamMember(
val name: String,
val role: Role,
val photoUrl: String,
val socialUrl: String,
var githubUsername: String? = null,
)
private data class Link(
@DrawableRes val iconResId: Int,
@StringRes val labelResId: Int,
val url: String,
)
private val product = listOf(
TeamMember(
name = "Amogh Lele",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/31761843",
socialUrl = "https://www.linkedin.com/in/amogh-lele/",
),
TeamMember(
name = "Antonio J. Roa Valverde",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/914983",
socialUrl = "https://x.com/6020peaks",
),
TeamMember(
name = "David Sn",
role = Role.DevOps,
photoUrl = "https://i.imgur.com/b65akTl.png",
socialUrl = "https://codebucket.de",
),
TeamMember(
name = "Goooler",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/10363352",
socialUrl = "https://github.com/Goooler",
githubUsername = "Goooler",
),
TeamMember(
name = "Harsh Shandilya",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/13348378",
socialUrl = "https://github.com/msfjarvis",
),
TeamMember(
name = "John Andrew Camu (MrSluffy)",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/36076410",
socialUrl = "https://github.com/MrSluffy",
githubUsername = "MrSluffy",
),
TeamMember(
name = "Kshitij Gupta",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/18647641",
socialUrl = "https://x.com/Agent_Fabulous",
),
TeamMember(
name = "Manuel Lorenzo",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/183264",
socialUrl = "https://x.com/noloman",
),
TeamMember(
name = "paphonb",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/8080853",
socialUrl = "https://x.com/paphonb",
),
TeamMember(
name = "raphtlw",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/47694127",
socialUrl = "https://x.com/raphtlw",
),
TeamMember(
name = "Rhyse Simpson",
role = Role.QuickSwitchMaintenance,
photoUrl = "https://avatars.githubusercontent.com/u/7065700",
socialUrl = "https://x.com/skittles9823",
),
TeamMember(
name = "SuperDragonXD",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/70206496",
socialUrl = "https://github.com/SuperDragonXD",
githubUsername = "SuperDragonXD",
),
TeamMember(
name = "Yasan Glass",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/41836211",
socialUrl = "https://yasan.glass",
githubUsername = "yasanglass",
),
)
private val supportAndPr = listOf(
TeamMember(
name = "Daniel Souza",
role = Role.Support,
photoUrl = "https://avatars.githubusercontent.com/u/32078304",
socialUrl = "https://github.com/DanGLES3",
),
TeamMember(
name = "Giuseppe Longobardo",
role = Role.Support,
photoUrl = "https://avatars.githubusercontent.com/u/49398464",
socialUrl = "https://github.com/joseph-20",
),
TeamMember(
name = "Rik Koedoot",
role = Role.SupportAndPr,
photoUrl = "https://avatars.githubusercontent.com/u/29402532",
socialUrl = "https://x.com/rikkoedoot",
),
)
private val links = listOf(
Link(
iconResId = R.drawable.ic_new_releases,
labelResId = R.string.news,
url = "https://t.me/lawnchairci",
),
Link(
iconResId = R.drawable.ic_help,
labelResId = R.string.support,
url = "https://t.me/lccommunity",
),
Link(
iconResId = R.drawable.ic_x_twitter,
labelResId = R.string.x_twitter,
url = "https://x.com/lawnchairapp",
),
Link(
iconResId = R.drawable.ic_github,
labelResId = R.string.github,
url = "https://github.com/LawnchairLauncher/lawnchair",
),
Link(
iconResId = R.drawable.ic_discord,
labelResId = R.string.discord,
url = "https://discord.com/invite/3x8qNWxgGZ",
),
)
object AboutRoutes {
const val LICENSES = "licenses"
}
@Composable
fun About(
modifier: Modifier = Modifier,
viewModel: AboutViewModel = viewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
PreferenceLayout(
PreferenceLayoutLazyColumn(
label = stringResource(id = R.string.about_label),
modifier = modifier,
backArrowVisible = !LocalIsExpandedScreen.current,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier = Modifier.padding(top = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(id = R.drawable.ic_launcher_home_comp),
contentDescription = null,
item {
Spacer(Modifier.padding(top = 8.dp))
}
item {
Row(
modifier = Modifier
.size(72.dp)
.clip(CircleShape),
)
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
Image(
painter = painterResource(id = R.drawable.ic_launcher_home_comp),
contentDescription = null,
modifier = Modifier
.size(72.dp)
.clip(CircleShape),
)
}
}
item {
Spacer(modifier = Modifier.height(12.dp))
}
item {
Text(
text = stringResource(id = R.string.derived_app_name),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Text(
text = BuildConfig.VERSION_DISPLAY_NAME,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.combinedClickable(
onClick = {},
onLongClick = {
val commitUrl = "https://github.com/LawnchairLauncher/lawnchair/commit/${BuildConfig.COMMIT_HASH}"
context.startActivity(Intent(Intent.ACTION_VIEW, commitUrl.toUri()))
},
),
)
val fileAccessManager = remember { FileAccessManager.getInstance(context) }
val allFileAccessState = fileAccessManager.allFilesAccessState.collectAsStateWithLifecycle().value
if (BuildConfig.APPLICATION_ID.contains("nightly") &&
allFileAccessState == FileAccessState.Full
}
item {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
Spacer(modifier = Modifier.height(8.dp))
CheckUpdate()
Text(
text = BuildConfig.VERSION_DISPLAY_NAME,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.combinedClickable(
onClick = {},
onLongClick = {
val commitUrl =
"https://github.com/LawnchairLauncher/lawnchair/commit/${BuildConfig.COMMIT_HASH}"
context.startActivity(Intent(Intent.ACTION_VIEW, commitUrl.toUri()))
},
),
)
}
}
item {
Spacer(modifier = Modifier.height(8.dp))
}
item {
UpdateSection(
updateState = uiState.updateState,
onEvent = viewModel::onEvent,
)
}
item {
Spacer(modifier = Modifier.requiredHeight(16.dp))
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
links.forEach { link ->
uiState.links.forEach { link ->
LawnchairLink(
iconResId = link.iconResId,
label = stringResource(id = link.labelResId),
@@ -281,53 +146,75 @@ fun About(
}
}
}
PreferenceGroup(heading = stringResource(id = R.string.product)) {
product.forEach {
ContributorRow(
name = it.name,
description = stringResource(it.role.descriptionResId),
url = it.socialUrl,
photoUrl = it.photoUrl,
githubUsername = it.githubUsername,
preferenceGroupItems(
items = uiState.coreTeam,
key = { _, it -> it.name },
isFirstChild = false,
heading = { stringResource(id = R.string.product) },
) { _, it ->
ContributorRow(
member = it,
)
}
preferenceGroupItems(
items = uiState.supportAndPr,
key = { _, it -> it.name },
isFirstChild = false,
heading = { stringResource(id = R.string.support_and_pr) },
) { _, it ->
ContributorRow(
member = it,
)
}
item {
Spacer(modifier = Modifier.requiredHeight(16.dp))
}
item {
PreferenceGroupItem(
cutTop = false,
cutBottom = true,
) {
NavigationActionPreference(
label = stringResource(id = R.string.acknowledgements),
destination = AboutLicenses,
)
}
}
PreferenceGroup(heading = stringResource(id = R.string.support_and_pr)) {
supportAndPr.forEach {
ContributorRow(
name = it.name,
description = stringResource(it.role.descriptionResId),
url = it.socialUrl,
photoUrl = it.photoUrl,
githubUsername = null,
item {
PreferenceGroupItem(
cutTop = true,
cutBottom = true,
) {
PreferenceDivider()
ClickablePreference(
label = stringResource(id = R.string.translate),
onClick = {
val webpage = CROWDIN_URL.toUri()
val intent = Intent(Intent.ACTION_VIEW, webpage)
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
}
},
)
}
}
PreferenceGroup {
NavigationActionPreference(
label = stringResource(id = R.string.acknowledgements),
destination = AboutLicenses,
)
ClickablePreference(
label = stringResource(id = R.string.translate),
onClick = {
val webpage = Uri.parse(CROWDIN_URL)
val intent = Intent(Intent.ACTION_VIEW, webpage)
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
}
},
)
ClickablePreference(
label = stringResource(id = R.string.donate),
onClick = {
val webpage = Uri.parse(OPENCOLLECTIVE_FUNDING_URL)
val intent = Intent(Intent.ACTION_VIEW, webpage)
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
}
},
)
item {
PreferenceGroupItem(
cutTop = true,
cutBottom = false,
) {
PreferenceDivider()
ClickablePreference(
label = stringResource(id = R.string.donate),
onClick = {
val webpage = OPENCOLLECTIVE_FUNDING_URL.toUri()
val intent = Intent(Intent.ACTION_VIEW, webpage)
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
}
},
)
}
}
}
}
@@ -0,0 +1,146 @@
package app.lawnchair.ui.preferences.about
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.android.launcher3.R
import java.io.File
/**
* Represents the UI state for the "About" screen.
*
* This data class holds all the information displayed on the "About" screen,
* including application version details, team member lists, relevant links,
* and the current state of the update checker.
*
* @param versionName The current version name of the application.
* @param commitHash The commit hash of the current build.
* @param coreTeam A list of [TeamMember] objects representing the core development team.
* @param supportAndPr A list of [TeamMember] objects representing those involved in support and public relations.
* @param links A list of [Link] objects representing useful external links (e.g., social media, website).
* @param updateState The current [UpdateState] of the application's update checker.
*/
data class AboutUiState(
val versionName: String = "",
val commitHash: String = "",
val coreTeam: List<TeamMember> = emptyList(),
val supportAndPr: List<TeamMember> = emptyList(),
val links: List<Link> = emptyList(),
val updateState: UpdateState = UpdateState.Hidden,
)
/**
* Represents a team member involved in the project.
*
* This data class stores information about a team member, including their name,
* role, photo, social media link, GitHub username, and current contribution status.
*
* @param name The name of the team member.
* @param role The [Role] of the team member within the project.
* @param photoUrl The URL of the team member's profile photo.
* @param socialUrl The URL to the team member's primary social media profile or website.
* @param githubUsername The team member's GitHub username, if available. Defaults to `null`.
* @param status The current [ContributorStatus] of the team member. Defaults to [ContributorStatus.Idle].
*/
data class TeamMember(
val name: String,
val role: Role,
val photoUrl: String,
val socialUrl: String,
val githubUsername: String? = null,
val status: ContributorStatus = ContributorStatus.Idle,
)
/**
* Represents the role of a team member within the project.
*
* This enum class defines the different roles a contributor can have,
* each associated with a string resource ID for its description.
*
* @param descriptionResId The resource ID of the string describing the role.
*/
enum class Role(val descriptionResId: Int) {
Development(descriptionResId = R.string.development),
DevOps(descriptionResId = R.string.devops),
QuickSwitchMaintenance(descriptionResId = R.string.quickswitch_maintenance),
Support(descriptionResId = R.string.support),
SupportAndPr(descriptionResId = R.string.support_and_pr),
}
/**
* Represents a social or community link.
*
* This data class is used to store information about external links,
* such as links to social media profiles or community forums.
*
* @param iconResId The resource ID of the drawable to be used as the icon for the link.
* @param labelResId The resource ID of the string to be used as the label for the link.
* @param url The URL string that the link points to.
*/
data class Link(
@DrawableRes val iconResId: Int,
@StringRes val labelResId: Int,
val url: String,
)
/**
* Sealed interface representing the state of the update checker.
*
* This interface defines the different states the update checker can be in,
* from hidden to downloaded, including intermediate states like checking and downloading.
*/
sealed interface UpdateState {
/** The update checker is hidden, typically because it's not a nightly build or update checking is disabled. */
data object Hidden : UpdateState
/** The update checker is currently checking for updates. */
data object Checking : UpdateState
/** The application is up to date, no new updates available. */
data object UpToDate : UpdateState
/**
* A new update is available. Contains the name and URL of the update.
* @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
/**
* An update is currently being downloaded. Contains the download progress.
* @param progress The progress of the download, as a float between 0.0 and 1.0 (used in `Downloading` state).
*/
data class Downloading(val progress: Float) : UpdateState
/**
* An update has been successfully downloaded. Contains the downloaded file.
* @param file The [File] object representing the downloaded update package (used in `Downloaded` state).
*/
data class Downloaded(val file: File) : UpdateState
/** An update download has failed. */
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
}
/**
* Represents the status of a contributor.
*
* This enum is used to indicate whether a team member is currently active in the project or is currently idle.
*
* @property Active Indicates that the contributor is currently active.
* @property Idle Indicates that the contributor is currently idle.
*/
enum class ContributorStatus {
Active,
Idle,
}
@@ -0,0 +1,207 @@
package app.lawnchair.ui.preferences.about
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.android.launcher3.BuildConfig
import com.android.launcher3.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import retrofit2.create
class AboutViewModel(
application: Application,
) : AndroidViewModel(application) {
private val nightlyBuildsRepository = NightlyBuildsRepository(
applicationContext = application,
api = gitHubApiRetrofit.create(),
)
private val _uiState = MutableStateFlow(AboutUiState())
val uiState = _uiState.asStateFlow()
val updateState = nightlyBuildsRepository.updateState
init {
_uiState.update {
it.copy(
versionName = BuildConfig.VERSION_NAME,
commitHash = BuildConfig.COMMIT_HASH,
coreTeam = team,
supportAndPr = supportAndPr,
links = links,
)
}
viewModelScope.launch(Dispatchers.Default) {
val activeContributors = fetchActiveContributors()
val updatedCoreTeam = _uiState.value.coreTeam.map { member ->
val status = if (member.githubUsername != null && activeContributors.contains(member.githubUsername.lowercase())) ContributorStatus.Active else ContributorStatus.Idle
member.copy(status = status)
}
_uiState.update { it.copy(coreTeam = updatedCoreTeam) }
}
if (BuildConfig.APPLICATION_ID.contains("nightly")) {
nightlyBuildsRepository.checkForUpdate()
viewModelScope.launch {
nightlyBuildsRepository.updateState.collect { state ->
_uiState.update { it.copy(updateState = state) }
}
}
}
}
fun onEvent(event: AboutEvent) {
when (event) {
is AboutEvent.OnDownloadClicked -> nightlyBuildsRepository.downloadUpdate()
is AboutEvent.OnInstallClicked -> nightlyBuildsRepository.installUpdate(event.file)
}
}
private suspend fun fetchActiveContributors(): Set<String> {
return runCatching {
nightlyBuildsRepository.api.getRepositoryEvents("LawnchairLauncher", "lawnchair")
.map { it.actor.login.lowercase() }
.toSet()
}.getOrDefault(emptySet())
}
companion object {
private val team = listOf(
TeamMember(
name = "Amogh Lele",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/31761843",
socialUrl = "https://www.linkedin.com/in/amogh-lele/",
),
TeamMember(
name = "Antonio J. Roa Valverde",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/914983",
socialUrl = "https://x.com/6020peaks",
),
TeamMember(
name = "David Sn",
role = Role.DevOps,
photoUrl = "https://i.imgur.com/b65akTl.png",
socialUrl = "https://codebucket.de",
),
TeamMember(
name = "Goooler",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/10363352",
socialUrl = "https://github.com/Goooler",
githubUsername = "Goooler",
),
TeamMember(
name = "Harsh Shandilya",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/13348378",
socialUrl = "https://github.com/msfjarvis",
),
TeamMember(
name = "John Andrew Camu (MrSluffy)",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/36076410",
socialUrl = "https://github.com/MrSluffy",
githubUsername = "MrSluffy",
),
TeamMember(
name = "Kshitij Gupta",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/18647641",
socialUrl = "https://x.com/Agent_Fabulous",
),
TeamMember(
name = "Manuel Lorenzo",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/183264",
socialUrl = "https://x.com/noloman",
),
TeamMember(
name = "paphonb",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/8080853",
socialUrl = "https://x.com/paphonb",
),
TeamMember(
name = "raphtlw",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/47694127",
socialUrl = "https://x.com/raphtlw",
),
TeamMember(
name = "Rhyse Simpson",
role = Role.QuickSwitchMaintenance,
photoUrl = "https://avatars.githubusercontent.com/u/7065700",
socialUrl = "https://x.com/skittles9823",
),
TeamMember(
name = "SuperDragonXD",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/70206496",
socialUrl = "https://github.com/SuperDragonXD",
githubUsername = "SuperDragonXD",
),
TeamMember(
name = "Yasan Glass",
role = Role.Development,
photoUrl = "https://avatars.githubusercontent.com/u/41836211",
socialUrl = "https://yasan.glass",
githubUsername = "yasanglass",
),
)
private val links = listOf(
Link(
iconResId = R.drawable.ic_new_releases,
labelResId = R.string.news,
url = "https://t.me/lawnchairci",
),
Link(
iconResId = R.drawable.ic_help,
labelResId = R.string.support,
url = "https://t.me/lccommunity",
),
Link(
iconResId = R.drawable.ic_x_twitter,
labelResId = R.string.x_twitter,
url = "https://x.com/lawnchairapp",
),
Link(
iconResId = R.drawable.ic_github,
labelResId = R.string.github,
url = "https://github.com/LawnchairLauncher/lawnchair",
),
Link(
iconResId = R.drawable.ic_discord,
labelResId = R.string.discord,
url = "https://discord.com/invite/3x8qNWxgGZ",
),
)
private val supportAndPr = listOf(
TeamMember(
name = "Daniel Souza",
role = Role.Support,
photoUrl = "https://avatars.githubusercontent.com/u/32078304",
socialUrl = "https://github.com/DanGLES3",
),
TeamMember(
name = "Giuseppe Longobardo",
role = Role.Support,
photoUrl = "https://avatars.githubusercontent.com/u/49398464",
socialUrl = "https://github.com/joseph-20",
),
TeamMember(
name = "Rik Koedoot",
role = Role.SupportAndPr,
photoUrl = "https://avatars.githubusercontent.com/u/29402532",
socialUrl = "https://x.com/rikkoedoot",
),
)
}
}
@@ -17,7 +17,6 @@
package app.lawnchair.ui.preferences.about
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -27,74 +26,81 @@ import androidx.compose.foundation.shape.CircleShape
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.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
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.lawnchair.api.gh.api
import androidx.core.net.toUri
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 coil.compose.SubcomposeAsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.android.launcher3.R
suspend fun checkUserContribution(userName: String): String {
return withContext(Dispatchers.IO) {
try {
val events = api.getRepositoryEvents("LawnchairLauncher", "lawnchair")
val isActive = events.any {
it.actor.login == userName
}
if (isActive) "Active" else "Idle"
} catch (e: Exception) {
e.printStackTrace()
""
}
/**
* Displays a row with contributor information.
*
* @param member The [TeamMember] data for the contributor.
* @param modifier Optional [Modifier] for customization.
*/
@Composable
fun ContributorRow(
member: TeamMember,
modifier: Modifier = Modifier,
) {
val statusText = when (member.status) {
ContributorStatus.Active -> stringResource(R.string.contributor_status_active)
ContributorStatus.Idle -> ""
}
val context = LocalContext.current
val description = "${
stringResource(member.role.descriptionResId)
} ${if (member.status == ContributorStatus.Active && statusText.isNotBlank()) "$statusText" else ""}"
ContributorRow(
name = member.name,
description = description,
photoUrl = member.photoUrl,
onClick = {
val webpage = member.socialUrl.toUri()
val intent = Intent(Intent.ACTION_VIEW, webpage)
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
}
},
modifier = modifier,
)
}
/**
* Displays a row with contributor information.
*
* @param name The name of the contributor.
* @param description The role and status of the contributor.
* @param photoUrl The URL of the contributor's photo.
* @param onClick The action to perform when the row is clicked.
* @param modifier Optional [Modifier] for customization.
*/
@Composable
fun ContributorRow(
name: String,
description: String,
photoUrl: String,
url: String,
githubUsername: String?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var contributionStatus by remember { mutableStateOf("") }
if (githubUsername != null) {
LaunchedEffect(githubUsername) {
coroutineScope.launch {
contributionStatus = checkUserContribution(githubUsername)
}
}
}
PreferenceTemplate(
title = { Text(text = name) },
modifier = modifier
.clickable {
val webpage = Uri.parse(url)
val intent = Intent(Intent.ACTION_VIEW, webpage)
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
}
},
description = { Text(text = "$description ${if (!contributionStatus.isBlank() or !contributionStatus.isEmpty()) "•" else ""} $contributionStatus") },
.clickable(onClick = onClick),
description = {
Text(
text = description,
)
},
startWidget = {
SubcomposeAsyncImage(
model = photoUrl,
@@ -0,0 +1,94 @@
package app.lawnchair.ui.preferences.about
import app.lawnchair.util.kotlinxJson
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Streaming
import retrofit2.http.Url
/**
* Interface for interacting with the GitHub API.
*
* This interface defines methods for fetching data from GitHub, such as releases and repository events.
* It uses Retrofit for making HTTP requests and kotlinx.serialization for JSON parsing.
*/
interface GitHubService {
@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>
@Streaming
@GET
suspend fun downloadFile(@Url fileUrl: String): ResponseBody
}
/**
* Represents a GitHub release.
*
* @property tagName The tag name of the release.
* @property assets A list of assets associated with the release.
*/
@Serializable
data class GitHubRelease(
@SerialName("tag_name")
val tagName: String,
val assets: List<GitHubAsset>,
) {
/**
* Represents an asset associated with a GitHub release.
*
* @property name The name of the asset.
* @property browserDownloadUrl The URL to download the asset from a browser.
*/
@Serializable
data class GitHubAsset(
val name: String,
@SerialName("browser_download_url")
val browserDownloadUrl: String,
)
}
/**
* Represents a GitHub event.
*
* @property type The type of the event (e.g., "PushEvent", "PullRequestEvent").
* @property actor The actor who triggered the event.
* @property createdAt The timestamp when the event was created.
*/
@Serializable
data class GitHubEvent(
val type: String,
val actor: Actor,
@SerialName("created_at")
val createdAt: String,
) {
/**
* Represents the actor (user) who triggered a GitHub event.
*
* @property login The username of the actor.
*/
@Serializable
data class Actor(
val login: String,
)
}
internal val gitHubApiRetrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(kotlinxJson.asConverterFactory("application/json".toMediaType()))
.build()
}
private const val BASE_URL = "https://api.github.com/"
@@ -0,0 +1,168 @@
package app.lawnchair.ui.preferences.about
import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.util.Log
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import com.android.launcher3.BuildConfig
import com.android.launcher3.Utilities
import java.io.File
import java.io.IOException
import kotlin.io.path.createDirectories
import kotlin.io.path.deleteIfExists
import kotlin.io.path.outputStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class NightlyBuildsRepository(
val applicationContext: Context,
val api: GitHubService,
) {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _updateState = MutableStateFlow<UpdateState>(UpdateState.UpToDate)
val updateState = _updateState.asStateFlow()
fun checkForUpdate() {
coroutineScope.launch(Dispatchers.Default) {
_updateState.update { UpdateState.Checking }
try {
val releases = api.getReleases()
val nightly = releases.firstOrNull { it.tagName == "nightly" }
val asset = nightly?.assets?.firstOrNull()
val currentVersion = BuildConfig.VERSION_DISPLAY_NAME
.substringAfter("_")
.substringBefore("-")
.toIntOrNull() ?: 0
val latestVersion =
asset?.name?.substringAfter("_")?.substringBefore("-")?.toIntOrNull() ?: 0
if (asset != null && latestVersion > currentVersion) {
_updateState.update {
UpdateState.Available(
asset.name,
asset.browserDownloadUrl,
)
}
} else {
_updateState.update { UpdateState.UpToDate }
}
} catch (e: Exception) {
when (e) {
is IOException -> {
Log.e(TAG, "Network error during update check", e)
}
else -> {
Log.e(TAG, "Failed to check for update", e)
}
}
_updateState.update { UpdateState.Failed }
}
}
}
fun downloadUpdate() {
val currentState = _updateState.value
if (currentState !is UpdateState.Available) return
coroutineScope.launch(Dispatchers.IO) {
_updateState.update { UpdateState.Downloading(0f) }
try {
val file = downloadApk(currentState.url) { progress ->
_updateState.update { UpdateState.Downloading(progress) }
}
if (file != null) {
_updateState.update { UpdateState.Downloaded(file) }
} else {
Log.e(TAG, "Downloaded file is null")
_updateState.update { UpdateState.Failed }
}
} catch (e: Exception) {
Log.e(TAG, "Download failed", e)
_updateState.update { UpdateState.Failed }
}
}
}
fun installUpdate(file: File) {
if (!applicationContext.hasInstallPermission()) {
// todo expose proper permission UI instead of requesting immediately on click
applicationContext.requestInstallPermission()
return
}
val uri = FileProvider.getUriForFile(
applicationContext,
"${BuildConfig.APPLICATION_ID}.fileprovider",
file,
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
applicationContext.startActivity(intent)
}
private suspend fun downloadApk(url: String, onProgress: (Float) -> Unit): File? {
return try {
val cacheDir = applicationContext.cacheDir
val apkDirPath = cacheDir.toPath().resolve("updates").createDirectories()
val apkFilePath = apkDirPath.resolve("Lawnchair-update.apk").apply { deleteIfExists() }
val responseBody = api.downloadFile(url)
val totalBytes = responseBody.contentLength().toFloat()
if (totalBytes <= 0) {
Log.w(TAG, "Content length is invalid: $totalBytes")
return null
}
responseBody.byteStream().use { input ->
apkFilePath.outputStream().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)
}
}
}
apkFilePath.toFile()
} catch (e: Exception) {
Log.e(TAG, "APK download failed", e)
null
}
}
companion object {
private const val TAG = "NightlyBuildsRepository"
}
}
private fun Context.hasInstallPermission(): Boolean {
return if (Utilities.ATLEAST_O) {
packageManager.canRequestPackageInstalls()
} else {
true
}
}
private fun Context.requestInstallPermission() {
if (Utilities.ATLEAST_O) {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
"package:$packageName".toUri(),
).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(intent)
}
}
@@ -0,0 +1,77 @@
package app.lawnchair.ui.preferences.about
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.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.android.launcher3.R
@Composable
fun UpdateSection(
updateState: UpdateState,
onEvent: (AboutEvent) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (updateState) {
UpdateState.Hidden -> { /* Render nothing */ }
UpdateState.Checking -> {
CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp))
}
UpdateState.UpToDate -> {
Text(
text = stringResource(R.string.pro_updated),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
is UpdateState.Available -> {
Button(
onClick = { onEvent(AboutEvent.OnDownloadClicked) },
modifier = Modifier.padding(top = 8.dp),
) {
Text(text = stringResource(R.string.download_update))
}
}
is UpdateState.Downloading -> {
LinearProgressIndicator(
progress = { updateState.progress },
modifier = Modifier.fillMaxWidth().padding(16.dp),
)
Text(
text = "${(updateState.progress * 100).toInt()}%",
style = MaterialTheme.typography.bodySmall,
)
}
is UpdateState.Downloaded -> {
Button(
onClick = { onEvent(AboutEvent.OnInstallClicked(updateState.file)) },
modifier = Modifier.padding(top = 8.dp),
) {
Text(text = stringResource(R.string.install_update))
}
}
UpdateState.Failed -> {
Text(
text = stringResource(R.string.update_check_failed),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
}
@@ -16,129 +16,59 @@
package app.lawnchair.ui.preferences.about.acknowledgements
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.lawnchair.ui.ModalBottomSheetContent
import app.lawnchair.ui.preferences.LocalPreferenceInteractor
import app.lawnchair.ui.preferences.components.layout.LoadingScreen
import androidx.lifecycle.viewmodel.compose.viewModel
import app.lawnchair.ui.preferences.components.controls.ClickablePreference
import app.lawnchair.ui.preferences.components.layout.PreferenceLayoutLazyColumn
import app.lawnchair.ui.preferences.components.layout.PreferenceTemplate
import app.lawnchair.ui.preferences.components.layout.preferenceGroupItems
import app.lawnchair.ui.util.bottomSheetHandler
import com.android.launcher3.R
@Composable
fun Acknowledgements(
modifier: Modifier = Modifier,
viewModel: AcknowledgementsViewModel = viewModel(),
) {
val ossLibraries by LocalPreferenceInteractor.current.ossLibraries.collectAsStateWithLifecycle()
LoadingScreen(
obj = ossLibraries,
val ossLibraries by viewModel.ossLibraries.collectAsStateWithLifecycle()
PreferenceLayoutLazyColumn(
label = stringResource(id = R.string.acknowledgements),
modifier = modifier,
) { libraries ->
PreferenceLayoutLazyColumn(
label = stringResource(id = R.string.acknowledgements),
) {
preferenceGroupItems(libraries, isFirstChild = true) { index, library ->
OssLibraryItem(
ossLibrary = library,
index = index,
)
}
) {
preferenceGroupItems(ossLibraries, isFirstChild = true) { _, library ->
OssLibraryItem(
name = library.name,
license = library.license,
)
}
}
}
@Composable
fun OssLibraryItem(
ossLibrary: OssLibrary,
index: Int,
name: String,
license: OssLibrary.License?,
modifier: Modifier = Modifier,
) {
val bottomSheetHandler = bottomSheetHandler
val context = LocalContext.current
PreferenceTemplate(
title = {
Text(
text = ossLibrary.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
modifier = modifier
.clickable {
bottomSheetHandler.show {
NoticePage(ossLibrary = ossLibrary)
ClickablePreference(
label = name,
modifier = modifier,
subtitle = license?.name,
onClick = {
license?.url?.let { urlString ->
val webpage = urlString.toUri()
val intent = Intent(Intent.ACTION_VIEW, webpage)
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
}
},
}
},
)
}
@Composable
fun NoticePage(
ossLibrary: OssLibrary?,
modifier: Modifier = Modifier,
) {
val dataState = ossLibrary?.let { loadNotice(ossLibrary = it) }
val data = dataState?.value
ModalBottomSheetContent(
title = {
Text(text = ossLibrary?.name ?: stringResource(id = R.string.loading))
},
buttons = {},
modifier = modifier,
) {
Column {
Crossfade(targetState = data, label = "") { it ->
it ?: return@Crossfade
val uriHandler = LocalUriHandler.current
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator = Modifier.pointerInput(Unit) {
detectTapGestures { pos ->
layoutResult.value?.let { layoutResult ->
val position = layoutResult.getOffsetForPosition(pos)
val annotation =
it.notice.getStringAnnotations(position, position).firstOrNull()
if (annotation?.tag == "URL") {
uriHandler.openUri(annotation.item)
}
}
}
}
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp)
.then(pressIndicator),
text = it.notice,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
onTextLayout = {
layoutResult.value = it
},
)
}
}
}
}
@@ -0,0 +1,51 @@
package app.lawnchair.ui.preferences.about.acknowledgements
import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import app.lawnchair.util.kotlinxJson
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
class AcknowledgementsViewModel(
application: Application,
) : AndroidViewModel(application) {
val ossLibraries: StateFlow<List<OssLibrary>> = flow {
val jsonString = application.resources.assets.open(ACKNOWLEDGEMENTS_FILE_PATH)
.bufferedReader().use { it.readText() }
val libraries = kotlinxJson.decodeFromString<List<OssLibrary>>(jsonString)
.asSequence()
.distinctBy { "${it.groupId}:${it.artifactId}" }
.sortedBy { it.name }
.toList()
emit(libraries)
}
.catch { e ->
val errorMessage = when (e) {
is IOException -> "Error reading acknowledgements file"
is kotlinx.serialization.SerializationException -> "Error parsing acknowledgements JSON"
else -> "Unexpected error in ossLibraries flow"
}
Log.e(TAG, errorMessage, e)
emit(emptyList())
}
.flowOn(Dispatchers.IO)
.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = emptyList(),
)
companion object {
private const val ACKNOWLEDGEMENTS_FILE_PATH = "app/cash/licensee/artifacts.json"
private const val TAG = "AcknowledgementsViewmModel"
}
}
@@ -1,92 +0,0 @@
/*
* Copyright 2022, Lawnchair
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package app.lawnchair.ui.preferences.about.acknowledgements
import android.text.SpannableString
import android.text.style.URLSpan
import android.text.util.Linkify
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import kotlinx.serialization.Serializable
@Composable
fun loadNotice(ossLibrary: OssLibrary): State<OssLibraryWithNotice?> {
val noticeStringState = remember { mutableStateOf<OssLibraryWithNotice?>(null) }
val accentColor = MaterialTheme.colorScheme.primary
DisposableEffect(Unit) {
val string = (ossLibrary.spdxLicenses ?: ossLibrary.unknownLicenses)
?.firstOrNull()?.url.orEmpty()
val spannable = SpannableString(string)
Linkify.addLinks(spannable, Linkify.WEB_URLS)
val spans = spannable.getSpans(0, string.length, URLSpan::class.java)
val annotatedString = buildAnnotatedString {
append(string)
spans.forEach { urlSpan ->
val start = spannable.getSpanStart(urlSpan)
val end = spannable.getSpanEnd(urlSpan)
addStyle(
style = SpanStyle(
color = accentColor,
textDecoration = TextDecoration.Underline,
),
start = start,
end = end,
)
addStringAnnotation(
tag = "URL",
annotation = urlSpan.url,
start = start,
end = end,
)
}
}
noticeStringState.value = OssLibraryWithNotice(ossLibrary, annotatedString)
onDispose { }
}
return noticeStringState
}
@Serializable
data class OssLibrary(
val groupId: String,
val artifactId: String,
val name: String = UNKNOWN_NAME,
val spdxLicenses: List<License>? = null,
val unknownLicenses: List<License>? = null,
) {
@Serializable
data class License(
val url: String,
)
companion object {
const val UNKNOWN_NAME = "Unknown"
}
}
data class OssLibraryWithNotice(
val ossLibrary: OssLibrary,
val notice: AnnotatedString,
)
@@ -0,0 +1,36 @@
/*
* Copyright 2022, Lawnchair
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package app.lawnchair.ui.preferences.about.acknowledgements
import kotlinx.serialization.Serializable
@Serializable
data class OssLibrary(
val groupId: String,
val artifactId: String,
val name: String = artifactId,
val spdxLicenses: List<License>? = null,
val unknownLicenses: List<License>? = null,
) {
val license: License? = spdxLicenses?.firstOrNull() ?: unknownLicenses?.firstOrNull()
@Serializable
data class License(
val name: String,
val url: String,
)
}
@@ -1,202 +0,0 @@
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) }
// As of now the version string looks like this (CI builds only):
// <major>.<branch>.(#<CI build number>)
// This is done inside build.gradle in the source root. Reflect
// changes from there if needed.
val currentVersionNumber = BuildConfig.VERSION_DISPLAY_NAME
.substringAfterLast("#")
.removeSuffix(")")
.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
}
}