manager: show changelog before update module
This commit is contained in:
@@ -111,4 +111,6 @@ dependencies {
|
||||
implementation(libs.sheet.compose.dialogs.core)
|
||||
implementation(libs.sheet.compose.dialogs.list)
|
||||
implementation(libs.sheet.compose.dialogs.input)
|
||||
|
||||
implementation(libs.markdown)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,46 @@
|
||||
package me.weishu.kernelsu.ui.component
|
||||
|
||||
import android.graphics.text.LineBreaker
|
||||
import android.text.Layout
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.maxkeppeker.sheets.core.CoreDialog
|
||||
import com.maxkeppeker.sheets.core.models.CoreSelection
|
||||
import com.maxkeppeker.sheets.core.models.base.Header
|
||||
import com.maxkeppeker.sheets.core.models.base.SelectionButton
|
||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
||||
import io.noties.markwon.Markwon
|
||||
import io.noties.markwon.utils.NoCopySpannableFactory
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.weishu.kernelsu.ui.util.LocalDialogHost
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
@@ -36,6 +56,7 @@ interface PromptDialogVisuals : DialogVisuals {
|
||||
interface ConfirmDialogVisuals : PromptDialogVisuals {
|
||||
val confirm: String?
|
||||
val dismiss: String?
|
||||
val isMarkdown: Boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -68,15 +89,15 @@ class DialogHostState {
|
||||
private object LoadingDialogVisualsImpl : LoadingDialogVisuals
|
||||
|
||||
private data class PromptDialogVisualsImpl(
|
||||
override val title: String,
|
||||
override val content: String
|
||||
override val title: String, override val content: String
|
||||
) : PromptDialogVisuals
|
||||
|
||||
private data class ConfirmDialogVisualsImpl(
|
||||
override val title: String,
|
||||
override val content: String,
|
||||
override val confirm: String?,
|
||||
override val dismiss: String?
|
||||
override val dismiss: String?,
|
||||
override val isMarkdown: Boolean,
|
||||
) : ConfirmDialogVisuals
|
||||
|
||||
private data class LoadingDialogDataImpl(
|
||||
@@ -121,8 +142,7 @@ class DialogHostState {
|
||||
mutex.withLock {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
currentDialogData = LoadingDialogDataImpl(
|
||||
visuals = LoadingDialogVisualsImpl,
|
||||
continuation = continuation
|
||||
visuals = LoadingDialogVisualsImpl, continuation = continuation
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -159,15 +179,12 @@ class DialogHostState {
|
||||
}
|
||||
|
||||
suspend fun showConfirm(
|
||||
title: String,
|
||||
content: String,
|
||||
confirm: String? = null,
|
||||
dismiss: String? = null
|
||||
title: String, content: String, markdown: Boolean = false, confirm: String? = null, dismiss: String? = null
|
||||
): ConfirmResult = mutex.withLock {
|
||||
try {
|
||||
return@withLock suspendCancellableCoroutine { continuation ->
|
||||
currentDialogData = ConfirmDialogDataImpl(
|
||||
visuals = ConfirmDialogVisualsImpl(title, content, confirm, dismiss),
|
||||
visuals = ConfirmDialogVisualsImpl(title, content, confirm, dismiss, markdown),
|
||||
continuation = continuation
|
||||
)
|
||||
}
|
||||
@@ -201,9 +218,7 @@ fun LoadingDialog(
|
||||
}
|
||||
Dialog(onDismissRequest = {}, properties = dialogProperties) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(100.dp),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
modifier = Modifier.size(100.dp), shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -240,11 +255,44 @@ fun PromptDialog(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConfirmDialog(state: DialogHostState = LocalDialogHost.current) {
|
||||
val confirmDialogData = state.currentDialogData.tryInto<ConfirmDialogData>() ?: return
|
||||
|
||||
val visuals = confirmDialogData.visuals
|
||||
|
||||
if (visuals.isMarkdown) {
|
||||
CoreDialog(
|
||||
state = rememberUseCaseState(visible = true, onCloseRequest = {
|
||||
confirmDialogData.dismiss()
|
||||
}),
|
||||
header = Header.Default(
|
||||
title = visuals.title
|
||||
),
|
||||
selection = CoreSelection(
|
||||
withButtonView = true,
|
||||
negativeButton = SelectionButton(
|
||||
visuals.dismiss ?: stringResource(id = android.R.string.cancel),
|
||||
),
|
||||
positiveButton = SelectionButton(
|
||||
visuals.confirm ?: stringResource(id = android.R.string.ok),
|
||||
),
|
||||
onPositiveClick = {
|
||||
confirmDialogData.confirm()
|
||||
},
|
||||
onNegativeClick = {
|
||||
confirmDialogData.dismiss()
|
||||
},
|
||||
),
|
||||
onPositiveValid = true,
|
||||
body = {
|
||||
MarkdownContent(visuals.content)
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
confirmDialogData.dismiss()
|
||||
@@ -266,4 +314,28 @@ fun ConfirmDialog(state: DialogHostState = LocalDialogHost.current) {
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@Composable
|
||||
private fun MarkdownContent(content: String) {
|
||||
val contentColor = LocalContentColor.current
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
TextView(context).apply {
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
setSpannableFactory(NoCopySpannableFactory.getInstance())
|
||||
breakStrategy = LineBreaker.BREAK_STRATEGY_SIMPLE
|
||||
hyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NONE
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
update = {
|
||||
Markwon.create(it.context).setMarkdown(it, content)
|
||||
it.setTextColor(contentColor.toArgb())
|
||||
})
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import me.weishu.kernelsu.ui.component.LoadingDialog
|
||||
import me.weishu.kernelsu.ui.screen.destinations.InstallScreenDestination
|
||||
import me.weishu.kernelsu.ui.util.*
|
||||
import me.weishu.kernelsu.ui.viewmodel.ModuleViewModel
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
@Destination
|
||||
@Composable
|
||||
@@ -145,9 +146,68 @@ private fun ModuleList(
|
||||
val uninstall = stringResource(id = R.string.uninstall)
|
||||
val cancel = stringResource(id = android.R.string.cancel)
|
||||
val moduleUninstallConfirm = stringResource(id = R.string.module_uninstall_confirm)
|
||||
val updateText = stringResource(R.string.module_update)
|
||||
val changelogText = stringResource(R.string.module_changelog)
|
||||
val downloadingText = stringResource(R.string.module_downloading)
|
||||
val startDownloadingText = stringResource(R.string.module_start_downloading)
|
||||
|
||||
val dialogHost = LocalDialogHost.current
|
||||
val snackBarHost = LocalSnackbarHost.current
|
||||
val context = LocalContext.current
|
||||
|
||||
suspend fun onModuleUpdate(
|
||||
module: ModuleViewModel.ModuleInfo,
|
||||
changelogUrl: String,
|
||||
downloadUrl: String,
|
||||
fileName: String
|
||||
) {
|
||||
val changelog = dialogHost.withLoading {
|
||||
withContext(Dispatchers.IO) {
|
||||
val str = OkHttpClient().newCall(
|
||||
okhttp3.Request.Builder().url(changelogUrl).build()
|
||||
).execute().body!!.string()
|
||||
if (str.length > 1000) str.substring(0, 1000) else str
|
||||
}
|
||||
}
|
||||
|
||||
if (changelog.isNotEmpty()) {
|
||||
// changelog is not empty, show it and wait for confirm
|
||||
val confirmResult = dialogHost.showConfirm(
|
||||
changelogText,
|
||||
content = changelog,
|
||||
markdown = true,
|
||||
confirm = updateText,
|
||||
)
|
||||
|
||||
if (confirmResult != ConfirmResult.Confirmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
startDownloadingText.format(module.name),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
val downloading = downloadingText.format(module.name)
|
||||
withContext(Dispatchers.IO) {
|
||||
download(
|
||||
context,
|
||||
downloadUrl,
|
||||
fileName,
|
||||
downloading,
|
||||
onDownloaded = onInstallModule,
|
||||
onDownloading = {
|
||||
launch(Dispatchers.Main) {
|
||||
Toast.makeText(context, downloading, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun onModuleUninstall(module: ModuleViewModel.ModuleInfo) {
|
||||
val confirmResult = dialogHost.showConfirm(
|
||||
@@ -209,33 +269,38 @@ private fun ModuleList(
|
||||
modifier = Modifier.fillParentMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(stringResource(R.string.module_overlay_fs_not_available), textAlign = TextAlign.Center)
|
||||
Text(
|
||||
stringResource(R.string.module_overlay_fs_not_available),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.moduleList.isEmpty() -> {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(stringResource(R.string.module_empty), textAlign = TextAlign.Center)
|
||||
Text(
|
||||
stringResource(R.string.module_empty),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
items(viewModel.moduleList) { module ->
|
||||
var isChecked by rememberSaveable(module) { mutableStateOf(module.enabled) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val updatedModule by produceState(initialValue = "" to "") {
|
||||
val updatedModule by produceState(initialValue = Triple("", "", "")) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
value = viewModel.checkUpdate(module)
|
||||
}
|
||||
}
|
||||
|
||||
val downloadingText = stringResource(R.string.module_downloading)
|
||||
val startDownloadingText = stringResource(R.string.module_start_downloading)
|
||||
|
||||
ModuleItem(module, isChecked, updatedModule.first, onUninstall = {
|
||||
scope.launch { onModuleUninstall(module) }
|
||||
}, onCheckChanged = {
|
||||
@@ -261,26 +326,14 @@ private fun ModuleList(
|
||||
}
|
||||
}
|
||||
}, onUpdate = {
|
||||
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
startDownloadingText.format(module.name),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
onModuleUpdate(
|
||||
module,
|
||||
updatedModule.third,
|
||||
updatedModule.first,
|
||||
"${module.name}-${updatedModule.second}.zip"
|
||||
)
|
||||
}
|
||||
|
||||
val downloading = downloadingText.format(module.name)
|
||||
download(
|
||||
context,
|
||||
updatedModule.first,
|
||||
"${module.name}-${updatedModule.second}.zip",
|
||||
downloading,
|
||||
onDownloaded = onInstallModule,
|
||||
onDownloading = {
|
||||
Toast.makeText(context, downloading, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// fix last item shadow incomplete in LazyColumn
|
||||
|
||||
@@ -115,8 +115,8 @@ class ModuleViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun checkUpdate(m: ModuleInfo): Pair<String, String> {
|
||||
val empty = "" to ""
|
||||
fun checkUpdate(m: ModuleInfo): Triple<String, String, String> {
|
||||
val empty = Triple("", "", "")
|
||||
if (m.updateJson.isEmpty() || m.remove || m.update || !m.enabled) {
|
||||
return empty
|
||||
}
|
||||
@@ -155,6 +155,6 @@ class ModuleViewModel : ViewModel() {
|
||||
return empty
|
||||
}
|
||||
|
||||
return zipUrl to version
|
||||
return Triple(zipUrl, version, changelog)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,4 +84,5 @@
|
||||
<string name="force_stop_app">Force Stop</string>
|
||||
<string name="restart_app">Restart</string>
|
||||
<string name="failed_to_update_sepolicy">Failed to update SELinux rules for: %s</string>
|
||||
<string name="module_changelog">Changelog</string>
|
||||
</resources>
|
||||
|
||||
@@ -9,6 +9,7 @@ navigation = "2.5.3"
|
||||
compose-destination = "1.9.42-beta"
|
||||
libsu = "5.0.5"
|
||||
sheets-compose-dialogs = "1.2.0"
|
||||
markdown = "4.6.2"
|
||||
|
||||
[plugins]
|
||||
agp-app = { id = "com.android.application", version.ref = "agp" }
|
||||
@@ -56,3 +57,5 @@ compose-destinations-ksp = { group = "io.github.raamcosta.compose-destinations",
|
||||
sheet-compose-dialogs-core = { group = "com.maxkeppeler.sheets-compose-dialogs", name = "core", version.ref = "sheets-compose-dialogs"}
|
||||
sheet-compose-dialogs-list = { group = "com.maxkeppeler.sheets-compose-dialogs", name = "list", version.ref = "sheets-compose-dialogs"}
|
||||
sheet-compose-dialogs-input = { group = "com.maxkeppeler.sheets-compose-dialogs", name = "input", version.ref = "sheets-compose-dialogs"}
|
||||
|
||||
markdown = { group = "io.noties.markwon", name = "core", version.ref = "markdown" }
|
||||
Reference in New Issue
Block a user