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.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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" }
|
||||||
Reference in New Issue
Block a user