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:
@@ -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,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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-100
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user