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)
//)
//}
} }
} }
@@ -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,26 +78,168 @@ 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)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.build()
val request = okhttp3.Request.Builder()
.url(url)
.header("User-Agent", CUSTOM_USER_AGENT)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {
Log.d("CheckUpdate", "Network request failed: ${response.message}") Log.d("CheckUpdate", "Network request failed: ${response.message}")
return defaultValue return defaultValue
} }
val body = response.body?.string() val body = response.body?.string()
if (body == null) { if (body == null) {
Log.d("CheckUpdate", "Response body is null") Log.d("CheckUpdate", "Return data is null")
return defaultValue return defaultValue
} }
Log.d("CheckUpdate", "Response body: $body") Log.d("CheckUpdate", "Return data: $body")
val json = org.json.JSONObject(body) val json = org.json.JSONObject(body)
// 直接从 tag_name 提取版本号(如 v1.1 // 直接从 tag_name 提取版本号(如 v1.1
@@ -95,11 +256,10 @@ fun checkNewVersion(): LatestVersionInfo {
val name = asset.getString("name") val name = asset.getString("name")
if (!name.endsWith(".apk")) continue if (!name.endsWith(".apk")) continue
// 修改正则表达式,只匹配 SukiSU 和版本号
val regex = Regex("SukiSU.*_(\\d+)-release") val regex = Regex("SukiSU.*_(\\d+)-release")
val matchResult = regex.find(name) val matchResult = regex.find(name)
if (matchResult == null) { if (matchResult == null) {
Log.d("CheckUpdate", "No match found in $name, skipping") Log.d("CheckUpdate", "No matches found: $name, skip over")
continue continue
} }
val versionCode = matchResult.groupValues[1].toInt() val versionCode = matchResult.groupValues[1].toInt()
@@ -112,13 +272,12 @@ fun checkNewVersion(): LatestVersionInfo {
versionName versionName
) )
} }
Log.d("CheckUpdate", "No valid apk asset found, returning default value") Log.d("CheckUpdate", "No valid APK resource found, return default value")
defaultValue 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)
.writeTimeout(15, TimeUnit.SECONDS)
.build() .build()
).execute()
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>