diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Module.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Module.kt index a1aae7ca..924272e8 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Module.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/screen/Module.kt @@ -66,6 +66,7 @@ import com.sukisu.ultra.ui.theme.getCardColors import com.sukisu.ultra.ui.viewmodel.ModuleViewModel import java.io.BufferedReader import java.io.InputStreamReader +import java.util.concurrent.TimeUnit import java.util.zip.ZipInputStream import androidx.core.content.edit import com.sukisu.ultra.R @@ -443,6 +444,7 @@ private fun ModuleList( val downloadingText = stringResource(R.string.module_downloading) val startDownloadingText = stringResource(R.string.module_start_downloading) val fetchChangeLogFailed = stringResource(R.string.module_changelog_failed) + val downloadErrorText = stringResource(R.string.module_download_error) val loadingDialog = rememberLoadingDialog() val confirmDialog = rememberConfirmDialog() @@ -453,12 +455,20 @@ private fun ModuleList( downloadUrl: String, fileName: String ) { + val client = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + val request = okhttp3.Request.Builder() + .url(changelogUrl) + .header("User-Agent", "SukiSU-Ultra/2.0") + .build() + val changelogResult = loadingDialog.withLoading { withContext(Dispatchers.IO) { runCatching { - OkHttpClient().newCall( - okhttp3.Request.Builder().url(changelogUrl).build() - ).execute().body!!.string() + client.newCall(request).execute().body!!.string() } } } @@ -507,6 +517,11 @@ private fun ModuleList( launch(Dispatchers.Main) { Toast.makeText(context, downloading, Toast.LENGTH_SHORT).show() } + }, + onError = { errorMsg -> + launch(Dispatchers.Main) { + Toast.makeText(context, "$downloadErrorText: $errorMsg", Toast.LENGTH_LONG).show() + } } ) } @@ -823,14 +838,6 @@ fun ModuleItem( imageVector = Icons.Outlined.PlayArrow, contentDescription = null ) - //if (!module.hasWebUi && updateUrl.isEmpty()) { - //Text( - // modifier = Modifier.padding(start = 7.dp), - // text = stringResource(R.string.action), - // fontFamily = MaterialTheme.typography.labelMedium.fontFamily, - // fontSize = MaterialTheme.typography.labelMedium.fontSize - //) - //} } } @@ -849,14 +856,6 @@ fun ModuleItem( imageVector = Icons.AutoMirrored.Outlined.Wysiwyg, contentDescription = null ) - //if (!module.hasActionScript && updateUrl.isEmpty()) { - //Text( - // modifier = Modifier.padding(start = 7.dp), - // fontFamily = MaterialTheme.typography.labelMedium.fontFamily, - // fontSize = MaterialTheme.typography.labelMedium.fontSize, - // text = stringResource(R.string.open) - //) - //} } } @@ -875,14 +874,6 @@ fun ModuleItem( imageVector = Icons.Outlined.Download, contentDescription = null ) - //if (!module.hasActionScript || !module.hasWebUi) { - //Text( - // modifier = Modifier.padding(start = 7.dp), - // fontFamily = MaterialTheme.typography.labelMedium.fontFamily, - // fontSize = MaterialTheme.typography.labelMedium.fontSize, - // text = stringResource(R.string.module_update) - //) - //} } } @@ -891,7 +882,7 @@ fun ModuleItem( onClick = { onUninstallClicked(module) }, contentPadding = ButtonDefaults.TextButtonContentPadding, colors = ButtonDefaults.filledTonalButtonColors( - containerColor = if (!module.remove) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer) + containerColor = if (!module.remove) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer) ) { if (!module.remove) { Icon( @@ -906,15 +897,6 @@ fun ModuleItem( contentDescription = null ) } - //if (!module.hasActionScript && !module.hasWebUi && updateUrl.isEmpty()) { - //Text( - // modifier = Modifier.padding(start = 7.dp), - // fontFamily = MaterialTheme.typography.labelMedium.fontFamily, - // fontSize = MaterialTheme.typography.labelMedium.fontSize, - // text = stringResource(if (!module.remove) R.string.uninstall else R.string.restore), - // color = if (!module.remove) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSecondaryContainer - //) - //} } } } diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/util/Downloader.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/util/Downloader.kt index f17cfe31..af7a314a 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/util/Downloader.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/util/Downloader.kt @@ -7,13 +7,23 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.net.Uri +import android.os.Build import android.os.Environment +import android.os.Handler +import android.os.Looper import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.core.content.ContextCompat import com.sukisu.ultra.ui.util.module.LatestVersionInfo import androidx.core.net.toUri +import java.io.File +import java.util.concurrent.TimeUnit + +private const val TAG = "DownloadUtil" +private val CUSTOM_USER_AGENT = "SukiSU-Ultra/2.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})" +private const val MAX_RETRY_COUNT = 3 +private const val RETRY_DELAY_MS = 3000L /** * @author weishu @@ -26,8 +36,10 @@ fun download( fileName: String, description: String, onDownloaded: (Uri) -> Unit = {}, - onDownloading: () -> Unit = {} + onDownloading: () -> Unit = {}, + onError: (String) -> Unit = {} ) { + Log.d(TAG, "Start Download: $url") val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val query = DownloadManager.Query() @@ -49,6 +61,13 @@ fun download( } } } + val downloadFile = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + fileName + ) + if (downloadFile.exists()) { + downloadFile.delete() + } val request = DownloadManager.Request(url.toUri()) .setDestinationInExternalPublicDir( @@ -59,66 +78,206 @@ fun download( .setMimeType("application/zip") .setTitle(fileName) .setDescription(description) + .addRequestHeader("User-Agent", CUSTOM_USER_AGENT) + .setAllowedOverMetered(true) + .setAllowedOverRoaming(true) + .setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE) - downloadManager.enqueue(request) + try { + val downloadId = downloadManager.enqueue(request) + Log.d(TAG, "Successful launch of the download,ID: $downloadId") + monitorDownload(context, downloadManager, downloadId, url, fileName, description, onDownloaded, onDownloading, onError) + } catch (e: Exception) { + Log.e(TAG, "Download startup failure", e) + onError("Download startup failure: ${e.message}") + } +} + +private fun monitorDownload( + context: Context, + downloadManager: DownloadManager, + downloadId: Long, + url: String, + fileName: String, + description: String, + onDownloaded: (Uri) -> Unit, + onDownloading: () -> Unit, + onError: (String) -> Unit, + retryCount: Int = 0 +) { + val handler = Handler(Looper.getMainLooper()) + val query = DownloadManager.Query().setFilterById(downloadId) + + var lastProgress = -1 + var stuckCounter = 0 + + val runnable = object : Runnable { + override fun run() { + downloadManager.query(query).use { cursor -> + if (cursor != null && cursor.moveToFirst()) { + @SuppressLint("Range") + val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) + + when (status) { + DownloadManager.STATUS_SUCCESSFUL -> { + @SuppressLint("Range") + val localUri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)) + Log.d(TAG, "Download Successfully: $localUri") + onDownloaded(localUri.toUri()) + return + } + DownloadManager.STATUS_FAILED -> { + @SuppressLint("Range") + val reason = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON)) + Log.d(TAG, "Download failed with reason code: $reason") + + if (retryCount < MAX_RETRY_COUNT) { + Log.d(TAG, "Attempts to re download, number of retries: ${retryCount + 1}") + handler.postDelayed({ + downloadManager.remove(downloadId) + download(context, url, fileName, description, onDownloaded, onDownloading, onError) + }, RETRY_DELAY_MS) + } else { + onError("Download failed, please check network connection or storage space") + } + return + } + DownloadManager.STATUS_RUNNING, DownloadManager.STATUS_PENDING, DownloadManager.STATUS_PAUSED -> { + @SuppressLint("Range") + val totalBytes = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) + @SuppressLint("Range") + val downloadedBytes = cursor.getLong(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) + + if (totalBytes > 0) { + val progress = (downloadedBytes * 100 / totalBytes).toInt() + if (progress == lastProgress) { + stuckCounter++ + if (stuckCounter > 30) { + if (retryCount < MAX_RETRY_COUNT) { + Log.d(TAG, "Download stalled and restarted") + downloadManager.remove(downloadId) + download(context, url, fileName, description, onDownloaded, onDownloading, onError) + return + } + } + } else { + lastProgress = progress + stuckCounter = 0 + Log.d(TAG, "Download progress: $progress% ($downloadedBytes/$totalBytes)") + } + } + } + } + } + } + handler.postDelayed(this, 1000) + } + } + handler.post(runnable) + + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) ?: -1 + if (id == downloadId) { + handler.removeCallbacks(runnable) + + val query = DownloadManager.Query().setFilterById(downloadId) + downloadManager.query(query).use { cursor -> + if (cursor != null && cursor.moveToFirst()) { + @SuppressLint("Range") + val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) + + if (status == DownloadManager.STATUS_SUCCESSFUL) { + @SuppressLint("Range") + val localUri = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)) + onDownloaded(localUri.toUri()) + } else { + if (retryCount < MAX_RETRY_COUNT) { + download(context!!, url, fileName, description, onDownloaded, onDownloading, onError) + } else { + onError("Download failed, please try again later") + } + } + } + } + + context?.unregisterReceiver(this) + } + } + } + + ContextCompat.registerReceiver( + context, + receiver, + IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), + ContextCompat.RECEIVER_EXPORTED + ) } fun checkNewVersion(): LatestVersionInfo { val url = "https://api.github.com/repos/ShirkNeko/SukiSU-Ultra/releases/latest" val defaultValue = LatestVersionInfo() return runCatching { - okhttp3.OkHttpClient().newCall(okhttp3.Request.Builder().url(url).build()).execute() - .use { response -> - if (!response.isSuccessful) { - Log.d("CheckUpdate", "Network request failed: ${response.message}") - return defaultValue - } - val body = response.body?.string() - if (body == null) { - Log.d("CheckUpdate", "Response body is null") - return defaultValue - } - Log.d("CheckUpdate", "Response body: $body") - val json = org.json.JSONObject(body) + val client = okhttp3.OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .build() - // 直接从 tag_name 提取版本号(如 v1.1) - val tagName = json.optString("tag_name", "") - val versionName = tagName.removePrefix("v") // 移除前缀 "v" + val request = okhttp3.Request.Builder() + .url(url) + .header("User-Agent", CUSTOM_USER_AGENT) + .build() - // 从 body 字段获取更新日志(保留换行符) - val changelog = json.optString("body") - .replace("\\r\\n", "\n") // 转换换行符 - - val assets = json.getJSONArray("assets") - for (i in 0 until assets.length()) { - val asset = assets.getJSONObject(i) - val name = asset.getString("name") - if (!name.endsWith(".apk")) continue - - // 修改正则表达式,只匹配 SukiSU 和版本号 - val regex = Regex("SukiSU.*_(\\d+)-release") - val matchResult = regex.find(name) - if (matchResult == null) { - Log.d("CheckUpdate", "No match found in $name, skipping") - continue - } - val versionCode = matchResult.groupValues[1].toInt() - - val downloadUrl = asset.getString("browser_download_url") - return LatestVersionInfo( - versionCode, - downloadUrl, - changelog, - versionName - ) - } - Log.d("CheckUpdate", "No valid apk asset found, returning default value") - defaultValue + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Log.d("CheckUpdate", "Network request failed: ${response.message}") + return defaultValue } + val body = response.body?.string() + if (body == null) { + Log.d("CheckUpdate", "Return data is null") + return defaultValue + } + Log.d("CheckUpdate", "Return data: $body") + val json = org.json.JSONObject(body) + + // 直接从 tag_name 提取版本号(如 v1.1) + val tagName = json.optString("tag_name", "") + val versionName = tagName.removePrefix("v") // 移除前缀 "v" + + // 从 body 字段获取更新日志(保留换行符) + val changelog = json.optString("body") + .replace("\\r\\n", "\n") // 转换换行符 + + val assets = json.getJSONArray("assets") + for (i in 0 until assets.length()) { + val asset = assets.getJSONObject(i) + val name = asset.getString("name") + if (!name.endsWith(".apk")) continue + + val regex = Regex("SukiSU.*_(\\d+)-release") + val matchResult = regex.find(name) + if (matchResult == null) { + Log.d("CheckUpdate", "No matches found: $name, skip over") + continue + } + val versionCode = matchResult.groupValues[1].toInt() + + val downloadUrl = asset.getString("browser_download_url") + return LatestVersionInfo( + versionCode, + downloadUrl, + changelog, + versionName + ) + } + Log.d("CheckUpdate", "No valid APK resource found, return default value") + defaultValue + } }.getOrDefault(defaultValue) } - @Composable fun DownloadListener(context: Context, onDownloaded: (Uri) -> Unit) { DisposableEffect(context) { @@ -157,5 +316,4 @@ fun DownloadListener(context: Context, onDownloaded: (Uri) -> Unit) { context.unregisterReceiver(receiver) } } -} - +} \ No newline at end of file diff --git a/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/ModuleViewModel.kt b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/ModuleViewModel.kt index 60676a93..3a75455c 100644 --- a/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/ModuleViewModel.kt +++ b/manager/app/src/main/java/com/sukisu/ultra/ui/viewmodel/ModuleViewModel.kt @@ -16,12 +16,14 @@ import org.json.JSONArray import org.json.JSONObject import java.text.Collator import java.util.Locale +import java.util.concurrent.TimeUnit class ModuleViewModel : ViewModel() { companion object { private const val TAG = "ModuleViewModel" private var modules by mutableStateOf>(emptyList()) + private const val CUSTOM_USER_AGENT = "SukiSU-Ultra/2.0" } class ModuleInfo( @@ -117,6 +119,10 @@ class ModuleViewModel : ViewModel() { } } + private fun sanitizeVersionString(version: String): String { + return version.replace(Regex("[^a-zA-Z0-9.\\-_]"), "_") + } + fun checkUpdate(m: ModuleInfo): Triple { val empty = Triple("", "", "") if (m.updateJson.isEmpty() || m.remove || m.update || !m.enabled) { @@ -126,19 +132,32 @@ class ModuleViewModel : ViewModel() { 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() + + val client = okhttp3.OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .build() + + val request = okhttp3.Request.Builder() + .url(url) + .header("User-Agent", CUSTOM_USER_AGENT) + .build() + + val response = client.newCall(request).execute() + Log.d(TAG, "checkUpdate code: ${response.code}") if (response.isSuccessful) { response.body?.string() ?: "" } else { + Log.d(TAG, "checkUpdate failed: ${response.message}") "" } - }.getOrDefault("") + }.getOrElse { e -> + Log.e(TAG, "checkUpdate exception", e) + "" + } + Log.i(TAG, "checkUpdate result: $result") if (result.isEmpty()) { @@ -149,7 +168,8 @@ class ModuleViewModel : ViewModel() { JSONObject(result) }.getOrNull() ?: return empty - val version = updateJson.optString("version", "") + var version = updateJson.optString("version", "") + version = sanitizeVersionString(version) val versionCode = updateJson.optInt("versionCode", 0) val zipUrl = updateJson.optString("zipUrl", "") val changelog = updateJson.optString("changelog", "") @@ -159,4 +179,4 @@ class ModuleViewModel : ViewModel() { return Triple(zipUrl, version, changelog) } -} +} \ No newline at end of file diff --git a/manager/app/src/main/res/values/strings.xml b/manager/app/src/main/res/values/strings.xml index 74da7d04..ab69082f 100644 --- a/manager/app/src/main/res/values/strings.xml +++ b/manager/app/src/main/res/values/strings.xml @@ -366,4 +366,5 @@ Please check the log Module being installed %1$d/%2$d %d Failed to install a new module + Module download failed