Skip to content

Commit a2ffb6c

Browse files
feat(android): 新增生物識別鎖、段考變更提醒、網路層執行緒安全強化、課表小工具 session 邊界與多項安全修正
--- 網路層與 Session 安全 --- - SchoolCookieJar 改用 synchronized 同步鎖保護所有讀寫方法 (saveFromResponse、loadForRequest、replace、clear、snapshot),取代原本的 CopyOnWriteArrayList,消除並行 API 呼叫時的 Cookie 覆蓋競態導致 HTTP 401 - SchoolGradeClient 移除各 API 方法內的無條件 cookieJar.replace(),改以學號過濾與鎖保護的 prepareSession() 進行按需載入 --- 生物識別防護鎖 (Biometric Lock) --- - 新增 BiometricHelper:實作雙層分層加密 (Key Wrapping) 模式,使用 4~6 位數 PIN 碼 + PBKDF2 加密 Session,再以 Android Keystore 硬體金鑰 (setUserAuthenticationRequired) 加密 PIN,指紋變更時捕獲 KeyPermanentlyInvalidatedException 並自動切換備用 PIN 解鎖 - 新增 BiometricLockScreen 全螢幕覆蓋層與 PinDialogs (設定/驗證 PIN) - SessionStore 新增 saveBiometricSession / loadBiometricSession / clearBiometricSession,儲存加密後的 session 與 salt / IV - MainActivity 整合生物識別流程:冷啟動 / 背景喚醒自動鎖定、single-flight 防止 BiometricPrompt 重複觸發、prompt 顯示期間暫停背景鎖定判斷、FLAG_SECURE 防止多工縮圖洩漏成績 - SettingsScreen 新增生物識別開關與 PIN 設定入口 --- 段考資訊變更提醒 --- - 新增 GradeReportDiffer:比對成績、排名、平均、五標、標準差、級距與缺考/作弊狀態,五標/分布區塊的新增或移除也算可見資訊變更 - 新增 GradeReminderModels / GradeReminderText / GradeReminderRepository 管理提醒狀態、快照、最近變更集與到期邏輯 (48 小時) - 新增 GradeReminderWorker (WorkManager 15 分鐘週期)、GradeReminderScheduler、GradeReminderNotifier (IMPORTANCE_HIGH)、BatteryOptimizationHelper (忽略電池最佳化請求) - ScoreViewModel 新增提醒啟用/停用流程、snapshot diff 比對、App 啟動自我修復排程、連續失敗自動停止與 session 清除 - 段考提醒使用獨立 reminderSession (saveReminderSession / loadReminderSession),隨提醒狀態存在,登出/學生切換/停止/過期時清除 - 新增 debug-only GradeReminderDebugReceiver 供 ADB 測試完整 worker 鏈 - GradesScreen 新增段考提醒開關 UI 區域 - DeveloperSettingsScreen 新增段考提醒測試通知按鈕 (debug only) --- 通知權限處理 --- - 新增 NotificationPermissionHelper 統一通知權限檢查 - NotificationPromptDialog 改為開啟 App 通知設定頁而非直接 requestPermission,避免 FragmentActivity requestCode 超過 lower 16 bits 導致閃退 - ScoreApp 根層同步系統通知權限與 App 內通知開關狀態 --- 課表小工具 (Schedule Widget) Session 邊界 --- - ScheduleWidget 改為只讀 GradeCacheStore 的 widget 專用課表快照,不再讀取 session / cookie / token - GradeCacheStore 新增 widget 課表快照讀寫,登出與快取清除時連動清除 - ScheduleRepository 優先使用已解鎖的 in-memory active session,存在 biometric session 時不 fallback 到一般 SessionStore - ScheduleWidget 自動更新改用 AlarmManager.setAndAllowWhileIdle,在午夜與每節課下課時觸發,避開 SCHEDULE_EXACT_ALARM 限制 - 新增 ArchitectureBoundaryTest 防止 widget 重新依賴登入狀態,並檢查 PIN 解鎖必須先 activate session 再解除鎖定 --- Release 瘦身與 ProGuard --- - proguard-rules.pro 移除 broad keep (androidx.*, org.jsoup.*),改依賴 library 自帶 consumer rules,僅保留最小 WorkManager 規則 --- 測試 --- - 新增 ArchitectureBoundaryTest (widget session 邊界 + PIN 解鎖順序) - 新增 ActiveSessionResolverTest (biometric session 優先解析) - 新增 GradeReportDifferTest (成績/五標/級距 diff 覆蓋) - 新增 UpdateCheckerTest、SchoolGradeClientTest、DeveloperDiagnosticsTest、ScheduleViewModelTest - 擴充 ScoreViewModelTest (提醒啟用/停用、snapshot diff) --- 其他 --- - UpdateChecker 重構錯誤處理與 edge case - DeveloperDiagnostics 新增更多開發者診斷資訊 - GradeExporter 微調匯出格式 - Material Symbols subset 新增 icon ligature (lock、fingerprint 等)
1 parent 90bd7db commit a2ffb6c

59 files changed

Lines changed: 4615 additions & 382 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- 不要把帳密、token、cookie 或正式環境 secret 寫進程式碼或對話;使用 `.env`、local properties 或本機設定檔。
1515
- 此 repo 的 Markdown 預設會被 `.gitignore` 忽略;新增或更新 agent 文件後要確認 `AGENTS.md` 沒有被 ignore。
1616
- 文件預設使用繁體中文;程式碼註解只在能降低理解成本時加入。
17+
- **Cookie 與 Session 同步安全**:為避免並行 API 呼叫時出現 Cookie 覆蓋或競態條件導致 HTTP 401 錯誤,請勿在各個 API 請求方法中無條件呼叫 `cookieJar.replace(session.cookies, ...)`。應經由以學號過濾與鎖保護的 `prepareSession(session)` 進行按需載入。且 `SchoolCookieJar` 的讀寫方法(`saveFromResponse`, `loadForRequest`, `replace`, `clear`)都必須在 `synchronized` 同步鎖保護下執行,確保執行緒安全。
1718

1819
## 常用驗證
1920

@@ -41,21 +42,55 @@
4142
- Material Symbols rounded icon 由 `android/app/src/main/res/font/material_symbols_rounded_*_subset.ttf` 提供,不要重新加入 `dev.vicart:compose-material-symbols` 整包依賴。
4243
- 新增 icon ligature 時,先更新 `android/scripts/generate_material_symbol_subset.py``ICONS` 清單,再執行 `python android/scripts/generate_material_symbol_subset.py` 重新產生 outline / filled subset font。
4344

45+
## Android R8 與安裝包大小
46+
47+
- Release build 已啟用 `isMinifyEnabled``isShrinkResources`;新增 library 或功能時不要用 `-keep class androidx.**``-keep class org.jsoup.**` 這類 broad keep 擋住 R8。優先依賴 library 自帶的 consumer rules,只針對 app 端需要反射或跨版本保留名稱的入口加最小規則,例如 WorkManager worker class name,並用 `:app:assembleRelease` 比對 APK 大小。
48+
4449
## Android FCM notifications
4550

4651
- Android app 使用 Firebase Cloud Messaging 接收手動推播;目前發送端預設是 Firebase Console,不需要把 FCM server key、service account 或其他私鑰放進 app。
4752
- `android/app/google-services.json` 是 Firebase app 設定檔,需保留在 app module 根目錄並允許進版控;不要提交 Firebase service account JSON。
4853
- 使用者在設定頁開啟通知後,app 會訂閱 `general``app_updates` topics;關閉通知時會取消訂閱。
54+
- `POST_NOTIFICATIONS` 權限不要用 Compose `ActivityResultContracts.RequestPermission()` 直接請求;此 app 使用 `FragmentActivity`,實機曾因 requestCode 超過 lower 16 bits 閃退。改開 App 通知設定頁並在返回 App 時檢查權限。
55+
- 系統通知權限與 App 內通知開關的同步由 `ScoreApp` 根層處理;不要只放在設定頁,否則使用者從系統設定封鎖通知後,其他入口回 App 時狀態會不一致。
4956
- 發送 app 更新通知時使用 `app_updates` topic,一般公告使用 `general` topic。可在 FCM data payload 帶 `url`,使用者點通知時會開啟該網址。
5057

58+
## Android 段考資訊變更提醒
59+
60+
- 段考提醒是本機背景功能,不使用 FCM topic,也不要把 session、cookie 或成績送到伺服器。
61+
- 背景檢查由 WorkManager unique periodic work `grade_reminder_poll` 執行,週期為 Android 允許的最短 15 分鐘;即使請使用者忽略電池最佳化,仍不能保證即時執行。
62+
- 開始前必須取得通知權限,並透過 `ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` 請使用者允許忽略電池最佳化;若系統不支援或拋出例外,退到 `ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS`。未允許時不啟用。
63+
- `GradeReportDiffer` 只比對使用者可見資訊:成績、排名、平均、五標、標準差、級距與缺考/作弊等狀態;不要把 `rawResult`、HTTP 格式差異或 `StudentInfo.updatedAt` 納入通知觸發條件。
64+
- 五標/分布或整體成績區塊的新增、移除也屬於可見資訊變更,需產生 diff;不要只比較兩邊都存在的欄位。
65+
- 啟用段考提醒成功後,`SessionStore.saveReminderSession(...)` 會保存一份提醒專用臨時 session,供生物識別解鎖清除一般 session 後的背景 worker 使用;它必須只跟隨提醒狀態存在,並在 48 小時到期、關閉提醒、登出、學生切換或連續失敗停止時清除。
66+
- 停止或過期段考提醒時,除了取消 WorkManager 與清除 reminder session,也要清掉 reminder snapshot 與 latest change set,避免本機殘留舊成績或舊通知明細。
67+
- App 啟動後若 DataStore 仍有未過期的提醒狀態,`ScoreViewModel` 會補排 `grade_reminder_poll` 作為自我修復;避免在每次 worker 更新 state 時重複 reschedule。
68+
- 段考提醒 channel `grade_reminders` 預設使用 `IMPORTANCE_HIGH`,讓新資訊通知有機會 heads-up 彈出;已安裝 App 的既有 channel 可能仍需使用者到系統設定手動調整。
69+
- 開發者選項內的「段考提醒測試通知」只應在 debug build 顯示,用來測試正式通知 channel 與通知文案;不要把它當成正式背景檢查或 release 使用者功能。
70+
- `src/debug``GradeReminderDebugReceiver` 只供 ADB 測完整 worker 鏈:它會把目前提醒 state 的上一版 snapshot 改舊,再 enqueue 真正的 `GradeReminderWorker`;不得移到 main/release,也不得改成會外送成績資料。
71+
5172
## Android Widget
5273

5374
- 桌面課表小工具 (`ScheduleWidget`) 使用 Jetpack Glance 實作。
5475
- 自動更新依賴 `AlarmManager.setAndAllowWhileIdle` (`WidgetUpdateReceiver`),在每日午夜與每節課下課時觸發更新,避開了需申請 `SCHEDULE_EXACT_ALARM` 權限的限制。
5576
- 測試 Widget UI 時,注意 Glance 的 RemoteViews 資源回收問題:所有動態修飾 (`GlanceModifier`),包括 `background``cornerRadius`,在條件分支 (`if-else`) 中都必須明確設置(例如重設為 `Color.Transparent``0.dp`),否則滑動列表時樣式會錯誤殘留。
77+
- 從 Widget 或 `scoreapp://schedule` deep link 進入 app 時,不得繞過生物識別鎖;若存在 biometric session,`MainActivity` 必須先顯示 `BiometricLockScreen`。課表頁網路 repository 要優先使用已解鎖的 in-memory active session;存在 biometric session 時不得 fallback 到一般 `SessionStore`,避免繞過鎖或誤顯示未登入。
78+
- Widget 本體不得讀取一般 session、biometric session、cookie 或 token;只能讀 `GradeCacheStore` 的 widget 專用課表快照。課表查詢成功或從舊的學生課表快取載入成功時,要同步寫入 widget 快照;登出、學生快取清除或生物識別資料失效時要清掉該快照並刷新 widget。
79+
- `ArchitectureBoundaryTest` 會防止 Widget 重新依賴登入狀態,並檢查 PIN 解鎖必須先 activate in-memory session 再解除鎖定;修改 widget、課表或生物識別流程時要保留這些邊界。
5680

5781
## Android 成績匯出
5882

5983
- 設定頁的「匯出成績」使用 `GradeExporter``data/GradeExporter.kt`)產生 BOM+UTF-8 CSV,透過 `MediaStore` API 存到 Downloads。
6084
- 匯出流程由 `ScoreViewModel.exportGrades()` 驅動,支援跨學期多考試批次匯出;未快取的考試會自動從網路拉取。
6185
- 考試勾選 UI 在 `ui/ExportDialog.kt`,依學期分組並預設全選。
86+
87+
## Android 生物識別防護鎖 (Biometric Lock)
88+
89+
- 生物識別解鎖採用**雙層分層加密 (Key Wrapping) 模式**與硬體密鑰綁定(`setUserAuthenticationRequired(true)``setInvalidatedByBiometricEnrollment(true)`)。
90+
- **資料層加密 (Session)**:使用使用者設定的 4~6 位數備用 PIN 碼,透過 PBKDF2 衍生出對稱金鑰來加密 Session 資料。
91+
- **密碼層加密 (PIN)**:將使用者的 PIN 碼,使用 Android Keystore 中與生物識別綁定的硬體金鑰進行加密儲存。
92+
- **解鎖流程**:指紋驗證成功 -> 硬體金鑰解密出 PIN 碼 -> 使用 PIN 碼衍生金鑰解密出 Session -> 解鎖成功。若使用者選擇密碼解鎖,則直接使用輸入的 PIN 碼解密 Session。
93+
- **密鑰失效與重新註冊**:若使用者在系統增刪指紋,解密金鑰會失效並拋出 `KeyPermanentlyInvalidatedException`,App 將會捕獲該異常,提示使用者生物特徵已變更,並自動切換為備用 PIN 碼解鎖流程。驗證 PIN 碼成功解密 Session 後,會自動重新呼叫 `BiometricPrompt` 進行新硬體金鑰的綁定與 PIN 碼重新加密。
94+
- **冷啟動與背景鎖定**:利用 `DefaultLifecycleObserver` 監聽 App 生命週期。當 App 冷啟動或從背景喚醒時,若開啟了生物識別,會將 App 鎖定(`isAppLocked = true`)並顯示 `BiometricLockScreen` 覆蓋層以防洩漏隱私。解鎖後的 Session 絕不寫回硬碟的普通明文儲存,以維持最高安全性。
95+
- `BiometricPrompt` 顯示期間可能造成 Activity lifecycle 變化;不得把 prompt 覆蓋造成的 `onStop/onStart` 當作真正背景回來。`MainActivity` 必須用 single-flight 狀態避免重複呼叫 `authenticate(...)`,並在 prompt 顯示期間暫停背景鎖定判斷,避免從 Widget/deep link 進入時連續要求兩次解鎖。
96+
- **多工頁面防護**:只要生物識別已啟用、存在生物識別 session,或 App 正在鎖定狀態,`MainActivity` 會套用 `WindowManager.LayoutParams.FLAG_SECURE`,讓系統多工縮圖與截圖/錄影無法顯示成績畫面;關閉生物識別後才移除此 flag。

android/app/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ dependencies {
126126
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
127127
implementation("androidx.navigation:navigation-compose:2.9.8")
128128
implementation("androidx.security:security-crypto:1.1.0")
129+
implementation("androidx.biometric:biometric:1.2.0-alpha05")
130+
implementation("androidx.work:work-runtime-ktx:2.11.2")
129131
implementation("com.google.firebase:firebase-analytics")
130132
implementation("com.google.firebase:firebase-messaging")
131133
implementation("com.squareup.okhttp3:okhttp:5.4.0")

android/app/proguard-rules.pro

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,11 @@
3232
}
3333

3434
# Jsoup
35-
-keep class org.jsoup.** { *; }
3635
-dontwarn org.jsoup.**
3736

38-
# Glance and Widgets
39-
-keep class androidx.glance.** { *; }
40-
-keep class com.clhs.score.widget.** { *; }
41-
42-
# WorkManager + Room + SQLite (transitive dep from Firebase Messaging)
43-
-keep class androidx.work.** { *; }
44-
-keep class androidx.room.** { *; }
45-
-keep class androidx.sqlite.** { *; }
46-
47-
# DataStore
48-
-keep class androidx.datastore.** { *; }
37+
# WorkManager stores worker class names in its database. Keep the app worker name
38+
# stable without preventing R8 from shrinking WorkManager and its transitive deps.
39+
-keepnames class com.clhs.score.reminders.GradeReminderWorker
40+
-keepclassmembers class com.clhs.score.reminders.GradeReminderWorker {
41+
public <init>(android.content.Context, androidx.work.WorkerParameters);
42+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest
3+
xmlns:android="http://schemas.android.com/apk/res/android"
4+
xmlns:tools="http://schemas.android.com/tools">
5+
6+
<application>
7+
<receiver
8+
android:name=".reminders.GradeReminderDebugReceiver"
9+
android:exported="true"
10+
tools:ignore="ExportedReceiver" />
11+
</application>
12+
13+
</manifest>
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package com.clhs.score.reminders
2+
3+
import android.content.BroadcastReceiver
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.util.Log
7+
import androidx.work.Constraints
8+
import androidx.work.ExistingWorkPolicy
9+
import androidx.work.NetworkType
10+
import androidx.work.OneTimeWorkRequestBuilder
11+
import androidx.work.WorkManager
12+
import com.clhs.score.BuildConfig
13+
import com.clhs.score.data.GradeReminderRepository
14+
import com.clhs.score.data.GradeReminderSnapshot
15+
import kotlinx.coroutines.CoroutineScope
16+
import kotlinx.coroutines.Dispatchers
17+
import kotlinx.coroutines.launch
18+
19+
class GradeReminderDebugReceiver : BroadcastReceiver() {
20+
override fun onReceive(context: Context, intent: Intent) {
21+
val expectedAction = "${BuildConfig.APPLICATION_ID}.RUN_GRADE_REMINDER_WORKER_TEST"
22+
if (intent.action != expectedAction) {
23+
Log.w(TAG, "Ignored unexpected action: ${intent.action}")
24+
return
25+
}
26+
27+
val pendingResult = goAsync()
28+
CoroutineScope(Dispatchers.IO).launch {
29+
runCatching {
30+
prepareStaleSnapshotAndRunWorker(context.applicationContext)
31+
}.onFailure { error ->
32+
Log.e(TAG, "Worker test setup failed", error)
33+
}
34+
pendingResult.finish()
35+
}
36+
}
37+
38+
private suspend fun prepareStaleSnapshotAndRunWorker(context: Context) {
39+
val now = System.currentTimeMillis()
40+
val repository = GradeReminderRepository(context)
41+
val state = repository.loadState()
42+
43+
if (!state.enabled) {
44+
Log.w(TAG, "Skipped worker test: grade reminder is not enabled")
45+
return
46+
}
47+
if (now >= state.expiresAtMillis) {
48+
Log.w(TAG, "Skipped worker test: grade reminder is expired")
49+
return
50+
}
51+
52+
val snapshot = state.snapshot
53+
if (snapshot == null) {
54+
Log.w(TAG, "Skipped worker test: no baseline snapshot")
55+
return
56+
}
57+
58+
val staleSnapshot = snapshot.withDebugStaleValue()
59+
if (staleSnapshot == null) {
60+
Log.w(TAG, "Skipped worker test: no testable snapshot field")
61+
return
62+
}
63+
64+
repository.saveState(
65+
state.copy(
66+
snapshot = staleSnapshot,
67+
latestChangeSet = null,
68+
consecutiveFailures = 0,
69+
stoppedReason = null,
70+
expiresAtMillis = maxOf(state.expiresAtMillis, now + MIN_TEST_VALIDITY_MILLIS),
71+
),
72+
)
73+
74+
val request = OneTimeWorkRequestBuilder<GradeReminderWorker>()
75+
.setConstraints(
76+
Constraints.Builder()
77+
.setRequiredNetworkType(NetworkType.CONNECTED)
78+
.build(),
79+
)
80+
.addTag(DEBUG_WORK_TAG)
81+
.addTag(GradeReminderScheduler.WORK_TAG)
82+
.build()
83+
84+
WorkManager.getInstance(context).enqueueUniqueWork(
85+
UNIQUE_DEBUG_WORK_NAME,
86+
ExistingWorkPolicy.REPLACE,
87+
request,
88+
)
89+
Log.i(TAG, "Prepared stale snapshot and enqueued GradeReminderWorker test")
90+
}
91+
92+
private fun GradeReminderSnapshot.withDebugStaleValue(): GradeReminderSnapshot? {
93+
val firstSubject = subjects.firstOrNull()
94+
if (firstSubject != null) {
95+
return copy(
96+
subjects = listOf(firstSubject.copy(score = DEBUG_OLD_VALUE)) + subjects.drop(1),
97+
)
98+
}
99+
100+
val currentSummary = summary ?: return null
101+
return copy(
102+
summary = currentSummary.copy(averageScore = DEBUG_OLD_VALUE),
103+
)
104+
}
105+
106+
private companion object {
107+
const val DEBUG_WORK_TAG = "grade_reminder_debug_test"
108+
private const val UNIQUE_DEBUG_WORK_NAME = "grade_reminder_debug_test"
109+
private const val DEBUG_OLD_VALUE = "測試舊資料"
110+
private const val MIN_TEST_VALIDITY_MILLIS = 15 * 60 * 1000L
111+
private const val TAG = "GradeReminderDebug"
112+
}
113+
}

android/app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
<uses-permission android:name="android.permission.INTERNET" />
55
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
6+
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
7+
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
68

79
<application
810
android:allowBackup="false"
@@ -16,6 +18,7 @@
1618
android:usesCleartextTraffic="false">
1719
<activity
1820
android:name=".MainActivity"
21+
android:launchMode="singleTask"
1922
android:exported="true">
2023
<intent-filter>
2124
<action android:name="android.intent.action.MAIN" />

0 commit comments

Comments
 (0)