Add support for OTA emoji download.

This commit is contained in:
Alex Hart
2021-04-19 10:36:33 -03:00
committed by Cody Henthorne
parent 7fa200401c
commit 85e0e74bc6
55 changed files with 1653 additions and 621 deletions

View File

@@ -0,0 +1,23 @@
package org.thoughtcrime.securesms.emoji
import androidx.annotation.AttrRes
import org.thoughtcrime.securesms.R
/**
* All the different Emoji categories the app is aware of in the order we want to display them.
*/
enum class EmojiCategory(val priority: Int, val key: String, @AttrRes val icon: Int) {
PEOPLE(0, "People", R.attr.emoji_category_people),
NATURE(1, "Nature", R.attr.emoji_category_nature),
FOODS(2, "Foods", R.attr.emoji_category_foods),
ACTIVITY(3, "Activity", R.attr.emoji_category_activity),
PLACES(4, "Places", R.attr.emoji_category_places),
OBJECTS(5, "Objects", R.attr.emoji_category_objects),
SYMBOLS(6, "Symbols", R.attr.emoji_category_symbols),
FLAGS(7, "Flags", R.attr.emoji_category_flags),
EMOTICONS(8, "Emoticons", R.attr.emoji_category_emoticons);
companion object {
fun forKey(key: String) = values().first { it.key == key }
}
}

View File

@@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.emoji
import android.content.Context
import android.content.Intent
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.service.PersistentAlarmManagerListener
import java.util.concurrent.TimeUnit
private val INTERVAL_WITHOUT_REMOTE_DOWNLOAD = TimeUnit.DAYS.toMillis(1)
private val INTERVAL_WITH_REMOTE_DOWNLOAD = TimeUnit.DAYS.toMillis(7)
class EmojiDownloadListener : PersistentAlarmManagerListener() {
override fun getNextScheduledExecutionTime(context: Context): Long = SignalStore.emojiValues().nextScheduledCheck
override fun onAlarm(context: Context, scheduledTime: Long): Long {
ApplicationDependencies.getJobManager().add(DownloadLatestEmojiDataJob(false))
val nextTime: Long = System.currentTimeMillis() + if (EmojiFiles.Version.exists(context)) INTERVAL_WITH_REMOTE_DOWNLOAD else INTERVAL_WITHOUT_REMOTE_DOWNLOAD
SignalStore.emojiValues().nextScheduledCheck = nextTime
return nextTime
}
companion object {
@JvmStatic
fun schedule(context: Context) {
EmojiDownloadListener().onReceive(context, Intent())
}
}
}

View File

@@ -0,0 +1,205 @@
package org.thoughtcrime.securesms.emoji
import android.content.Context
import android.net.Uri
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
import okio.Okio
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream
import org.thoughtcrime.securesms.mms.PartAuthority
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.lang.Exception
import java.util.UUID
/**
* File structure:
* <p>
* emoji/
* .version -- Contains MD5 hash of current version plus a uuid mapping
* `uuid`/ -- Directory for a specific MD5hash underneath which all the data lives.
* | .names -- Contains name mappings for downloaded files. When a file finishes downloading, we create a random UUID name for it and add it to .names
* | `uuid1`
* | `uuid2`
* | ...
* <p>
* .version format:
* <p>
* {"version": ..., "uuid": "..."}
* <p>
* .name format:
* [
* {"name": "...", "uuid": "..."}
* ]
*/
private const val TAG = "EmojiFiles"
private const val EMOJI_DIRECTORY = "emoji"
private const val VERSION_FILE = ".version"
private const val NAME_FILE = ".names"
private const val EMOJI_JSON = "emoji_data.json"
private fun Context.getEmojiDirectory(): File = getDir(EMOJI_DIRECTORY, Context.MODE_PRIVATE)
private fun Context.getVersionFile(): File = File(getEmojiDirectory(), VERSION_FILE)
private fun Context.getNameFile(versionUuid: UUID): File = File(File(getEmojiDirectory(), versionUuid.toString()).apply { mkdir() }, NAME_FILE)
private fun getFilesUri(name: String, format: String): Uri = PartAuthority.getEmojiUri(name)
private fun getOutputStream(context: Context, outputFile: File): OutputStream {
val attachmentSecret = AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret
return ModernEncryptingPartOutputStream.createFor(attachmentSecret, outputFile, true).second
}
private fun getInputStream(context: Context, inputFile: File): InputStream {
val attachmentSecret = AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret
return ModernDecryptingPartInputStream.createFor(attachmentSecret, inputFile, 0)
}
object EmojiFiles {
@JvmStatic
fun getBaseDirectory(context: Context): File = context.getEmojiDirectory()
@JvmStatic
fun delete(context: Context, version: Version, uuid: UUID) {
try {
version.getFile(context, uuid).delete()
} catch (e: IOException) {
Log.i(TAG, "Failed to delete file.")
}
}
@JvmStatic
fun openForReading(context: Context, name: String): InputStream {
val version: Version = Version.readVersion(context) ?: throw IOException("No emoji version is present on disk")
val names: NameCollection = NameCollection.read(context, version)
val dataUuid: UUID = names.getUUIDForName(name) ?: throw IOException("Could not get UUID for name $name")
val file: File = version.getFile(context, dataUuid)
return getInputStream(context, file)
}
@JvmStatic
fun openForWriting(context: Context, version: Version, uuid: UUID): OutputStream {
return getOutputStream(context, version.getFile(context, uuid))
}
@JvmStatic
fun getMd5(context: Context, version: Version, uuid: UUID): ByteArray? {
val file = version.getFile(context, uuid)
try {
getInputStream(context, file).use {
return Okio.buffer(Okio.source(it)).buffer.md5().toByteArray()
}
} catch (e: Exception) {
Log.i(TAG, "Could not read emoji data file md5", e)
return null
}
}
@JvmStatic
fun getLatestEmojiData(context: Context, version: Version): EmojiData? {
val names = NameCollection.read(context, version)
val dataUuid = names.getUUIDForEmojiData() ?: return null
val file = version.getFile(context, dataUuid)
getInputStream(context, file).use {
return EmojiJsonParser.parse(it, ::getFilesUri).getOrElse { throwable ->
Log.w(TAG, "Failed to parse emoji_data", throwable)
null
}
}
}
class Version(@JsonProperty val version: Int, @JsonProperty val uuid: UUID, @JsonProperty val density: String) {
fun getFile(context: Context, uuid: UUID): File = File(getDirectory(context), uuid.toString())
private fun getDirectory(context: Context): File = File(context.getEmojiDirectory(), this.uuid.toString()).apply { mkdir() }
companion object {
private val objectMapper = ObjectMapper().registerKotlinModule()
@JvmStatic
fun exists(context: Context): Boolean = context.getVersionFile().exists()
@JvmStatic
fun readVersion(context: Context): Version? {
try {
getInputStream(context, context.getVersionFile()).use {
return objectMapper.readValue(it, Version::class.java)
}
} catch (e: Exception) {
Log.w(TAG, "Could not read current emoji version from disk.")
return null
}
}
@JvmStatic
fun writeVersion(context: Context, version: Version) {
val versionFile: File = context.getVersionFile()
try {
if (versionFile.exists()) {
versionFile.delete()
}
getOutputStream(context, versionFile).use {
objectMapper.writeValue(it, version)
}
} catch (e: Exception) {
Log.w(TAG, "Could not write current emoji version from disk.")
}
}
}
}
class Name(@JsonProperty val name: String, @JsonProperty val uuid: UUID) {
companion object {
@JvmStatic
fun forEmojiDataJson(): Name = Name(EMOJI_JSON, UUID.randomUUID())
}
}
class NameCollection(@JsonProperty val versionUuid: UUID, @JsonProperty val names: List<Name>) {
companion object {
private val objectMapper = ObjectMapper().registerKotlinModule()
@JvmStatic
fun read(context: Context, version: Version): NameCollection {
try {
getInputStream(context, context.getNameFile(version.uuid)).use {
return objectMapper.readValue(it)
}
} catch (e: Exception) {
return NameCollection(version.uuid, listOf())
}
}
@JvmStatic
fun append(context: Context, nameCollection: NameCollection, name: Name): NameCollection {
val collection = NameCollection(nameCollection.versionUuid, nameCollection.names + name)
getOutputStream(context, context.getNameFile(nameCollection.versionUuid)).use {
objectMapper.writeValue(it, collection)
}
return collection
}
}
@JsonIgnore
fun getUUIDForEmojiData(): UUID? = getUUIDForName(EMOJI_JSON)
@JsonIgnore
fun getUUIDForName(name: String): UUID? = names.firstOrNull { it.name == name }?.uuid
}
}

View File

@@ -0,0 +1,115 @@
package org.thoughtcrime.securesms.emoji
import android.net.Uri
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.google.android.gms.common.util.Hex
import org.thoughtcrime.securesms.components.emoji.CompositeEmojiPageModel
import org.thoughtcrime.securesms.components.emoji.Emoji
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
import org.thoughtcrime.securesms.components.emoji.StaticEmojiPageModel
import java.io.InputStream
import java.nio.charset.Charset
typealias UriFactory = (sprite: String, format: String) -> Uri
/**
* Takes an emoji_data.json file data and parses it into an EmojiSource
*/
object EmojiJsonParser {
private val OBJECT_MAPPER = ObjectMapper()
@JvmStatic
fun verify(body: InputStream) {
parse(body) { _, _ -> Uri.EMPTY }.getOrThrow()
}
fun parse(body: InputStream, uriFactory: UriFactory): Result<ParsedEmojiData> {
return try {
Result.success(buildEmojiSourceFromNode(OBJECT_MAPPER.readTree(body), uriFactory))
} catch (e: Exception) {
Result.failure(e)
}
}
private fun buildEmojiSourceFromNode(node: JsonNode, uriFactory: UriFactory): ParsedEmojiData {
val format: String = node["format"].textValue()
val obsolete: List<ObsoleteEmoji> = node["obsolete"].toObseleteList()
val dataPages: List<EmojiPageModel> = getDataPages(format, node["emoji"], uriFactory)
val displayPages: List<EmojiPageModel> = mergeToDisplayPages(dataPages)
val metrics: EmojiMetrics = node["metrics"].toEmojiMetrics()
val densities: List<String> = node["densities"].toDensityList()
return ParsedEmojiData(metrics, densities, format, displayPages, dataPages, obsolete)
}
private fun getDataPages(format: String, emoji: JsonNode, uriFactory: UriFactory): List<EmojiPageModel> {
return emoji.fields()
.asSequence()
.sortedWith { lhs, rhs ->
val lhsCategory = EmojiCategory.forKey(lhs.key.asCategoryKey())
val rhsCategory = EmojiCategory.forKey(rhs.key.asCategoryKey())
val comp = lhsCategory.priority.compareTo(rhsCategory.priority)
if (comp == 0) {
val lhsIndex = lhs.key.getPageIndex()
val rhsIndex = rhs.key.getPageIndex()
lhsIndex.compareTo(rhsIndex)
} else {
comp
}
}
.map { createPage(it.key, format, it.value, uriFactory) }
.toList()
}
private fun createPage(pageName: String, format: String, page: JsonNode, uriFactory: UriFactory): EmojiPageModel {
val category = EmojiCategory.forKey(pageName.asCategoryKey())
val pageList = page.mapIndexed { i, data ->
if (data.size() == 0) {
throw IllegalStateException("Page index $pageName.$i had no data")
} else {
Emoji(data.map { it.textValue().encodeAsUtf16() })
}
}
return StaticEmojiPageModel(category.icon, pageList, uriFactory(pageName, format))
}
private fun mergeToDisplayPages(dataPages: List<EmojiPageModel>): List<EmojiPageModel> {
return dataPages.groupBy { it.iconAttr }
.map { (icon, pages) -> if (pages.size <= 1) pages.first() else CompositeEmojiPageModel(icon, pages) }
}
}
private fun JsonNode?.toObseleteList(): List<ObsoleteEmoji> {
return if (this == null) {
listOf()
} else {
map { node ->
ObsoleteEmoji(node["obsoleted"].textValue().encodeAsUtf16(), node["replace_with"].textValue().encodeAsUtf16())
}.toList()
}
}
private fun JsonNode.toEmojiMetrics(): EmojiMetrics {
return EmojiMetrics(this["raw_width"].asInt(), this["raw_height"].asInt(), this["per_row"].asInt())
}
private fun JsonNode.toDensityList(): List<String> {
return map { it.textValue() }
}
private fun String.encodeAsUtf16() = String(Hex.stringToBytes(this), Charset.forName("UTF-16"))
private fun String.asCategoryKey() = replace("(_\\d+)*$".toRegex(), "")
private fun String.getPageIndex() = "^.*_(\\d+)+$".toRegex().find(this)?.let { it.groupValues[1] }?.toInt() ?: throw IllegalStateException("No index.")
data class ParsedEmojiData(
override val metrics: EmojiMetrics,
override val densities: List<String>,
override val format: String,
override val displayPages: List<EmojiPageModel>,
override val dataPages: List<EmojiPageModel>,
override val obsolete: List<ObsoleteEmoji>
) : EmojiData

View File

@@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.emoji
import android.net.Uri
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
/**
* Used by Emoji provider to set up a glide request.
*/
class EmojiPageReference {
val model: Any
constructor(uri: Uri) {
model = uri
}
constructor(decryptableUri: DecryptableStreamUriLoader.DecryptableUri) {
model = decryptableUri
}
}
typealias EmojiPageReferenceFactory = (uri: Uri) -> EmojiPageReference

View File

@@ -0,0 +1,95 @@
package org.thoughtcrime.securesms.emoji
import okhttp3.Request
import okhttp3.Response
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import java.io.IOException
private const val VERSION_URL = "https://updates.signal.org/dynamic/android/emoji/version.txt"
private const val BASE_STATIC_BUCKET_URL = "https://updates.signal.org/static/android/emoji"
/**
* Responsible for communicating with S3 to download Emoji related objects.
*/
object EmojiRemote {
private const val TAG = "EmojiRemote"
private val okHttpClient = ApplicationDependencies.getOkHttpClient()
@JvmStatic
@Throws(IOException::class)
fun getVersion(): Int {
val request = Request.Builder()
.get()
.url(VERSION_URL)
.build()
try {
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException()
}
return response.body()?.bytes()?.let { String(it).trim().toIntOrNull() } ?: throw IOException()
}
} catch (e: IOException) {
throw e
}
}
/**
* Downloads and returns the MD5 hash stored in an S3 object's ETag
*/
@JvmStatic
fun getMd5(emojiRequest: EmojiRequest): ByteArray? {
val request = Request.Builder()
.head()
.url(emojiRequest.url)
.build()
try {
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException()
}
return response.header("ETag")?.toByteArray()
}
} catch (e: IOException) {
Log.w(TAG, "Could not retrieve md5", e)
return null
}
}
/**
* Downloads an object for the specified name.
*/
@JvmStatic
fun getObject(emojiRequest: EmojiRequest): Response {
val request = Request.Builder()
.get()
.url(emojiRequest.url)
.build()
return okHttpClient.newCall(request).execute()
}
}
interface EmojiRequest {
val url: String
}
class EmojiJsonRequest(version: Int) : EmojiRequest {
override val url: String = "$BASE_STATIC_BUCKET_URL/$version/emoji_data.json"
}
class EmojiImageRequest(
version: Int,
density: String,
name: String,
format: String
) : EmojiRequest {
override val url: String = "$BASE_STATIC_BUCKET_URL/$version/$density/$name.$format"
}

View File

@@ -0,0 +1,159 @@
package org.thoughtcrime.securesms.emoji
import android.net.Uri
import androidx.annotation.WorkerThread
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.Emoji
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
import org.thoughtcrime.securesms.components.emoji.StaticEmojiPageModel
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiDrawInfo
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiTree
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
import org.thoughtcrime.securesms.util.ScreenDensity
import java.io.InputStream
import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicReference
import kotlin.math.min
/**
* The entry point for the application to request Emoji data for custom emojis.
*/
class EmojiSource(
val decodeScale: Float,
private val emojiData: EmojiData,
private val emojiPageReferenceFactory: EmojiPageReferenceFactory
) : EmojiData by emojiData {
val variationMap: Map<String, String> by lazy {
val map = mutableMapOf<String, String>()
for (page: EmojiPageModel in dataPages) {
for (emoji: Emoji in page.displayEmoji) {
for (variation: String in emoji.variations) {
map[variation] = emoji.value
}
}
}
map
}
val maxEmojiLength: Int by lazy {
dataPages.map { it.emoji.map(String::length) }
.flatten()
.maxOrZero()
}
val emojiTree: EmojiTree by lazy {
val tree = EmojiTree()
dataPages
.filter { it.spriteUri != null }
.forEach { page ->
val reference = emojiPageReferenceFactory(page.spriteUri!!)
page.emoji.forEachIndexed { idx, emoji ->
tree.add(emoji, EmojiDrawInfo(reference, idx))
}
}
obsolete.forEach {
tree.add(it.obsolete, tree.getEmoji(it.replaceWith, 0, it.replaceWith.length))
}
tree
}
companion object {
private val emojiSource = AtomicReference<EmojiSource>()
private val emojiLatch = CountDownLatch(1)
@JvmStatic
val latest: EmojiSource
get() {
emojiLatch.await()
return emojiSource.get()
}
@JvmStatic
@WorkerThread
fun refresh() {
emojiSource.set(getEmojiSource())
emojiLatch.countDown()
}
private fun getEmojiSource(): EmojiSource {
return loadRemoteBasedEmojis() ?: loadAssetBasedEmojis()
}
private fun loadRemoteBasedEmojis(): EmojiSource? {
val context = ApplicationDependencies.getApplication()
val version = EmojiFiles.Version.readVersion(context) ?: return null
val emojiData = EmojiFiles.getLatestEmojiData(context, version)
val density = ScreenDensity.xhdpiRelativeDensityScaleFactor(version.density)
return emojiData?.let {
val decodeScale = min(1f, context.resources.getDimension(R.dimen.emoji_drawer_size) / it.metrics.rawHeight)
EmojiSource(decodeScale * density, it) { uri: Uri -> EmojiPageReference(DecryptableStreamUriLoader.DecryptableUri(uri)) }
}
}
private fun loadAssetBasedEmojis(): EmojiSource {
val context = ApplicationDependencies.getApplication()
val emojiData: InputStream = ApplicationDependencies.getApplication().assets.open("emoji/emoji_data.json")
emojiData.use {
val parsedData: ParsedEmojiData = EmojiJsonParser.parse(it, ::getAssetsUri).getOrThrow()
val decodeScale = min(1f, context.resources.getDimension(R.dimen.emoji_drawer_size) / parsedData.metrics.rawHeight)
return EmojiSource(
decodeScale * ScreenDensity.xhdpiRelativeDensityScaleFactor("xhdpi"),
parsedData.copy(
displayPages = parsedData.displayPages + PAGE_EMOTICONS,
dataPages = parsedData.dataPages + PAGE_EMOTICONS
)
) { uri: Uri -> EmojiPageReference(uri) }
}
}
}
}
private fun List<Int>.maxOrZero(): Int = maxOrNull() ?: 0
interface EmojiData {
val metrics: EmojiMetrics
val densities: List<String>
val format: String
val displayPages: List<EmojiPageModel>
val dataPages: List<EmojiPageModel>
val obsolete: List<ObsoleteEmoji>
}
data class ObsoleteEmoji(val obsolete: String, val replaceWith: String)
data class EmojiMetrics(val rawHeight: Int, val rawWidth: Int, val perRow: Int)
private fun getAssetsUri(name: String, format: String): Uri = Uri.parse("file:///android_asset/emoji/$name.$format")
private val PAGE_EMOTICONS: EmojiPageModel = StaticEmojiPageModel(
EmojiCategory.EMOTICONS.icon,
arrayOf(
":-)", ";-)", "(-:", ":->", ":-D", "\\o/",
":-P", "B-)", ":-$", ":-*", "O:-)", "=-O",
"O_O", "O_o", "o_O", ":O", ":-!", ":-x",
":-|", ":-\\", ":-(", ":'(", ":-[", ">:-(",
"^.^", "^_^", "\\(\u02c6\u02da\u02c6)/",
"\u30fd(\u00b0\u25c7\u00b0 )\u30ce", "\u00af\\(\u00b0_o)/\u00af",
"\u00af\\_(\u30c4)_/\u00af", "(\u00ac_\u00ac)",
"(>_<)", "(\u2565\ufe4f\u2565)", "(\u261e\uff9f\u30ee\uff9f)\u261e",
"\u261c(\uff9f\u30ee\uff9f\u261c)", "\u261c(\u2312\u25bd\u2312)\u261e",
"(\u256f\u00b0\u25a1\u00b0)\u256f\ufe35", "\u253b\u2501\u253b",
"\u252c\u2500\u252c", "\u30ce(\u00b0\u2013\u00b0\u30ce)",
"(^._.^)\uff89", "\u0e05^\u2022\ufecc\u2022^\u0e05",
"\u0295\u2022\u1d25\u2022\u0294", "(\u2022_\u2022)",
" \u25a0-\u25a0\u00ac <(\u2022_\u2022) ", "(\u25a0_\u25a0\u00ac)",
"\u01aa(\u0693\u05f2)\u200e\u01aa\u200b\u200b"
),
null
)