Bug 1858193: Add a OneTimeWorkRequest to collect Telemetry for Installed Fonts
parent
e71661ba64
commit
c06482e340
@ -0,0 +1,223 @@
|
||||
/* 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.fonts
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.fonts.Font
|
||||
import android.graphics.fonts.SystemFonts
|
||||
import android.os.Build
|
||||
import android.os.LocaleList
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import mozilla.components.service.glean.private.CommonMetricData
|
||||
import mozilla.components.service.glean.private.Lifetime
|
||||
import mozilla.components.service.glean.private.TextMetricType
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.mozilla.fenix.Config
|
||||
import org.mozilla.fenix.ext.settings
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
/**
|
||||
* Parse all of the fonts on the user's phone, then put them into the
|
||||
* `font_list_json` Metric to be submitted via Telemetry later.
|
||||
*/
|
||||
class FontEnumerationWorker(
|
||||
context: Context,
|
||||
workerParameters: WorkerParameters,
|
||||
) : CoroutineWorker(context, workerParameters) {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
val s: String
|
||||
try {
|
||||
readAllFonts()
|
||||
s = createJSONString()
|
||||
} catch (e: Exception) {
|
||||
return@withContext Result.retry()
|
||||
}
|
||||
|
||||
// We only want to get one submission per user, so set lifetime=Ping,
|
||||
// And the value will be only set once (when this WorkRequest is run)
|
||||
val fontList =
|
||||
TextMetricType(
|
||||
CommonMetricData(
|
||||
category = "metrics",
|
||||
name = "font_list_json",
|
||||
sendInPings = listOf("font-list"),
|
||||
lifetime = Lifetime.PING,
|
||||
disabled = false,
|
||||
),
|
||||
)
|
||||
fontList.set(s)
|
||||
|
||||
// To avoid getting multiple submissions from new installs, set directly
|
||||
// to the desired number of submissions
|
||||
applicationContext.settings().numFontListSent = kDesiredSubmissions
|
||||
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
private val brokenFonts: ArrayList<Pair<String, String>> = ArrayList()
|
||||
private val fonts: MutableSet<FontMetric> = HashSet()
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
private fun readAllFonts() {
|
||||
for (path in getSystemFonts()) {
|
||||
try {
|
||||
fonts.add(FontParser.parse(path))
|
||||
} catch (e: Exception) {
|
||||
brokenFonts.add(Pair(path, FontParser.calculateFileHash(path)))
|
||||
}
|
||||
}
|
||||
for (path in getAPIFonts()) {
|
||||
try {
|
||||
fonts.add(FontParser.parse(path))
|
||||
} catch (e: Exception) {
|
||||
brokenFonts.add(Pair(path, FontParser.calculateFileHash(path)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function creates a single JSON String containing
|
||||
* The user's phone information, as well as all the fonts and their information,
|
||||
* And the names of files that encountered a parsing error.
|
||||
*/
|
||||
@Throws(JSONException::class)
|
||||
fun createJSONString(): String {
|
||||
val submission = JSONObject()
|
||||
|
||||
run {
|
||||
submission.put("brand", Build.BRAND)
|
||||
submission.put("device", Build.DEVICE)
|
||||
submission.put("hardware", Build.HARDWARE)
|
||||
submission.put("manufacturer", Build.MANUFACTURER)
|
||||
submission.put("model", Build.MODEL)
|
||||
submission.put("product", Build.PRODUCT)
|
||||
submission.put("release_version", Build.VERSION.RELEASE)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
submission.put("security_patch", Build.VERSION.SECURITY_PATCH)
|
||||
submission.put("base_os", Build.VERSION.BASE_OS)
|
||||
} else {
|
||||
submission.put("security_patch", "too-low-version")
|
||||
submission.put("base_os", "too-low-version")
|
||||
}
|
||||
val config: Configuration = this.applicationContext.resources.configuration
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val supportedLocales: LocaleList = LocaleList.getDefault()
|
||||
val sb = StringBuilder()
|
||||
for (i in 0 until supportedLocales.size()) {
|
||||
val locale: Locale = supportedLocales.get(i)
|
||||
sb.append(locale.toString())
|
||||
sb.append(",")
|
||||
}
|
||||
submission.put("current_locale", config.locales[0].toString())
|
||||
submission.put("all_locales", sb.toString())
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
submission.put("current_locale", config.locale.toString())
|
||||
submission.put("all_locales", "too-low-version")
|
||||
}
|
||||
}
|
||||
|
||||
val fontArr = JSONArray()
|
||||
for (fontDetails in fonts) {
|
||||
fontArr.put(fontDetails.toJson())
|
||||
}
|
||||
|
||||
val errorArr = JSONArray()
|
||||
for (error in brokenFonts) {
|
||||
val errorObj = JSONObject()
|
||||
errorObj.put("path", error.first)
|
||||
errorObj.put("hash", error.second)
|
||||
errorArr.put(errorObj)
|
||||
}
|
||||
|
||||
submission.put("fonts", fontArr)
|
||||
submission.put("errors", errorArr)
|
||||
|
||||
return submission.toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FONT_ENUMERATOR_WORK_NAME = "org.mozilla.fenix.metrics.font.work"
|
||||
private val HOUR_MILLIS: Long = 1.hours.inWholeMilliseconds
|
||||
private const val SIX: Long = 6
|
||||
|
||||
/**
|
||||
* Schedules the Activated User event if needed.
|
||||
*/
|
||||
fun sendActivatedSignalIfNeeded(context: Context) {
|
||||
val instanceWorkManager = WorkManager.getInstance(context)
|
||||
|
||||
if (!Config.channel.isNightlyOrDebug) {
|
||||
return
|
||||
}
|
||||
|
||||
if (context.settings().numFontListSent >= kDesiredSubmissions) {
|
||||
return
|
||||
}
|
||||
|
||||
val fontEnumeratorWork =
|
||||
OneTimeWorkRequest.Builder(FontEnumerationWorker::class.java)
|
||||
.setInitialDelay(HOUR_MILLIS, TimeUnit.MILLISECONDS)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, SIX, TimeUnit.HOURS)
|
||||
.build()
|
||||
|
||||
instanceWorkManager.beginUniqueWork(
|
||||
FONT_ENUMERATOR_WORK_NAME,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
fontEnumeratorWork,
|
||||
).enqueue()
|
||||
}
|
||||
|
||||
private fun getSystemFonts(): ArrayList<String> {
|
||||
val file = File("/system/fonts")
|
||||
val ff: Array<out File>? = file.listFiles()
|
||||
val systemFonts: ArrayList<String> = ArrayList()
|
||||
if (ff != null) {
|
||||
for (f in ff) {
|
||||
systemFonts.add(f.absolutePath)
|
||||
}
|
||||
}
|
||||
return systemFonts
|
||||
}
|
||||
|
||||
private fun getAPIFonts(): List<String> {
|
||||
val aPIFonts: List<String>
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
aPIFonts = emptyList()
|
||||
} else {
|
||||
aPIFonts = ArrayList()
|
||||
val apiFonts: Set<Font> = SystemFonts.getAvailableFonts()
|
||||
for (f in apiFonts) {
|
||||
f.file?.let {
|
||||
aPIFonts.add(it.absolutePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
return aPIFonts
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of font submissions we would like from a user.
|
||||
* We will increment this number by one (via a code patch) when
|
||||
* we wish to perform another data collection effort on the Nightly
|
||||
* population.
|
||||
*/
|
||||
const val kDesiredSubmissions: Int = 1
|
||||
}
|
||||
}
|
@ -0,0 +1,245 @@
|
||||
/* 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.fonts
|
||||
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.io.DataInputStream
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* FontMetric represents the information about a Font File
|
||||
*/
|
||||
data class FontMetric(
|
||||
val path: String = "",
|
||||
val hash: String = "",
|
||||
) {
|
||||
var family: String = ""
|
||||
var subFamily: String = ""
|
||||
var uniqueSubFamily: String = ""
|
||||
var fullName: String = ""
|
||||
var fontVersion: String = ""
|
||||
var revision: Int = -1
|
||||
var created: Long = -1L
|
||||
var modified: Long = -1L
|
||||
|
||||
/**
|
||||
* Return a JSONObject of this Font's details
|
||||
*/
|
||||
fun toJson(): JSONObject {
|
||||
val jsonObject = JSONObject()
|
||||
try {
|
||||
// Use abbreviations to make the json smaller
|
||||
jsonObject.put("F", family.replace("\u0000", ""))
|
||||
jsonObject.put("SF", subFamily.replace("\u0000", ""))
|
||||
jsonObject.put("USF", uniqueSubFamily.replace("\u0000", ""))
|
||||
jsonObject.put("FN", fullName.replace("\u0000", ""))
|
||||
jsonObject.put("V", fontVersion.replace("\u0000", ""))
|
||||
jsonObject.put("R", revision)
|
||||
jsonObject.put("C", created)
|
||||
jsonObject.put("M", modified)
|
||||
jsonObject.put("H", hash)
|
||||
jsonObject.put("P", path.replace("\u0000", ""))
|
||||
} catch (_: JSONException) {
|
||||
}
|
||||
return jsonObject
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a font, given via an InputStream, to extract the Font information
|
||||
* including Family, SubFamily, Revision, etc
|
||||
*/
|
||||
object FontParser {
|
||||
/**
|
||||
* Parse a font file and return a FontMetric object describing it
|
||||
*/
|
||||
fun parse(path: String): FontMetric {
|
||||
return parse(path, FileInputStream(path))
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a font file and return a FontMetric object describing it
|
||||
*/
|
||||
fun parse(path: String, inputStream: InputStream): FontMetric {
|
||||
val hash = calculateFileHash(inputStream)
|
||||
val fontDetails = FontMetric(path, hash)
|
||||
|
||||
inputStream.reset()
|
||||
readFontFile(inputStream, fontDetails)
|
||||
|
||||
return fontDetails
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun readFontFile(inputStream: InputStream, fontDetails: FontMetric) {
|
||||
val file = DataInputStream(inputStream)
|
||||
val numFonts: Int
|
||||
val magicNumber = file.readInt()
|
||||
var bytesReadSoFar = 4
|
||||
|
||||
if (magicNumber == 0x74746366) {
|
||||
// The Font File has a TTC Header
|
||||
val majorVersion = file.readUnsignedShort()
|
||||
file.skipBytes(2) // Minor Version
|
||||
numFonts = file.readInt()
|
||||
bytesReadSoFar += 8
|
||||
|
||||
file.skipBytes(4 * numFonts) // OffsetTable
|
||||
bytesReadSoFar += 4 * numFonts
|
||||
if (majorVersion == 2) {
|
||||
file.skipBytes(12)
|
||||
bytesReadSoFar += 12
|
||||
}
|
||||
|
||||
file.skipBytes(4) // Magic Number for the Font
|
||||
bytesReadSoFar += 4
|
||||
}
|
||||
val numTables: Int = file.readUnsignedShort()
|
||||
bytesReadSoFar += 2
|
||||
file.skipBytes(6) // Rest of header
|
||||
bytesReadSoFar += 6
|
||||
|
||||
// Find the head table
|
||||
var headOffset = 0
|
||||
var nameOffset = 0
|
||||
var nameLength = 0
|
||||
for (i in 0 until numTables) {
|
||||
val tableName =
|
||||
CharArray(4) {
|
||||
file.readUnsignedByte().toChar()
|
||||
}
|
||||
file.skipBytes(4) // checksum
|
||||
val offset = file.readInt() // technically it's unsigned but we should be okay
|
||||
val length = file.readInt() // technically it's unsigned but we should be okay
|
||||
|
||||
bytesReadSoFar += 16
|
||||
|
||||
if (String(tableName) == "head") {
|
||||
headOffset = offset
|
||||
} else if (String(tableName) == "name") {
|
||||
nameOffset = offset
|
||||
nameLength = length
|
||||
}
|
||||
}
|
||||
|
||||
if (headOffset == 0 || nameOffset == 0) {
|
||||
throw IOException("Could not find head or name table")
|
||||
}
|
||||
|
||||
if (headOffset < nameOffset) {
|
||||
file.skipBytes(headOffset - bytesReadSoFar)
|
||||
bytesReadSoFar = headOffset
|
||||
bytesReadSoFar += readHeadTable(file, fontDetails)
|
||||
file.skipBytes(nameOffset - bytesReadSoFar)
|
||||
readNameTable(file, nameLength, fontDetails)
|
||||
} else {
|
||||
file.skipBytes(nameOffset - bytesReadSoFar)
|
||||
bytesReadSoFar = nameOffset
|
||||
bytesReadSoFar += readNameTable(file, nameLength, fontDetails)
|
||||
file.skipBytes(headOffset - bytesReadSoFar)
|
||||
readHeadTable(file, fontDetails)
|
||||
}
|
||||
file.close()
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun readHeadTable(file: DataInputStream, fontDetails: FontMetric): Int {
|
||||
// Find the details in the head table
|
||||
file.skipBytes(4) // Fixed version
|
||||
fontDetails.revision = file.readInt()
|
||||
file.skipBytes(12) // checksum, magic, flags, units
|
||||
fontDetails.created = file.readLong()
|
||||
fontDetails.modified = file.readLong()
|
||||
return 36
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private fun readNameTable(
|
||||
file: DataInputStream,
|
||||
tableLength: Int,
|
||||
fontDetails: FontMetric,
|
||||
): Int {
|
||||
file.skipBytes(2) // format
|
||||
val numNames = file.readUnsignedShort()
|
||||
val stringOffset = file.readUnsignedShort()
|
||||
var bytesReadSoFar = 6
|
||||
val nameTable = arrayListOf<Triple<Int, Int, Int>>()
|
||||
|
||||
for (i in 0 until numNames) {
|
||||
file.skipBytes(6) // platform id, encoding id, langid
|
||||
val nameID = file.readUnsignedShort()
|
||||
val length = file.readUnsignedShort()
|
||||
val offset = file.readUnsignedShort()
|
||||
nameTable.add(Triple(nameID, length, offset))
|
||||
bytesReadSoFar += 12
|
||||
}
|
||||
|
||||
val stringTableSize = min(tableLength - bytesReadSoFar, tableLength - stringOffset)
|
||||
val stringTable = ByteArray(stringTableSize)
|
||||
|
||||
if (stringTable.size != file.read(stringTable)) {
|
||||
throw IOException("Did not read entire string table")
|
||||
}
|
||||
|
||||
bytesReadSoFar += stringTable.size
|
||||
|
||||
// Now we're at the beginning of the string table
|
||||
for (i in nameTable) {
|
||||
when (i.first) {
|
||||
1 -> fontDetails.family = getString(stringTable, i.third, i.second)
|
||||
2 -> fontDetails.subFamily = getString(stringTable, i.third, i.second)
|
||||
3 -> fontDetails.uniqueSubFamily = getString(stringTable, i.third, i.second)
|
||||
4 -> fontDetails.fullName = getString(stringTable, i.third, i.second)
|
||||
5 -> fontDetails.fontVersion = getString(stringTable, i.third, i.second)
|
||||
}
|
||||
}
|
||||
return bytesReadSoFar
|
||||
}
|
||||
|
||||
private fun getString(
|
||||
stringTable: ByteArray,
|
||||
offset: Int,
|
||||
length: Int,
|
||||
): String {
|
||||
return String(stringTable.copyOfRange(offset, offset + length))
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the SHA-256 hash of the file passed
|
||||
*/
|
||||
fun calculateFileHash(path: String): String {
|
||||
return calculateFileHash(FileInputStream(path))
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the SHA-256 hash of the InputStream passed
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
private fun calculateFileHash(inputStream: InputStream): String {
|
||||
try {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
val buffer = ByteArray(8192)
|
||||
var bytesRead: Int
|
||||
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
|
||||
md.update(buffer, 0, bytesRead)
|
||||
}
|
||||
val digest = md.digest()
|
||||
// Convert the byte array to a hexadecimal string
|
||||
val hashBuilder = StringBuilder()
|
||||
for (b in digest) {
|
||||
hashBuilder.append(String.format("%02X", b))
|
||||
}
|
||||
return hashBuilder.toString()
|
||||
} catch (_: NoSuchAlgorithmException) {
|
||||
return "sha-256-not-found"
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue