manager: add basic module repo support

This commit is contained in:
YuKongA
2025-11-27 09:58:25 +08:00
committed by shirkneko
parent 228b6b1273
commit 3a97e6580f
8 changed files with 959 additions and 49 deletions

View File

@@ -1,14 +1,7 @@
package com.sukisu.ultra.ui.component package com.sukisu.ultra.ui.component
import android.graphics.text.LineBreaker
import android.os.Build
import android.os.Parcelable import android.os.Parcelable
import android.text.Layout
import android.text.method.LinkMovementMethod
import android.util.Log import android.util.Log
import android.view.ViewGroup
import android.widget.ScrollView
import android.widget.TextView
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -21,7 +14,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -33,15 +25,10 @@ import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Layout
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import io.noties.markwon.Markwon
import io.noties.markwon.utils.NoCopySpannableFactory
import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -477,36 +464,3 @@ private fun ConfirmDialog(
} }
) )
} }
@Composable
private fun MarkdownContent(content: String) {
val contentColor = MiuixTheme.colorScheme.onBackground.toArgb()
AndroidView(
factory = { context ->
val scrollView = ScrollView(context)
val textView = TextView(context).apply {
movementMethod = LinkMovementMethod.getInstance()
setSpannableFactory(NoCopySpannableFactory.getInstance())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
breakStrategy = LineBreaker.BREAK_STRATEGY_SIMPLE
}
hyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NONE
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
)
}
scrollView.addView(textView)
scrollView
},
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.clipToBounds(),
update = {
val textView = it.getChildAt(0) as TextView
Markwon.create(textView.context).setMarkdown(textView, content)
textView.setTextColor(contentColor)
}
)
}

View File

@@ -0,0 +1,52 @@
package com.sukisu.ultra.ui.component
import android.graphics.text.LineBreaker
import android.os.Build
import android.text.Layout
import android.text.method.LinkMovementMethod
import android.view.ViewGroup
import android.widget.ScrollView
import android.widget.TextView
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView
import io.noties.markwon.Markwon
import io.noties.markwon.utils.NoCopySpannableFactory
import top.yukonga.miuix.kmp.theme.MiuixTheme
@Composable
fun MarkdownContent(content: String) {
val contentColor = MiuixTheme.colorScheme.onBackground.toArgb()
AndroidView(
factory = { context ->
val scrollView = ScrollView(context)
val textView = TextView(context).apply {
movementMethod = LinkMovementMethod.getInstance()
setSpannableFactory(NoCopySpannableFactory.getInstance())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
breakStrategy = LineBreaker.BREAK_STRATEGY_SIMPLE
}
hyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NONE
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
)
}
scrollView.addView(textView)
scrollView
},
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.clipToBounds(),
update = {
val textView = it.getChildAt(0) as TextView
Markwon.create(textView.context).setMarkdown(textView, content)
textView.setTextColor(contentColor)
}
)
}

View File

@@ -45,7 +45,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Code import androidx.compose.material.icons.rounded.Code
import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Download
@@ -90,6 +89,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.kyant.capsule.ContinuousRoundedRectangle import com.kyant.capsule.ContinuousRoundedRectangle
import com.ramcosta.composedestinations.generated.destinations.ExecuteModuleActionScreenDestination import com.ramcosta.composedestinations.generated.destinations.ExecuteModuleActionScreenDestination
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
import com.ramcosta.composedestinations.generated.destinations.ModuleRepoScreenDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle import dev.chrisbanes.haze.HazeStyle
@@ -137,7 +137,9 @@ import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.basic.rememberPullToRefreshState import top.yukonga.miuix.kmp.basic.rememberPullToRefreshState
import top.yukonga.miuix.kmp.extra.DropdownImpl import top.yukonga.miuix.kmp.extra.DropdownImpl
import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.useful.Delete
import top.yukonga.miuix.kmp.icon.icons.useful.ImmersionMore import top.yukonga.miuix.kmp.icon.icons.useful.ImmersionMore
import top.yukonga.miuix.kmp.icon.icons.useful.New
import top.yukonga.miuix.kmp.icon.icons.useful.Undo import top.yukonga.miuix.kmp.icon.icons.useful.Undo
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.utils.getWindowSize import top.yukonga.miuix.kmp.utils.getWindowSize
@@ -458,6 +460,22 @@ fun ModulePager(
alignment = PopupPositionProvider.Align.TopRight alignment = PopupPositionProvider.Align.TopRight
) )
}, },
navigationIcon = {
IconButton(
modifier = Modifier.padding(start = 16.dp),
onClick = {
navigator.navigate(ModuleRepoScreenDestination) {
launchSingleTop = true
}
},
) {
Icon(
imageVector = MiuixIcons.Useful.New,
tint = colorScheme.onSurface,
contentDescription = stringResource(id = R.string.settings)
)
}
},
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior
) )
} }
@@ -1110,7 +1128,7 @@ fun ModuleItem(
imageVector = if (module.remove) { imageVector = if (module.remove) {
MiuixIcons.Useful.Undo MiuixIcons.Useful.Undo
} else { } else {
Icons.Outlined.Delete MiuixIcons.Useful.Delete
}, },
tint = actionIconTint, tint = actionIconTint,
contentDescription = null contentDescription = null

View File

@@ -0,0 +1,729 @@
package com.sukisu.ultra.ui.screen
import android.annotation.SuppressLint
import android.net.Uri
import android.os.Parcelable
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Code
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.Link
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.annotation.RootGraph
import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination
import com.ramcosta.composedestinations.generated.destinations.ModuleRepoDetailScreenDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import com.sukisu.ultra.R
import com.sukisu.ultra.ksuApp
import com.sukisu.ultra.ui.component.MarkdownContent
import com.sukisu.ultra.ui.component.rememberConfirmDialog
import com.sukisu.ultra.ui.util.DownloadListener
import com.sukisu.ultra.ui.util.download
import com.sukisu.ultra.ui.viewmodel.ModuleRepoViewModel
import okhttp3.Request
import org.json.JSONObject
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.HorizontalDivider
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.InfiniteProgressIndicator
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SmallTitle
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.icons.useful.Back
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.utils.PressFeedbackType
import top.yukonga.miuix.kmp.utils.getWindowSize
import top.yukonga.miuix.kmp.utils.overScrollVertical
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
@Parcelize
data class ReleaseAssetArg(
val name: String,
val downloadUrl: String,
val size: Long
) : Parcelable
@Parcelize
data class ReleaseArg(
val tagName: String,
val name: String,
val publishedAt: String,
val assets: List<ReleaseAssetArg>
) : Parcelable
@Parcelize
data class AuthorArg(
val name: String,
val link: String,
) : Parcelable
@Parcelize
data class RepoModuleArg(
val moduleId: String,
val moduleName: String,
val authors: String,
val authorsList: List<AuthorArg>,
val homepageUrl: String,
val sourceUrl: String,
val latestRelease: String,
val latestReleaseTime: String,
val releases: List<ReleaseArg>
) : Parcelable
@SuppressLint("StringFormatInvalid")
@Composable
@Destination<RootGraph>
fun ModuleRepoScreen(
navigator: DestinationsNavigator
) {
val viewModel = viewModel<ModuleRepoViewModel>()
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
if (viewModel.modules.value.isEmpty()) {
viewModel.refresh()
}
}
val scrollBehavior = MiuixScrollBehavior()
val hazeState = remember { HazeState() }
val hazeStyle = HazeStyle(
backgroundColor = colorScheme.surface,
tint = HazeTint(colorScheme.surface.copy(0.8f))
)
val onInstallModule: (Uri) -> Unit = { uri ->
navigator.navigate(FlashScreenDestination(FlashIt.FlashModules(listOf(uri)))) {
launchSingleTop = true
}
}
val confirmTitle = stringResource(R.string.module)
var pendingDownload by remember { mutableStateOf<(() -> Unit)?>(null) }
val confirmDialog = rememberConfirmDialog(onConfirm = { pendingDownload?.invoke() })
Scaffold(
topBar = {
TopAppBar(
modifier = Modifier.hazeEffect(hazeState) {
style = hazeStyle
blurRadius = 30.dp
noiseFactor = 0f
},
color = Color.Transparent,
title = stringResource(R.string.module_repo),
scrollBehavior = scrollBehavior,
navigationIcon = {
IconButton(
modifier = Modifier.padding(start = 16.dp),
onClick = {
navigator.popBackStack()
}
) {
Icon(
imageVector = MiuixIcons.Useful.Back,
contentDescription = null,
tint = colorScheme.onSurface
)
}
}
)
}
) { innerPadding ->
val layoutDirection = LocalLayoutDirection.current
val isLoading = viewModel.modules.value.isEmpty()
if (isLoading) {
Box(
modifier = Modifier
.height(getWindowSize().height.dp)
.hazeSource(state = hazeState),
contentAlignment = Alignment.Center
) {
InfiniteProgressIndicator()
}
} else {
LazyColumn(
modifier = Modifier
.height(getWindowSize().height.dp)
.scrollEndHaptic()
.overScrollVertical()
.nestedScroll(scrollBehavior.nestedScrollConnection)
.hazeSource(state = hazeState),
contentPadding = PaddingValues(
top = innerPadding.calculateTopPadding(),
start = innerPadding.calculateStartPadding(layoutDirection),
end = innerPadding.calculateEndPadding(layoutDirection)
),
) {
items(viewModel.modules.value, key = { it.moduleId }) { module ->
val moduleName = remember(module.moduleName) { module.moduleName }
val moduleId = remember(module.moduleId) { module.moduleId }
val authors = remember(module.authors) { module.authors }
val summary = remember(module.summary) { module.summary }
val latestReleaseTime = remember(module.latestReleaseTime) { module.latestReleaseTime }
val latestTag = remember(module.latestRelease) { module.latestRelease }
val latestRel = remember(module.releases, latestTag) {
module.releases.find { it.tagName == latestTag } ?: module.releases.firstOrNull()
}
val latestAsset = remember(latestRel) { latestRel?.assets?.firstOrNull() }
val moduleVersion = stringResource(id = R.string.module_version)
val moduleAuthor = stringResource(id = R.string.module_author)
val secondaryContainer = colorScheme.secondaryContainer.copy(alpha = 0.8f)
val actionIconTint = colorScheme.onSurface.copy(alpha = 0.9f)
val installBg = colorScheme.tertiaryContainer.copy(alpha = 0.6f)
val installTint = colorScheme.onTertiaryContainer.copy(alpha = 0.8f)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(vertical = 8.dp),
insideMargin = PaddingValues(16.dp),
showIndication = true,
pressFeedbackType = PressFeedbackType.Sink,
onClick = {
val args = RepoModuleArg(
moduleId = module.moduleId,
moduleName = module.moduleName,
authors = authors,
authorsList = module.authorList.map { AuthorArg(it.name, it.link) },
homepageUrl = module.homepageUrl,
sourceUrl = module.sourceUrl,
latestRelease = module.latestRelease,
latestReleaseTime = module.latestReleaseTime,
releases = module.releases.map { r ->
ReleaseArg(
tagName = r.tagName,
name = r.name,
publishedAt = r.publishedAt,
assets = r.assets.map { a ->
ReleaseAssetArg(name = a.name, downloadUrl = a.downloadUrl, size = a.size)
}
)
}
)
navigator.navigate(ModuleRepoDetailScreenDestination(args)) {
launchSingleTop = true
}
}
) {
Column {
if (moduleName.isNotBlank()) {
Text(
text = moduleName,
fontSize = 17.sp,
fontWeight = FontWeight(550),
color = colorScheme.onSurface
)
}
if (moduleId.isNotBlank()) {
Text(
text = moduleId,
fontSize = 14.sp,
fontWeight = FontWeight(550),
color = colorScheme.onSurfaceVariantSummary,
)
}
Text(
text = "$moduleVersion: $latestTag",
fontSize = 12.sp,
modifier = Modifier.padding(top = 2.dp),
fontWeight = FontWeight(550),
color = colorScheme.onSurfaceVariantSummary,
)
Text(
text = "$moduleAuthor: $authors",
fontSize = 12.sp,
modifier = Modifier.padding(bottom = 1.dp),
fontWeight = FontWeight(550),
color = colorScheme.onSurfaceVariantSummary,
)
if (summary.isNotBlank()) {
Text(
text = summary,
fontSize = 14.sp,
color = colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(top = 2.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 4,
)
}
if (latestReleaseTime.isNotBlank()) {
Text(
modifier = Modifier.fillMaxWidth(),
text = latestReleaseTime,
fontSize = 12.sp,
color = colorScheme.onSurfaceVariantSummary,
textAlign = TextAlign.End
)
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = 0.5.dp,
color = colorScheme.outline.copy(alpha = 0.5f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (module.homepageUrl.isNotBlank()) {
IconButton(
backgroundColor = secondaryContainer,
minHeight = 35.dp,
minWidth = 35.dp,
onClick = { uriHandler.openUri(module.homepageUrl) },
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = Icons.Rounded.Link,
tint = actionIconTint,
contentDescription = null
)
}
}
if (module.sourceUrl.isNotBlank()) {
IconButton(
backgroundColor = secondaryContainer,
minHeight = 35.dp,
minWidth = 35.dp,
onClick = { uriHandler.openUri(module.sourceUrl) },
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = Icons.Rounded.Code,
tint = actionIconTint,
contentDescription = null
)
}
}
Spacer(Modifier.weight(1f))
if (latestAsset != null) {
val fileName = latestAsset.name
val downloadingText = stringResource(R.string.module_downloading)
IconButton(
backgroundColor = installBg,
minHeight = 35.dp,
minWidth = 35.dp,
onClick = {
pendingDownload = {
scope.launch(Dispatchers.IO) {
download(
context,
latestAsset.downloadUrl,
fileName,
downloadingText.format(module.moduleId)
)
}
}
val confirmContent = context.getString(R.string.module_install_prompt_with_name, fileName)
confirmDialog.showConfirm(
title = confirmTitle,
content = confirmContent
)
},
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = Icons.Rounded.Download,
tint = installTint,
contentDescription = stringResource(R.string.install)
)
Text(
modifier = Modifier.padding(start = 4.dp, end = 3.dp),
text = stringResource(R.string.install),
color = installTint,
fontWeight = FontWeight.Medium,
fontSize = 15.sp
)
}
}
}
}
}
}
}
item { Spacer(Modifier.height(12.dp)) }
}
}
DownloadListener(context, onInstallModule)
}
}
@SuppressLint("StringFormatInvalid", "DefaultLocale")
@Composable
@Destination<RootGraph>
fun ModuleRepoDetailScreen(
navigator: DestinationsNavigator,
module: RepoModuleArg
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val confirmTitle = stringResource(R.string.module)
var pendingDownload by remember { mutableStateOf<(() -> Unit)?>(null) }
val confirmDialog = rememberConfirmDialog(onConfirm = { pendingDownload?.invoke() })
var readmeText by remember(module.moduleId) { mutableStateOf<String?>(null) }
var readmeLoaded by remember(module.moduleId) { mutableStateOf(false) }
val scrollBehavior = MiuixScrollBehavior()
val hazeState = remember { HazeState() }
val hazeStyle = HazeStyle(
backgroundColor = colorScheme.surface,
tint = HazeTint(colorScheme.surface.copy(0.8f))
)
Scaffold(
topBar = {
TopAppBar(
modifier = Modifier.hazeEffect(hazeState) {
style = hazeStyle
blurRadius = 30.dp
noiseFactor = 0f
},
color = Color.Transparent,
title = module.moduleId,
scrollBehavior = scrollBehavior,
navigationIcon = {
IconButton(
modifier = Modifier.padding(start = 16.dp),
onClick = {
navigator.popBackStack()
}
) {
Icon(
imageVector = MiuixIcons.Useful.Back,
contentDescription = null,
tint = colorScheme.onSurface
)
}
}
)
}
) { innerPadding ->
LaunchedEffect(module.moduleId) {
if (module.moduleId.isNotEmpty()) {
withContext(Dispatchers.IO) {
runCatching {
val url = "https://modules.kernelsu.org/module/${module.moduleId}.json"
ksuApp.okhttpClient.newCall(Request.Builder().url(url).build()).execute().use { resp ->
if (!resp.isSuccessful) return@use
val body = resp.body?.string() ?: return@use
val obj = JSONObject(body)
val readme = obj.optString("readme", "")
readmeText = readme.ifBlank { null }
}
}.onSuccess {
readmeLoaded = true
}.onFailure {
readmeLoaded = true
}
}
} else {
readmeLoaded = true
}
}
LazyColumn(
modifier = Modifier
.height(getWindowSize().height.dp)
.scrollEndHaptic()
.overScrollVertical()
.nestedScroll(scrollBehavior.nestedScrollConnection)
.hazeSource(state = hazeState),
contentPadding = PaddingValues(
top = innerPadding.calculateTopPadding(),
bottom = innerPadding.calculateBottomPadding(),
),
) {
if (readmeLoaded && readmeText != null) {
item {
SmallTitle(text = "README")
Card(
modifier = Modifier
.padding(horizontal = 12.dp),
insideMargin = PaddingValues(16.dp)
) {
Column {
MarkdownContent(content = readmeText!!)
}
}
}
}
if (module.authorsList.isNotEmpty()) {
item {
SmallTitle(
text = "AUTHORS",
modifier = Modifier.padding(top = 10.dp)
)
}
item {
val secondaryContainer = colorScheme.secondaryContainer.copy(alpha = 0.8f)
val actionIconTint = colorScheme.onSurface.copy(alpha = 0.9f)
val uriHandler = LocalUriHandler.current
Card(
modifier = Modifier
.padding(horizontal = 12.dp),
insideMargin = PaddingValues(16.dp)
) {
Column {
module.authorsList.forEachIndexed { index, author ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = author.name,
fontSize = 14.sp,
color = colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
val clickable = author.link.isNotBlank()
val tint = if (clickable) actionIconTint else actionIconTint.copy(alpha = 0.35f)
IconButton(
backgroundColor = secondaryContainer,
minHeight = 35.dp,
minWidth = 35.dp,
enabled = clickable,
onClick = {
if (clickable) {
uriHandler.openUri(author.link)
}
},
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = Icons.Rounded.Link,
tint = tint,
contentDescription = null
)
}
}
if (index != module.authorsList.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = 0.5.dp,
color = colorScheme.outline.copy(alpha = 0.5f)
)
}
}
}
}
}
}
if (module.releases.isNotEmpty()) {
item {
SmallTitle(
text = "RELEASES",
modifier = Modifier.padding(top = 10.dp)
)
}
items(module.releases, key = { it.tagName }) { rel ->
val title = remember(rel.name, rel.tagName) { rel.name.ifBlank { rel.tagName } }
val installBg = colorScheme.tertiaryContainer.copy(alpha = 0.6f)
val installTint = colorScheme.onTertiaryContainer.copy(alpha = 0.8f)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(bottom = 8.dp),
insideMargin = PaddingValues(16.dp)
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
fontSize = 17.sp,
fontWeight = FontWeight(550),
color = colorScheme.onSurface
)
Text(
text = rel.tagName,
fontSize = 12.sp,
fontWeight = FontWeight(550),
color = colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(top = 2.dp)
)
}
Text(
text = rel.publishedAt,
fontSize = 12.sp,
color = colorScheme.onSurfaceVariantSummary,
)
}
AnimatedVisibility(
visible = rel.assets.isNotEmpty(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column {
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = 0.5.dp,
color = colorScheme.outline.copy(alpha = 0.5f)
)
rel.assets.forEachIndexed { index, asset ->
val fileName = asset.name
stringResource(R.string.module_start_downloading)
val downloadingText = stringResource(R.string.module_downloading)
val sizeText = remember(asset.size) {
val s = asset.size
when {
s >= 1024L * 1024L * 1024L -> String.format("%.1f GB", s / (1024f * 1024f * 1024f))
s >= 1024L * 1024L -> String.format("%.1f MB", s / (1024f * 1024f))
s >= 1024L -> String.format("%.0f KB", s / 1024f)
else -> "$s B"
}
}
val onClickDownload = remember(fileName, asset.downloadUrl) {
{
pendingDownload = {
scope.launch(Dispatchers.IO) {
download(
context,
asset.downloadUrl,
fileName,
downloadingText.format(module.moduleId)
)
}
}
val confirmContent = context.getString(R.string.module_install_prompt_with_name, fileName)
confirmDialog.showConfirm(
title = confirmTitle,
content = confirmContent
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = fileName,
fontSize = 14.sp,
color = colorScheme.onSurface
)
Text(
text = sizeText,
fontSize = 12.sp,
color = colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(top = 2.dp)
)
}
IconButton(
backgroundColor = installBg,
minHeight = 35.dp,
minWidth = 35.dp,
onClick = onClickDownload,
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = Icons.Rounded.Download,
tint = installTint,
contentDescription = stringResource(R.string.install)
)
Text(
modifier = Modifier.padding(start = 4.dp, end = 3.dp),
text = stringResource(R.string.install),
color = installTint,
fontWeight = FontWeight.Medium,
fontSize = 15.sp
)
}
}
}
if (index != rel.assets.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
thickness = 0.5.dp,
color = colorScheme.outline.copy(alpha = 0.5f)
)
}
}
}
}
}
}
}
}
item { Spacer(Modifier.height(12.dp)) }
}
}
}

View File

@@ -334,7 +334,7 @@ private fun TopBar(
Icon( Icon(
imageVector = MiuixIcons.Useful.Back, imageVector = MiuixIcons.Useful.Back,
contentDescription = null, contentDescription = null,
tint = colorScheme.onBackground tint = colorScheme.onSurface
) )
} }
}, },

View File

@@ -0,0 +1,155 @@
package com.sukisu.ultra.ui.viewmodel
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.sukisu.ultra.ksuApp
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
class ModuleRepoViewModel : ViewModel() {
companion object {
private const val TAG = "ModuleRepoViewModel"
private const val MODULES_URL = "https://modules.kernelsu.org/modules.json"
}
@Immutable
data class Author(
val name: String,
val link: String,
)
@Immutable
data class ReleaseAsset(
val name: String,
val downloadUrl: String,
val size: Long
)
@Immutable
data class Release(
val tagName: String,
val name: String,
val publishedAt: String,
val assets: List<ReleaseAsset>
)
@Immutable
data class RepoModule(
val moduleId: String,
val moduleName: String,
val authors: String,
val authorList: List<Author>,
val summary: String,
val homepageUrl: String,
val sourceUrl: String,
val latestRelease: String,
val latestReleaseTime: String,
val releases: List<Release>,
)
private var _modules = mutableStateOf<List<RepoModule>>(emptyList())
val modules: State<List<RepoModule>> = _modules
var isRefreshing by mutableStateOf(false)
private set
fun refresh() {
viewModelScope.launch {
withContext(Dispatchers.Main) { isRefreshing = true }
val parsed = withContext(Dispatchers.IO) { fetchModulesInternal() }
withContext(Dispatchers.Main) {
_modules.value = parsed
isRefreshing = false
}
}
}
private fun fetchModulesInternal(): List<RepoModule> {
return runCatching {
val request = Request.Builder().url(MODULES_URL).build()
ksuApp.okhttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return emptyList()
val body = resp.body?.string() ?: return emptyList()
val json = kotlin.runCatching { JSONArray(body) }.getOrElse {
val obj = JSONObject(body)
obj.optJSONArray("modules") ?: JSONArray()
}
(0 until json.length()).mapNotNull { idx ->
val item = json.optJSONObject(idx) ?: return@mapNotNull null
parseRepoModule(item)
}
}
}.getOrElse {
Log.e(TAG, "fetch modules failed", it)
emptyList()
}
}
private fun parseRepoModule(item: JSONObject): RepoModule? {
val moduleId = item.optString("moduleId", "")
if (moduleId.isEmpty()) return null
val moduleName = item.optString("moduleName", "")
val authorsArray = item.optJSONArray("authors")
val authorList = if (authorsArray != null) {
(0 until authorsArray.length())
.mapNotNull { idx ->
val authorObj = authorsArray.optJSONObject(idx) ?: return@mapNotNull null
val name = authorObj.optString("name", "").trim()
val link = authorObj.optString("link", "").trim()
if (name.isEmpty()) null else Author(name = name, link = link)
}
} else {
emptyList()
}
val authors = if (authorList.isNotEmpty()) authorList.joinToString(", ") { it.name } else item.optString("authors", "")
val summary = item.optString("summary", "")
val homepageUrl = item.optString("homepageUrl", item.optString("url", ""))
val sourceUrl = item.optString("sourceUrl", item.optString("url", ""))
val latestRelease = item.optString("latestRelease", "")
val latestReleaseTime = item.optString("latestReleaseTime", "")
val releasesArray = item.optJSONArray("releases") ?: JSONArray()
val releases = (0 until releasesArray.length()).mapNotNull { rIdx ->
val r = releasesArray.optJSONObject(rIdx) ?: return@mapNotNull null
val tag = r.optString("tagName", r.optString("name", ""))
val rname = r.optString("name", tag)
val publishedAt = r.optString("publishedAt", r.optString("updatedAt", ""))
val assetsArray = r.optJSONArray("releaseAssets") ?: r.optJSONArray("assets") ?: JSONArray()
val assets = (0 until assetsArray.length()).mapNotNull { aIdx ->
val a = assetsArray.optJSONObject(aIdx) ?: return@mapNotNull null
val aname = a.optString("name", "")
val downloadUrl = a.optString("downloadUrl", a.optString("browser_download_url", ""))
if (aname.isEmpty() || downloadUrl.isEmpty()) null else ReleaseAsset(
name = aname,
downloadUrl = downloadUrl,
size = a.optLong("size", 0L)
)
}
Release(tagName = tag, name = rname, publishedAt = publishedAt, assets = assets)
}
return RepoModule(
moduleId = moduleId,
moduleName = moduleName,
authors = authors,
authorList = authorList,
summary = summary,
homepageUrl = homepageUrl,
sourceUrl = sourceUrl,
latestRelease = latestRelease,
latestReleaseTime = latestReleaseTime,
releases = releases,
)
}
}

View File

@@ -20,6 +20,7 @@
<string name="module_failed_to_disable">无法禁用模块:%s</string> <string name="module_failed_to_disable">无法禁用模块:%s</string>
<string name="module_empty">没有安装模块</string> <string name="module_empty">没有安装模块</string>
<string name="module">模块</string> <string name="module">模块</string>
<string name="module_repo">模块仓库</string>
<string name="module_sort_action_first">可执行优先</string> <string name="module_sort_action_first">可执行优先</string>
<string name="module_sort_enabled_first">已启用优先</string> <string name="module_sort_enabled_first">已启用优先</string>
<string name="uninstall">卸载</string> <string name="uninstall">卸载</string>

View File

@@ -21,6 +21,7 @@
<string name="module_failed_to_disable">Failed to disable module: %s</string> <string name="module_failed_to_disable">Failed to disable module: %s</string>
<string name="module_empty">No module installed</string> <string name="module_empty">No module installed</string>
<string name="module">Module</string> <string name="module">Module</string>
<string name="module_repo">Module repository</string>
<string name="module_install_prompt_with_name">The following modules will be installed: %1$s</string> <string name="module_install_prompt_with_name">The following modules will be installed: %1$s</string>
<string name="module_sort_action_first">Action first</string> <string name="module_sort_action_first">Action first</string>
<string name="module_sort_enabled_first">Enabled first</string> <string name="module_sort_enabled_first">Enabled first</string>