Signal-Android/app/src/main/java/org/thoughtcrime/securesms/emoji/EmojiFiles.kt

238 wiersze
7.8 KiB
Kotlin

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.HashingSink
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.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)
@Suppress("UNUSED_PARAMETER")
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 {
HashingSink.md5(Okio.blackhole()).use { hashingSink ->
Okio.buffer(Okio.source(getInputStream(context, file))).use { source ->
source.readAll(hashingSink)
return hashingSink.hash().toByteArray()
}
}
} catch (e: Exception) {
Log.i(TAG, "Could not read emoji data file md5", e)
return null
}
}
@JvmStatic
fun getLatestEmojiData(context: Context, version: Version): ParsedEmojiData? {
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 readVersion(context: Context): Version? {
val version = try {
getInputStream(context, context.getVersionFile()).use {
objectMapper.readValue(it, Version::class.java)
}
} catch (e: Exception) {
Log.w(TAG, "Could not read current emoji version from disk.")
null
}
return if (isVersionValid(context, version)) {
version
} else {
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.")
}
}
@JvmStatic
fun isVersionValid(context: Context, version: Version?): Boolean {
if (version == null) {
Log.d(TAG, "Version does not exist.")
return false
}
val nameCollection = NameCollection.read(context, version)
return if (nameCollection.names.isEmpty()) {
Log.d(TAG, "NameCollection file is empty.")
false
} else {
Log.d(TAG, "Verifying all name files exist.")
val allNamesExist = nameCollection.names
.map { version.getFile(context, it.uuid) }
.all { it.exists() }
Log.d(TAG, "All names exist? $allNamesExist")
allNamesExist
}
}
}
}
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
}
}