|
| 1 | +package com.clhs.score.data |
| 2 | + |
| 3 | +import android.content.Context |
| 4 | +import android.os.Build |
| 5 | +import android.webkit.CookieManager |
| 6 | +import android.webkit.WebStorage |
| 7 | +import com.clhs.score.BuildConfig |
| 8 | +import kotlinx.coroutines.Dispatchers |
| 9 | +import kotlinx.coroutines.withContext |
| 10 | +import java.io.File |
| 11 | +import java.time.Instant |
| 12 | + |
| 13 | +enum class LocalDataCategory( |
| 14 | + val key: String, |
| 15 | + val label: String, |
| 16 | + val isClearable: Boolean = true, |
| 17 | +) { |
| 18 | + GradeCache("grade_cache", "成績快取"), |
| 19 | + Settings("settings", "設定資料", isClearable = false), |
| 20 | + Session("session", "登入資料"), |
| 21 | + WebView("webview", "WebView 資料"), |
| 22 | + Cache("cache", "Cache"), |
| 23 | + NoBackupWebView("no_backup_webview", "No backup WebView 資料"), |
| 24 | +} |
| 25 | + |
| 26 | +data class StorageEntry( |
| 27 | + val category: LocalDataCategory, |
| 28 | + val bytes: Long, |
| 29 | +) { |
| 30 | + val key: String = category.key |
| 31 | + val label: String = category.label |
| 32 | + val isClearable: Boolean = category.isClearable |
| 33 | +} |
| 34 | + |
| 35 | +data class StorageDiagnostics( |
| 36 | + val generatedAt: String, |
| 37 | + val entries: List<StorageEntry>, |
| 38 | +) { |
| 39 | + val totalBytes: Long = entries.sumOf { it.bytes } |
| 40 | +} |
| 41 | + |
| 42 | +data class LocalDataCleanupResult( |
| 43 | + val removedBytes: Long, |
| 44 | + val storageAfterCleanup: StorageDiagnostics, |
| 45 | +) |
| 46 | + |
| 47 | +data class ErrorDiagnosticContext( |
| 48 | + val isLoggedIn: Boolean, |
| 49 | + val loginErrorMessage: String?, |
| 50 | + val gradesErrorMessage: String?, |
| 51 | +) |
| 52 | + |
| 53 | +class DeveloperDiagnostics(private val context: Context) { |
| 54 | + private val appContext = context.applicationContext |
| 55 | + |
| 56 | + suspend fun collectStorageDiagnostics(): StorageDiagnostics = withContext(Dispatchers.IO) { |
| 57 | + buildStorageDiagnostics() |
| 58 | + } |
| 59 | + |
| 60 | + suspend fun clearLocalData( |
| 61 | + categories: Set<LocalDataCategory> = LocalDataCategory.entries.filter { it.isClearable }.toSet(), |
| 62 | + ): LocalDataCleanupResult = withContext(Dispatchers.IO) { |
| 63 | + val before = buildStorageDiagnostics().totalBytes |
| 64 | + val clearableCategories = categories.filter { it.isClearable }.toSet() |
| 65 | + |
| 66 | + if (LocalDataCategory.GradeCache in clearableCategories) { |
| 67 | + GradeCacheStore(appContext).clearAll() |
| 68 | + } |
| 69 | + if (LocalDataCategory.Session in clearableCategories) { |
| 70 | + SessionStore(appContext).clear() |
| 71 | + } |
| 72 | + if (LocalDataCategory.WebView in clearableCategories) { |
| 73 | + clearWebViewData() |
| 74 | + appContext.dataDir.resolve("app_webview").deleteRecursively() |
| 75 | + } |
| 76 | + if (LocalDataCategory.Cache in clearableCategories) { |
| 77 | + appContext.cacheDir.deleteContents() |
| 78 | + } |
| 79 | + if (LocalDataCategory.NoBackupWebView in clearableCategories) { |
| 80 | + appContext.noBackupFilesDir.resolve(".webview").deleteRecursively() |
| 81 | + } |
| 82 | + |
| 83 | + val after = buildStorageDiagnostics() |
| 84 | + LocalDataCleanupResult( |
| 85 | + removedBytes = (before - after.totalBytes).coerceAtLeast(0L), |
| 86 | + storageAfterCleanup = after, |
| 87 | + ) |
| 88 | + } |
| 89 | + |
| 90 | + suspend fun buildErrorReport(context: ErrorDiagnosticContext): String = withContext(Dispatchers.IO) { |
| 91 | + val storage = buildStorageDiagnostics() |
| 92 | + buildString { |
| 93 | + appendLine("CLHS Score 診斷包") |
| 94 | + appendLine("產生時間: ${storage.generatedAt}") |
| 95 | + appendLine("App ID: ${BuildConfig.APPLICATION_ID}") |
| 96 | + appendLine("版本: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") |
| 97 | + appendLine("Build type: ${BuildConfig.BUILD_TYPE}") |
| 98 | + appendLine("Android: ${Build.VERSION.RELEASE} / API ${Build.VERSION.SDK_INT}") |
| 99 | + appendLine("裝置: ${Build.MANUFACTURER} ${Build.MODEL}") |
| 100 | + appendLine("登入狀態: ${if (context.isLoggedIn) "已登入" else "未登入"}") |
| 101 | + appendLine("登入錯誤: ${context.loginErrorMessage?.takeIf { it.isNotBlank() } ?: "無"}") |
| 102 | + appendLine("成績錯誤: ${context.gradesErrorMessage?.takeIf { it.isNotBlank() } ?: "無"}") |
| 103 | + appendLine() |
| 104 | + appendLine("儲存空間") |
| 105 | + storage.entries.forEach { entry -> |
| 106 | + appendLine("- ${entry.label}: ${entry.bytes.toReadableSize()}") |
| 107 | + } |
| 108 | + appendLine("- 合計: ${storage.totalBytes.toReadableSize()}") |
| 109 | + appendLine() |
| 110 | + appendLine("隱私: 此診斷包不包含帳密、cookie、token、學生姓名或成績內容。") |
| 111 | + } |
| 112 | + } |
| 113 | + |
| 114 | + private fun buildStorageDiagnostics(): StorageDiagnostics { |
| 115 | + val dataDir = appContext.dataDir |
| 116 | + val entries = listOf( |
| 117 | + StorageEntry( |
| 118 | + LocalDataCategory.GradeCache, |
| 119 | + dataDir.resolve("files/datastore/grade_cache.preferences_pb").safeSize(), |
| 120 | + ), |
| 121 | + StorageEntry( |
| 122 | + LocalDataCategory.Settings, |
| 123 | + dataDir.resolve("files/datastore/app_settings.preferences_pb").safeSize(), |
| 124 | + ), |
| 125 | + StorageEntry( |
| 126 | + LocalDataCategory.Session, |
| 127 | + dataDir.resolve("shared_prefs/score_session.xml").safeSize(), |
| 128 | + ), |
| 129 | + StorageEntry(LocalDataCategory.WebView, dataDir.resolve("app_webview").safeSize()), |
| 130 | + StorageEntry(LocalDataCategory.Cache, appContext.cacheDir.safeSize()), |
| 131 | + StorageEntry( |
| 132 | + LocalDataCategory.NoBackupWebView, |
| 133 | + appContext.noBackupFilesDir.resolve(".webview").safeSize(), |
| 134 | + ), |
| 135 | + ) |
| 136 | + return StorageDiagnostics( |
| 137 | + generatedAt = Instant.now().toString(), |
| 138 | + entries = entries, |
| 139 | + ) |
| 140 | + } |
| 141 | + |
| 142 | + private fun clearWebViewData() { |
| 143 | + CookieManager.getInstance().removeAllCookies(null) |
| 144 | + CookieManager.getInstance().flush() |
| 145 | + WebStorage.getInstance().deleteAllData() |
| 146 | + } |
| 147 | + |
| 148 | + private fun File.deleteContents() { |
| 149 | + listFiles()?.forEach { child -> child.deleteRecursively() } |
| 150 | + } |
| 151 | + |
| 152 | + private fun File.safeSize(): Long { |
| 153 | + if (!exists()) return 0L |
| 154 | + if (isFile) return length() |
| 155 | + return walkTopDown() |
| 156 | + .filter { it.isFile } |
| 157 | + .sumOf { file -> runCatching { file.length() }.getOrDefault(0L) } |
| 158 | + } |
| 159 | +} |
| 160 | + |
| 161 | +fun defaultClearableLocalDataCategories(): Set<LocalDataCategory> = |
| 162 | + LocalDataCategory.entries.filter { it.isClearable }.toSet() |
| 163 | + |
| 164 | +fun Long.toReadableSize(): String { |
| 165 | + val units = listOf("B", "KB", "MB", "GB") |
| 166 | + var value = toDouble() |
| 167 | + var unitIndex = 0 |
| 168 | + while (value >= 1024.0 && unitIndex < units.lastIndex) { |
| 169 | + value /= 1024.0 |
| 170 | + unitIndex++ |
| 171 | + } |
| 172 | + return if (unitIndex == 0) { |
| 173 | + "${value.toLong()} ${units[unitIndex]}" |
| 174 | + } else { |
| 175 | + "%.2f %s".format(value, units[unitIndex]) |
| 176 | + } |
| 177 | +} |
0 commit comments