manager: support module update online
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".KernelSUApplication"
|
android:name=".KernelSUApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package me.weishu.kernelsu.ui.screen
|
|||||||
|
|
||||||
import android.app.Activity.RESULT_OK
|
import android.app.Activity.RESULT_OK
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -19,6 +20,7 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -105,13 +107,15 @@ fun ModuleScreen(navigator: DestinationsNavigator) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
ModuleList(
|
ModuleList(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel, modifier = Modifier
|
||||||
modifier = Modifier
|
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
)
|
) {
|
||||||
|
navigator.navigate(InstallScreenDestination(it))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,7 +123,9 @@ fun ModuleScreen(navigator: DestinationsNavigator) {
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@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 failedEnable = stringResource(R.string.module_failed_to_enable)
|
||||||
val failedDisable = stringResource(R.string.module_failed_to_disable)
|
val failedDisable = stringResource(R.string.module_failed_to_disable)
|
||||||
val failedUninstall = stringResource(R.string.module_uninstall_failed)
|
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 moduleStr = stringResource(id = R.string.module)
|
||||||
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 =
|
val moduleUninstallConfirm = stringResource(id = R.string.module_uninstall_confirm)
|
||||||
stringResource(id = R.string.module_uninstall_confirm)
|
|
||||||
|
|
||||||
val dialogHost = LocalDialogHost.current
|
val dialogHost = LocalDialogHost.current
|
||||||
val snackBarHost = LocalSnackbarHost.current
|
val snackBarHost = LocalSnackbarHost.current
|
||||||
@@ -166,10 +171,8 @@ private fun ModuleList(viewModel: ModuleViewModel, modifier: Modifier = Modifier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val refreshState = rememberPullRefreshState(
|
val refreshState = rememberPullRefreshState(refreshing = viewModel.isRefreshing,
|
||||||
refreshing = viewModel.isRefreshing,
|
onRefresh = { viewModel.fetchModuleList() })
|
||||||
onRefresh = { viewModel.fetchModuleList() }
|
|
||||||
)
|
|
||||||
Box(modifier.pullRefresh(refreshState)) {
|
Box(modifier.pullRefresh(refreshState)) {
|
||||||
if (viewModel.isOverlayAvailable) {
|
if (viewModel.isOverlayAvailable) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -180,8 +183,7 @@ private fun ModuleList(viewModel: ModuleViewModel, modifier: Modifier = Modifier
|
|||||||
start = 16.dp,
|
start = 16.dp,
|
||||||
top = 16.dp,
|
top = 16.dp,
|
||||||
end = 16.dp,
|
end = 16.dp,
|
||||||
bottom = 16.dp
|
bottom = 16.dp + 16.dp + 56.dp /* Scaffold Fab Spacing + Fab container height */
|
||||||
+ 16.dp + 56.dp /* Scaffold Fab Spacing + Fab container height */
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -189,8 +191,7 @@ private fun ModuleList(viewModel: ModuleViewModel, modifier: Modifier = Modifier
|
|||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
item {
|
item {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.module_empty))
|
Text(stringResource(R.string.module_empty))
|
||||||
}
|
}
|
||||||
@@ -199,7 +200,14 @@ private fun ModuleList(viewModel: ModuleViewModel, modifier: Modifier = Modifier
|
|||||||
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()
|
||||||
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) }
|
scope.launch { onModuleUninstall(module) }
|
||||||
}, onCheckChanged = {
|
}, onCheckChanged = {
|
||||||
val success = toggleModule(module.id, !isChecked)
|
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
|
val message = if (isChecked) failedDisable else failedEnable
|
||||||
snackBarHost.showSnackbar(message.format(module.name))
|
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
|
// fix last item shadow incomplete in LazyColumn
|
||||||
Spacer(Modifier.height(1.dp))
|
Spacer(Modifier.height(1.dp))
|
||||||
}
|
}
|
||||||
@@ -232,9 +252,7 @@ private fun ModuleList(viewModel: ModuleViewModel, modifier: Modifier = Modifier
|
|||||||
}
|
}
|
||||||
|
|
||||||
PullRefreshIndicator(
|
PullRefreshIndicator(
|
||||||
refreshing = viewModel.isRefreshing,
|
refreshing = viewModel.isRefreshing, state = refreshState, modifier = Modifier.align(
|
||||||
state = refreshState,
|
|
||||||
modifier = Modifier.align(
|
|
||||||
Alignment.TopCenter
|
Alignment.TopCenter
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -251,8 +269,10 @@ private fun TopBar() {
|
|||||||
private fun ModuleItem(
|
private fun ModuleItem(
|
||||||
module: ModuleViewModel.ModuleInfo,
|
module: ModuleViewModel.ModuleInfo,
|
||||||
isChecked: Boolean,
|
isChecked: Boolean,
|
||||||
|
updateUrl: String,
|
||||||
onUninstall: (ModuleViewModel.ModuleInfo) -> Unit,
|
onUninstall: (ModuleViewModel.ModuleInfo) -> Unit,
|
||||||
onCheckChanged: (Boolean) -> Unit
|
onCheckChanged: (Boolean) -> Unit,
|
||||||
|
onUpdate: (ModuleViewModel.ModuleInfo) -> Unit,
|
||||||
) {
|
) {
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -334,6 +354,18 @@ private fun ModuleItem(
|
|||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.weight(1f, true))
|
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(
|
TextButton(
|
||||||
enabled = !module.remove,
|
enabled = !module.remove,
|
||||||
onClick = { onUninstall(module) },
|
onClick = { onUninstall(module) },
|
||||||
@@ -362,6 +394,7 @@ fun ModuleItemPreview() {
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
update = true,
|
update = true,
|
||||||
remove = 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
|
package me.weishu.kernelsu.ui.viewmodel
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.runtime.derivedStateOf
|
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.listModules
|
||||||
import me.weishu.kernelsu.ui.util.overlayFsAvailable
|
import me.weishu.kernelsu.ui.util.overlayFsAvailable
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
import java.text.Collator
|
import java.text.Collator
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@@ -33,6 +35,14 @@ class ModuleViewModel : ViewModel() {
|
|||||||
val enabled: Boolean,
|
val enabled: Boolean,
|
||||||
val update: Boolean,
|
val update: Boolean,
|
||||||
val remove: 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)
|
var isRefreshing by mutableStateOf(false)
|
||||||
@@ -78,6 +88,7 @@ class ModuleViewModel : ViewModel() {
|
|||||||
obj.getBoolean("enabled"),
|
obj.getBoolean("enabled"),
|
||||||
obj.getBoolean("update"),
|
obj.getBoolean("update"),
|
||||||
obj.getBoolean("remove"),
|
obj.getBoolean("remove"),
|
||||||
|
obj.optString("updateJson", "")
|
||||||
)
|
)
|
||||||
}.toList()
|
}.toList()
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
@@ -94,4 +105,57 @@ class ModuleViewModel : ViewModel() {
|
|||||||
Log.i(TAG, "load cost: ${SystemClock.elapsedRealtime() - start}, modules: $modules")
|
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">默认卸载模块</string>
|
||||||
<string name="settings_umount_modules_default_summary">App Profile 中\"卸载模块\"的全局默认值,如果启用,将会为没有设置 Profile 的应用移除所有模块针对系统的修改</string>
|
<string name="settings_umount_modules_default_summary">App Profile 中\"卸载模块\"的全局默认值,如果启用,将会为没有设置 Profile 的应用移除所有模块针对系统的修改</string>
|
||||||
<string name="profile_umount_modules_summary">启用后将允许 KernelSU 为本应用还原被模块修改过的文件</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>
|
</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_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_domain">Domain</string>
|
||||||
<string name="profile_selinux_rules">Rules</string>
|
<string name="profile_selinux_rules">Rules</string>
|
||||||
|
<string name="module_update">Update</string>
|
||||||
|
<string name="module_downloading">Downloading module: %s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user