Skip to content

Commit 910eb59

Browse files
fix(theme-store): validate downloaded theme file integrity and improve error feedback
- Add post-download integrity check in ThemeDownloader: verify file size against Content-Length, detect web error pages disguised as .fpt files, and check minimum file structure requirements - Improve ThemeManager.kt import error messages with specific failure reasons (corrupted ZIP, bad padding, missing config) - Add pre-apply quick validation in ThemeStoreViewModel to skip obviously broken files before full decryption - Update my_themes_apply_failed string in all 16 locales to suggest long-press delete and re-download - Add theme_file_corrupt and theme_file_incomplete strings to all locales
1 parent e13738e commit 910eb59

22 files changed

Lines changed: 191 additions & 37 deletions

File tree

app/src/main/java/me/bmax/apatch/ui/theme/ThemeManager.kt

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -425,35 +425,57 @@ object ThemeManager {
425425
try {
426426
// 1. Decrypt and Unzip
427427
SafeUriResolver.openInputStream(context, uri)?.use { `is` ->
428-
// Read IV
428+
// Read IV (first 16 bytes of the encrypted file)
429429
val iv = ByteArray(16)
430-
if (`is`.read(iv) != 16) throw Exception("Invalid theme file")
430+
val ivBytesRead = `is`.read(iv)
431+
if (ivBytesRead != 16) {
432+
val msg = if (ivBytesRead <= 0) {
433+
"Theme file is empty or unreadable"
434+
} else {
435+
"Theme file incomplete: only read $ivBytesRead of 16 IV bytes"
436+
}
437+
throw Exception(msg)
438+
}
431439

432440
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
433441
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), IvParameterSpec(iv))
434442

435-
CipherInputStream(`is`, cipher).use { cis ->
436-
ZipInputStream(BufferedInputStream(cis)).use { zis ->
437-
var entry: ZipEntry?
438-
while (zis.nextEntry.also { entry = it } != null) {
439-
val file = File(cacheDir, entry!!.name)
440-
// Prevent path traversal
441-
if (!file.canonicalPath.startsWith(cacheDir.canonicalPath)) {
442-
continue
443-
}
444-
FileOutputStream(file).use { fos ->
445-
zis.copyTo(fos)
443+
try {
444+
CipherInputStream(`is`, cipher).use { cis ->
445+
ZipInputStream(BufferedInputStream(cis)).use { zis ->
446+
var entry: ZipEntry?
447+
while (zis.nextEntry.also { entry = it } != null) {
448+
val file = File(cacheDir, entry!!.name)
449+
// Prevent path traversal
450+
if (!file.canonicalPath.startsWith(cacheDir.canonicalPath)) {
451+
continue
452+
}
453+
FileOutputStream(file).use { fos ->
454+
zis.copyTo(fos)
455+
}
446456
}
447457
}
448458
}
459+
} catch (e: java.util.zip.ZipException) {
460+
throw Exception("Theme file corrupted: decrypted data is not a valid ZIP — the file may be incomplete or damaged", e)
461+
} catch (e: javax.crypto.BadPaddingException) {
462+
throw Exception("Theme file corrupted: decryption failed (bad padding) — the file may be incomplete or damaged", e)
449463
}
450464
}
451465

452466
// 2. Read Config
453467
val configFile = File(cacheDir, THEME_CONFIG_FILENAME)
454-
if (!configFile.exists()) return@withContext false
468+
if (!configFile.exists()) {
469+
Log.e(TAG, "theme.json missing from theme package — archive may be incomplete")
470+
return@withContext false
471+
}
455472

456-
val json = JSONObject(configFile.readText())
473+
val json = try {
474+
JSONObject(configFile.readText())
475+
} catch (e: Exception) {
476+
Log.e(TAG, "theme.json is malformed", e)
477+
return@withContext false
478+
}
457479
val isBackgroundEnabled = json.optBoolean("isBackgroundEnabled", false)
458480
val backgroundOpacity = json.optDouble("backgroundOpacity", 0.5).toFloat()
459481
val backgroundBlur = json.optDouble("backgroundBlur", 0.0).toFloat()
@@ -782,7 +804,7 @@ object ThemeManager {
782804

783805
true
784806
} catch (e: Exception) {
785-
Log.e(TAG, "Import failed", e)
807+
Log.e(TAG, "Import failed: ${e.message ?: e.javaClass.simpleName}", e)
786808
false
787809
} finally {
788810
cacheDir.deleteRecursively()

app/src/main/java/me/bmax/apatch/ui/viewmodel/ThemeStoreViewModel.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,12 @@ class ThemeStoreViewModel(private val context: Context) : ViewModel() {
440440
Log.e(TAG, "Theme file not found: ${localTheme.localPath}")
441441
return@withContext false
442442
}
443+
444+
// 应用前快速预检:避免明显残缺/损坏的文件进入完整解密流程
445+
if (!validateThemeFileQuick(themeFile)) {
446+
Log.e(TAG, "Theme file failed pre-check, skipping import: ${localTheme.localPath}")
447+
return@withContext false
448+
}
443449

444450
val uri = Uri.fromFile(themeFile)
445451
val success = ThemeManager.importTheme(context, uri)
@@ -457,6 +463,30 @@ class ThemeStoreViewModel(private val context: Context) : ViewModel() {
457463
}
458464
}
459465

466+
/**
467+
* 应用前的快速完整性检查:验证 .fpt 文件基本结构。
468+
* 不执行完整解密,仅检查文件大小和首字节签名,避免浪费资源在明显损坏的文件上。
469+
*/
470+
private fun validateThemeFileQuick(file: File): Boolean {
471+
if (file.length() < 32L) {
472+
Log.w(TAG, "Theme file too small (${file.length()} bytes), may be incomplete")
473+
return false
474+
}
475+
// 检查首字节:加密数据应该是随机字节,不应该是 HTML/JSON 文本
476+
return runCatching {
477+
file.inputStream().use { input ->
478+
val firstByte = input.read()
479+
if (firstByte == -1) return@runCatching false
480+
// HTML 通常 '<', JSON 通常 '{', 纯文本错误页
481+
if (firstByte == '<'.code || firstByte == '{'.code) {
482+
Log.w(TAG, "Theme file starts with text character (0x${firstByte.toString(16)}), may be a web error page")
483+
return@runCatching false
484+
}
485+
true
486+
}
487+
}.getOrDefault(false)
488+
}
489+
460490
/**
461491
* 删除主题
462492
*/

app/src/main/java/me/bmax/apatch/util/ThemeDownloader.kt

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,8 +294,12 @@ class ThemeDownloader(private val context: Context) {
294294

295295
while (retryCount < MAX_RETRIES) {
296296
try {
297-
downloadOnce(url, file, taskId, onProgress)
297+
val expectedSize = downloadOnce(url, file, taskId, onProgress)
298298
Log.d(TAG, "Download completed: ${file.absolutePath}")
299+
300+
// 下载后完整性校验:避免残缺/被 CDN 替换的文件被标记为成功
301+
validateDownloadedFile(file, isThemeFile, expectedSize)
302+
299303
return@withContext DownloadFileResult(true)
300304
} catch (e: Exception) {
301305
lastError = describeException(e)
@@ -317,13 +321,14 @@ class ThemeDownloader(private val context: Context) {
317321

318322
/**
319323
* 执行一次下载尝试。正确处理 206 续传 / 200 全量覆盖两种情形。
324+
* @return 整个文件的期望总大小(字节),未知时为 -1
320325
*/
321326
private fun downloadOnce(
322327
url: String,
323328
file: File,
324329
taskId: String,
325330
onProgress: (Float) -> Unit
326-
) {
331+
): Long {
327332
// 已下载字节数(断点续传起点)
328333
val downloadedBytes = if (file.exists()) file.length() else 0L
329334

@@ -336,6 +341,9 @@ class ThemeDownloader(private val context: Context) {
336341
}
337342
.build()
338343

344+
// 捕获期望总大小以便返回给调用方做完整性校验
345+
var expectedSize = -1L
346+
339347
client.newCall(request).execute().use { response ->
340348
// 处理响应
341349
if (response.code !in 200..299) {
@@ -355,6 +363,7 @@ class ThemeDownloader(private val context: Context) {
355363
} else {
356364
-1L // 未知总大小
357365
}
366+
expectedSize = totalBytes
358367

359368
// 写文件:续传用 append,全量用 truncate(覆盖)
360369
FileOutputStream(file, resumeFromPartial).use { output ->
@@ -385,6 +394,61 @@ class ThemeDownloader(private val context: Context) {
385394
}
386395
}
387396
}
397+
398+
return expectedSize
399+
}
400+
401+
/**
402+
* 下载完成后校验文件完整性。
403+
* 防止以下情况被误标记为"下载完成":
404+
* - CDN/代理返回 200 但内容是 HTML 错误页
405+
* - 传输中途连接断开但 InputStream 未抛异常
406+
* - Content-Length 与实际大小不匹配
407+
*
408+
* @throws IOException 若校验不通过,触发重试
409+
*/
410+
@Throws(IOException::class)
411+
private fun validateDownloadedFile(file: File, isThemeFile: Boolean, expectedSize: Long) {
412+
if (!file.exists() || file.length() <= 0L) {
413+
throw IOException("Downloaded file is empty or missing")
414+
}
415+
416+
// 如果知道期望大小,比对实际大小
417+
if (expectedSize > 0L && file.length() != expectedSize) {
418+
throw IOException(
419+
"File size mismatch: expected ${expectedSize}, got ${file.length()}"
420+
)
421+
}
422+
423+
// 对 .fpt 主题文件做额外检查
424+
if (isThemeFile) {
425+
val fileSize = file.length()
426+
427+
// .fpt 至少需要 16 字节 IV + 最小加密块(16 字节 AES 块)
428+
if (fileSize < 32L) {
429+
throw IOException("Theme file too small (${fileSize} bytes), minimum is 32")
430+
}
431+
432+
// 读文件首部,检测是否被 CDN/代理替换为 HTML/JSON 错误页
433+
val header = ByteArray(16)
434+
runCatching {
435+
file.inputStream().use { it.read(header) }
436+
}.onFailure {
437+
throw IOException("Cannot read theme file header", it)
438+
}
439+
440+
// 检查 HTML/JSON/纯零 签名
441+
// HTML 通常以 '<' 开头,JSON 以 '{' 开头,全零表示传输失败
442+
val firstByte = header[0].toInt() and 0xFF
443+
if (firstByte == '<'.code || firstByte == '{'.code) {
444+
val preview = String(header, 0, minOf(header.size, 60), Charsets.UTF_8)
445+
.replace(0x00.toChar(), '.')
446+
throw IOException("Theme file appears to be a web error page, not encrypted data: $preview")
447+
}
448+
if (header.all { it == 0.toByte() }) {
449+
throw IOException("Theme file header is all zeros — transfer likely incomplete")
450+
}
451+
}
388452
}
389453

390454
/**

app/src/main/res/values-ar/strings.xml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -979,13 +979,15 @@
979979
<string name="my_themes_delete_confirm">هل أنت متأكد من حذف هذه السمة؟</string>
980980
<string name="my_themes_deleted">تم حذف السمة</string>
981981
<string name="my_themes_applied">تم تطبيق السمة</string>
982-
<string name="my_themes_apply_failed">فشل تطبيق السمة</string>
982+
<string name="my_themes_apply_failed">فشل تطبيق السمة — اضغط مطولاً للحذف وإعادة التنزيل</string>
983983
<string name="my_themes_details">تفاصيل السمة</string>
984984

985985
<!-- حوار التنزيل -->
986986
<string name="theme_download_title">جارٍ تنزيل السمة</string>
987987
<string name="theme_download_completed">اكتمل التنزيل</string>
988988
<string name="theme_download_failed">فشل التنزيل</string>
989+
<string name="theme_file_corrupt">ملف السمة تالف، يرجى إعادة التنزيل</string>
990+
<string name="theme_file_incomplete">ملف السمة غير مكتمل، يرجى إعادة التنزيل</string>
989991
<string name="theme_download_progress">التقدم</string>
990992
<string name="theme_download_file">ملف السمة</string>
991993
<string name="theme_download_image">صورة المعاينة</string>

app/src/main/res/values-es/strings.xml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -994,13 +994,15 @@ Por ejemplo, los KPM que previenen la modificación de particiones solo pueden u
994994
<string name="my_themes_delete_confirm">¿Estás seguro de que quieres eliminar este tema?</string>
995995
<string name="my_themes_deleted">Tema eliminado</string>
996996
<string name="my_themes_applied">Tema aplicado</string>
997-
<string name="my_themes_apply_failed">Error al aplicar el tema</string>
997+
<string name="my_themes_apply_failed">Error al aplicar el tema — mantén pulsado para eliminar y volver a descargar</string>
998998
<string name="my_themes_details">Detalles del Tema</string>
999999

10001000
<!-- Diálogo de Descarga -->
10011001
<string name="theme_download_title">Descargando Tema</string>
10021002
<string name="theme_download_completed">Descarga completada</string>
10031003
<string name="theme_download_failed">Descarga fallida</string>
1004+
<string name="theme_file_corrupt">El archivo del tema está dañado, vuelve a descargarlo</string>
1005+
<string name="theme_file_incomplete">El archivo del tema está incompleto, vuelve a descargarlo</string>
10041006
<string name="theme_download_progress">Progreso</string>
10051007
<string name="theme_download_file">Archivo de Tema</string>
10061008
<string name="theme_download_image">Imagen de Vista Previa</string>

app/src/main/res/values-in/strings.xml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -973,13 +973,15 @@ Pastikan aplikasi ditutup sepenuhnya dan dimulai ulang untuk menjalankan perinta
973973
<string name="my_themes_delete_confirm">Apakah Anda yakin ingin menghapus tema ini?</string>
974974
<string name="my_themes_deleted">Tema dihapus</string>
975975
<string name="my_themes_applied">Tema diterapkan</string>
976-
<string name="my_themes_apply_failed">Gagal menerapkan tema</string>
976+
<string name="my_themes_apply_failed">Gagal menerapkan tema — tekan lama untuk hapus dan unduh ulang</string>
977977
<string name="my_themes_details">Detail Tema</string>
978978

979979
<!-- Dialog Unduh -->
980980
<string name="theme_download_title">Mengunduh Tema</string>
981981
<string name="theme_download_completed">Unduhan selesai</string>
982982
<string name="theme_download_failed">Unduhan gagal</string>
983+
<string name="theme_file_corrupt">Berkas tema rusak, silakan unduh ulang</string>
984+
<string name="theme_file_incomplete">Berkas tema tidak lengkap, silakan unduh ulang</string>
983985
<string name="theme_download_progress">Progres</string>
984986
<string name="theme_download_file">File Tema</string>
985987
<string name="theme_download_image">Gambar Pratinjau</string>

app/src/main/res/values-ja/strings.xml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -753,12 +753,14 @@
753753
<string name="my_themes_delete_confirm">このテーマを削除してもよろしいですか?</string>
754754
<string name="my_themes_deleted">テーマを削除しました。</string>
755755
<string name="my_themes_applied">テーマを適用しました。</string>
756-
<string name="my_themes_apply_failed">テーマの適用に失敗しました</string>
756+
<string name="my_themes_apply_failed">テーマの適用に失敗しました — 長押しで削除して再ダウンロードしてください</string>
757757
<string name="my_themes_details">テーマの詳細</string>
758758
<!-- Download Dialog -->
759759
<string name="theme_download_title">テーマをダウンロード中</string>
760760
<string name="theme_download_completed">ダウンロード完了</string>
761761
<string name="theme_download_failed">ダウンロード失敗</string>
762+
<string name="theme_file_corrupt">テーマファイルが破損しています。再ダウンロードしてください</string>
763+
<string name="theme_file_incomplete">テーマファイルが不完全です。再ダウンロードしてください</string>
762764
<string name="theme_download_progress">進捗</string>
763765
<string name="theme_download_file">テーマファイル</string>
764766
<string name="theme_download_image">画像のプレビュー</string>

app/src/main/res/values-ko/strings.xml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -840,13 +840,15 @@
840840
<string name="my_themes_delete_confirm">이 테마를 삭제할까요?</string>
841841
<string name="my_themes_deleted">테마 삭제됨</string>
842842
<string name="my_themes_applied">테마 적용됨</string>
843-
<string name="my_themes_apply_failed">테마 적용 실패</string>
843+
<string name="my_themes_apply_failed">테마 적용 실패 — 길게 눌러 삭제하고 다시 다운로드하세요</string>
844844
<string name="my_themes_details">테마 세부정보</string>
845845

846846
<!-- Download Dialog -->
847847
<string name="theme_download_title">테마 다운로드 중</string>
848848
<string name="theme_download_completed">다운로드 완료</string>
849849
<string name="theme_download_failed">다운로드 실패</string>
850+
<string name="theme_file_corrupt">테마 파일이 손상되었습니다. 다시 다운로드하세요</string>
851+
<string name="theme_file_incomplete">테마 파일이 불완전합니다. 다시 다운로드하세요</string>
850852
<string name="theme_download_progress">진행률</string>
851853
<string name="theme_download_file">테마 파일</string>
852854
<string name="theme_download_image">미리보기 이미지</string>

app/src/main/res/values-mgl/strings.xml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -977,13 +977,15 @@
977977
<string name="my_themes_delete_confirm">确定要遗忘这个主题魔法吗?</string>
978978
<string name="my_themes_deleted">主题已遗忘</string>
979979
<string name="my_themes_applied">主题已施展</string>
980-
<string name="my_themes_apply_failed">施展主题失败</string>
980+
<string name="my_themes_apply_failed">施展主题失败 — 长按遗忘后重新召唤吧</string>
981981
<string name="my_themes_details">主题魔法详情</string>
982982

983983
<!-- 下载对话框 -->
984984
<string name="theme_download_title">正在召唤主题</string>
985985
<string name="theme_download_completed">召唤完成</string>
986986
<string name="theme_download_failed">召唤失败</string>
987+
<string name="theme_file_corrupt">主题魔法已失谐,请重新召唤</string>
988+
<string name="theme_file_incomplete">主题魔法不完整,请重新召唤</string>
987989
<string name="theme_download_progress">召唤进度</string>
988990
<string name="theme_download_file">主题魔法书</string>
989991
<string name="theme_download_image">魔法预览</string>

app/src/main/res/values-pl/strings.xml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -883,12 +883,14 @@ Upewnij się, że aplikacja jest całkowicie zamknięta i ponownie otwarta, aby
883883
<string name="my_themes_delete_confirm">Czy na pewno chcesz usunąć ten motyw?</string>
884884
<string name="my_themes_deleted">Usunięto motyw</string>
885885
<string name="my_themes_applied">Zastosowano motyw</string>
886-
<string name="my_themes_apply_failed">Nie udało się zastosować motywu</string>
886+
<string name="my_themes_apply_failed">Nie udało się zastosować motywu — przytrzymaj, aby usunąć i pobrać ponownie</string>
887887
<string name="my_themes_details">Szczegóły motywu</string>
888888
<!-- Dialog Pobierania -->
889889
<string name="theme_download_title">Pobieranie motywu</string>
890890
<string name="theme_download_completed">Pobieranie zakończone</string>
891891
<string name="theme_download_failed">Pobieranie nieudane</string>
892+
<string name="theme_file_corrupt">Plik motywu jest uszkodzony, pobierz go ponownie</string>
893+
<string name="theme_file_incomplete">Plik motywu jest niekompletny, pobierz go ponownie</string>
892894
<string name="theme_download_progress">Postęp</string>
893895
<string name="theme_download_file">Plik motywu</string>
894896
<string name="theme_download_image">Obraz podglądu</string>

0 commit comments

Comments
 (0)