manager: support module update online

This commit is contained in:
weishu
2023-06-22 18:40:28 +08:00
parent c7c9e9c3ed
commit 07273b6971
6 changed files with 196 additions and 20 deletions

View File

@@ -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"

View File

@@ -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, "", {}, {}, {})
} }

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
} }

View File

@@ -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>

View File

@@ -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>