Skip to content

Commit defc73e

Browse files
feat: enhance developer settings and diagnostics features
- Added debug build type with application ID suffix and version name suffix. - Introduced new functions for weighted total calculations in GradeAnalysis. - Implemented clearAll function in GradeCacheStore for local data management. - Enhanced DeveloperSettingsScreen with diagnostics and local data management options. - Added DeveloperDiagnostics class for managing local data and generating diagnostic reports. - Created unit tests for DeveloperDiagnostics and GradeAnalysis functionalities.
1 parent 75997fe commit defc73e

10 files changed

Lines changed: 821 additions & 65 deletions

File tree

android/app/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ android {
7474
}
7575

7676
buildTypes {
77+
debug {
78+
applicationIdSuffix = ".debug"
79+
versionNameSuffix = "-debug"
80+
}
81+
7782
release {
7883
isMinifyEnabled = true
7984
isShrinkResources = true
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<resources>
2+
<string name="app_name">壢中成績 Debug</string>
3+
</resources>
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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+
}

android/app/src/main/java/com/clhs/score/data/GradeAnalysis.kt

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -372,18 +372,32 @@ fun weightedAverageFor(
372372
adjustedScores: Map<String, Double> = emptyMap(),
373373
includedSubjects: Set<String>? = null,
374374
): Double {
375-
val activeSubjects = if (includedSubjects != null) {
376-
subjects.filter { cleanSubjectName(it.subjectName) in includedSubjects }
377-
} else {
378-
subjects
379-
}
375+
val activeSubjects = activeWeightedSubjects(subjects, includedSubjects)
380376
val totalWeight = activeSubjects.sumOf { subjectWeight(it.subjectName) }
381377
if (totalWeight <= 0) return 0.0
382-
val weightedTotal = activeSubjects.sumOf { subject ->
378+
return weightedTotalFor(activeSubjects, adjustedScores) / totalWeight
379+
}
380+
381+
fun weightedTotalFor(
382+
subjects: List<SubjectScore>,
383+
adjustedScores: Map<String, Double> = emptyMap(),
384+
includedSubjects: Set<String>? = null,
385+
): Double {
386+
return activeWeightedSubjects(subjects, includedSubjects).sumOf { subject ->
383387
val score = adjustedScores[cleanSubjectName(subject.subjectName)] ?: subject.scoreValue
384388
score.coerceIn(0.0, 100.0) * subjectWeight(subject.subjectName)
385389
}
386-
return weightedTotal / totalWeight
390+
}
391+
392+
private fun activeWeightedSubjects(
393+
subjects: List<SubjectScore>,
394+
includedSubjects: Set<String>?,
395+
): List<SubjectScore> {
396+
return if (includedSubjects != null) {
397+
subjects.filter { cleanSubjectName(it.subjectName) in includedSubjects }
398+
} else {
399+
subjects
400+
}
387401
}
388402

389403
private fun SubjectScore.classAverageOrNull(): Double? =

android/app/src/main/java/com/clhs/score/data/GradeCacheStore.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ class GradeCacheStore(private val context: Context) {
6767
}
6868
}
6969

70+
suspend fun clearAll() {
71+
context.gradeDataStore.edit { prefs ->
72+
prefs.clear()
73+
}
74+
}
75+
7076
private fun decodeCachedGradeReport(serialized: String): GradeReport? =
7177
runCatching {
7278
json.decodeFromString<CachedGradeReport>(serialized).toGradeReport()

0 commit comments

Comments
 (0)