diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/Dialog.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/Dialog.kt index cc9448a9..d32e4ecf 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/component/Dialog.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/Dialog.kt @@ -1,14 +1,7 @@ package com.sukisu.ultra.ui.component -import android.graphics.text.LineBreaker -import android.os.Build import android.os.Parcelable -import android.text.Layout -import android.text.method.LinkMovementMethod 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.Box 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.width import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue @@ -33,15 +25,10 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment 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.res.stringResource import androidx.compose.ui.text.font.FontWeight 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.CoroutineScope 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) - } - ) -} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/component/MarkdownContent.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/component/MarkdownContent.kt new file mode 100644 index 00000000..eb1be919 --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/component/MarkdownContent.kt @@ -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) + } + ) +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Module.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Module.kt index 37b20d36..3edb7800 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Module.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Module.kt @@ -45,7 +45,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape 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.Code import androidx.compose.material.icons.rounded.Download @@ -90,6 +89,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.kyant.capsule.ContinuousRoundedRectangle import com.ramcosta.composedestinations.generated.destinations.ExecuteModuleActionScreenDestination import com.ramcosta.composedestinations.generated.destinations.FlashScreenDestination +import com.ramcosta.composedestinations.generated.destinations.ModuleRepoScreenDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import dev.chrisbanes.haze.HazeState 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.extra.DropdownImpl 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.New import top.yukonga.miuix.kmp.icon.icons.useful.Undo import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme import top.yukonga.miuix.kmp.utils.getWindowSize @@ -458,6 +460,22 @@ fun ModulePager( 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 ) } @@ -1110,7 +1128,7 @@ fun ModuleItem( imageVector = if (module.remove) { MiuixIcons.Useful.Undo } else { - Icons.Outlined.Delete + MiuixIcons.Useful.Delete }, tint = actionIconTint, contentDescription = null diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/ModuleRepo.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/ModuleRepo.kt new file mode 100644 index 00000000..1699c5de --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/ModuleRepo.kt @@ -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 +) : 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, + val homepageUrl: String, + val sourceUrl: String, + val latestRelease: String, + val latestReleaseTime: String, + val releases: List +) : Parcelable + +@SuppressLint("StringFormatInvalid") +@Composable +@Destination +fun ModuleRepoScreen( + navigator: DestinationsNavigator +) { + val viewModel = viewModel() + 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 +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(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)) } + } + } +} diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/TemplateEditor.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/TemplateEditor.kt index 1c17f7c1..92cbd447 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/TemplateEditor.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/TemplateEditor.kt @@ -334,7 +334,7 @@ private fun TopBar( Icon( imageVector = MiuixIcons.Useful.Back, contentDescription = null, - tint = colorScheme.onBackground + tint = colorScheme.onSurface ) } }, diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/ModuleRepoViewModel.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/ModuleRepoViewModel.kt new file mode 100644 index 00000000..c6e6846b --- /dev/null +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/ModuleRepoViewModel.kt @@ -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 + ) + + @Immutable + data class RepoModule( + val moduleId: String, + val moduleName: String, + val authors: String, + val authorList: List, + val summary: String, + val homepageUrl: String, + val sourceUrl: String, + val latestRelease: String, + val latestReleaseTime: String, + val releases: List, + ) + + private var _modules = mutableStateOf>(emptyList()) + val modules: State> = _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 { + 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, + ) + } +} diff --git a/manager/app/src/main/res/values-zh-rCN/strings.xml b/manager/app/src/main/res/values-zh-rCN/strings.xml index 5fa41c87..3d9a3eb9 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -20,6 +20,7 @@ 无法禁用模块:%s 没有安装模块 模块 + 模块仓库 可执行优先 已启用优先 卸载 diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index 200e62f8..c07c28b2 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -21,6 +21,7 @@ Failed to disable module: %s No module installed Module + Module repository The following modules will be installed: %1$s Action first Enabled first