Revision control

Copy as Markdown

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.focus.telemetry
import android.content.Context
import android.os.Build
import android.os.RemoteException
import android.os.StrictMode
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationManagerCompat
import androidx.preference.PreferenceManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine
import mozilla.components.feature.search.ext.waitForSelectedOrDefaultSearchEngine
import mozilla.components.feature.search.telemetry.SearchProviderModel
import mozilla.components.feature.search.telemetry.SerpTelemetryRepository
import mozilla.components.service.glean.net.ConceptFetchHttpUploader
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.ktx.android.content.res.readJSONObject
import mozilla.telemetry.glean.Glean
import mozilla.telemetry.glean.config.Configuration
import org.mozilla.focus.BuildConfig
import org.mozilla.focus.Components
import org.mozilla.focus.GleanMetrics.Browser
import org.mozilla.focus.GleanMetrics.GleanBuildInfo
import org.mozilla.focus.GleanMetrics.Metrics
import org.mozilla.focus.GleanMetrics.MozillaProducts
import org.mozilla.focus.GleanMetrics.Notifications
import org.mozilla.focus.GleanMetrics.Pings
import org.mozilla.focus.GleanMetrics.Preferences
import org.mozilla.focus.GleanMetrics.Shortcuts
import org.mozilla.focus.GleanMetrics.TrackingProtection
import org.mozilla.focus.R
import org.mozilla.focus.ext.components
import org.mozilla.focus.ext.settings
import org.mozilla.focus.topsites.DefaultTopSitesStorage.Companion.TOP_SITES_MAX_LIMIT
import org.mozilla.focus.utils.AppConstants
import org.mozilla.focus.utils.Settings
/**
* Glean telemetry service.
*
* To track events, use Glean's generated bindings directly.
*/
class GleanMetricsService(context: Context) : MetricsService {
@Suppress("UnusedPrivateMember")
private val activationPing = ActivationPing(context)
companion object {
// collection name to fetch from server for SERP telemetry
const val COLLECTION_NAME = "search-telemetry-v2"
// urls for prod and stage remote settings server
internal const val REMOTE_PROD_ENDPOINT_URL = "https://firefox.settings.services.mozilla.com"
internal const val REMOTE_STAGE_ENDPOINT_URL = "https://firefox.settings.services.allizom.org"
private val isEnabledByDefault: Boolean
get() = !AppConstants.isKlarBuild
private fun isDeviceWithTelemetryDisabled(): Boolean {
val brand = "blackberry"
val device = "bbf100"
return Build.BRAND == brand && Build.DEVICE == device
}
/**
* Determines whether or not telemetry is enabled.
*/
@JvmStatic
fun isTelemetryEnabled(context: Context): Boolean {
if (isDeviceWithTelemetryDisabled()) { return false }
// The first access to shared preferences will require a disk read.
val threadPolicy = StrictMode.allowThreadDiskReads()
try {
val resources = context.resources
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
return preferences.getBoolean(
resources.getString(R.string.pref_key_telemetry),
isEnabledByDefault,
) && !AppConstants.isDevBuild
} finally {
StrictMode.setThreadPolicy(threadPolicy)
}
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun initialize(context: Context) {
val components = context.components
val settings = context.settings
val telemetryEnabled = isTelemetryEnabled(context)
Glean.initialize(
applicationContext = context,
uploadEnabled = telemetryEnabled,
configuration = Configuration(
channel = BuildConfig.FLAVOR,
httpClient = ConceptFetchHttpUploader(
client = lazy(LazyThreadSafetyMode.NONE) { components.client },
usePrivateRequest = true,
),
),
buildInfo = GleanBuildInfo.buildInfo,
)
Glean.registerPings(Pings)
if (telemetryEnabled) {
CoroutineScope(Dispatchers.Main).launch {
val readJson = { context.assets.readJSONObject("search/search_telemetry_v2.json") }
val providerList = withContext(Dispatchers.IO) {
SerpTelemetryRepository(
rootStorageDirectory = context.filesDir,
readJson = readJson,
collectionName = COLLECTION_NAME,
serverUrl = if (context.settings.useProductionRemoteSettingsServer) {
REMOTE_PROD_ENDPOINT_URL
} else {
REMOTE_STAGE_ENDPOINT_URL
},
).updateProviderList()
}
installSearchTelemetryExtensions(components, providerList)
}
}
// Do this immediately after init.
GlobalScope.launch(IO) {
// Wait for preferences to be collected before we send the activation ping.
collectPrefMetricsAsync(components, settings, context).await()
components.store.waitForSelectedOrDefaultSearchEngine { searchEngine ->
if (searchEngine != null) {
Browser.defaultSearchEngine.set(getDefaultSearchEngineIdentifierForTelemetry(context))
}
activationPing.checkAndSend()
}
}
}
private fun collectPrefMetricsAsync(
components: Components,
settings: Settings,
context: Context,
) = CoroutineScope(IO).async {
val installedBrowsers = BrowsersCache.all(context)
val hasFenixInstalled = FenixProductDetector.getInstalledFenixVersions(context).isNotEmpty()
val isFenixDefaultBrowser = FenixProductDetector.isFenixDefaultBrowser(installedBrowsers.defaultBrowser)
val isFocusDefaultBrowser = installedBrowsers.isDefaultBrowser
Metrics.searchWidgetInstalled.set(settings.searchWidgetInstalled)
Browser.isDefault.set(isFocusDefaultBrowser)
Browser.localeOverride.set(components.store.state.locale?.displayName ?: "none")
val shortcutsOnHomeNumber = components.topSitesStorage.getTopSites(
totalSites = TOP_SITES_MAX_LIMIT,
frecencyConfig = null,
).size
Shortcuts.shortcutsOnHomeNumber.set(shortcutsOnHomeNumber.toLong())
val installSourcePackage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.packageManager.getInstallSourceInfo(context.packageName).installingPackageName
} else {
@Suppress("DEPRECATION")
context.packageManager.getInstallerPackageName(context.packageName)
}
Browser.installSource.set(installSourcePackage.orEmpty())
// Fenix telemetry
MozillaProducts.hasFenixInstalled.set(hasFenixInstalled)
MozillaProducts.isFenixDefaultBrowser.set(isFenixDefaultBrowser)
// tracking protection metrics
TrackingProtection.hasAdvertisingBlocked.set(settings.hasAdvertisingBlocked())
TrackingProtection.hasAnalyticsBlocked.set(settings.hasAnalyticsBlocked())
TrackingProtection.hasContentBlocked.set(settings.shouldBlockOtherTrackers())
TrackingProtection.hasSocialBlocked.set(settings.hasSocialBlocked())
// theme telemetry
val currentTheme =
when {
settings.lightThemeSelected -> {
"Light"
}
settings.darkThemeSelected -> {
"Dark"
}
settings.useDefaultThemeSelected -> {
"Follow device"
}
else -> ""
}
if (currentTheme.isNotEmpty()) {
Preferences.userTheme.set(currentTheme)
}
try {
Notifications.permissionGranted.set(
NotificationManagerCompat.from(context).areNotificationsEnabled(),
)
} catch (e: RemoteException) {
Logger.warn("Failed to check notifications state", e)
}
}
private fun getDefaultSearchEngineIdentifierForTelemetry(context: Context): String {
val searchEngine = context.components.store.state.search.selectedOrDefaultSearchEngine
return if (searchEngine?.type == SearchEngine.Type.CUSTOM) {
"custom"
} else {
searchEngine?.name ?: "<none>"
}
}
@VisibleForTesting
internal suspend fun installSearchTelemetryExtensions(
components: Components,
providerList: List<SearchProviderModel>,
) {
val engine = components.engine
components.store.apply {
components.adsTelemetry.install(engine, this@apply, providerList)
components.searchTelemetry.install(engine, this@apply, providerList)
}
}
}