Skip to content

Commit ca9daa6

Browse files
feat(android): 新增 Firebase Analytics 匿名事件追蹤系統
- 新增 analytics/ 模組:AnalyticsLogger 介面、FirebaseAnalyticsLogger 實作、AnalyticsEvents 集中定義事件常數、AnalyticsParameterSanitizer 白名單過濾敏感參數 - 嚴格匿名策略:禁止 setUserId,禁止送學號、姓名、班級、座號、成績、排名、科目名稱、考試名稱、URL、cookie、token、rawResult 或錯誤原文;事件參數僅允許 enum 字串、布林、計數與 bucket - ScoreApp 初始化 AnalyticsLogger 並注入各 ViewModel - ScoreViewModel / SettingsViewModel / ScheduleViewModel 接入事件記錄 (登入成功/失敗、成績載入、匯出、提醒切換、課表操作、設定變更等) - MainActivity 記錄 app 啟動、deep link、通知點擊、生物識別解鎖事件 - 新增 ArchitectureBoundaryTest 規則確保 ViewModel/Service 不直接呼叫 Firebase logEvent - 新增 AnalyticsParameterSanitizerTest 驗證敏感欄位過濾 - 擴充 ScoreViewModelTest / ScheduleViewModelTest 驗證 analytics 注入
1 parent a2ffb6c commit ca9daa6

15 files changed

Lines changed: 789 additions & 19 deletions

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@
5555
- 系統通知權限與 App 內通知開關的同步由 `ScoreApp` 根層處理;不要只放在設定頁,否則使用者從系統設定封鎖通知後,其他入口回 App 時狀態會不一致。
5656
- 發送 app 更新通知時使用 `app_updates` topic,一般公告使用 `general` topic。可在 FCM data payload 帶 `url`,使用者點通知時會開啟該網址。
5757

58+
## Android Firebase Analytics
59+
60+
- Firebase Analytics 事件經由 `com.clhs.score.analytics.AnalyticsLogger``FirebaseAnalyticsLogger` 集中記錄;不要在 UI、ViewModel 或 service 內直接呼叫 Firebase `logEvent`,避免事件名稱與隱私規則分散。
61+
- Analytics 採嚴格匿名策略:不得呼叫 `setUserId`,不得送學號、姓名、班級、座號、成績、排名、科目名稱、考試名稱、URL、cookie、token、rawResult 或錯誤原文。事件參數只允許 enum 字串、布林、計數與 bucket。
62+
- 新增事件或參數時,先更新 `AnalyticsEvents.kt` / `AnalyticsParameterSanitizer.kt` 的常數與白名單,並補 `AnalyticsParameterSanitizerTest``ArchitectureBoundaryTest`,確保敏感欄位不會被送出。
63+
5864
## Android 段考資訊變更提醒
5965

6066
- 段考提醒是本機背景功能,不使用 FCM topic,也不要把 session、cookie 或成績送到伺服器。

android/app/src/main/java/com/clhs/score/MainActivity.kt

Lines changed: 92 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import androidx.lifecycle.LifecycleOwner
1717
import androidx.lifecycle.compose.collectAsStateWithLifecycle
1818
import androidx.lifecycle.lifecycleScope
1919
import androidx.lifecycle.viewmodel.compose.viewModel
20+
import com.clhs.score.analytics.AnalyticsEvents
21+
import com.clhs.score.analytics.AnalyticsLogger
22+
import com.clhs.score.analytics.AnalyticsParams
23+
import com.clhs.score.analytics.AnalyticsValues
24+
import com.clhs.score.analytics.FirebaseAnalyticsLogger
2025
import com.clhs.score.data.AuthenticatedSession
2126
import com.clhs.score.data.BiometricHelper
2227
import com.clhs.score.data.GradeCacheStore
@@ -34,7 +39,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
3439
import kotlinx.coroutines.launch
3540

3641
class MainActivity : androidx.fragment.app.FragmentActivity() {
37-
private val checkUpdateChannel = Channel<Unit>(Channel.BUFFERED)
42+
private val checkUpdateChannel = Channel<String>(Channel.BUFFERED)
3843
private val gradeReminderOpenChannel = Channel<Pair<String, String>>(Channel.BUFFERED)
3944
private val pendingScheduleOpen = mutableStateOf(false)
4045
private val isAppLocked = mutableStateOf(false)
@@ -44,10 +49,12 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
4449
private var shouldLockOnInitialReady = false
4550
private var isBiometricPromptShowing = false
4651
private lateinit var sessionStore: SessionStore
52+
private lateinit var analyticsLogger: AnalyticsLogger
4753

4854
override fun onCreate(savedInstanceState: Bundle?) {
4955
super.onCreate(savedInstanceState)
5056
sessionStore = SessionStore(applicationContext)
57+
analyticsLogger = FirebaseAnalyticsLogger(applicationContext)
5158
val hasBiometricSession = sessionStore.hasBiometricSession()
5259
isAppLocked.value = savedInstanceState?.getBoolean(KEY_APP_LOCKED, false) == true &&
5360
hasBiometricSession
@@ -86,8 +93,8 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
8693
val isReady by settingsVm.isReady.collectAsStateWithLifecycle()
8794

8895
LaunchedEffect(Unit) {
89-
checkUpdateChannel.receiveAsFlow().collect {
90-
settingsVm.checkUpdate()
96+
checkUpdateChannel.receiveAsFlow().collect { trigger ->
97+
settingsVm.checkUpdate(trigger)
9198
}
9299
}
93100

@@ -156,6 +163,13 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
156163
val session = sessionStore.loadSessionWithPin(pin)
157164
if (session != null) {
158165
scoreVm.loginWithBiometricSession(session)
166+
analyticsLogger.logEvent(
167+
AnalyticsEvents.BIOMETRIC_UNLOCK_RESULT,
168+
mapOf(
169+
AnalyticsParams.METHOD to AnalyticsValues.METHOD_PIN,
170+
AnalyticsParams.RESULT to AnalyticsValues.RESULT_SUCCESS,
171+
),
172+
)
159173
isAppLocked.value = false
160174
if (isBiometricInvalidated.value) {
161175
isBiometricInvalidated.value = false
@@ -167,13 +181,20 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
167181
)
168182
}
169183
} else {
184+
analyticsLogger.logEvent(
185+
AnalyticsEvents.BIOMETRIC_UNLOCK_RESULT,
186+
mapOf(
187+
AnalyticsParams.METHOD to AnalyticsValues.METHOD_PIN,
188+
AnalyticsParams.RESULT to AnalyticsValues.RESULT_FAILURE,
189+
),
190+
)
170191
Toast.makeText(this@MainActivity, "密碼錯誤", Toast.LENGTH_SHORT).show()
171192
}
172193
},
173194
onLogout = {
174195
android.webkit.CookieManager.getInstance().removeAllCookies(null)
175196
android.webkit.CookieManager.getInstance().flush()
176-
scoreVm.logout()
197+
scoreVm.logout(AnalyticsValues.SOURCE_LOCK_SCREEN)
177198
clearWidgetScheduleCache()
178199
sessionStore.clearBiometricSession()
179200
settingsVm.setBiometricEnabled(false)
@@ -213,7 +234,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
213234
onSetDynamicColor = settingsVm::setDynamicColor,
214235
onSetAmoledBlack = settingsVm::setAmoledBlack,
215236
onSetNotificationsEnabled = settingsVm::setNotificationsEnabled,
216-
onCheckUpdate = settingsVm::checkUpdate,
237+
onCheckUpdate = { settingsVm.checkUpdate() },
217238
onDismissUpdateResult = settingsVm::dismissUpdateResult,
218239
onVersionTap = settingsVm::onVersionTap,
219240
onDismissDeveloperToast = settingsVm::dismissDeveloperToast,
@@ -222,6 +243,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
222243
onDismissNotificationPrompt = settingsVm::dismissNotificationPrompt,
223244
onExportGrades = { selections -> scoreVm.exportGrades(selections, applicationContext) },
224245
onDismissExportResult = scoreVm::dismissExportResult,
246+
analyticsLogger = analyticsLogger,
225247
onSetBiometricEnabled = { enabled, pin ->
226248
if (enabled && pin != null) {
227249
val currentSession = scoreVm.getCurrentSession()
@@ -261,14 +283,16 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
261283

262284
private fun handleIntent(intent: Intent?) {
263285
if (intent == null) return
286+
var routeSource: String? = null
264287

265288
val isUpdateTopic = intent.getStringExtra("from") == "/topics/app_updates" ||
266289
intent.getStringExtra(ScoreFirebaseMessagingService.EXTRA_CHECK_UPDATE) == "true" ||
267290
intent.getStringExtra("action") == "check_update" ||
268291
intent.getBooleanExtra(ScoreFirebaseMessagingService.EXTRA_CHECK_UPDATE, false)
269292

270293
if (isUpdateTopic) {
271-
checkUpdateChannel.trySend(Unit)
294+
routeSource = AnalyticsValues.SOURCE_NOTIFICATION_UPDATE
295+
checkUpdateChannel.trySend(AnalyticsValues.TRIGGER_NOTIFICATION)
272296
intent.removeExtra("from")
273297
intent.removeExtra("action")
274298
intent.removeExtra(ScoreFirebaseMessagingService.EXTRA_CHECK_UPDATE)
@@ -279,6 +303,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
279303
false,
280304
)
281305
if (isGradeReminderOpen) {
306+
routeSource = AnalyticsValues.SOURCE_GRADE_REMINDER
282307
val yearValue = intent.getStringExtra(GradeReminderNotifier.EXTRA_YEAR_VALUE).orEmpty()
283308
val examValue = intent.getStringExtra(GradeReminderNotifier.EXTRA_EXAM_VALUE).orEmpty()
284309
if (yearValue.isNotBlank() && examValue.isNotBlank()) {
@@ -291,9 +316,27 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
291316

292317
val data = intent.data
293318
if (data?.scheme == "scoreapp" && data.host == "schedule") {
319+
routeSource = AnalyticsValues.SOURCE_WIDGET_SCHEDULE
294320
pendingScheduleOpen.value = true
295321
intent.data = null
296322
}
323+
324+
if (routeSource == null &&
325+
intent.action == Intent.ACTION_MAIN &&
326+
intent.hasCategory(Intent.CATEGORY_LAUNCHER)
327+
) {
328+
routeSource = AnalyticsValues.SOURCE_LAUNCHER
329+
}
330+
331+
routeSource?.let { source ->
332+
analyticsLogger.logEvent(
333+
AnalyticsEvents.APP_OPEN_ROUTE,
334+
mapOf(
335+
AnalyticsParams.SOURCE to source,
336+
AnalyticsParams.LOCKED to (isAppLocked.value || sessionStore.hasBiometricSession()),
337+
),
338+
)
339+
}
297340
}
298341

299342
private fun showBiometricUnlockPrompt(
@@ -312,6 +355,13 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
312355
} catch (e: Exception) {
313356
if (BiometricHelper.isKeyPermanentlyInvalidated(e)) {
314357
isBiometricInvalidated.value = true
358+
analyticsLogger.logEvent(
359+
AnalyticsEvents.BIOMETRIC_UNLOCK_RESULT,
360+
mapOf(
361+
AnalyticsParams.METHOD to AnalyticsValues.METHOD_BIOMETRIC,
362+
AnalyticsParams.RESULT to AnalyticsValues.RESULT_INVALIDATED,
363+
),
364+
)
315365
} else {
316366
handleKeyInvalidated(scoreVm, settingsVm, "初始化安全金鑰失敗,請重新登入")
317367
}
@@ -323,6 +373,20 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
323373
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
324374
super.onAuthenticationError(errorCode, errString)
325375
isBiometricPromptShowing = false
376+
analyticsLogger.logEvent(
377+
AnalyticsEvents.BIOMETRIC_UNLOCK_RESULT,
378+
mapOf(
379+
AnalyticsParams.METHOD to AnalyticsValues.METHOD_BIOMETRIC,
380+
AnalyticsParams.RESULT to if (
381+
errorCode == BiometricPrompt.ERROR_USER_CANCELED ||
382+
errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON
383+
) {
384+
AnalyticsValues.RESULT_CANCELLED
385+
} else {
386+
AnalyticsValues.RESULT_FAILURE
387+
},
388+
),
389+
)
326390
if (errorCode != BiometricPrompt.ERROR_USER_CANCELED && errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
327391
Toast.makeText(this@MainActivity, "驗證錯誤: $errString", Toast.LENGTH_SHORT).show()
328392
}
@@ -336,8 +400,22 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
336400
val session = sessionStore.loadBiometricSession(decryptCipher)
337401
if (session != null) {
338402
scoreVm.loginWithBiometricSession(session)
403+
analyticsLogger.logEvent(
404+
AnalyticsEvents.BIOMETRIC_UNLOCK_RESULT,
405+
mapOf(
406+
AnalyticsParams.METHOD to AnalyticsValues.METHOD_BIOMETRIC,
407+
AnalyticsParams.RESULT to AnalyticsValues.RESULT_SUCCESS,
408+
),
409+
)
339410
isAppLocked.value = false
340411
} else {
412+
analyticsLogger.logEvent(
413+
AnalyticsEvents.BIOMETRIC_UNLOCK_RESULT,
414+
mapOf(
415+
AnalyticsParams.METHOD to AnalyticsValues.METHOD_BIOMETRIC,
416+
AnalyticsParams.RESULT to AnalyticsValues.RESULT_FAILURE,
417+
),
418+
)
341419
handleKeyInvalidated(scoreVm, settingsVm, "解析登入資訊失敗,請重新登入")
342420
}
343421
}
@@ -357,6 +435,13 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
357435
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
358436
}.onFailure { error ->
359437
isBiometricPromptShowing = false
438+
analyticsLogger.logEvent(
439+
AnalyticsEvents.BIOMETRIC_UNLOCK_RESULT,
440+
mapOf(
441+
AnalyticsParams.METHOD to AnalyticsValues.METHOD_BIOMETRIC,
442+
AnalyticsParams.RESULT to AnalyticsValues.RESULT_FAILURE,
443+
),
444+
)
360445
Toast.makeText(this, "啟動生物識別失敗: ${error.message ?: "未知錯誤"}", Toast.LENGTH_SHORT).show()
361446
}
362447
}
@@ -442,7 +527,7 @@ class MainActivity : androidx.fragment.app.FragmentActivity() {
442527
sessionStore.clearBiometricSession()
443528
sessionStore.clearNormalSession()
444529
settingsVm.setBiometricEnabled(false)
445-
scoreVm.logout()
530+
scoreVm.logout(AnalyticsValues.SOURCE_LOCK_SCREEN)
446531
clearWidgetScheduleCache()
447532
isAppLocked.value = false
448533
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package com.clhs.score.analytics
2+
3+
object AnalyticsEvents {
4+
const val APP_OPEN_ROUTE = "app_open_route"
5+
const val LOGIN_START = "login_start"
6+
const val LOGIN_RESULT = "login_result"
7+
const val LOGOUT = "logout"
8+
const val BIOMETRIC_UNLOCK_RESULT = "biometric_unlock_result"
9+
const val GRADE_STRUCTURE_LOAD = "grade_structure_load"
10+
const val GRADE_QUERY = "grade_query"
11+
const val SCHEDULE_OPEN = "schedule_open"
12+
const val SCHEDULE_QUERY = "schedule_query"
13+
const val SCHEDULE_WIDGET_SETTINGS_SAVE = "schedule_widget_settings_save"
14+
const val NOTIFICATION_TOGGLE = "notification_toggle"
15+
const val NOTIFICATION_PROMPT_ACTION = "notification_prompt_action"
16+
const val GRADE_REMINDER_START = "grade_reminder_start"
17+
const val GRADE_REMINDER_STOP = "grade_reminder_stop"
18+
const val GRADE_REMINDER_NOTIFICATION_OPEN = "grade_reminder_notification_open"
19+
const val EXPORT_GRADES = "export_grades"
20+
const val UPDATE_CHECK = "update_check"
21+
const val SUBJECT_TREND_LOAD = "subject_trend_load"
22+
const val SCORE_SIMULATOR_USED = "score_simulator_used"
23+
const val FEATURE_OPEN = "feature_open"
24+
}
25+
26+
object AnalyticsParams {
27+
const val ACTION = "action"
28+
const val CACHED = "cached"
29+
const val ENABLED = "enabled"
30+
const val ERROR_TYPE = "error_type"
31+
const val EXAM_COUNT_BUCKET = "exam_count_bucket"
32+
const val FEATURE = "feature"
33+
const val FAILURE_REASON = "failure_reason"
34+
const val LOCKED = "locked"
35+
const val METHOD = "method"
36+
const val MODE = "mode"
37+
const val REASON = "reason"
38+
const val RESULT = "result"
39+
const val SCREEN = "screen"
40+
const val SELECTION_COUNT_BUCKET = "selection_count_bucket"
41+
const val SHOW_CLASSROOM = "show_classroom"
42+
const val SHOW_TEACHER = "show_teacher"
43+
const val SHOW_TIME = "show_time"
44+
const val SOURCE = "source"
45+
const val SUBJECT_COUNT = "subject_count"
46+
const val SUBJECT_COUNT_BUCKET = "subject_count_bucket"
47+
const val SYSTEM_PERMISSION = "system_permission"
48+
const val TRIGGER = "trigger"
49+
const val YEAR_COUNT = "year_count"
50+
}
51+
52+
object AnalyticsValues {
53+
const val ACTION_AUTO_DISABLED = "auto_disabled"
54+
const val ACTION_DISMISS = "dismiss"
55+
const val ACTION_OPEN_SETTINGS = "open_settings"
56+
const val FEATURE_SCHEDULE = "schedule"
57+
const val FEATURE_SCORE_SIMULATOR = "score_simulator"
58+
const val FEATURE_SETTINGS = "settings"
59+
const val FEATURE_SUBJECT_TREND = "subject_trend"
60+
const val METHOD_BIOMETRIC = "biometric"
61+
const val METHOD_DEMO = "demo"
62+
const val METHOD_PIN = "pin"
63+
const val METHOD_WEBVIEW = "webview"
64+
const val MODE_CLASS = "class"
65+
const val PERMISSION_BLOCKED = "blocked"
66+
const val PERMISSION_GRANTED = "granted"
67+
const val PERMISSION_NOT_REQUIRED = "not_required"
68+
const val REASON_BATTERY = "battery"
69+
const val REASON_FAILURE = "failure"
70+
const val REASON_LOGOUT = "logout"
71+
const val REASON_NO_EXAM = "no_exam"
72+
const val REASON_PERMISSION = "permission"
73+
const val REASON_UNKNOWN = "unknown"
74+
const val REASON_USER = "user"
75+
const val RESULT_AVAILABLE = "available"
76+
const val RESULT_CANCELLED = "cancelled"
77+
const val RESULT_EMPTY = "empty"
78+
const val RESULT_ERROR = "error"
79+
const val RESULT_FAILURE = "failure"
80+
const val RESULT_INVALIDATED = "invalidated"
81+
const val RESULT_NOT_AVAILABLE = "not_available"
82+
const val RESULT_SUCCESS = "success"
83+
const val SOURCE_GRADE_REMINDER = "grade_reminder"
84+
const val SOURCE_LAUNCHER = "launcher"
85+
const val SOURCE_LOCK_SCREEN = "lock_screen"
86+
const val SOURCE_NOTIFICATION_UPDATE = "notification_update"
87+
const val SOURCE_SETTINGS = "settings"
88+
const val SOURCE_TAB = "tab"
89+
const val SOURCE_WIDGET_SCHEDULE = "widget_schedule"
90+
const val TRIGGER_EXAM_SELECT = "exam_select"
91+
const val TRIGGER_INITIAL = "initial"
92+
const val TRIGGER_MANUAL = "manual"
93+
const val TRIGGER_NOTIFICATION = "notification"
94+
const val TRIGGER_REFRESH = "refresh"
95+
const val TRIGGER_REMINDER_TARGET = "reminder_target"
96+
const val TRIGGER_YEAR_SELECT = "year_select"
97+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.clhs.score.analytics
2+
3+
interface AnalyticsLogger {
4+
fun logEvent(name: String, parameters: Map<String, Any?> = emptyMap())
5+
}
6+
7+
object NoOpAnalyticsLogger : AnalyticsLogger {
8+
override fun logEvent(name: String, parameters: Map<String, Any?>) = Unit
9+
}

0 commit comments

Comments
 (0)