Add module download error alerts and optimize update checking logic

- Add a formatting string for the update list
- Fix module update failures caused by spaces and other non Linux readable characters.

Signed-off-by: ShirkNeko <109797057+ShirkNeko@users.noreply.github.com>
This commit is contained in:
ShirkNeko
2025-05-20 12:12:22 +08:00
parent 29033e9b80
commit 58a4ff94e4
4 changed files with 257 additions and 96 deletions

View File

@@ -66,6 +66,7 @@ import com.sukisu.ultra.ui.theme.getCardColors
import com.sukisu.ultra.ui.viewmodel.ModuleViewModel import com.sukisu.ultra.ui.viewmodel.ModuleViewModel
import java.io.BufferedReader import java.io.BufferedReader
import java.io.InputStreamReader import java.io.InputStreamReader
import java.util.concurrent.TimeUnit
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import androidx.core.content.edit import androidx.core.content.edit
import com.sukisu.ultra.R import com.sukisu.ultra.R
@@ -443,6 +444,7 @@ private fun ModuleList(
val downloadingText = stringResource(R.string.module_downloading) val downloadingText = stringResource(R.string.module_downloading)
val startDownloadingText = stringResource(R.string.module_start_downloading) val startDownloadingText = stringResource(R.string.module_start_downloading)
val fetchChangeLogFailed = stringResource(R.string.module_changelog_failed) val fetchChangeLogFailed = stringResource(R.string.module_changelog_failed)
val downloadErrorText = stringResource(R.string.module_download_error)
val loadingDialog = rememberLoadingDialog() val loadingDialog = rememberLoadingDialog()
val confirmDialog = rememberConfirmDialog() val confirmDialog = rememberConfirmDialog()
@@ -453,12 +455,20 @@ private fun ModuleList(
downloadUrl: String, downloadUrl: String,
fileName: 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 { val changelogResult = loadingDialog.withLoading {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { runCatching {
OkHttpClient().newCall( client.newCall(request).execute().body!!.string()
okhttp3.Request.Builder().url(changelogUrl).build()
).execute().body!!.string()
} }
} }
} }
@@ -507,6 +517,11 @@ private fun ModuleList(
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
Toast.makeText(context, downloading, Toast.LENGTH_SHORT).show() 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, imageVector = Icons.Outlined.PlayArrow,
contentDescription = null 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, imageVector = Icons.AutoMirrored.Outlined.Wysiwyg,
contentDescription = null 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, imageVector = Icons.Outlined.Download,
contentDescription = null 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) }, onClick = { onUninstallClicked(module) },
contentPadding = ButtonDefaults.TextButtonContentPadding, contentPadding = ButtonDefaults.TextButtonContentPadding,
colors = ButtonDefaults.filledTonalButtonColors( 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) { if (!module.remove) {
Icon( Icon(
@@ -906,15 +897,6 @@ fun ModuleItem(
contentDescription = null 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
//)
//}
} }
} }
} }

View File

@@ -7,13 +7,23 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.sukisu.ultra.ui.util.module.LatestVersionInfo import com.sukisu.ultra.ui.util.module.LatestVersionInfo
import androidx.core.net.toUri 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 * @author weishu
@@ -26,8 +36,10 @@ fun download(
fileName: String, fileName: String,
description: String, description: String,
onDownloaded: (Uri) -> Unit = {}, 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 downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val query = DownloadManager.Query() 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()) val request = DownloadManager.Request(url.toUri())
.setDestinationInExternalPublicDir( .setDestinationInExternalPublicDir(
@@ -59,66 +78,206 @@ fun download(
.setMimeType("application/zip") .setMimeType("application/zip")
.setTitle(fileName) .setTitle(fileName)
.setDescription(description) .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 downloadID: $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 { fun checkNewVersion(): LatestVersionInfo {
val url = "https://api.github.com/repos/ShirkNeko/SukiSU-Ultra/releases/latest" val url = "https://api.github.com/repos/ShirkNeko/SukiSU-Ultra/releases/latest"
val defaultValue = LatestVersionInfo() val defaultValue = LatestVersionInfo()
return runCatching { return runCatching {
okhttp3.OkHttpClient().newCall(okhttp3.Request.Builder().url(url).build()).execute() val client = okhttp3.OkHttpClient.Builder()
.use { response -> .connectTimeout(15, TimeUnit.SECONDS)
if (!response.isSuccessful) { .readTimeout(30, TimeUnit.SECONDS)
Log.d("CheckUpdate", "Network request failed: ${response.message}") .writeTimeout(15, TimeUnit.SECONDS)
return defaultValue .build()
}
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)
// 直接从 tag_name 提取版本号(如 v1.1 val request = okhttp3.Request.Builder()
val tagName = json.optString("tag_name", "") .url(url)
val versionName = tagName.removePrefix("v") // 移除前缀 "v" .header("User-Agent", CUSTOM_USER_AGENT)
.build()
// 从 body 字段获取更新日志(保留换行符) client.newCall(request).execute().use { response ->
val changelog = json.optString("body") if (!response.isSuccessful) {
.replace("\\r\\n", "\n") // 转换换行符 Log.d("CheckUpdate", "Network request failed: ${response.message}")
return defaultValue
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
} }
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) }.getOrDefault(defaultValue)
} }
@Composable @Composable
fun DownloadListener(context: Context, onDownloaded: (Uri) -> Unit) { fun DownloadListener(context: Context, onDownloaded: (Uri) -> Unit) {
DisposableEffect(context) { DisposableEffect(context) {
@@ -158,4 +317,3 @@ fun DownloadListener(context: Context, onDownloaded: (Uri) -> Unit) {
} }
} }
} }

View File

@@ -16,12 +16,14 @@ import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.text.Collator import java.text.Collator
import java.util.Locale import java.util.Locale
import java.util.concurrent.TimeUnit
class ModuleViewModel : ViewModel() { class ModuleViewModel : ViewModel() {
companion object { companion object {
private const val TAG = "ModuleViewModel" private const val TAG = "ModuleViewModel"
private var modules by mutableStateOf<List<ModuleInfo>>(emptyList()) private var modules by mutableStateOf<List<ModuleInfo>>(emptyList())
private const val CUSTOM_USER_AGENT = "SukiSU-Ultra/2.0"
} }
class ModuleInfo( 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<String, String, String> { fun checkUpdate(m: ModuleInfo): Triple<String, String, String> {
val empty = Triple("", "", "") val empty = Triple("", "", "")
if (m.updateJson.isEmpty() || m.remove || m.update || !m.enabled) { if (m.updateJson.isEmpty() || m.remove || m.update || !m.enabled) {
@@ -126,19 +132,32 @@ class ModuleViewModel : ViewModel() {
val result = kotlin.runCatching { val result = kotlin.runCatching {
val url = m.updateJson val url = m.updateJson
Log.i(TAG, "checkUpdate url: $url") Log.i(TAG, "checkUpdate url: $url")
val response = okhttp3.OkHttpClient()
.newCall( val client = okhttp3.OkHttpClient.Builder()
okhttp3.Request.Builder() .connectTimeout(15, TimeUnit.SECONDS)
.url(url) .readTimeout(30, TimeUnit.SECONDS)
.build() .writeTimeout(15, TimeUnit.SECONDS)
).execute() .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}") Log.d(TAG, "checkUpdate code: ${response.code}")
if (response.isSuccessful) { if (response.isSuccessful) {
response.body?.string() ?: "" response.body?.string() ?: ""
} else { } else {
Log.d(TAG, "checkUpdate failed: ${response.message}")
"" ""
} }
}.getOrDefault("") }.getOrElse { e ->
Log.e(TAG, "checkUpdate exception", e)
""
}
Log.i(TAG, "checkUpdate result: $result") Log.i(TAG, "checkUpdate result: $result")
if (result.isEmpty()) { if (result.isEmpty()) {
@@ -149,7 +168,8 @@ class ModuleViewModel : ViewModel() {
JSONObject(result) JSONObject(result)
}.getOrNull() ?: return empty }.getOrNull() ?: return empty
val version = updateJson.optString("version", "") var version = updateJson.optString("version", "")
version = sanitizeVersionString(version)
val versionCode = updateJson.optInt("versionCode", 0) val versionCode = updateJson.optInt("versionCode", 0)
val zipUrl = updateJson.optString("zipUrl", "") val zipUrl = updateJson.optString("zipUrl", "")
val changelog = updateJson.optString("changelog", "") val changelog = updateJson.optString("changelog", "")

View File

@@ -366,4 +366,5 @@
<string name="check_log">Please check the log</string> <string name="check_log">Please check the log</string>
<string name="installing_module">Module being installed %1$d/%2$d</string> <string name="installing_module">Module being installed %1$d/%2$d</string>
<string name="module_failed_count">%d Failed to install a new module</string> <string name="module_failed_count">%d Failed to install a new module</string>
<string name="module_download_error">Module download failed</string>
</resources> </resources>