manager: show changelog before update module

This commit is contained in:
weishu
2023-09-11 00:03:21 +08:00
parent 52234d040f
commit 81bbb31098
6 changed files with 171 additions and 40 deletions

View File

@@ -111,4 +111,6 @@ dependencies {
implementation(libs.sheet.compose.dialogs.core) implementation(libs.sheet.compose.dialogs.core)
implementation(libs.sheet.compose.dialogs.list) implementation(libs.sheet.compose.dialogs.list)
implementation(libs.sheet.compose.dialogs.input) implementation(libs.sheet.compose.dialogs.input)
implementation(libs.markdown)
} }

View File

@@ -1,26 +1,46 @@
package me.weishu.kernelsu.ui.component 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.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties 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.CancellableContinuation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import me.weishu.kernelsu.ui.util.LocalDialogHost import me.weishu.kernelsu.ui.util.LocalDialogHost
import kotlin.coroutines.resume import kotlin.coroutines.resume
@@ -36,6 +56,7 @@ interface PromptDialogVisuals : DialogVisuals {
interface ConfirmDialogVisuals : PromptDialogVisuals { interface ConfirmDialogVisuals : PromptDialogVisuals {
val confirm: String? val confirm: String?
val dismiss: String? val dismiss: String?
val isMarkdown: Boolean
} }
@@ -68,15 +89,15 @@ class DialogHostState {
private object LoadingDialogVisualsImpl : LoadingDialogVisuals private object LoadingDialogVisualsImpl : LoadingDialogVisuals
private data class PromptDialogVisualsImpl( private data class PromptDialogVisualsImpl(
override val title: String, override val title: String, override val content: String
override val content: String
) : PromptDialogVisuals ) : PromptDialogVisuals
private data class ConfirmDialogVisualsImpl( private data class ConfirmDialogVisualsImpl(
override val title: String, override val title: String,
override val content: String, override val content: String,
override val confirm: String?, override val confirm: String?,
override val dismiss: String? override val dismiss: String?,
override val isMarkdown: Boolean,
) : ConfirmDialogVisuals ) : ConfirmDialogVisuals
private data class LoadingDialogDataImpl( private data class LoadingDialogDataImpl(
@@ -121,8 +142,7 @@ class DialogHostState {
mutex.withLock { mutex.withLock {
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
currentDialogData = LoadingDialogDataImpl( currentDialogData = LoadingDialogDataImpl(
visuals = LoadingDialogVisualsImpl, visuals = LoadingDialogVisualsImpl, continuation = continuation
continuation = continuation
) )
} }
} }
@@ -159,15 +179,12 @@ class DialogHostState {
} }
suspend fun showConfirm( suspend fun showConfirm(
title: String, title: String, content: String, markdown: Boolean = false, confirm: String? = null, dismiss: String? = null
content: String,
confirm: String? = null,
dismiss: String? = null
): ConfirmResult = mutex.withLock { ): ConfirmResult = mutex.withLock {
try { try {
return@withLock suspendCancellableCoroutine { continuation -> return@withLock suspendCancellableCoroutine { continuation ->
currentDialogData = ConfirmDialogDataImpl( currentDialogData = ConfirmDialogDataImpl(
visuals = ConfirmDialogVisualsImpl(title, content, confirm, dismiss), visuals = ConfirmDialogVisualsImpl(title, content, confirm, dismiss, markdown),
continuation = continuation continuation = continuation
) )
} }
@@ -201,9 +218,7 @@ fun LoadingDialog(
} }
Dialog(onDismissRequest = {}, properties = dialogProperties) { Dialog(onDismissRequest = {}, properties = dialogProperties) {
Surface( Surface(
modifier = Modifier modifier = Modifier.size(100.dp), shape = RoundedCornerShape(8.dp)
.size(100.dp),
shape = RoundedCornerShape(8.dp)
) { ) {
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -240,11 +255,44 @@ fun PromptDialog(
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ConfirmDialog(state: DialogHostState = LocalDialogHost.current) { fun ConfirmDialog(state: DialogHostState = LocalDialogHost.current) {
val confirmDialogData = state.currentDialogData.tryInto<ConfirmDialogData>() ?: return val confirmDialogData = state.currentDialogData.tryInto<ConfirmDialogData>() ?: return
val visuals = confirmDialogData.visuals 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( AlertDialog(
onDismissRequest = { onDismissRequest = {
confirmDialogData.dismiss() confirmDialogData.dismiss()
@@ -267,3 +315,27 @@ 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())
})
}

View File

@@ -45,6 +45,7 @@ import me.weishu.kernelsu.ui.component.LoadingDialog
import me.weishu.kernelsu.ui.screen.destinations.InstallScreenDestination import me.weishu.kernelsu.ui.screen.destinations.InstallScreenDestination
import me.weishu.kernelsu.ui.util.* import me.weishu.kernelsu.ui.util.*
import me.weishu.kernelsu.ui.viewmodel.ModuleViewModel import me.weishu.kernelsu.ui.viewmodel.ModuleViewModel
import okhttp3.OkHttpClient
@Destination @Destination
@Composable @Composable
@@ -145,9 +146,68 @@ private fun ModuleList(
val uninstall = stringResource(id = R.string.uninstall) val uninstall = stringResource(id = R.string.uninstall)
val cancel = stringResource(id = android.R.string.cancel) val cancel = stringResource(id = android.R.string.cancel)
val moduleUninstallConfirm = stringResource(id = R.string.module_uninstall_confirm) 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 dialogHost = LocalDialogHost.current
val snackBarHost = LocalSnackbarHost.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) { suspend fun onModuleUninstall(module: ModuleViewModel.ModuleInfo) {
val confirmResult = dialogHost.showConfirm( val confirmResult = dialogHost.showConfirm(
@@ -209,33 +269,38 @@ private fun ModuleList(
modifier = Modifier.fillParentMaxSize(), modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center 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() -> { viewModel.moduleList.isEmpty() -> {
item { item {
Box( Box(
modifier = Modifier.fillParentMaxSize(), modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text(stringResource(R.string.module_empty), textAlign = TextAlign.Center) Text(
stringResource(R.string.module_empty),
textAlign = TextAlign.Center
)
} }
} }
} }
else -> { else -> {
items(viewModel.moduleList) { module -> items(viewModel.moduleList) { module ->
var isChecked by rememberSaveable(module) { mutableStateOf(module.enabled) } var isChecked by rememberSaveable(module) { mutableStateOf(module.enabled) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val updatedModule by produceState(initialValue = "" to "") { val updatedModule by produceState(initialValue = Triple("", "", "")) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
value = viewModel.checkUpdate(module) 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 = { ModuleItem(module, isChecked, updatedModule.first, onUninstall = {
scope.launch { onModuleUninstall(module) } scope.launch { onModuleUninstall(module) }
}, onCheckChanged = { }, onCheckChanged = {
@@ -261,26 +326,14 @@ private fun ModuleList(
} }
} }
}, onUpdate = { }, onUpdate = {
scope.launch { scope.launch {
Toast.makeText( onModuleUpdate(
context, module,
startDownloadingText.format(module.name), updatedModule.third,
Toast.LENGTH_SHORT
).show()
}
val downloading = downloadingText.format(module.name)
download(
context,
updatedModule.first, updatedModule.first,
"${module.name}-${updatedModule.second}.zip", "${module.name}-${updatedModule.second}.zip"
downloading,
onDownloaded = onInstallModule,
onDownloading = {
Toast.makeText(context, downloading, Toast.LENGTH_SHORT).show()
}
) )
}
}) })
// fix last item shadow incomplete in LazyColumn // fix last item shadow incomplete in LazyColumn

View File

@@ -115,8 +115,8 @@ class ModuleViewModel : ViewModel() {
} }
} }
fun checkUpdate(m: ModuleInfo): Pair<String, String> { fun checkUpdate(m: ModuleInfo): Triple<String, String, String> {
val empty = "" to "" val empty = Triple("", "", "")
if (m.updateJson.isEmpty() || m.remove || m.update || !m.enabled) { if (m.updateJson.isEmpty() || m.remove || m.update || !m.enabled) {
return empty return empty
} }
@@ -155,6 +155,6 @@ class ModuleViewModel : ViewModel() {
return empty return empty
} }
return zipUrl to version return Triple(zipUrl, version, changelog)
} }
} }

View File

@@ -84,4 +84,5 @@
<string name="force_stop_app">Force Stop</string> <string name="force_stop_app">Force Stop</string>
<string name="restart_app">Restart</string> <string name="restart_app">Restart</string>
<string name="failed_to_update_sepolicy">Failed to update SELinux rules for: %s</string> <string name="failed_to_update_sepolicy">Failed to update SELinux rules for: %s</string>
<string name="module_changelog">Changelog</string>
</resources> </resources>

View File

@@ -9,6 +9,7 @@ navigation = "2.5.3"
compose-destination = "1.9.42-beta" compose-destination = "1.9.42-beta"
libsu = "5.0.5" libsu = "5.0.5"
sheets-compose-dialogs = "1.2.0" sheets-compose-dialogs = "1.2.0"
markdown = "4.6.2"
[plugins] [plugins]
agp-app = { id = "com.android.application", version.ref = "agp" } 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-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-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"} 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" }