diff --git a/manager/app/src/main/AndroidManifest.xml b/manager/app/src/main/AndroidManifest.xml index 026ba015..96562cd8 100644 --- a/manager/app/src/main/AndroidManifest.xml +++ b/manager/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + { 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, "", {}, {}, {}) } \ No newline at end of file diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/util/Downloader.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/util/Downloader.kt new file mode 100644 index 00000000..1f18dba2 --- /dev/null +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/util/Downloader.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/ModuleViewModel.kt b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/ModuleViewModel.kt index 3d2a6e61..cdbc63d1 100644 --- a/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/ModuleViewModel.kt +++ b/manager/app/src/main/java/me/weishu/kernelsu/ui/viewmodel/ModuleViewModel.kt @@ -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) + } + } + } diff --git a/manager/app/src/main/res/values-zh-rCN/strings.xml b/manager/app/src/main/res/values-zh-rCN/strings.xml index 8cf8836e..db7a5ca3 100644 --- a/manager/app/src/main/res/values-zh-rCN/strings.xml +++ b/manager/app/src/main/res/values-zh-rCN/strings.xml @@ -58,4 +58,8 @@ 默认卸载模块 App Profile 中\"卸载模块\"的全局默认值,如果启用,将会为没有设置 Profile 的应用移除所有模块针对系统的修改 启用后将允许 KernelSU 为本应用还原被模块修改过的文件 + + 规则 + 更新 + 正在下载模块:%s diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index 28258ccd..35e0ee77 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -76,4 +76,6 @@ Enabling this option will allow KernelSU to restore any modified files by the modules for this application. Domain Rules + Update + Downloading module: %s