Skip to content

Commit 76fb29e

Browse files
committed
feat: implementation for new GA4 library
1 parent 7bd12c5 commit 76fb29e

4 files changed

Lines changed: 417 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)