Skip to content

Commit ae4fe5c

Browse files
committed
feat: implementation for new GA4 library
1 parent 9309e54 commit ae4fe5c

4 files changed

Lines changed: 363 additions & 0 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// SPDX-FileCopyrightText: 2026 Ashish Yadav <mailtoashish693@gmail.com>
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
package com.ichi2.anki.analytics
5+
6+
import timber.log.Timber
7+
8+
/**
9+
* A custom [Thread.UncaughtExceptionHandler] designed to intercept fatal app crashes,
10+
* log them to the analytics provider (e.g., GA4), and safely pass them down the chain
11+
* to the original exception handler.
12+
*
13+
* @property originalHandler The pre-existing exception handler in the thread's chain.
14+
* This is called immediately after the analytics report is dispatched.
15+
* @property sendExceptionReport A delegated function responsible for transmitting the
16+
* [Throwable] to the analytics engine. The boolean parameter
17+
* indicates if the exception is fatal (always `true` here).
18+
*/
19+
class AnalyticsExceptionHandler(
20+
val originalHandler: Thread.UncaughtExceptionHandler?,
21+
private val sendExceptionReport: (Throwable, Boolean) -> Unit,
22+
) : Thread.UncaughtExceptionHandler {
23+
override fun uncaughtException(
24+
thread: Thread,
25+
throwable: Throwable,
26+
) {
27+
sendExceptionReport(throwable, true)
28+
originalHandler?.uncaughtException(thread, throwable)
29+
}
30+
31+
companion object {
32+
/**
33+
* Safely installs the [AnalyticsExceptionHandler] as the default handler for the thread.
34+
*
35+
* @param sendExceptionReport The function reference to be used for sending the crash data.
36+
*/
37+
fun install(sendExceptionReport: (Throwable, Boolean) -> Unit) {
38+
val currentHandler = Thread.getDefaultUncaughtExceptionHandler()
39+
if (currentHandler !is AnalyticsExceptionHandler) {
40+
Thread.setDefaultUncaughtExceptionHandler(
41+
AnalyticsExceptionHandler(currentHandler, sendExceptionReport),
42+
)
43+
Timber.d("AnalyticsExceptionHandler installed.")
44+
}
45+
}
46+
47+
/**
48+
* Uninstalls the [AnalyticsExceptionHandler] by restoring the thread's default
49+
* exception handler back to the [originalHandler] that was present before installation.
50+
*/
51+
fun uninstall() {
52+
val currentHandler = Thread.getDefaultUncaughtExceptionHandler()
53+
if (currentHandler is AnalyticsExceptionHandler) {
54+
Thread.setDefaultUncaughtExceptionHandler(currentHandler.originalHandler)
55+
Timber.d("AnalyticsExceptionHandler uninstalled.")
56+
}
57+
}
58+
}
59+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// SPDX-FileCopyrightText: 2026 Ashish Yadav <mailtoashish693@gmail.com>
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
package com.ichi2.anki.analytics
5+
6+
/**
7+
* A type-safe wrapper representing the sampling rate for Google Analytics (GA) events.
8+
*
9+
* This defines the percentage of events sent to GA, while the remainder are dropped
10+
* client-side to limit reporting volume. It is backed by an [Int] ranging from `0..100`.
11+
*
12+
* The [Uninitialized] state serves as a sentinel value indicating that the rate has not
13+
* yet been loaded from resources, eliminating the need for callers to handle custom
14+
* out-of-band integers for this state.
15+
*/
16+
@JvmInline
17+
value class AnalyticsSamplePercentage(
18+
val value: Int,
19+
) {
20+
init {
21+
require(value == UNINITIALIZED_VALUE || value in 0..100) {
22+
"Analytics sample percentage must be in 0..100 or Uninitialized (-1), got $value"
23+
}
24+
}
25+
26+
/** `true` once the percentage has been loaded from resources or set explicitly. */
27+
val isInitialized: Boolean get() = value != UNINITIALIZED_VALUE
28+
29+
companion object {
30+
private const val UNINITIALIZED_VALUE = -1
31+
32+
/** Sentinel: not yet loaded from resources. */
33+
val Uninitialized = AnalyticsSamplePercentage(UNINITIALIZED_VALUE)
34+
35+
/** 100% of events are sent; used by [AnkiDroidUsageAnalytics.setDevMode]. */
36+
val Full = AnalyticsSamplePercentage(100)
37+
}
38+
}
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
// SPDX-FileCopyrightText: 2026 Ashish Yadav <mailtoashish693@gmail.com>
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
package com.ichi2.anki.analytics
5+
6+
import android.content.Context
7+
import android.content.SharedPreferences
8+
import androidx.core.content.edit
9+
import com.criticalay.GoogleAnalytics
10+
import com.ichi2.anki.AnkiDroidApp
11+
import com.ichi2.anki.BuildConfig
12+
import com.ichi2.anki.R
13+
import com.ichi2.anki.common.annotations.NeedsTest
14+
import com.ichi2.anki.common.utils.ext.getRootCause
15+
import com.ichi2.anki.preferences.sharedPrefs
16+
import kotlinx.coroutines.CoroutineScope
17+
import kotlinx.coroutines.Dispatchers
18+
import kotlinx.coroutines.SupervisorJob
19+
import kotlinx.coroutines.launch
20+
import timber.log.Timber
21+
import java.util.UUID
22+
23+
@NeedsTest("Add coverage for opt-in handling, client id persistence and event/exception sending")
24+
object AnkiDroidUsageAnalytics {
25+
private const val ANALYTICS_OPTIN_KEY = "analytics_opt_in"
26+
private const val ANALYTICS_CLIENT_ID = "googleAnalyticsClientId"
27+
28+
/**
29+
* Hard cap on the length of an exception description sent to GA.
30+
* GA's `exception_description` parameter is bounded truncate
31+
* defensively so an overly long stack/message isn't rejected at the wire.
32+
*/
33+
private const val MAX_EXCEPTION_DESCRIPTION_LENGTH = 150
34+
35+
/**
36+
* Dedicated prefs file (separate from the user-facing app preferences) for the
37+
* analytics client id. Keeping it isolated avoids exposing the id through
38+
* preference screens, backups, or bulk prefs operations on the main file.
39+
*
40+
* The id is install-scoped (one per device install) rather than per-profile
41+
* profiles share the same analytics client id, which matches GA's expectations
42+
* and avoids fragmenting analytics across profile switches.
43+
*/
44+
private const val ANALYTICS_PREFS = "analyticsPrefs"
45+
46+
@Volatile private var analytics: GoogleAnalytics? = null
47+
48+
@Volatile private var optIn = false
49+
50+
/**
51+
* Application context captured during [initialize]. Held here so we don't
52+
* rely on [AnkiDroidApp.instance] from background paths the singleton can
53+
* be uninitialized in rare Android scenarios (e.g. BackupManager) and
54+
* analytics is a startup concern that must not crash.
55+
*/
56+
private lateinit var appContext: Context
57+
58+
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
59+
private val clientId: String by lazy { getOrCreateClientId(appContext) }
60+
61+
private val sharedPrefsListener =
62+
SharedPreferences.OnSharedPreferenceChangeListener { prefs, key ->
63+
if (key == ANALYTICS_OPTIN_KEY) {
64+
optIn = prefs.getBoolean(key, false)
65+
Timber.i("Setting analytics opt-in to: %b", optIn)
66+
}
67+
}
68+
69+
/**
70+
* Cached percentage of analytics events that are actually sent; the rest
71+
* are dropped client-side to limit volume.
72+
*
73+
* Starts as [AnalyticsSamplePercentage.Uninitialized] and is loaded lazily
74+
* from `R.integer.ga_sampleFrequency` on first read. [setDevMode] overrides
75+
* it to [AnalyticsSamplePercentage.Full] so every event is sent during
76+
* development.
77+
*/
78+
private var samplePercentage: AnalyticsSamplePercentage = AnalyticsSamplePercentage.Uninitialized
79+
80+
/**
81+
* Resolved preference keys (the localized string-resource values, not
82+
* resource ids) whose changes both the key and the new value are
83+
* reported to analytics. Populated during [initialize] from
84+
* [AnalyticsConstants.reportablePrefKeys].
85+
*/
86+
lateinit var reportablePreferences: Set<String>
87+
private set
88+
89+
fun initialize(context: Context) {
90+
appContext = context.applicationContext
91+
92+
Timber.i("AnkiDroidUsageAnalytics:: initialize()")
93+
94+
// Read opt-in before building the client so `enabled` reflects the
95+
// user's choice rather than the default.
96+
handlePreferences(appContext)
97+
98+
if (analytics == null) {
99+
analytics =
100+
GoogleAnalytics.builder {
101+
measurementId = appContext.getString(R.string.ga_trackingId)
102+
apiSecret = BuildConfig.ANALYTICS_API_KEY
103+
appName = appContext.getString(R.string.app_name)
104+
appVersion = BuildConfig.VERSION_NAME
105+
enabled = optIn
106+
samplePercentage = getAnalyticsSamplePercentage(appContext)
107+
debug = false
108+
}
109+
}
110+
111+
initializePrefKeys(appContext)
112+
113+
AnalyticsExceptionHandler.install(this::sendAnalyticsException)
114+
}
115+
116+
private fun handlePreferences(context: Context) {
117+
val userPrefs = context.sharedPrefs()
118+
optIn = userPrefs.getBoolean(ANALYTICS_OPTIN_KEY, false)
119+
userPrefs.registerOnSharedPreferenceChangeListener(sharedPrefsListener)
120+
}
121+
122+
fun reinitialize(context: Context) {
123+
Timber.i("reInitialize()")
124+
AnalyticsExceptionHandler.uninstall()
125+
126+
serviceScope.launch {
127+
runCatching { analytics?.flush() }.onFailure { e ->
128+
Timber.w(e, "Failed to flush analytics")
129+
}
130+
analytics = null
131+
initialize(context)
132+
}
133+
}
134+
135+
/**
136+
* Records a screen view named after the runtime class of [screen].
137+
*
138+
* Uses [Class.getSimpleName] so the screen name is the unqualified class
139+
* name (e.g. `DeckPicker`, not `com.ichi2.anki.DeckPicker`). Pass an
140+
* activity/fragment/`this` from the screen you want to record.
141+
*/
142+
fun sendAnalyticsScreenView(screen: Any) = sendAnalyticsScreenView(screen.javaClass.simpleName)
143+
144+
fun sendAnalyticsScreenView(screenName: String) {
145+
Timber.d("AnkiDroidUsageAnalytics: screenView($screenName)")
146+
if (!optIn) return
147+
analytics?.screenView(clientId)?.screenName(screenName)?.sendAsync()
148+
}
149+
150+
fun sendAnalyticsEvent(
151+
category: String,
152+
action: String,
153+
value: Int? = null,
154+
label: String? = null,
155+
) {
156+
Timber.d("AnkiDroidUsageAnalytics: event(category=$category action=$action)")
157+
if (!optIn) return
158+
val analytics = analytics ?: return
159+
val event =
160+
analytics
161+
.event(clientId)
162+
.category(category)
163+
.action(action)
164+
label?.let { event.label(it) }
165+
value?.let { event.value(it) }
166+
event.sendAsync()
167+
}
168+
169+
fun sendAnalyticsException(
170+
t: Throwable,
171+
fatal: Boolean,
172+
) {
173+
val cause = t.getRootCause()
174+
sendAnalyticsException("${cause::class.simpleName}: ${cause.message}", fatal)
175+
}
176+
177+
fun sendAnalyticsException(
178+
description: String,
179+
fatal: Boolean,
180+
) {
181+
if (!optIn) return
182+
183+
Timber.d("AnkiDroidUsageAnalytics: exception(fatal=$fatal)")
184+
val analytics = analytics ?: return
185+
analytics
186+
.exception(clientId)
187+
.description(description.take(MAX_EXCEPTION_DESCRIPTION_LENGTH))
188+
.fatal(fatal)
189+
.sendAsync()
190+
}
191+
192+
private fun getOrCreateClientId(context: Context): String {
193+
Timber.d("AnkiDroidUsageAnalytics:: getting client Id")
194+
val prefs = context.getSharedPreferences(ANALYTICS_PREFS, Context.MODE_PRIVATE)
195+
return prefs.getString(ANALYTICS_CLIENT_ID, null) ?: UUID.randomUUID().toString().also {
196+
prefs.edit { putString(ANALYTICS_CLIENT_ID, it) }
197+
}
198+
}
199+
200+
private fun getAnalyticsSamplePercentage(context: Context): Int {
201+
Timber.d("AnkiDroidUsageAnalytics:: getting sample percentage")
202+
if (!samplePercentage.isInitialized) {
203+
samplePercentage =
204+
AnalyticsSamplePercentage(context.resources.getInteger(R.integer.ga_sampleFrequency))
205+
}
206+
return samplePercentage.value
207+
}
208+
209+
/**
210+
* Resolves [AnalyticsConstants.reportablePrefKeys] (string-resource ids)
211+
* to their localized key strings via [context] and caches the result in
212+
* [reportablePreferences] for fast membership checks at the call site.
213+
*/
214+
private fun initializePrefKeys(context: Context) {
215+
Timber.d("AnkiDroidUsageAnalytics:: initializing pref keys")
216+
reportablePreferences =
217+
AnalyticsConstants.reportablePrefKeys.mapTo(
218+
HashSet(AnalyticsConstants.reportablePrefKeys.size),
219+
) { context.getString(it) }
220+
}
221+
222+
var isEnabled: Boolean
223+
get() = optIn
224+
set(value) {
225+
optIn = value
226+
AnkiDroidApp.instance.sharedPrefs().edit {
227+
putBoolean(ANALYTICS_OPTIN_KEY, value)
228+
}
229+
// Rebuild the underlying client so its own `enabled` flag picks
230+
// up the new opt-in state without waiting for the next launch.
231+
if (::appContext.isInitialized) {
232+
reinitialize(appContext)
233+
}
234+
}
235+
236+
/**
237+
* Switches analytics into "development" mode: forces the [samplePercentage]
238+
* to 100 so every event is sent (production samples a subset to limit
239+
* volume) and reinitialized the underlying analytics client to apply it.
240+
*
241+
* Intended for debug builds and testing not for production use.
242+
*/
243+
fun setDevMode(context: Context) {
244+
Timber.d("setDevMode() re-configuring for development analytics tagging")
245+
samplePercentage = AnalyticsSamplePercentage.Full
246+
reinitialize(context)
247+
}
248+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// SPDX-FileCopyrightText: 2026 Ashish Yadav <mailtoashish693@gmail.com>
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
package com.ichi2.anki.common.utils.ext
5+
6+
/**
7+
* Walks the cause chain and returns the deepest non-null cause.
8+
*
9+
* Returns the receiver itself if it has no cause, and is safe against
10+
* self-referential cause cycles.
11+
*/
12+
fun Throwable.getRootCause(): Throwable {
13+
var result = this
14+
while (result.cause != null && result.cause !== result) {
15+
result = result.cause!!
16+
}
17+
return result
18+
}

0 commit comments

Comments
 (0)