Unverified Commit 02f6e686 authored by Sawyer Blatz's avatar Sawyer Blatz Committed by GitHub
Browse files

For #10426: Adds identifier to Glean for 24 hours (#10446)

parent 51a8fb4a
......@@ -2073,6 +2073,19 @@ installation:
notification_emails:
- fenix-core@mozilla.com
expires: "2020-09-01"
identifier:
send_in_pings:
- installation
type: string
description: |
The hashed and salted GAID. Used for a short term installation validation test.
bugs:
- https://github.com/mozilla-mobile/fenix/issues/10426
data_reviews:
- https://github.com/mozilla-mobile/fenix/pull/10446#issuecomment-624816258
notification_emails:
- fenix-core@mozilla.com
expires: "2020-05-10"
browser.search:
with_ads:
......
......@@ -6,23 +6,14 @@ package org.mozilla.fenix.components.metrics
import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import androidx.annotation.VisibleForTesting
import com.google.android.gms.ads.identifier.AdvertisingIdClient
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
import com.google.android.gms.common.GooglePlayServicesRepairableException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.support.base.log.logger.Logger
import org.mozilla.fenix.GleanMetrics.Activation
import org.mozilla.fenix.GleanMetrics.Pings
import java.io.IOException
import java.security.NoSuchAlgorithmException
import java.security.spec.InvalidKeySpecException
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
import org.mozilla.fenix.components.metrics.MetricsUtils.getHashedIdentifier
class ActivationPing(private val context: Context) {
companion object {
......@@ -62,92 +53,6 @@ class ActivationPing(private val context: Context) {
prefs.edit().putBoolean("ping_sent", true).apply()
}
/**
* Query the Google Advertising API to get the Google Advertising ID.
*
* This is meant to be used off the main thread. The API will throw an
* exception and we will print a log message otherwise.
*
* @return a String containing the Google Advertising ID or null.
*/
@Suppress("TooGenericExceptionCaught")
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun getAdvertisingID(): String? {
return try {
AdvertisingIdClient.getAdvertisingIdInfo(context).id
} catch (e: GooglePlayServicesNotAvailableException) {
Logger.debug("ActivationPing - Google Play not installed on the device")
null
} catch (e: GooglePlayServicesRepairableException) {
Logger.debug("ActivationPing - recoverable error connecting to Google Play Services")
null
} catch (e: IllegalStateException) {
// This is unlikely to happen, as this should be running off the main thread.
Logger.debug("ActivationPing - AdvertisingIdClient must be called off the main thread")
null
} catch (e: IOException) {
Logger.debug("ActivationPing - unable to connect to Google Play Services")
null
} catch (e: NullPointerException) {
Logger.debug("ActivationPing - no Google Advertising ID available")
null
}
}
/**
* Get the salt to use for hashing. This is a convenience
* function to help with unit tests.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun getHashingSalt(): String = "org.mozilla.fenix-salt"
/**
* Produces an hashed version of the Google Advertising ID.
* We want users using more than one of our products to report a different
* ID in each of them. This function runs off the main thread and is CPU-bound.
*
* @return an hashed and salted Google Advertising ID or null if it was not possible
* to get the Google Advertising ID.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal suspend fun getHashedIdentifier(): String? = withContext(Dispatchers.Default) {
getAdvertisingID()?.let { unhashedID ->
// Add some salt to the ID, before hashing. For this specific use-case, it's ok
// to use the same salt value for all the hashes. We want hashes to be stable
// within a single product, but we don't want hashes to be the same across different
// products (e.g. Fennec vs Fenix).
val salt = getHashingSalt()
// Apply hashing.
try {
// Note that we intentionally want to use slow hashing functions here in order
// to increase the cost of potentially repeatedly guess the original unhashed
// identifier.
val keySpec = PBEKeySpec(
unhashedID.toCharArray(),
salt.toByteArray(),
PBKDF2_ITERATIONS,
PBKDF2_KEY_LEN_BITS)
val keyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
val hashedBytes = keyFactory.generateSecret(keySpec).encoded
Base64.encodeToString(hashedBytes, Base64.NO_WRAP)
} catch (e: java.lang.NullPointerException) {
Logger.error("ActivationPing - missing or wrong salt parameter")
null
} catch (e: IllegalArgumentException) {
Logger.error("ActivationPing - wrong parameter", e)
null
} catch (e: NoSuchAlgorithmException) {
Logger.error("ActivationPing - algorithm not available")
null
} catch (e: InvalidKeySpecException) {
Logger.error("ActivationPing - invalid key spec")
null
}
}
}
/**
* Fills the metrics and triggers the 'activation' ping.
* This is a separate function to simplify unit-testing.
......@@ -158,7 +63,7 @@ class ActivationPing(private val context: Context) {
Activation.activationId.generateAndSet()
CoroutineScope(Dispatchers.IO).launch {
val hashedId = getHashedIdentifier()
val hashedId = getHashedIdentifier(context)
if (hashedId != null) {
Logger.info("ActivationPing - generating ping with the hashed id")
// We have a valid, hashed Google Advertising ID.
......
......@@ -11,6 +11,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import mozilla.components.support.base.log.logger.Logger
import org.mozilla.fenix.GleanMetrics.Activation
import org.mozilla.fenix.GleanMetrics.Installation
import org.mozilla.fenix.GleanMetrics.Pings
import org.mozilla.fenix.ext.settings
......@@ -64,6 +65,10 @@ class InstallationPing(private val context: Context) {
}
CoroutineScope(Dispatchers.IO).launch {
MetricsUtils.getHashedIdentifier(context)?.let {
Activation.identifier.set(it)
}
Pings.installation.submit()
markAsTriggered()
}
......
......@@ -5,10 +5,23 @@
package org.mozilla.fenix.components.metrics
import android.content.Context
import android.util.Base64
import androidx.annotation.VisibleForTesting
import com.google.android.gms.ads.identifier.AdvertisingIdClient
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
import com.google.android.gms.common.GooglePlayServicesRepairableException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mozilla.components.browser.search.SearchEngine
import mozilla.components.support.base.log.logger.Logger
import org.mozilla.fenix.components.metrics.Event.PerformedSearch.SearchAccessPoint
import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore
import org.mozilla.fenix.ext.searchEngineManager
import java.io.IOException
import java.security.NoSuchAlgorithmException
import java.security.spec.InvalidKeySpecException
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
object MetricsUtils {
fun createSearchEvent(
......@@ -51,4 +64,91 @@ object MetricsUtils {
)
}
}
/**
* Get the salt to use for hashing. This is a convenience
* function to help with unit tests.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun getHashingSalt(): String = "org.mozilla.fenix-salt"
/**
* Query the Google Advertising API to get the Google Advertising ID.
*
* This is meant to be used off the main thread. The API will throw an
* exception and we will print a log message otherwise.
*
* @return a String containing the Google Advertising ID or null.
*/
@Suppress("TooGenericExceptionCaught")
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun getAdvertisingID(context: Context): String? {
return try {
AdvertisingIdClient.getAdvertisingIdInfo(context).id
} catch (e: GooglePlayServicesNotAvailableException) {
Logger.debug("ActivationPing - Google Play not installed on the device")
null
} catch (e: GooglePlayServicesRepairableException) {
Logger.debug("ActivationPing - recoverable error connecting to Google Play Services")
null
} catch (e: IllegalStateException) {
// This is unlikely to happen, as this should be running off the main thread.
Logger.debug("ActivationPing - AdvertisingIdClient must be called off the main thread")
null
} catch (e: IOException) {
Logger.debug("ActivationPing - unable to connect to Google Play Services")
null
} catch (e: NullPointerException) {
Logger.debug("ActivationPing - no Google Advertising ID available")
null
}
}
/**
* Produces a hashed version of the Google Advertising ID.
* We want users using more than one of our products to report a different
* ID in each of them. This function runs off the main thread and is CPU-bound.
*
* @return an hashed and salted Google Advertising ID or null if it was not possible
* to get the Google Advertising ID.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
suspend fun getHashedIdentifier(context: Context): String? = withContext(Dispatchers.Default) {
getAdvertisingID(context)?.let { unhashedID ->
// Add some salt to the ID, before hashing. For this specific use-case, it's ok
// to use the same salt value for all the hashes. We want hashes to be stable
// within a single product, but we don't want hashes to be the same across different
// products (e.g. Fennec vs Fenix).
val salt = getHashingSalt()
// Apply hashing.
try {
// Note that we intentionally want to use slow hashing functions here in order
// to increase the cost of potentially repeatedly guess the original unhashed
// identifier.
val keySpec = PBEKeySpec(
unhashedID.toCharArray(),
salt.toByteArray(),
ActivationPing.PBKDF2_ITERATIONS,
ActivationPing.PBKDF2_KEY_LEN_BITS
)
val keyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
val hashedBytes = keyFactory.generateSecret(keySpec).encoded
Base64.encodeToString(hashedBytes, Base64.NO_WRAP)
} catch (e: java.lang.NullPointerException) {
Logger.error("ActivationPing - missing or wrong salt parameter")
null
} catch (e: IllegalArgumentException) {
Logger.error("ActivationPing - wrong parameter", e)
null
} catch (e: NoSuchAlgorithmException) {
Logger.error("ActivationPing - algorithm not available")
null
} catch (e: InvalidKeySpecException) {
Logger.error("ActivationPing - invalid key spec")
null
}
}
}
}
......@@ -4,104 +4,15 @@
package org.mozilla.fenix.components.metrics
import android.util.Base64
import com.google.android.gms.ads.identifier.AdvertisingIdClient
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
import com.google.android.gms.common.GooglePlayServicesRepairableException
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.spyk
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Ignore
import org.junit.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyString
import java.io.IOException
internal class ActivationPingTest {
@Ignore("This test has side-effects that cause it to fail other unrelated tests.")
@Test
fun `getAdvertisingID() returns null if the API throws`() {
mockkStatic(AdvertisingIdClient::class)
val exceptions = listOf(
GooglePlayServicesNotAvailableException(1),
GooglePlayServicesRepairableException(0, anyString(), any()),
IllegalStateException(),
IOException()
)
val ap = ActivationPing(mockk())
exceptions.forEach {
every {
AdvertisingIdClient.getAdvertisingIdInfo(any())
} throws it
assertNull(ap.getAdvertisingID())
}
}
@Test
fun `getAdvertisingID() returns null if the API returns null info`() {
mockkStatic(AdvertisingIdClient::class)
every { AdvertisingIdClient.getAdvertisingIdInfo(any()) } returns null
val ap = ActivationPing(mockk())
assertNull(ap.getAdvertisingID())
}
@Test
fun `getAdvertisingID() returns a valid string if the API returns a valid ID`() {
val testId = "test-value-id"
mockkStatic(AdvertisingIdClient::class)
every {
AdvertisingIdClient.getAdvertisingIdInfo(any())
} returns AdvertisingIdClient.Info(testId, false)
val ap = ActivationPing(mockk())
assertEquals(testId, ap.getAdvertisingID())
}
@Test
fun `getHashedIdentifier() returns an hashed identifier`() {
val testId = "test-value-id"
val testPackageName = "org.mozilla-test.fenix"
val mockedHexReturn = "mocked-HEX"
// Mock the Base64 to record the byte array that is passed in,
// which is the actual digest. We can't simply test the return value
// of |getHashedIdentifier| as these Android tests require us to mock
// Android-specific APIs.
mockkStatic(Base64::class)
val shaDigest = slot<ByteArray>()
every {
Base64.encodeToString(capture(shaDigest), any())
} returns mockedHexReturn
// Get the hash identifier.
val mockAp = spyk(ActivationPing(mockk()))
every { mockAp.getAdvertisingID() } returns testId
every { mockAp.getHashingSalt() } returns testPackageName
runBlocking {
assertEquals(mockedHexReturn, mockAp.getHashedIdentifier())
}
// Check that the digest of the identifier matches with what we expect.
// Please note that in the real world, Base64.encodeToString would encode
// this to something much shorter, which we'd send with the ping.
val expectedDigestBytes =
"[52, -79, -84, 79, 101, 22, -82, -44, -44, -14, 21, 15, 48, 88, -94, -74, -8, 25, -72, -120, -37, 108, 47, 16, 2, -37, 126, 41, 102, -92, 103, 24]"
assertEquals(expectedDigestBytes, shaDigest.captured.contentToString())
}
@Test
fun `checkAndSend() triggers the ping if it wasn't marked as triggered`() {
val mockAp = spyk(ActivationPing(mockk()), recordPrivateCalls = true)
......
package org.mozilla.fenix.components.metrics
import android.content.Context
import android.util.Base64
import com.google.android.gms.ads.identifier.AdvertisingIdClient
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
import com.google.android.gms.common.GooglePlayServicesRepairableException
import io.mockk.every
import io.mockk.mockk
import mozilla.components.browser.search.SearchEngine
import mozilla.components.support.test.robolectric.testContext
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.slot
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mockito.ArgumentMatchers
import java.io.IOException
@RunWith(FenixRobolectricTestRunner::class)
class MetricsUtilsTest {
private val context: Context = mockk(relaxed = true)
@Ignore("This test has side-effects that cause it to fail other unrelated tests.")
@Test
fun createSearchEvent() {
val engine: SearchEngine = mockk(relaxed = true)
val context = testContext
every { engine.identifier } returns ENGINE_SOURCE_IDENTIFIER
assertEquals(
"$ENGINE_SOURCE_IDENTIFIER.suggestion",
MetricsUtils.createSearchEvent(
engine,
context,
Event.PerformedSearch.SearchAccessPoint.SUGGESTION
)?.eventSource?.countLabel
)
assertEquals(
"$ENGINE_SOURCE_IDENTIFIER.action",
MetricsUtils.createSearchEvent(
engine,
context,
Event.PerformedSearch.SearchAccessPoint.ACTION
)?.eventSource?.countLabel
)
assertEquals(
"$ENGINE_SOURCE_IDENTIFIER.widget",
MetricsUtils.createSearchEvent(
engine,
context,
Event.PerformedSearch.SearchAccessPoint.WIDGET
)?.eventSource?.countLabel
)
assertEquals(
"$ENGINE_SOURCE_IDENTIFIER.shortcut",
MetricsUtils.createSearchEvent(
engine,
context,
Event.PerformedSearch.SearchAccessPoint.SHORTCUT
)?.eventSource?.countLabel
fun `getAdvertisingID() returns null if the API throws`() {
val exceptions = listOf(
GooglePlayServicesNotAvailableException(1),
GooglePlayServicesRepairableException(0, ArgumentMatchers.anyString(), ArgumentMatchers.any()),
IllegalStateException(),
IOException()
)
exceptions.forEach {
every {
AdvertisingIdClient.getAdvertisingIdInfo(any())
} throws it
Assert.assertNull(MetricsUtils.getAdvertisingID(context))
}
}
@Test
fun `getAdvertisingID() returns null if the API returns null info`() {
mockkStatic(AdvertisingIdClient::class)
every { AdvertisingIdClient.getAdvertisingIdInfo(any()) } returns null
Assert.assertNull(MetricsUtils.getAdvertisingID(context))
}
@Test
fun `getAdvertisingID() returns a valid string if the API returns a valid ID`() {
val testId = "test-value-id"
mockkStatic(AdvertisingIdClient::class)
every {
AdvertisingIdClient.getAdvertisingIdInfo(any())
} returns AdvertisingIdClient.Info(testId, false)
assertEquals(testId, MetricsUtils.getAdvertisingID(context))
}
@Test
fun `getHashedIdentifier() returns a hashed identifier`() {
val testId = "test-value-id"
val testPackageName = "org.mozilla-test.fenix"
val mockedHexReturn = "mocked-HEX"
// Mock the Base64 to record the byte array that is passed in,
// which is the actual digest. We can't simply test the return value
// of |getHashedIdentifier| as these Android tests require us to mock
// Android-specific APIs.
mockkStatic(Base64::class)
val shaDigest = slot<ByteArray>()
every {
Base64.encodeToString(capture(shaDigest), any())
} returns mockedHexReturn
// Get the hash identifier.
mockkObject(MetricsUtils)
every { MetricsUtils.getAdvertisingID(context) } returns testId
every { MetricsUtils.getHashingSalt() } returns testPackageName
runBlocking {
assertEquals(mockedHexReturn, MetricsUtils.getHashedIdentifier(context))
}
// Check that the digest of the identifier matches with what we expect.
// Please note that in the real world, Base64.encodeToString would encode
// this to something much shorter, which we'd send with the ping.
val expectedDigestBytes =
"[52, -79, -84, 79, 101, 22, -82, -44, -44, -14, 21, 15, 48, 88, -94, -74, -8, 25, -72, -120, -37, 108, 47, 16, 2, -37, 126, 41, 102, -92, 103, 24]"
assertEquals(expectedDigestBytes, shaDigest.captured.contentToString())
}
companion object {
......
/* 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.fenix.components.metrics
import io.mockk.every
import io.mockk.mockk
import mozilla.components.browser.search.SearchEngine
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
/**
* Just the Roboelectric tests for MetricsUtil. Splitting these files out means our other tests will run more quickly.
* FenixRobolectricTestRunner also breaks our ability to use mockkStatic on Base64.
*/
@RunWith(FenixRobolectricTestRunner::class)
class MetricsUtilsTestRoboelectric {
@Test
fun createSearchEvent() {
val context = testContext
val engine: SearchEngine = mockk(relaxed = true)
every { engine.identifier } returns MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER
Assert.assertEquals(
"${MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER}.suggestion",
MetricsUtils.createSearchEvent(
engine,
context,
Event.PerformedSearch.SearchAccessPoint.SUGGESTION
)?.eventSource?.countLabel
)
Assert.assertEquals(
"${MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER}.action",
MetricsUtils.createSearchEvent(
engine,
context,
Event.PerformedSearch.SearchAccessPoint.ACTION
)?.eventSource?.countLabel
)
Assert.assertEquals(
"${MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER}.widget",
MetricsUtils.createSearchEvent(
engine,
context,
Event.PerformedSearch.SearchAccessPoint.WIDGET
)?.eventSource?.countLabel
)
Assert.assertEquals(
"${MetricsUtilsTest.ENGINE_SOURCE_IDENTIFIER}.shortcut",
MetricsUtils.createSearchEvent(