mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-22 01:40:07 +01:00
Add support for OTA emoji download.
This commit is contained in:
committed by
Cody Henthorne
parent
7fa200401c
commit
85e0e74bc6
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
205
app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiFiles.kt
Normal file
205
app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiFiles.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user