diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7913b5c0f2..1b1b5fbfbe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -613,6 +613,7 @@ dependencies { implementation(project(":core:models-jvm")) implementation(project(":feature:camera")) implementation(project(":feature:registration")) + implementation(project(":lib:apng")) implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.appcompat) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt index 95cc225fe3..1f378e19aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt @@ -12,4 +12,5 @@ sealed interface LabsSettingsEvents { data class ToggleGroupSuggestionsForMembers(val enabled: Boolean) : LabsSettingsEvents data class ToggleBetterSearch(val enabled: Boolean) : LabsSettingsEvents data class ToggleAutoLowerHand(val enabled: Boolean) : LabsSettingsEvents + data class ToggleNewApngRenderer(val enabled: Boolean) : LabsSettingsEvents } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt index eef2cd82ba..fc4d63c10c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt @@ -142,6 +142,15 @@ private fun LabsSettingsContent( onCheckChanged = { onEvent(LabsSettingsEvents.ToggleAutoLowerHand(it)) } ) } + + item { + Rows.ToggleRow( + checked = state.newApngRenderer, + text = "New APNG Renderer", + label = "Use the new custom APNG renderer instead of the existing third-party library. Requires an app restart to take effect.", + onCheckChanged = { onEvent(LabsSettingsEvents.ToggleNewApngRenderer(it)) } + ) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt index 26d3af9eaa..d0ba8be66c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt @@ -14,5 +14,6 @@ data class LabsSettingsState( val incognito: Boolean = false, val groupSuggestionsForMembers: Boolean = false, val betterSearch: Boolean = false, - val autoLowerHand: Boolean = false + val autoLowerHand: Boolean = false, + val newApngRenderer: Boolean = false ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt index 7af5cf9cf2..726373e6f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt @@ -41,6 +41,10 @@ class LabsSettingsViewModel : ViewModel() { SignalStore.labs.autoLowerHand = event.enabled _state.value = _state.value.copy(autoLowerHand = event.enabled) } + is LabsSettingsEvents.ToggleNewApngRenderer -> { + SignalStore.labs.newApngRenderer = event.enabled + _state.value = _state.value.copy(newApngRenderer = event.enabled) + } } } @@ -51,7 +55,8 @@ class LabsSettingsViewModel : ViewModel() { incognito = SignalStore.labs.incognito, groupSuggestionsForMembers = SignalStore.labs.groupSuggestionsForMembers, betterSearch = SignalStore.labs.betterSearch, - autoLowerHand = SignalStore.labs.autoLowerHand + autoLowerHand = SignalStore.labs.autoLowerHand, + newApngRenderer = SignalStore.labs.newApngRenderer ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/SignalGlideComponents.java b/app/src/main/java/org/thoughtcrime/securesms/glide/SignalGlideComponents.java index b1ac9ebe57..0b8f80fde9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/glide/SignalGlideComponents.java +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/SignalGlideComponents.java @@ -16,11 +16,12 @@ import com.bumptech.glide.load.resource.gif.ByteBufferGifDecoder; import com.bumptech.glide.load.resource.gif.GifDrawable; import com.bumptech.glide.load.resource.gif.StreamGifDecoder; +import org.signal.apng.ApngDecoder; import org.signal.blurhash.BlurHash; +import org.signal.glide.load.resource.apng.decode.APNGDecoder; import org.signal.glide.blurhash.BlurHashModelLoader; import org.signal.glide.blurhash.BlurHashResourceDecoder; import org.signal.glide.common.io.InputStreamFactory; -import org.signal.glide.load.resource.apng.decode.APNGDecoder; import org.thoughtcrime.securesms.badges.load.BadgeLoader; import org.thoughtcrime.securesms.badges.load.GiftBadgeModel; import org.thoughtcrime.securesms.badges.models.Badge; @@ -29,9 +30,13 @@ import org.thoughtcrime.securesms.contacts.avatars.ContactPhotoLoader; import org.thoughtcrime.securesms.crypto.AttachmentSecret; import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; import org.thoughtcrime.securesms.giph.model.ChunkedImageUrl; +import org.thoughtcrime.securesms.glide.cache.ApngDrawableTranscoder; import org.thoughtcrime.securesms.glide.cache.ApngFrameDrawableTranscoder; +import org.thoughtcrime.securesms.glide.cache.ApngInputStreamFactoryResourceDecoder; +import org.thoughtcrime.securesms.glide.cache.ApngInputStreamResourceDecoder; import org.thoughtcrime.securesms.glide.cache.ByteBufferApngDecoder; import org.thoughtcrime.securesms.glide.cache.EncryptedApngCacheEncoder; +import org.thoughtcrime.securesms.glide.cache.EncryptedApngResourceEncoder; import org.thoughtcrime.securesms.glide.cache.EncryptedBitmapResourceEncoder; import org.thoughtcrime.securesms.glide.cache.EncryptedCacheDecoder; import org.thoughtcrime.securesms.glide.cache.EncryptedCacheEncoder; @@ -46,6 +51,7 @@ import org.signal.glide.decryptableuri.DecryptableUri; import org.signal.glide.decryptableuri.DecryptableUriStreamLoader; import org.thoughtcrime.securesms.mms.RegisterGlideComponents; import org.thoughtcrime.securesms.mms.SignalGlideModule; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.stickers.StickerRemoteUri; import org.thoughtcrime.securesms.stickers.StickerRemoteUriLoader; import org.thoughtcrime.securesms.stories.StoryTextPostModel; @@ -85,16 +91,25 @@ public class SignalGlideComponents implements RegisterGlideComponents { registry.prepend(Bitmap.class, new EncryptedBitmapResourceEncoder(secret)); registry.prepend(BitmapDrawable.class, new BitmapDrawableEncoder(glide.getBitmapPool(), encryptedBitmapResourceEncoder)); - ByteBufferApngDecoder byteBufferApngDecoder = new ByteBufferApngDecoder(); - StreamApngDecoder streamApngDecoder = new StreamApngDecoder(byteBufferApngDecoder); - StreamFactoryApngDecoder streamFactoryApngDecoder = new StreamFactoryApngDecoder(byteBufferApngDecoder, glide, registry); - registry.prepend(InputStream.class, APNGDecoder.class, streamApngDecoder); - registry.prepend(InputStreamFactory.class, APNGDecoder.class, streamFactoryApngDecoder); - registry.prepend(ByteBuffer.class, APNGDecoder.class, byteBufferApngDecoder); - registry.prepend(APNGDecoder.class, new EncryptedApngCacheEncoder(secret)); - registry.prepend(File.class, APNGDecoder.class, new EncryptedCacheDecoder<>(secret, streamApngDecoder)); - registry.register(APNGDecoder.class, Drawable.class, new ApngFrameDrawableTranscoder()); + if (SignalStore.labs().getNewApngRenderer()) { + registry.prepend(InputStream.class, ApngDecoder.class, new ApngInputStreamResourceDecoder()); + registry.prepend(InputStreamFactory.class, ApngDecoder.class, new ApngInputStreamFactoryResourceDecoder()); + registry.prepend(ApngDecoder.class, new EncryptedApngResourceEncoder(secret)); + registry.prepend(File.class, ApngDecoder.class, new EncryptedCacheDecoder<>(secret, new ApngInputStreamResourceDecoder())); + registry.register(ApngDecoder.class, Drawable.class, new ApngDrawableTranscoder()); + } else { + ByteBufferApngDecoder byteBufferApngDecoder = new ByteBufferApngDecoder(); + StreamApngDecoder streamApngDecoder = new StreamApngDecoder(byteBufferApngDecoder); + StreamFactoryApngDecoder streamFactoryApngDecoder = new StreamFactoryApngDecoder(byteBufferApngDecoder, glide, registry); + + registry.prepend(InputStream.class, APNGDecoder.class, streamApngDecoder); + registry.prepend(InputStreamFactory.class, APNGDecoder.class, streamFactoryApngDecoder); + registry.prepend(ByteBuffer.class, APNGDecoder.class, byteBufferApngDecoder); + registry.prepend(APNGDecoder.class, new EncryptedApngCacheEncoder(secret)); + registry.prepend(File.class, APNGDecoder.class, new EncryptedCacheDecoder<>(secret, streamApngDecoder)); + registry.register(APNGDecoder.class, Drawable.class, new ApngFrameDrawableTranscoder()); + } registry.prepend(BlurHash.class, Bitmap.class, new BlurHashResourceDecoder()); registry.prepend(StoryTextPostModel.class, Bitmap.class, new StoryTextPostModel.Decoder()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngDrawableTranscoder.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngDrawableTranscoder.kt new file mode 100644 index 0000000000..8c24cfd16f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngDrawableTranscoder.kt @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.glide.cache + +import android.graphics.drawable.Drawable +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.engine.Resource +import com.bumptech.glide.load.resource.drawable.DrawableResource +import com.bumptech.glide.load.resource.transcode.ResourceTranscoder +import org.signal.apng.ApngDecoder +import org.signal.apng.ApngDrawable + +class ApngDrawableTranscoder : ResourceTranscoder { + override fun transcode(toTranscode: Resource, options: Options): Resource { + val decoder = toTranscode.get() + val drawable = ApngDrawable(decoder).apply { + loopForever = true + } + + return object : DrawableResource(drawable) { + override fun getResourceClass(): Class = Drawable::class.java + + override fun getSize(): Int = 0 + + override fun recycle() { + (get() as ApngDrawable).recycle() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngInputStreamFactoryResourceDecoder.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngInputStreamFactoryResourceDecoder.kt new file mode 100644 index 0000000000..52ee9d90c4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngInputStreamFactoryResourceDecoder.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.glide.cache + +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.ResourceDecoder +import com.bumptech.glide.load.engine.Resource +import org.signal.apng.ApngDecoder +import org.signal.core.util.readFully +import org.signal.glide.apng.ApngOptions +import org.signal.glide.common.io.InputStreamFactory +import java.io.ByteArrayInputStream +import java.io.IOException + +class ApngInputStreamFactoryResourceDecoder : ResourceDecoder { + + override fun handles(source: InputStreamFactory, options: Options): Boolean { + return if (options.get(ApngOptions.ANIMATE) == true) { + ApngDecoder.isApng(source.create()) + } else { + false + } + } + + @Throws(IOException::class) + override fun decode(source: InputStreamFactory, width: Int, height: Int, options: Options): Resource? { + val data: ByteArray = source.create().readFully() + val decoder = ApngDecoder(ByteArrayInputStream(data)) + return ApngResource(decoder, data.size) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngInputStreamResourceDecoder.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngInputStreamResourceDecoder.kt new file mode 100644 index 0000000000..0a80526155 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngInputStreamResourceDecoder.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.glide.cache + +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.ResourceDecoder +import com.bumptech.glide.load.engine.Resource +import org.signal.apng.ApngDecoder +import org.signal.core.util.readFully +import org.signal.core.util.stream.LimitedInputStream +import org.signal.glide.apng.ApngOptions +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream + +class ApngInputStreamResourceDecoder : ResourceDecoder { + companion object { + /** Set to match [com.bumptech.glide.load.data.InputStreamRewinder]'s read limit */ + private const val READ_LIMIT: Long = 5 * 1024 * 1024 + } + + override fun handles(source: InputStream, options: Options): Boolean { + return if (options.get(ApngOptions.ANIMATE)!!) { + ApngDecoder.isApng(LimitedInputStream(source, READ_LIMIT)) + } else { + false + } + } + + @Throws(IOException::class) + override fun decode(source: InputStream, width: Int, height: Int, options: Options): Resource? { + val data: ByteArray = source.readFully() + val decoder = ApngDecoder(ByteArrayInputStream(data)) + return ApngResource(decoder, data.size) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngResource.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngResource.kt new file mode 100644 index 0000000000..c21de6eae4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/ApngResource.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.glide.cache + +import com.bumptech.glide.load.engine.Resource +import org.signal.apng.ApngDecoder + +class ApngResource(private val decoder: ApngDecoder, private val size: Int) : Resource { + override fun getResourceClass(): Class = ApngDecoder::class.java + + override fun get(): ApngDecoder = decoder + + override fun getSize(): Int = size + + override fun recycle() { + decoder.inputStream.close() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedApngResourceEncoder.kt b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedApngResourceEncoder.kt new file mode 100644 index 0000000000..f7d9e1fdba --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/glide/cache/EncryptedApngResourceEncoder.kt @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.glide.cache + +import com.bumptech.glide.load.EncodeStrategy +import com.bumptech.glide.load.Options +import com.bumptech.glide.load.ResourceEncoder +import com.bumptech.glide.load.engine.Resource +import org.signal.apng.ApngDecoder +import org.signal.core.util.logging.Log.tag +import org.signal.core.util.logging.Log.w +import java.io.File +import java.io.IOException + +internal class EncryptedApngResourceEncoder(private val secret: ByteArray) : EncryptedCoder(), ResourceEncoder { + override fun getEncodeStrategy(options: Options): EncodeStrategy { + return EncodeStrategy.SOURCE + } + + override fun encode(data: Resource, file: File, options: Options): Boolean { + try { + val input = data.get().inputStream + val output = createEncryptedOutputStream(secret, file) + + input.reset() + input.copyTo(output) + + return true + } catch (e: IOException) { + w(TAG, e) + } + return false + } + + companion object { + private val TAG = tag(EncryptedApngResourceEncoder::class.java) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt index 6119615431..473b72d80d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt @@ -10,6 +10,7 @@ class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues( const val GROUP_SUGGESTIONS_FOR_MEMBERS: String = "labs.group_suggestions_for_members" const val BETTER_SEARCH: String = "labs.better_search" const val AUTO_LOWER_HAND: String = "labs.auto_lower_hand" + const val NEW_APNG_RENDERER: String = "labs.new_apng_renderer" } public override fun onFirstEverAppLaunch() = Unit @@ -28,6 +29,8 @@ class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues( var autoLowerHand by booleanValue(AUTO_LOWER_HAND, true).falseForExternalUsers() + var newApngRenderer by booleanValue(NEW_APNG_RENDERER, true).falseForExternalUsers() + private fun SignalStoreValueDelegate.falseForExternalUsers(): SignalStoreValueDelegate { return this.map { actualValue -> RemoteConfig.internalUser && actualValue } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DeviceProperties.java b/app/src/main/java/org/thoughtcrime/securesms/util/DeviceProperties.java index 10842ee2de..7cdfde4cd8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DeviceProperties.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DeviceProperties.java @@ -12,6 +12,7 @@ import androidx.annotation.RequiresApi; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.dependencies.AppDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; /** * Easy access to various properties of the device, typically to make performance-related decisions. @@ -25,6 +26,10 @@ public final class DeviceProperties { * large numbers of APNGs simultaneously. */ public static boolean shouldAllowApngStickerAnimation(@NonNull Context context) { + if (SignalStore.labs().getNewApngRenderer()) { + return true; + } + MemoryInfo memoryInfo = getMemoryInfo(context); int memoryMb = (int) ByteUnit.BYTES.toMegabytes(memoryInfo.totalMem); diff --git a/core/util-jvm/src/main/java/org/signal/core/util/ByteArrayExtensions.kt b/core/util-jvm/src/main/java/org/signal/core/util/ByteArrayExtensions.kt new file mode 100644 index 0000000000..d4e71160ef --- /dev/null +++ b/core/util-jvm/src/main/java/org/signal/core/util/ByteArrayExtensions.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.util + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +fun ByteArray.toUInt(): UInt { + return ByteBuffer.wrap(this).order(ByteOrder.BIG_ENDIAN).int.toUInt() +} + +fun ByteArray.toUShort(): UShort { + return ByteBuffer.wrap(this).order(ByteOrder.BIG_ENDIAN).getShort().toUShort() +} diff --git a/core/util-jvm/src/main/java/org/signal/core/util/InputStreamExtensions.kt b/core/util-jvm/src/main/java/org/signal/core/util/InputStreamExtensions.kt index 3905def430..9e10bb9ec9 100644 --- a/core/util-jvm/src/main/java/org/signal/core/util/InputStreamExtensions.kt +++ b/core/util-jvm/src/main/java/org/signal/core/util/InputStreamExtensions.kt @@ -10,6 +10,7 @@ import java.io.ByteArrayOutputStream import java.io.IOException import java.io.InputStream import java.io.OutputStream +import kotlin.jvm.Throws import kotlin.math.min /** @@ -107,6 +108,13 @@ fun InputStream.readLength(): Long { return count } +/** + * Reads a 32-bit unsigned integer from the stream. + */ +fun InputStream.readUInt(): UInt { + return this.readNBytesOrThrow(4).toUInt() +} + /** * Reads the contents of the stream and discards them. */ diff --git a/core/util-jvm/src/main/java/org/signal/core/util/OutputStreamExtensions.kt b/core/util-jvm/src/main/java/org/signal/core/util/OutputStreamExtensions.kt index 5da9dee2d9..8cc75e9008 100644 --- a/core/util-jvm/src/main/java/org/signal/core/util/OutputStreamExtensions.kt +++ b/core/util-jvm/src/main/java/org/signal/core/util/OutputStreamExtensions.kt @@ -6,6 +6,8 @@ package org.signal.core.util import java.io.OutputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder /** * Writes a 32-bit variable-length integer to the stream. @@ -30,3 +32,11 @@ fun OutputStream.writeVarInt32(value: Int) { } } } + +/** + * Writes a 32-bit unsigned integer to the stream. + */ +fun OutputStream.writeUInt(value: UInt) { + // Note that casting to an int here is fine, because at the end of the day, we're just writing 4 bytes to the stream + this.write(ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(value.toInt()).array()) +} diff --git a/core/util-jvm/src/main/java/org/signal/core/util/stream/Crc32OutputStream.kt b/core/util-jvm/src/main/java/org/signal/core/util/stream/Crc32OutputStream.kt new file mode 100644 index 0000000000..98816ac3e1 --- /dev/null +++ b/core/util-jvm/src/main/java/org/signal/core/util/stream/Crc32OutputStream.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.util.stream + +import java.io.FilterOutputStream +import java.io.OutputStream +import java.util.zip.CRC32 + +/** + * A simple pass-through stream that calculates a CRC32 as data is written to the target [OutputStream]. + */ +class Crc32OutputStream(private val wrapped: OutputStream) : FilterOutputStream(wrapped) { + private val crc32 = CRC32() + + val currentCrc32: Long + get() = crc32.value + + override fun write(byte: Int) { + wrapped.write(byte) + crc32.update(byte) + } + + override fun write(data: ByteArray) { + write(data, 0, data.size) + } + + override fun write(data: ByteArray, offset: Int, length: Int) { + wrapped.write(data, offset, length) + crc32.update(data, offset, length) + } +} diff --git a/demo/apng/.gitignore b/demo/apng/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/demo/apng/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/demo/apng/build.gradle.kts b/demo/apng/build.gradle.kts new file mode 100644 index 0000000000..f0909cbe1f --- /dev/null +++ b/demo/apng/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id("signal-sample-app") + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "org.signal.apng" + + defaultConfig { + applicationId = "org.signal.apng" + } +} + +dependencies { + implementation(project(":lib:apng")) +} diff --git a/demo/apng/proguard-rules.pro b/demo/apng/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/demo/apng/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/demo/apng/src/main/AndroidManifest.xml b/demo/apng/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..225f270bce --- /dev/null +++ b/demo/apng/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/demo/apng/src/main/assets/ball.png b/demo/apng/src/main/assets/ball.png new file mode 100644 index 0000000000..a786990cec Binary files /dev/null and b/demo/apng/src/main/assets/ball.png differ diff --git a/demo/apng/src/main/assets/broken01.png b/demo/apng/src/main/assets/broken01.png new file mode 100644 index 0000000000..21c7c6e7f0 Binary files /dev/null and b/demo/apng/src/main/assets/broken01.png differ diff --git a/demo/apng/src/main/assets/broken02.png b/demo/apng/src/main/assets/broken02.png new file mode 100644 index 0000000000..b9ae06b6f5 Binary files /dev/null and b/demo/apng/src/main/assets/broken02.png differ diff --git a/demo/apng/src/main/assets/broken03.png b/demo/apng/src/main/assets/broken03.png new file mode 100644 index 0000000000..10b3c7779d Binary files /dev/null and b/demo/apng/src/main/assets/broken03.png differ diff --git a/demo/apng/src/main/assets/broken05.png b/demo/apng/src/main/assets/broken05.png new file mode 100644 index 0000000000..1c96a455b0 Binary files /dev/null and b/demo/apng/src/main/assets/broken05.png differ diff --git a/demo/apng/src/main/assets/clock.png b/demo/apng/src/main/assets/clock.png new file mode 100644 index 0000000000..10f8c7e474 Binary files /dev/null and b/demo/apng/src/main/assets/clock.png differ diff --git a/demo/apng/src/main/assets/elephant.png b/demo/apng/src/main/assets/elephant.png new file mode 100644 index 0000000000..47aad23c6d Binary files /dev/null and b/demo/apng/src/main/assets/elephant.png differ diff --git a/demo/apng/src/main/assets/test00.png b/demo/apng/src/main/assets/test00.png new file mode 100644 index 0000000000..0ab44c39e4 Binary files /dev/null and b/demo/apng/src/main/assets/test00.png differ diff --git a/demo/apng/src/main/assets/test01.png b/demo/apng/src/main/assets/test01.png new file mode 100644 index 0000000000..0cd5bea856 Binary files /dev/null and b/demo/apng/src/main/assets/test01.png differ diff --git a/demo/apng/src/main/assets/test02.png b/demo/apng/src/main/assets/test02.png new file mode 100644 index 0000000000..db7581fbdf Binary files /dev/null and b/demo/apng/src/main/assets/test02.png differ diff --git a/demo/apng/src/main/assets/test03.png b/demo/apng/src/main/assets/test03.png new file mode 100644 index 0000000000..0ac4a71164 Binary files /dev/null and b/demo/apng/src/main/assets/test03.png differ diff --git a/demo/apng/src/main/assets/test04.png b/demo/apng/src/main/assets/test04.png new file mode 100644 index 0000000000..48676537e8 Binary files /dev/null and b/demo/apng/src/main/assets/test04.png differ diff --git a/demo/apng/src/main/assets/test05.png b/demo/apng/src/main/assets/test05.png new file mode 100644 index 0000000000..2dc58b929a Binary files /dev/null and b/demo/apng/src/main/assets/test05.png differ diff --git a/demo/apng/src/main/assets/test06.png b/demo/apng/src/main/assets/test06.png new file mode 100644 index 0000000000..14a76d9d61 Binary files /dev/null and b/demo/apng/src/main/assets/test06.png differ diff --git a/demo/apng/src/main/assets/test07.png b/demo/apng/src/main/assets/test07.png new file mode 100644 index 0000000000..3094c1d23d Binary files /dev/null and b/demo/apng/src/main/assets/test07.png differ diff --git a/demo/apng/src/main/assets/test08.png b/demo/apng/src/main/assets/test08.png new file mode 100644 index 0000000000..b63ebc0b35 Binary files /dev/null and b/demo/apng/src/main/assets/test08.png differ diff --git a/demo/apng/src/main/assets/test09.png b/demo/apng/src/main/assets/test09.png new file mode 100644 index 0000000000..77694ff1d1 Binary files /dev/null and b/demo/apng/src/main/assets/test09.png differ diff --git a/demo/apng/src/main/assets/test10.png b/demo/apng/src/main/assets/test10.png new file mode 100644 index 0000000000..1c15f132fe Binary files /dev/null and b/demo/apng/src/main/assets/test10.png differ diff --git a/demo/apng/src/main/assets/test11.png b/demo/apng/src/main/assets/test11.png new file mode 100644 index 0000000000..858f6f0382 Binary files /dev/null and b/demo/apng/src/main/assets/test11.png differ diff --git a/demo/apng/src/main/assets/test12.png b/demo/apng/src/main/assets/test12.png new file mode 100644 index 0000000000..3f9b3cfae7 Binary files /dev/null and b/demo/apng/src/main/assets/test12.png differ diff --git a/demo/apng/src/main/assets/test13.png b/demo/apng/src/main/assets/test13.png new file mode 100644 index 0000000000..4e1dbf77e4 Binary files /dev/null and b/demo/apng/src/main/assets/test13.png differ diff --git a/demo/apng/src/main/assets/test14.png b/demo/apng/src/main/assets/test14.png new file mode 100644 index 0000000000..427b829a02 Binary files /dev/null and b/demo/apng/src/main/assets/test14.png differ diff --git a/demo/apng/src/main/assets/test15.png b/demo/apng/src/main/assets/test15.png new file mode 100644 index 0000000000..05948d44ae Binary files /dev/null and b/demo/apng/src/main/assets/test15.png differ diff --git a/demo/apng/src/main/assets/test16.png b/demo/apng/src/main/assets/test16.png new file mode 100644 index 0000000000..f326afa5c2 Binary files /dev/null and b/demo/apng/src/main/assets/test16.png differ diff --git a/demo/apng/src/main/assets/test17.png b/demo/apng/src/main/assets/test17.png new file mode 100644 index 0000000000..d90c54967b Binary files /dev/null and b/demo/apng/src/main/assets/test17.png differ diff --git a/demo/apng/src/main/assets/test18.png b/demo/apng/src/main/assets/test18.png new file mode 100644 index 0000000000..0f290fd7fd Binary files /dev/null and b/demo/apng/src/main/assets/test18.png differ diff --git a/demo/apng/src/main/assets/test19.png b/demo/apng/src/main/assets/test19.png new file mode 100644 index 0000000000..1af30f81f7 Binary files /dev/null and b/demo/apng/src/main/assets/test19.png differ diff --git a/demo/apng/src/main/assets/test20.png b/demo/apng/src/main/assets/test20.png new file mode 100644 index 0000000000..3fe0f4ca78 Binary files /dev/null and b/demo/apng/src/main/assets/test20.png differ diff --git a/demo/apng/src/main/assets/test21.png b/demo/apng/src/main/assets/test21.png new file mode 100644 index 0000000000..3ee5fe3bf2 Binary files /dev/null and b/demo/apng/src/main/assets/test21.png differ diff --git a/demo/apng/src/main/assets/test22.png b/demo/apng/src/main/assets/test22.png new file mode 100644 index 0000000000..3811351362 Binary files /dev/null and b/demo/apng/src/main/assets/test22.png differ diff --git a/demo/apng/src/main/assets/test23.png b/demo/apng/src/main/assets/test23.png new file mode 100644 index 0000000000..4f4afef50f Binary files /dev/null and b/demo/apng/src/main/assets/test23.png differ diff --git a/demo/apng/src/main/assets/test24.png b/demo/apng/src/main/assets/test24.png new file mode 100644 index 0000000000..d0418ddd75 Binary files /dev/null and b/demo/apng/src/main/assets/test24.png differ diff --git a/demo/apng/src/main/assets/test25.png b/demo/apng/src/main/assets/test25.png new file mode 100644 index 0000000000..64cceaae83 Binary files /dev/null and b/demo/apng/src/main/assets/test25.png differ diff --git a/demo/apng/src/main/assets/test26.png b/demo/apng/src/main/assets/test26.png new file mode 100644 index 0000000000..3f082665c9 Binary files /dev/null and b/demo/apng/src/main/assets/test26.png differ diff --git a/demo/apng/src/main/assets/test27.png b/demo/apng/src/main/assets/test27.png new file mode 100644 index 0000000000..99d53b7181 Binary files /dev/null and b/demo/apng/src/main/assets/test27.png differ diff --git a/demo/apng/src/main/assets/test28.png b/demo/apng/src/main/assets/test28.png new file mode 100644 index 0000000000..bad60c767f Binary files /dev/null and b/demo/apng/src/main/assets/test28.png differ diff --git a/demo/apng/src/main/assets/test29.png b/demo/apng/src/main/assets/test29.png new file mode 100644 index 0000000000..a029a959b5 Binary files /dev/null and b/demo/apng/src/main/assets/test29.png differ diff --git a/demo/apng/src/main/assets/test30.png b/demo/apng/src/main/assets/test30.png new file mode 100644 index 0000000000..4d76802e4c Binary files /dev/null and b/demo/apng/src/main/assets/test30.png differ diff --git a/demo/apng/src/main/assets/test31.png b/demo/apng/src/main/assets/test31.png new file mode 100644 index 0000000000..fb25394305 Binary files /dev/null and b/demo/apng/src/main/assets/test31.png differ diff --git a/demo/apng/src/main/assets/test32.png b/demo/apng/src/main/assets/test32.png new file mode 100644 index 0000000000..b8e06d1736 Binary files /dev/null and b/demo/apng/src/main/assets/test32.png differ diff --git a/demo/apng/src/main/assets/test33.png b/demo/apng/src/main/assets/test33.png new file mode 100644 index 0000000000..1210e37379 Binary files /dev/null and b/demo/apng/src/main/assets/test33.png differ diff --git a/demo/apng/src/main/assets/test34.png b/demo/apng/src/main/assets/test34.png new file mode 100644 index 0000000000..29ed7d1ea1 Binary files /dev/null and b/demo/apng/src/main/assets/test34.png differ diff --git a/demo/apng/src/main/assets/test35.png b/demo/apng/src/main/assets/test35.png new file mode 100644 index 0000000000..f9307f6350 Binary files /dev/null and b/demo/apng/src/main/assets/test35.png differ diff --git a/demo/apng/src/main/assets/test36.png b/demo/apng/src/main/assets/test36.png new file mode 100644 index 0000000000..11ccfb6cba Binary files /dev/null and b/demo/apng/src/main/assets/test36.png differ diff --git a/demo/apng/src/main/assets/test37.png b/demo/apng/src/main/assets/test37.png new file mode 100644 index 0000000000..f3c4c9f9e6 Binary files /dev/null and b/demo/apng/src/main/assets/test37.png differ diff --git a/demo/apng/src/main/assets/test38.png b/demo/apng/src/main/assets/test38.png new file mode 100644 index 0000000000..e95425ac19 Binary files /dev/null and b/demo/apng/src/main/assets/test38.png differ diff --git a/demo/apng/src/main/java/org/signal/apng/DemoActivity.kt b/demo/apng/src/main/java/org/signal/apng/DemoActivity.kt new file mode 100644 index 0000000000..bc6ee29500 --- /dev/null +++ b/demo/apng/src/main/java/org/signal/apng/DemoActivity.kt @@ -0,0 +1,298 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.apng + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder + +class DemoActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.demo_activity) + + val myAdapter = MyAdapter() + + findViewById(R.id.list).apply { + layoutManager = LinearLayoutManager(this@DemoActivity) + adapter = myAdapter + } + + // All test cases taken from: + // https://philip.html5.org/tests/apng/tests.html + myAdapter.submitList( + listOf( + TestModel.Heading("Basic cases"), + // TODO we don't yet render non-apngs +// TestModel( +// filename = "test00.png", +// description = "Trivial static image. This should be solid green." +// ), + TestModel.ApngModel( + filename = "test01.png", + description = "Trivial animated image - one frame; using default image. This should be solid green." + ), + TestModel.ApngModel( + filename = "test02.png", + description = "Trivial animated image - one frame; ignoring default image. This should be solid green." + ), + + // IDAT, fdAT splitting + // TODO we don't yet support split IDAT/fdAT +// _root_ide_package_.org.signal.apng.MainActivity.TestModel.ApngModel( +// filename = "test03.png", +// description = "Basic split IDAT. This should be solid green." +// ), +// _root_ide_package_.org.signal.apng.MainActivity.TestModel.ApngModel( +// filename = "test04.png", +// description = "Split IDAT with zero-length chunk. This should be solid green." +// ), +// _root_ide_package_.org.signal.apng.MainActivity.TestModel.ApngModel( +// filename = "test05.png", +// description = "Basic split fdAT. This should be solid green." +// ), +// _root_ide_package_.org.signal.apng.MainActivity.TestModel.ApngModel( +// filename = "test06.png", +// description = "Split fdAT with zero-length chunk. This should be solid green." +// ), + + TestModel.Heading("Dispose ops"), + TestModel.ApngModel( + filename = "test07.png", + description = "APNG_DISPOSE_OP_NONE - basic. This should be solid green." + ), + TestModel.ApngModel( + filename = "test08.png", + description = "APNG_DISPOSE_OP_BACKGROUND - basic. This should be transparent." + ), + TestModel.ApngModel( + filename = "test09.png", + description = "APNG_DISPOSE_OP_BACKGROUND - final frame. This should be solid green." + ), + TestModel.ApngModel( + filename = "test10.png", + description = "APNG_DISPOSE_OP_PREVIOUS - basic. This should be solid green." + ), + TestModel.ApngModel( + filename = "test11.png", + description = "APNG_DISPOSE_OP_PREVIOUS - final frame. This should be solid green." + ), + TestModel.ApngModel( + filename = "test12.png", + description = "APNG_DISPOSE_OP_PREVIOUS - first frame. This should be transparent." + ), + + TestModel.Heading("Dispose ops and regions"), + TestModel.ApngModel( + filename = "test13.png", + description = "APNG_DISPOSE_OP_NONE in region. This should be solid green." + ), + TestModel.ApngModel( + filename = "test14.png", + description = "APNG_DISPOSE_OP_BACKGROUND before region. This should be transparent." + ), + TestModel.ApngModel( + filename = "test15.png", + description = "APNG_DISPOSE_OP_BACKGROUND in region. This should be a solid blue rectangle containing a smaller transparent rectangle." + ), + TestModel.ApngModel( + filename = "test16.png", + description = "APNG_DISPOSE_OP_PREVIOUS in region. This should be solid green." + ), + + TestModel.Heading("Blend ops"), + TestModel.ApngModel( + filename = "test17.png", + description = "APNG_BLEND_OP_SOURCE on solid colour. This should be solid green." + ), + TestModel.ApngModel( + filename = "test18.png", + description = "APNG_BLEND_OP_SOURCE on transparent colour. This should be transparent." + ), + TestModel.ApngModel( + filename = "test19.png", + description = "APNG_BLEND_OP_SOURCE on nearly-transparent colour. This should be very nearly transparent." + ), + TestModel.ApngModel( + filename = "test20.png", + description = "APNG_BLEND_OP_OVER on solid and transparent colours. This should be solid green. " + ), + TestModel.ApngModel( + filename = "test21.png", + description = "APNG_BLEND_OP_OVER repeatedly with nearly-transparent colours. This should be solid green." + ), + + TestModel.Heading("Blending and gamma"), + TestModel.ApngModel( + filename = "test22.png", + description = "APNG_BLEND_OP_OVER This should be solid slightly-dark green." + ), + TestModel.ApngModel( + filename = "test23.png", + description = "APNG_BLEND_OP_OVER This should be solid nearly-black." + ), + + TestModel.Heading("Chunk ordering"), + TestModel.ApngModel( + filename = "test24.png", + description = "fcTL before acTL. This should be solid green." + ), + + TestModel.Heading("Delays"), + TestModel.ApngModel( + filename = "test25.png", + description = "Basic delays. This should flash blue for half a second, then yellow for one second, then repeat." + ), + TestModel.ApngModel( + filename = "test26.png", + description = "Rounding of division. This should flash blue for half a second, then yellow for one second, then repeat." + ), + TestModel.ApngModel( + filename = "test27.png", + description = "16-bit numerator/denominator. This should flash blue for half a second, then yellow for one second, then repeat." + ), + TestModel.ApngModel( + filename = "test28.png", + description = "Zero denominator. This should flash blue for half a second, then yellow for one second, then repeat." + ), + TestModel.ApngModel( + filename = "test29.png", + description = "Zero numerator. This should flash cyan for a short period of time (perhaps zero), then magenta for the same short period of time, then blue for half a second, then yellow for one second, then repeat." + ), + + TestModel.Heading("num_plays"), + TestModel.ApngModel( + filename = "test30.png", + description = "num_plays = 0. This should flash yellow for one second, then blue for one second, then repeat forever." + ), + TestModel.ApngModel( + filename = "test31.png", + description = "num_plays = 1. When first loaded, this should flash yellow for one second, then stay blue forever." + ), + TestModel.ApngModel( + filename = "test32.png", + description = "num_plays = 2. When first loaded, this should flash yellow for one second, then blue for one second, then yellow for one second, then blue forever." + ), + + TestModel.Heading("Other depths and color types"), + TestModel.ApngModel( + filename = "test33.png", + description = "16-bit colour. This should be dark blue." + ), + TestModel.ApngModel( + filename = "test34.png", + description = "8-bit greyscale. This should be a solid grey rectangle containing a solid white rectangle." + ), + TestModel.ApngModel( + filename = "test35.png", + description = "8-bit greyscale and alpha, with blending. This should be solid grey." + ), + TestModel.ApngModel( + filename = "test36.png", + description = "2-color palette. This should be solid green." + ), + TestModel.ApngModel( + filename = "test37.png", + description = "2-bit palette and alpha. This should be solid green." + ), + TestModel.ApngModel( + filename = "test38.png", + description = "1-bit palette and alpha, with blending. This should be solid dark blue." + ), + + TestModel.Heading("Random sample images"), + TestModel.ApngModel( + filename = "clock.png", + description = "A clock that uses BLEND_OP_OVER to draw moving hands." + ), + TestModel.ApngModel( + filename = "ball.png", + description = "Classic bouncing ball." + ), + TestModel.ApngModel( + filename = "elephant.png", + description = "A cute elephant" + ) + ) + ) + } + + sealed class TestModel { + data class Heading(val description: String) : TestModel() + data class ApngModel(val filename: String, val description: String) : TestModel() + } + + private class MyAdapter : ListAdapter(object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: TestModel, newItem: TestModel): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: TestModel, newItem: TestModel): Boolean { + return oldItem == newItem + } + }) { + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is TestModel.Heading -> 1 + is TestModel.ApngModel -> 2 + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TestViewHolder { + when (viewType) { + 1 -> return TestViewHolder.HeadingViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.list_item_heading, parent, false)) + 2 -> return TestViewHolder.ApngViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.list_item_apng, parent, false)) + else -> throw IllegalStateException() + } + } + + override fun onBindViewHolder(holder: TestViewHolder, position: Int) { + holder.bind(getItem(position)) + } + } + + sealed class TestViewHolder(itemView: View) : ViewHolder(itemView) { + abstract fun bind(testModel: TestModel) + + class HeadingViewHolder(itemView: View) : TestViewHolder(itemView) { + val description: TextView = itemView.findViewById(R.id.description) + + override fun bind(testModel: TestModel) { + if (testModel !is TestModel.Heading) { + throw IllegalStateException() + } + + description.text = testModel.description + } + } + + class ApngViewHolder(itemView: View) : TestViewHolder(itemView) { + val description: TextView = itemView.findViewById(R.id.description) + val image: ImageView = itemView.findViewById(R.id.image) + + override fun bind(testModel: TestModel) { + if (testModel !is TestModel.ApngModel) { + throw IllegalStateException() + } + + description.text = testModel.description + + val decoder = ApngDecoder(itemView.context.assets.open(testModel.filename)) + val drawable = ApngDrawable(decoder) + image.setImageDrawable(drawable) + } + } + } +} diff --git a/demo/apng/src/main/java/org/signal/apng/MainActivity.kt b/demo/apng/src/main/java/org/signal/apng/MainActivity.kt new file mode 100644 index 0000000000..c5cb1fb479 --- /dev/null +++ b/demo/apng/src/main/java/org/signal/apng/MainActivity.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.apng + +import android.content.Intent +import android.os.Bundle +import android.widget.Button +import androidx.activity.ComponentActivity + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.main_activity) + + findViewById