|
|
|
|
@ -234,7 +234,7 @@ class AvatarRepository(context: Context) {
|
|
|
|
|
require(!record.failed) { "core rejected stored group avatar" } |
|
|
|
|
val state = GroupAvatarState(record.version, record.hash, record.canEdit, hasImage = record.image.isNotEmpty()) |
|
|
|
|
val bitmap = if (record.image.isEmpty()) null else cache.get(groupKey(group, state)) ?: withContext(Dispatchers.IO) { |
|
|
|
|
decode(record.image, record.hash, true) |
|
|
|
|
decodeGroup(record.image, record.hash) |
|
|
|
|
} |
|
|
|
|
alive(epoch) |
|
|
|
|
if (!_groups.value.containsKey(group)) return@launch |
|
|
|
|
@ -383,30 +383,15 @@ class AvatarRepository(context: Context) {
|
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
private fun readImage(file: File): ByteArray { |
|
|
|
|
val size = file.length() |
|
|
|
|
require(size in 1..AvatarProtocol.MAX_USER) { "invalid cached file size=$size path=$file" } |
|
|
|
|
return file.inputStream().use { input -> |
|
|
|
|
val bytes = ByteArray(size.toInt()); var offset = 0 |
|
|
|
|
while (offset < bytes.size) { |
|
|
|
|
val count = input.read(bytes, offset, bytes.size - offset) |
|
|
|
|
require(count > 0) { "cached file truncated while reading" } |
|
|
|
|
offset += count |
|
|
|
|
} |
|
|
|
|
require(input.read() == -1) { "cached file grew while reading" } |
|
|
|
|
bytes |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/** Проверяем размер, хеш и геометрию до выделения декодированного изображения. */ |
|
|
|
|
private fun decode(bytes: ByteArray, hash: String, group: Boolean): Bitmap { |
|
|
|
|
require(bytes.size in 1..if (group) AvatarProtocol.MAX_GROUP else AvatarProtocol.MAX_USER) { "image byte limit" } |
|
|
|
|
private fun decodeGroup(bytes: ByteArray, hash: String): Bitmap { |
|
|
|
|
require(bytes.size in 1..AvatarProtocol.MAX_GROUP) { "image byte limit" } |
|
|
|
|
require(digest(bytes) == hash) { "image SHA256 mismatch" } |
|
|
|
|
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } |
|
|
|
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) |
|
|
|
|
val edge = if (group) 128 else 512 |
|
|
|
|
val edge = 128 |
|
|
|
|
require(options.outWidth in 1..edge && options.outHeight == options.outWidth && |
|
|
|
|
options.outMimeType == if (group) "image/jpeg" else "image/png") { |
|
|
|
|
options.outMimeType == "image/jpeg") { |
|
|
|
|
"invalid image ${options.outMimeType} ${options.outWidth}x${options.outHeight} limit=$edge" |
|
|
|
|
} |
|
|
|
|
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: throw IOException("image decode failed") |
|
|
|
|
@ -429,7 +414,7 @@ class AvatarRepository(context: Context) {
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fun editGroup(group: String, uri: Uri?, expectedAccount: Long) = edit("group:$group", expectedAccount) { epoch -> |
|
|
|
|
val bytes = if (uri == null) ByteArray(0) else prepare(uri, true) |
|
|
|
|
val bytes = if (uri == null) ByteArray(0) else prepareGroup(uri) |
|
|
|
|
alive(epoch) |
|
|
|
|
require(editTokens["group:$group"] == epoch) { "group edit superseded" } |
|
|
|
|
val reply = AvatarProtocol.published(request(52, group) { id, gid -> NativeLib.setGroupAvatar(id, gid, bytes) }) |
|
|
|
|
@ -466,14 +451,14 @@ class AvatarRepository(context: Context) {
|
|
|
|
|
if (account == 0L) start() |
|
|
|
|
if (expectedAccount == 0L || expectedAccount != account) { |
|
|
|
|
log("WARN", "Ignored stale picker result target=$key expected=${AvatarProtocol.hex(expectedAccount)} actual=${AvatarProtocol.hex(account)}") |
|
|
|
|
rejectedSource?.let { file -> scope.launch(Dispatchers.IO) { |
|
|
|
|
rejectedSource?.let { file -> scope.launch(Dispatchers.IO+NonCancellable) { |
|
|
|
|
if (!file.delete()) log("WARN", "Cannot remove stale editor source=$file") |
|
|
|
|
} } |
|
|
|
|
return |
|
|
|
|
} |
|
|
|
|
if (account == 0L || _edits.value[key] == AvatarEditState.WORKING) { |
|
|
|
|
log("WARN", "Rejected concurrent avatar edit target=$key") |
|
|
|
|
rejectedSource?.let { file -> scope.launch(Dispatchers.IO) { |
|
|
|
|
rejectedSource?.let { file -> scope.launch(Dispatchers.IO+NonCancellable) { |
|
|
|
|
if (!file.delete()) log("WARN", "Cannot remove rejected editor source=$file") |
|
|
|
|
} } |
|
|
|
|
return |
|
|
|
|
@ -551,7 +536,7 @@ class AvatarRepository(context: Context) {
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/** Чтение URI, EXIF, crop и кодирование выполняются вне UI и потока ядра. */ |
|
|
|
|
private suspend fun prepare(uri: Uri, group: Boolean): ByteArray = preparations.withPermit { withContext(Dispatchers.IO) { |
|
|
|
|
private suspend fun prepareGroup(uri: Uri): ByteArray = preparations.withPermit { withContext(Dispatchers.IO) { |
|
|
|
|
val temp = File.createTempFile("avatar-source-", ".image", app.cacheDir) |
|
|
|
|
try { |
|
|
|
|
app.contentResolver.openInputStream(uri)?.use { input -> |
|
|
|
|
@ -571,15 +556,15 @@ class AvatarRepository(context: Context) {
|
|
|
|
|
val decoded = PhotoUtil.decodeOriented(temp.path, sample) ?: throw IOException("source decode failed") |
|
|
|
|
val edge = minOf(decoded.width, decoded.height) |
|
|
|
|
val crop = Bitmap.createBitmap(decoded, (decoded.width - edge) / 2, (decoded.height - edge) / 2, edge, edge) |
|
|
|
|
val scaled = Bitmap.createScaledBitmap(crop, if (group) 128 else 256, if (group) 128 else 256, true) |
|
|
|
|
val scaled = Bitmap.createScaledBitmap(crop,128,128,true) |
|
|
|
|
try { |
|
|
|
|
for (quality in if (group) listOf(85, 70, 50, 35, 20) else listOf(100)) { |
|
|
|
|
for (quality in listOf(85,70,50,35,20)) { |
|
|
|
|
val output = java.io.ByteArrayOutputStream() |
|
|
|
|
require(scaled.compress(if (group) Bitmap.CompressFormat.JPEG else Bitmap.CompressFormat.PNG, quality, output)) { |
|
|
|
|
require(scaled.compress(Bitmap.CompressFormat.JPEG,quality,output)) { |
|
|
|
|
"avatar compression failed" |
|
|
|
|
} |
|
|
|
|
val bytes = output.toByteArray() |
|
|
|
|
if (bytes.size <= if (group) AvatarProtocol.MAX_GROUP else AvatarProtocol.MAX_USER) return@withContext bytes |
|
|
|
|
if (bytes.size<=AvatarProtocol.MAX_GROUP) return@withContext bytes |
|
|
|
|
} |
|
|
|
|
throw IOException("encoded avatar exceeds limit") |
|
|
|
|
} finally { |
|
|
|
|
|