manager: support module update online
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".KernelSUApplication"
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -2,6 +2,7 @@ package me.weishu.kernelsu.ui.screen
|
||||
|
||||
import android.app.Activity.RESULT_OK
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -19,6 +20,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -105,13 +107,15 @@ fun ModuleScreen(navigator: DestinationsNavigator) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
ModuleList(
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier
|
||||
viewModel = viewModel, modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
)
|
||||
) {
|
||||
navigator.navigate(InstallScreenDestination(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,7 +123,9 @@ fun ModuleScreen(navigator: DestinationsNavigator) {
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
private fun ModuleList(viewModel: ModuleViewModel, modifier: Modifier = Modifier) {
|
||||
private fun ModuleList(
|
||||
viewModel: ModuleViewModel, modifier: Modifier = Modifier, onInstallModule: (Uri) -> Unit
|
||||
) {
|
||||
val failedEnable = stringResource(R.string.module_failed_to_enable)
|
||||
val failedDisable = stringResource(R.string.module_failed_to_disable)
|
||||
val failedUninstall = stringResource(R.string.module_uninstall_failed)
|
||||
@@ -129,8 +135,7 @@ private fun ModuleList(viewModel: ModuleViewModel, modifier: Modifier = Modifier
|
||||
val moduleStr = stringResource(id = R.string.module)
|
||||
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 moduleUninstallConfirm = stringResource(id = R.string.module_uninstall_confirm)
|
||||
|
||||
val dialogHost = LocalDialogHost.current
|
||||
val snackBarHost = LocalSnackbarHost.current
|
||||
@@ -166,10 +171,8 @@ private fun ModuleList(viewModel: ModuleViewModel, modifier: Modifier = Modifier
|
||||
}
|
||||
}
|
||||
|
||||
val refreshState = rememberPullRefreshState(
|
||||
refreshing = viewModel.isRefreshing,
|
||||
onRefresh = { viewModel.fetchModuleList() }
|
||||
)
|
||||
val refreshState = rememberPullRefreshState(refreshing = viewModel.isRefreshing,
|
||||
onRefresh = { viewModel.fetchModuleList() })
|
||||
Box(modifier.pullRefresh(refreshState)) {
|
||||
if (viewModel.isOverlayAvailable) {
|
||||
LazyColumn(
|
||||
@@ -180,8 +183,7 @@ private fun ModuleList(viewModel: ModuleViewModel, modifier: Modifier = Modifier
|
||||
start = 16.dp,
|
||||
top = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 16.dp
|
||||
+ 16.dp + 56.dp /* Scaffold Fab Spacing + Fab container height */
|
||||
bottom = 16.dp + 16.dp + 56.dp /* Scaffold Fab Spacing + Fab container height */
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -189,8 +191,7 @@ private fun ModuleList(viewModel: ModuleViewModel, modifier: Modifier = Modifier
|
||||
if (isEmpty) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(stringResource(R.string.module_empty))
|
||||
}
|
||||
@@ -199,7 +200,14 @@ private fun ModuleList(viewModel: ModuleViewModel, modifier: Modifier = Modifier
|
||||
items(viewModel.moduleList) { module ->
|
||||
var isChecked by rememberSaveable(module) { mutableStateOf(module.enabled) }
|
||||
val scope = rememberCoroutineScope()
|
||||
ModuleItem(module, isChecked, onUninstall = {
|
||||
val updateUrl by produceState(initialValue = "") {
|
||||
viewModel.checkUpdate(module) { value = it.orEmpty() }
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val downloadingText = stringResource(R.string.module_downloading)
|
||||
|
||||
ModuleItem(module, isChecked, updateUrl, onUninstall = {
|
||||
scope.launch { onModuleUninstall(module) }
|
||||
}, onCheckChanged = {
|
||||
val success = toggleModule(module.id, !isChecked)
|
||||
@@ -219,7 +227,19 @@ private fun ModuleList(viewModel: ModuleViewModel, modifier: Modifier = Modifier
|
||||
val message = if (isChecked) failedDisable else failedEnable
|
||||
snackBarHost.showSnackbar(message.format(module.name))
|
||||
}
|
||||
}, onUpdate = {
|
||||
|
||||
download(
|
||||
context,
|
||||
Uri.parse(updateUrl),
|
||||
module.name,
|
||||
"${module.name}-${module.version}.zip",
|
||||
downloadingText.format(module.name)
|
||||
)
|
||||
})
|
||||
|
||||
DownloadListener(context, onInstallModule)
|
||||
|
||||
// fix last item shadow incomplete in LazyColumn
|
||||
Spacer(Modifier.height(1.dp))
|
||||
}
|
||||
@@ -232,9 +252,7 @@ private fun ModuleList(viewModel: ModuleViewModel, modifier: Modifier = Modifier
|
||||
}
|
||||
|
||||
PullRefreshIndicator(
|
||||
refreshing = viewModel.isRefreshing,
|
||||
state = refreshState,
|
||||
modifier = Modifier.align(
|
||||
refreshing = viewModel.isRefreshing, state = refreshState, modifier = Modifier.align(
|
||||
Alignment.TopCenter
|
||||
)
|
||||
)
|
||||
@@ -251,8 +269,10 @@ private fun TopBar() {
|
||||
private fun ModuleItem(
|
||||
module: ModuleViewModel.ModuleInfo,
|
||||
isChecked: Boolean,
|
||||
updateUrl: String,
|
||||
onUninstall: (ModuleViewModel.ModuleInfo) -> Unit,
|
||||
onCheckChanged: (Boolean) -> Unit
|
||||
onCheckChanged: (Boolean) -> Unit,
|
||||
onUpdate: (ModuleViewModel.ModuleInfo) -> Unit,
|
||||
) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -334,6 +354,18 @@ private fun ModuleItem(
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f, true))
|
||||
|
||||
if (updateUrl.isNotEmpty()) {
|
||||
TextButton(
|
||||
onClick = { onUpdate(module) },
|
||||
) {
|
||||
Text(
|
||||
fontFamily = MaterialTheme.typography.labelMedium.fontFamily,
|
||||
fontSize = MaterialTheme.typography.labelMedium.fontSize,
|
||||
text = stringResource(R.string.module_update),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(
|
||||
enabled = !module.remove,
|
||||
onClick = { onUninstall(module) },
|
||||
@@ -362,6 +394,7 @@ fun ModuleItemPreview() {
|
||||
enabled = true,
|
||||
update = true,
|
||||
remove = true,
|
||||
updateJson = ""
|
||||
)
|
||||
ModuleItem(module, true, {}, {})
|
||||
ModuleItem(module, true, "", {}, {}, {})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package me.weishu.kernelsu.ui.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
|
||||
/**
|
||||
* @author weishu
|
||||
* @date 2023/6/22.
|
||||
*/
|
||||
|
||||
fun download(context: Context, uri: Uri, title: String, fileName: String, description: String) {
|
||||
val downloadManager =
|
||||
context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
val request = DownloadManager.Request(uri)
|
||||
.setDestinationInExternalPublicDir(
|
||||
Environment.DIRECTORY_DOWNLOADS,
|
||||
fileName
|
||||
)
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setMimeType("application/zip")
|
||||
.setTitle(title)
|
||||
.setDescription(description)
|
||||
|
||||
downloadManager.enqueue(request)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DownloadListener(context: Context, onDownloaded: (Uri) -> Unit) {
|
||||
DisposableEffect(context) {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
@SuppressLint("Range")
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == DownloadManager.ACTION_DOWNLOAD_COMPLETE) {
|
||||
val id = intent.getLongExtra(
|
||||
DownloadManager.EXTRA_DOWNLOAD_ID, -1
|
||||
)
|
||||
val query = DownloadManager.Query().setFilterById(id)
|
||||
val downloadManager =
|
||||
context?.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
val cursor = downloadManager.query(query)
|
||||
if (cursor.moveToFirst()) {
|
||||
val status = cursor.getInt(
|
||||
cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
)
|
||||
if (status == DownloadManager.STATUS_SUCCESSFUL) {
|
||||
val uri = cursor.getString(
|
||||
cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
|
||||
)
|
||||
onDownloaded(Uri.parse(uri))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
context.registerReceiver(
|
||||
receiver,
|
||||
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
||||
)
|
||||
onDispose {
|
||||
context.unregisterReceiver(receiver)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package me.weishu.kernelsu.ui.viewmodel
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -13,6 +14,7 @@ import kotlinx.coroutines.launch
|
||||
import me.weishu.kernelsu.ui.util.listModules
|
||||
import me.weishu.kernelsu.ui.util.overlayFsAvailable
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.text.Collator
|
||||
import java.util.*
|
||||
|
||||
@@ -33,6 +35,14 @@ class ModuleViewModel : ViewModel() {
|
||||
val enabled: Boolean,
|
||||
val update: Boolean,
|
||||
val remove: Boolean,
|
||||
val updateJson: String,
|
||||
)
|
||||
|
||||
data class ModuleUpdateInfo(
|
||||
val version: String,
|
||||
val versionCode: Int,
|
||||
val zipUrl: String,
|
||||
val changelog: String,
|
||||
)
|
||||
|
||||
var isRefreshing by mutableStateOf(false)
|
||||
@@ -78,6 +88,7 @@ class ModuleViewModel : ViewModel() {
|
||||
obj.getBoolean("enabled"),
|
||||
obj.getBoolean("update"),
|
||||
obj.getBoolean("remove"),
|
||||
obj.optString("updateJson", "")
|
||||
)
|
||||
}.toList()
|
||||
}.onFailure { e ->
|
||||
@@ -94,4 +105,57 @@ class ModuleViewModel : ViewModel() {
|
||||
Log.i(TAG, "load cost: ${SystemClock.elapsedRealtime() - start}, modules: $modules")
|
||||
}
|
||||
}
|
||||
|
||||
fun checkUpdate(m: ModuleInfo, callback: (String?) -> Unit) {
|
||||
if (m.updateJson.isEmpty()) {
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// download updateJson
|
||||
val result = kotlin.runCatching {
|
||||
val url = m.updateJson
|
||||
Log.i(TAG, "checkUpdate url: $url")
|
||||
val response = okhttp3.OkHttpClient()
|
||||
.newCall(
|
||||
okhttp3.Request.Builder()
|
||||
.url(url)
|
||||
.build()
|
||||
).execute()
|
||||
Log.d(TAG, "checkUpdate code: ${response.code}")
|
||||
if (response.isSuccessful) {
|
||||
response.body?.string() ?: ""
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}.getOrDefault("")
|
||||
Log.i(TAG, "checkUpdate result: $result")
|
||||
|
||||
if (result.isEmpty()) {
|
||||
callback(null)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val updateJson = kotlin.runCatching {
|
||||
JSONObject(result)
|
||||
}.getOrNull()
|
||||
|
||||
if (updateJson == null) {
|
||||
callback(null)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val version = updateJson.optString("version", "")
|
||||
val versionCode = updateJson.optInt("versionCode", 0)
|
||||
val zipUrl = updateJson.optString("zipUrl", "")
|
||||
val changelog = updateJson.optString("changelog", "")
|
||||
if (versionCode < m.versionCode || zipUrl.isEmpty()) {
|
||||
callback(null)
|
||||
return@launch
|
||||
}
|
||||
|
||||
callback(zipUrl)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -58,4 +58,8 @@
|
||||
<string name="settings_umount_modules_default">默认卸载模块</string>
|
||||
<string name="settings_umount_modules_default_summary">App Profile 中\"卸载模块\"的全局默认值,如果启用,将会为没有设置 Profile 的应用移除所有模块针对系统的修改</string>
|
||||
<string name="profile_umount_modules_summary">启用后将允许 KernelSU 为本应用还原被模块修改过的文件</string>
|
||||
<string name="profile_selinux_domain">域</string>
|
||||
<string name="profile_selinux_rules">规则</string>
|
||||
<string name="module_update">更新</string>
|
||||
<string name="module_downloading">正在下载模块:%s</string>
|
||||
</resources>
|
||||
|
||||
@@ -76,4 +76,6 @@
|
||||
<string name="profile_umount_modules_summary">Enabling this option will allow KernelSU to restore any modified files by the modules for this application.</string>
|
||||
<string name="profile_selinux_domain">Domain</string>
|
||||
<string name="profile_selinux_rules">Rules</string>
|
||||
<string name="module_update">Update</string>
|
||||
<string name="module_downloading">Downloading module: %s</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user