From af21d501f6962e3ab9a62550a861f333abc740df Mon Sep 17 00:00:00 2001 From: Oupson Date: Tue, 22 Jun 2021 15:30:21 +0200 Subject: [PATCH 1/4] Add ApngLoader --- .../java/oupson/apng/decoder/ApngDecoder.kt | 261 ------------- .../java/oupson/apng/decoder/ApngLoader.kt | 353 ++++++++++++++++++ .../apngcreator/activities/ViewerActivity.kt | 28 +- .../fragments/ApngDecoderFragment.kt | 22 +- .../apngcreator/fragments/JavaFragment.java | 14 +- .../apngcreator/fragments/KotlinFragment.kt | 30 +- 6 files changed, 422 insertions(+), 286 deletions(-) create mode 100644 apng_library/src/main/java/oupson/apng/decoder/ApngLoader.kt diff --git a/apng_library/src/main/java/oupson/apng/decoder/ApngDecoder.kt b/apng_library/src/main/java/oupson/apng/decoder/ApngDecoder.kt index bf08a56..d56fa6a 100644 --- a/apng_library/src/main/java/oupson/apng/decoder/ApngDecoder.kt +++ b/apng_library/src/main/java/oupson/apng/decoder/ApngDecoder.kt @@ -3,13 +3,11 @@ package oupson.apng.decoder import android.content.Context import android.graphics.* import android.graphics.drawable.AnimatedImageDrawable -import android.graphics.drawable.AnimationDrawable import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.net.Uri import android.os.Build import android.util.Log -import android.widget.ImageView import androidx.annotation.RawRes import kotlinx.coroutines.* import oupson.apng.BuildConfig @@ -30,20 +28,6 @@ import java.util.zip.CRC32 * Call [decodeApng] */ class ApngDecoder { - interface Callback { - /** - * Function called when the file was successfully decoded. - * @param drawable Can be an [ApngDrawable] if successful and an [AnimatedImageDrawable] if the image decoded is not an APNG but a gif. If it is not an animated image, it is a [Drawable]. - */ - fun onSuccess(drawable: Drawable) - - /** - * Function called when something gone wrong. - * @param error The problem. - */ - fun onError(error: Exception) - } - class Config( internal var speed: Float = 1f, internal var bitmapConfig: Bitmap.Config = Bitmap.Config.ARGB_8888, @@ -645,252 +629,7 @@ class ApngDecoder { ) } - /** - * Load Apng into an imageView, asynchronously. - * @param context Context needed for animation drawable. - * @param file File to decode. - * @param imageView Image View. - * @param callback [ApngDecoder.Callback] to handle success and error. - * @param config Decoder configuration - */ - @Suppress("unused", "BlockingMethodInNonBlockingContext") - @JvmStatic - @JvmOverloads - fun decodeApngAsyncInto( - context: Context, - file: File, - imageView: ImageView, - callback: Callback? = null, - config: Config = Config(), - scope: CoroutineScope = GlobalScope - ) { - scope.launch(Dispatchers.Default) { - try { - val drawable = - decodeApng( - context, - withContext(Dispatchers.IO) { - FileInputStream(file) - }, - config - ) - withContext(Dispatchers.Main) { - imageView.setImageDrawable(drawable) - (drawable as? AnimationDrawable)?.start() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - (drawable as? AnimatedImageDrawable)?.start() - } - callback?.onSuccess(drawable) - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - callback?.onError(e) - } - } - } - } - /** - * Load Apng into an imageView, asynchronously. - * @param context Context needed for animation drawable and content resolver. - * @param uri Uri to load. - * @param imageView Image View. - * @param callback [ApngDecoder.Callback] to handle success and error. - * @param config Decoder configuration - */ - @Suppress("unused") - @JvmStatic - @JvmOverloads - fun decodeApngAsyncInto( - context: Context, - uri: Uri, - imageView: ImageView, - callback: Callback? = null, - config: Config = Config(), - scope: CoroutineScope = GlobalScope - ) { - val inputStream = context.contentResolver.openInputStream(uri)!! - scope.launch(Dispatchers.Default) { - try { - val drawable = - decodeApng( - context, - inputStream, - config - ) - withContext(Dispatchers.Main) { - imageView.setImageDrawable(drawable) - (drawable as? AnimationDrawable)?.start() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - (drawable as? AnimatedImageDrawable)?.start() - } - callback?.onSuccess(drawable) - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - callback?.onError(e) - } - } - } - } - - /** - * Load Apng into an imageView, asynchronously. - * @param context Context needed to decode the resource and for the animation drawable. - * @param res Raw resource to load. - * @param imageView Image View. - * @param callback [ApngDecoder.Callback] to handle success and error. - * @param config Decoder configuration - */ - @Suppress("unused") - @JvmStatic - @JvmOverloads - fun decodeApngAsyncInto( - context: Context, @RawRes res: Int, - imageView: ImageView, - callback: Callback? = null, - config: Config = Config(), - scope: CoroutineScope = GlobalScope - ) { - scope.launch(Dispatchers.Default) { - try { - val drawable = - decodeApng( - context, - context.resources.openRawResource(res), - config - ) - withContext(Dispatchers.Main) { - imageView.setImageDrawable(drawable) - (drawable as? AnimationDrawable)?.start() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - (drawable as? AnimatedImageDrawable)?.start() - } - callback?.onSuccess(drawable) - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - callback?.onError(e) - } - } - } - - } - - /** - * Load Apng into an imageView, asynchronously. - * @param context Context needed for the animation drawable. - * @param url URL to load. - * @param imageView Image View. - * @param callback [ApngDecoder.Callback] to handle success and error. - * @param config Decoder configuration - */ - @Suppress("unused", "BlockingMethodInNonBlockingContext") - @JvmStatic - @JvmOverloads - fun decodeApngAsyncInto( - context: Context, - url: URL, - imageView: ImageView, - callback: Callback? = null, - config: Config = Config(), - scope: CoroutineScope = GlobalScope - ) { - scope.launch(Dispatchers.Default) { - try { - val drawable = decodeApng( - context, - ByteArrayInputStream( - Loader.load( - url - ) - ), - config - ) - withContext(Dispatchers.Main) { - imageView.setImageDrawable(drawable) - (drawable as? AnimationDrawable)?.start() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - (drawable as? AnimatedImageDrawable)?.start() - } - callback?.onSuccess(drawable) - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - callback?.onError(e) - } - } - } - } - - /** - * Load Apng into an imageView, asynchronously. - * @param context Context needed for decoding the image and creating the animation drawable. - * @param string URL to load - * @param imageView Image View. - * @param callback [ApngDecoder.Callback] to handle success and error. - * @param config Decoder configuration - */ - @Suppress("unused") - @JvmStatic - @JvmOverloads - fun decodeApngAsyncInto( - context: Context, - string: String, - imageView: ImageView, - callback: Callback? = null, - config: Config = Config(), - scope: CoroutineScope = GlobalScope - ) { - scope.launch(Dispatchers.Default) { - try { - if (string.startsWith("http://") || string.startsWith("https://")) { - decodeApngAsyncInto( - context, - URL(string), - imageView, - callback, - config - ) - } else if (File(string).exists()) { - var pathToLoad = - if (string.startsWith("content://")) string else "file://$string" - pathToLoad = pathToLoad.replace("%", "%25").replace("#", "%23") - decodeApngAsyncInto( - context, - Uri.parse(pathToLoad), - imageView, - callback, - config - ) - } else if (string.startsWith("file://android_asset/")) { - val drawable = - decodeApng( - context, - context.assets.open(string.replace("file:///android_asset/", "")), - - config - ) - withContext(Dispatchers.Main) { - imageView.setImageDrawable(drawable) - (drawable as? AnimationDrawable)?.start() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - (drawable as? AnimatedImageDrawable)?.start() - } - callback?.onSuccess(drawable) - } - } else { - withContext(Dispatchers.Main) { - callback?.onError(Exception("Cannot open string")) - } - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - callback?.onError(e) - } - } - } - } /** * Generate a correct IHDR from the IHDR chunk of the APNG. diff --git a/apng_library/src/main/java/oupson/apng/decoder/ApngLoader.kt b/apng_library/src/main/java/oupson/apng/decoder/ApngLoader.kt new file mode 100644 index 0000000..bd05ac7 --- /dev/null +++ b/apng_library/src/main/java/oupson/apng/decoder/ApngLoader.kt @@ -0,0 +1,353 @@ +package oupson.apng.decoder + +import android.content.Context +import android.graphics.drawable.AnimatedImageDrawable +import android.graphics.drawable.AnimationDrawable +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Build +import android.widget.ImageView +import androidx.annotation.RawRes +import kotlinx.coroutines.* +import oupson.apng.drawable.ApngDrawable +import oupson.apng.utils.Loader +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.net.URL + +class ApngLoader(parent: Job? = null) { + interface Callback { + /** + * Function called when the file was successfully decoded. + * @param drawable Can be an [ApngDrawable] if successful and an [AnimatedImageDrawable] if the image decoded is not an APNG but a gif. If it is not an animated image, it is a [Drawable]. + */ + fun onSuccess(drawable: Drawable) + + /** + * Function called when something gone wrong. + * @param error The problem. + */ + fun onError(error: Exception) + } + + private val job = SupervisorJob(parent) + private val coroutineScope: CoroutineScope = CoroutineScope(job) + + fun cancelAll() { + coroutineScope.cancel(CancellationException("Loading was canceled")) + } + + + /** + * Load Apng into an imageView. + * @param context Context needed for animation drawable. + * @param file File to decode. + * @param imageView Image View. + * @param config Decoder configuration + */ + suspend fun decodeApngInto( + context: Context, + file: File, + imageView: ImageView, + config: ApngDecoder.Config = ApngDecoder.Config() + ): Drawable { + val drawable = + ApngDecoder.decodeApng( + context, + withContext(Dispatchers.IO) { + FileInputStream(file) + }, + config + ) + withContext(Dispatchers.Main) { + imageView.setImageDrawable(drawable) + (drawable as? AnimationDrawable)?.start() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + (drawable as? AnimatedImageDrawable)?.start() + } + } + return drawable + } + + /** + * Load Apng into an imageView. + * @param context Context needed for animation drawable and content resolver. + * @param uri Uri to load. + * @param imageView Image View. + * @param config Decoder configuration + */ + suspend fun decodeApngInto( + context: Context, + uri: Uri, + imageView: ImageView, + config: ApngDecoder.Config = ApngDecoder.Config() + ): Drawable { + val inputStream = + withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) } + ?: throw FileNotFoundException("Failed to load $uri") // TODO Better err ? + val drawable = + ApngDecoder.decodeApng( + context, + inputStream, + config + ) + withContext(Dispatchers.Main) { + imageView.setImageDrawable(drawable) + (drawable as? AnimationDrawable)?.start() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + (drawable as? AnimatedImageDrawable)?.start() + } + } + + return drawable + } + + /** + * Load Apng into an imageView. + * @param context Context needed to decode the resource and for the animation drawable. + * @param res Raw resource to load. + * @param imageView Image View. + * @param config Decoder configuration + */ + suspend fun decodeApngInto( + context: Context, @RawRes res: Int, + imageView: ImageView, + config: ApngDecoder.Config = ApngDecoder.Config() + ): Drawable { + val drawable = + ApngDecoder.decodeApng( + context, + context.resources.openRawResource(res), + config + ) + withContext(Dispatchers.Main) { + imageView.setImageDrawable(drawable) + (drawable as? AnimationDrawable)?.start() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + (drawable as? AnimatedImageDrawable)?.start() + } + } + return drawable + } + + /** + * Load Apng into an imageView, asynchronously. + * @param context Context needed for the animation drawable. + * @param url URL to load. + * @param imageView Image View. + * @param config Decoder configuration + */ + suspend fun decodeApngInto( + context: Context, + url: URL, + imageView: ImageView, + config: ApngDecoder.Config = ApngDecoder.Config() + ): Drawable { + + val drawable = ApngDecoder.decodeApng( + context, + ByteArrayInputStream( + Loader.load( + url + ) + ), + config + ) + withContext(Dispatchers.Main) { + imageView.setImageDrawable(drawable) + (drawable as? AnimationDrawable)?.start() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + (drawable as? AnimatedImageDrawable)?.start() + } + } + + return drawable + } + + /** + * Load Apng into an imageView, asynchronously. + * @param context Context needed for decoding the image and creating the animation drawable. + * @param string URL to load + * @param imageView Image View. + * @param config Decoder configuration + */ + @Suppress("unused") + suspend fun decodeApngInto( + context: Context, + string: String, + imageView: ImageView, + config: ApngDecoder.Config = ApngDecoder.Config() + ): Drawable { + return if (string.startsWith("http://") || string.startsWith("https://")) { + decodeApngInto( + context, + URL(string), + imageView, + config + ) + } else if (File(string).exists()) { + var pathToLoad = + if (string.startsWith("content://")) string else "file://$string" + pathToLoad = pathToLoad.replace("%", "%25").replace("#", "%23") + decodeApngInto( + context, + Uri.parse(pathToLoad), + imageView, + config + ) + } else if (string.startsWith("file://android_asset/")) { + val drawable = + ApngDecoder.decodeApng( + context, + context.assets.open(string.replace("file:///android_asset/", "")), + + config + ) + withContext(Dispatchers.Main) { + imageView.setImageDrawable(drawable) + (drawable as? AnimationDrawable)?.start() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + (drawable as? AnimatedImageDrawable)?.start() + } + } + drawable + } else { + throw Exception("Cannot open string") + } + } + + +// region with callback + /** + * Load Apng into an imageView, asynchronously. + * @param context Context needed for animation drawable. + * @param file File to decode. + * @param imageView Image View. + * @param callback [ApngLoader.Callback] to handle success and error. + * @param config Decoder configuration + */ + @Suppress("unused", "BlockingMethodInNonBlockingContext") + @JvmOverloads + fun decodeApngAsyncInto( + context: Context, + file: File, + imageView: ImageView, + callback: Callback? = null, + config: ApngDecoder.Config = ApngDecoder.Config() + ) = + coroutineScope.launch(Dispatchers.Default) { + try { + val drawable = decodeApngInto(context, file, imageView, config) + callback?.onSuccess(drawable) + } catch (e: Exception) { + callback?.onError(e) + } + } + + + /** + * Load Apng into an imageView, asynchronously. + * @param context Context needed for animation drawable and content resolver. + * @param uri Uri to load. + * @param imageView Image View. + * @param callback [ApngLoader.Callback] to handle success and error. + * @param config Decoder configuration + */ + @Suppress("unused") + @JvmOverloads + fun decodeApngAsyncInto( + context: Context, + uri: Uri, + imageView: ImageView, + callback: Callback? = null, + config: ApngDecoder.Config = ApngDecoder.Config() + ) = coroutineScope.launch(Dispatchers.Default) { + try { + val drawable = decodeApngInto(context, uri, imageView, config) + callback?.onSuccess(drawable) + } catch (e: Exception) { + callback?.onError(e) + } + } + + /** + * Load Apng into an imageView, asynchronously. + * @param context Context needed to decode the resource and for the animation drawable. + * @param res Raw resource to load. + * @param imageView Image View. + * @param callback [ApngLoader.Callback] to handle success and error. + * @param config Decoder configuration + */ + @Suppress("unused") + @JvmOverloads + fun decodeApngAsyncInto( + context: Context, @RawRes res: Int, + imageView: ImageView, + callback: Callback? = null, + config: ApngDecoder.Config = ApngDecoder.Config() + ) = coroutineScope.launch(Dispatchers.Default) { + try { + val drawable = decodeApngInto(context, res, imageView, config) + callback?.onSuccess(drawable) + } catch (e: Exception) { + callback?.onError(e) + } + } + + /** + * Load Apng into an imageView, asynchronously. + * @param context Context needed for the animation drawable. + * @param url URL to load. + * @param imageView Image View. + * @param callback [ApngLoader.Callback] to handle success and error. + * @param config Decoder configuration + */ + @Suppress("unused", "BlockingMethodInNonBlockingContext") + @JvmOverloads + fun decodeApngAsyncInto( + context: Context, + url: URL, + imageView: ImageView, + callback: Callback? = null, + config: ApngDecoder.Config = ApngDecoder.Config() + ) = coroutineScope.launch(Dispatchers.Default) { + try { + val drawable = decodeApngInto(context, url, imageView, config) + callback?.onSuccess(drawable) + } catch (e: Exception) { + callback?.onError(e) + } + } + + /** + * Load Apng into an imageView, asynchronously. + * @param context Context needed for decoding the image and creating the animation drawable. + * @param string URL to load + * @param imageView Image View. + * @param callback [ApngLoader.Callback] to handle success and error. + * @param config Decoder configuration + */ + @Suppress("unused") + @JvmOverloads + fun decodeApngAsyncInto( + context: Context, + string: String, + imageView: ImageView, + callback: Callback? = null, + config: ApngDecoder.Config = ApngDecoder.Config() + ) = + coroutineScope.launch(Dispatchers.Default) { + try { + val drawable = decodeApngInto(context, string, imageView, config) + callback?.onSuccess(drawable) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + callback?.onError(e) + } + } + } + +// endregion with callback +} \ No newline at end of file diff --git a/app-test/src/main/java/oupson/apngcreator/activities/ViewerActivity.kt b/app-test/src/main/java/oupson/apngcreator/activities/ViewerActivity.kt index 36a9cc7..e7ba8d1 100644 --- a/app-test/src/main/java/oupson/apngcreator/activities/ViewerActivity.kt +++ b/app-test/src/main/java/oupson/apngcreator/activities/ViewerActivity.kt @@ -11,13 +11,17 @@ import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import kotlinx.android.synthetic.main.activity_viewer.* import oupson.apng.decoder.ApngDecoder +import oupson.apng.decoder.ApngLoader import oupson.apngcreator.R class ViewerActivity : AppCompatActivity() { + private var apngLoader: ApngLoader? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_viewer) + this.apngLoader = ApngLoader() + window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN @@ -50,16 +54,24 @@ class ViewerActivity : AppCompatActivity() { private fun load() { val uri = intent.data ?: return - ApngDecoder.decodeApngAsyncInto(this, uri, viewerImageView, callback = object : ApngDecoder.Callback { - override fun onSuccess(drawable: Drawable) {} - override fun onError(error: Exception) { - Log.e("ViewerActivity", "Error when loading file", error) - } - }, ApngDecoder.Config(decodeCoverFrame = false)) + apngLoader?.decodeApngAsyncInto( + this, + uri, + viewerImageView, + callback = object : ApngLoader.Callback { + override fun onSuccess(drawable: Drawable) {} + override fun onError(error: Exception) { + Log.e("ViewerActivity", "Error when loading file", error) + } + }, + ApngDecoder.Config(decodeCoverFrame = false) + ) } - override fun onRequestPermissionsResult(requestCode: Int, - permissions: Array, grantResults: IntArray) { + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, grantResults: IntArray + ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) when (requestCode) { 2 -> { diff --git a/app-test/src/main/java/oupson/apngcreator/fragments/ApngDecoderFragment.kt b/app-test/src/main/java/oupson/apngcreator/fragments/ApngDecoderFragment.kt index c6543fc..4edb6e8 100644 --- a/app-test/src/main/java/oupson/apngcreator/fragments/ApngDecoderFragment.kt +++ b/app-test/src/main/java/oupson/apngcreator/fragments/ApngDecoderFragment.kt @@ -11,6 +11,7 @@ import android.view.ViewGroup import android.widget.ImageView import androidx.fragment.app.Fragment import oupson.apng.decoder.ApngDecoder +import oupson.apng.decoder.ApngLoader import oupson.apng.drawable.ApngDrawable import oupson.apngcreator.BuildConfig import oupson.apngcreator.R @@ -19,29 +20,40 @@ import java.net.URL class ApngDecoderFragment : Fragment() { companion object { private const val TAG = "ApngDecoderFragment" + @JvmStatic fun newInstance() = ApngDecoderFragment() } + private var apngLoader: ApngLoader? = null + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { val view = inflater.inflate(R.layout.fragment_apng_decoder, container, false) - val imageView : ImageView = view.findViewById(R.id.apngDecoderImageView) ?: return view + val imageView: ImageView = view.findViewById(R.id.apngDecoderImageView) ?: return view + + apngLoader = ApngLoader() if (context != null) { - ApngDecoder.decodeApngAsyncInto( + apngLoader?.decodeApngAsyncInto( this.requireContext(), URL("https://metagif.files.wordpress.com/2015/01/bugbuckbunny.png"), imageView, - config = ApngDecoder.Config(bitmapConfig = Bitmap.Config.RGB_565, decodeCoverFrame = true), - callback = object : ApngDecoder.Callback { + config = ApngDecoder.Config( + bitmapConfig = Bitmap.Config.RGB_565, + decodeCoverFrame = true + ), + callback = object : ApngLoader.Callback { override fun onSuccess(drawable: Drawable) { if (BuildConfig.DEBUG) - Log.i(TAG, "onSuccess(), has cover frame : ${(drawable as? ApngDrawable)?.coverFrame != null}") + Log.i( + TAG, + "onSuccess(), has cover frame : ${(drawable as? ApngDrawable)?.coverFrame != null}" + ) } override fun onError(error: Exception) { diff --git a/app-test/src/main/java/oupson/apngcreator/fragments/JavaFragment.java b/app-test/src/main/java/oupson/apngcreator/fragments/JavaFragment.java index 74dd6c1..49db007 100644 --- a/app-test/src/main/java/oupson/apngcreator/fragments/JavaFragment.java +++ b/app-test/src/main/java/oupson/apngcreator/fragments/JavaFragment.java @@ -16,6 +16,7 @@ import androidx.fragment.app.Fragment; import org.jetbrains.annotations.NotNull; import oupson.apng.decoder.ApngDecoder; +import oupson.apng.decoder.ApngLoader; import oupson.apngcreator.BuildConfig; import oupson.apngcreator.R; @@ -23,6 +24,8 @@ import oupson.apngcreator.R; public class JavaFragment extends Fragment { private static final String TAG = "JavaActivity"; + private ApngLoader apngLoader = null; + @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { @@ -36,10 +39,12 @@ public class JavaFragment extends Fragment { Context context = this.getContext(); + this.apngLoader = new ApngLoader(); + if (imageView != null && context != null) { if (BuildConfig.DEBUG) Log.v(TAG, "Loading " + imageUrl); - ApngDecoder.decodeApngAsyncInto(context, imageUrl, imageView, new ApngDecoder.Callback() { + this.apngLoader.decodeApngAsyncInto(context, imageUrl, imageView, new ApngLoader.Callback() { @Override public void onSuccess(@NotNull Drawable drawable) { if (BuildConfig.DEBUG) @@ -56,4 +61,11 @@ public class JavaFragment extends Fragment { return v; } + + @Override + public void onStop() { + super.onStop(); + + apngLoader.cancelAll(); + } } diff --git a/app-test/src/main/java/oupson/apngcreator/fragments/KotlinFragment.kt b/app-test/src/main/java/oupson/apngcreator/fragments/KotlinFragment.kt index 7c05231..a23f926 100644 --- a/app-test/src/main/java/oupson/apngcreator/fragments/KotlinFragment.kt +++ b/app-test/src/main/java/oupson/apngcreator/fragments/KotlinFragment.kt @@ -13,7 +13,7 @@ import android.widget.SeekBar import androidx.fragment.app.Fragment import com.squareup.picasso.Picasso import kotlinx.android.synthetic.main.activity_creator.* -import oupson.apng.decoder.ApngDecoder +import oupson.apng.decoder.ApngLoader import oupson.apng.drawable.ApngDrawable import oupson.apngcreator.BuildConfig import oupson.apngcreator.R @@ -22,22 +22,23 @@ import oupson.apngcreator.R class KotlinFragment : Fragment() { companion object { private const val TAG = "KotlinFragment" + @JvmStatic fun newInstance() = KotlinFragment() } - private var apngImageView : ImageView? = null - private var normalImageView : ImageView? = null + private var apngImageView: ImageView? = null + private var normalImageView: ImageView? = null - private var pauseButton : Button? = null - private var playButton : Button? = null + private var pauseButton: Button? = null + private var playButton: Button? = null - private var speedSeekBar : SeekBar? = null + private var speedSeekBar: SeekBar? = null //private var animator : ApngAnimator? = null - private var animation : ApngDrawable? = null - private var durations : IntArray? = null + private var animation: ApngDrawable? = null + private var durations: IntArray? = null private var frameIndex = 0 @@ -51,6 +52,8 @@ class KotlinFragment : Fragment() { ) private val selected = 4 + private var apngLoader: ApngLoader? = null + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? @@ -60,6 +63,8 @@ class KotlinFragment : Fragment() { val view = inflater.inflate(R.layout.fragment_kotlin, container, false) + apngLoader = ApngLoader() + apngImageView = view.findViewById(R.id.ApngImageView) normalImageView = view.findViewById(R.id.NormalImageView) @@ -126,7 +131,10 @@ class KotlinFragment : Fragment() { res.coverFrame = animation.coverFrame for (i in 0 until animation.numberOfFrames) { - res.addFrame(animation.getFrame(i), (durations!![i].toFloat() / speed).toInt()) + res.addFrame( + animation.getFrame(i), + (durations!![i].toFloat() / speed).toInt() + ) } apngImageView?.setImageDrawable(res) @@ -138,11 +146,11 @@ class KotlinFragment : Fragment() { }) if (animation == null) { - ApngDecoder.decodeApngAsyncInto( + apngLoader?.decodeApngAsyncInto( requireContext(), imageUrls[selected], apngImageView!!, - callback = object : ApngDecoder.Callback { + callback = object : ApngLoader.Callback { override fun onSuccess(drawable: Drawable) { animation = (drawable as? ApngDrawable) durations = IntArray(animation?.numberOfFrames ?: 0) { i -> From 4ef644632a196e03f0b50e954ebda22a6971e83b Mon Sep 17 00:00:00 2001 From: Oupson Date: Fri, 25 Jun 2021 13:31:35 +0200 Subject: [PATCH 2/4] Work on ApngDecoder --- .../java/oupson/apng/decoder/ApngDecoder.kt | 1152 ++++++++--------- .../java/oupson/apng/decoder/ApngLoader.kt | 210 +-- .../apngcreator/activities/ViewerActivity.kt | 2 +- .../fragments/ApngDecoderFragment.kt | 2 +- .../apngcreator/fragments/JavaFragment.java | 2 +- 5 files changed, 683 insertions(+), 685 deletions(-) diff --git a/apng_library/src/main/java/oupson/apng/decoder/ApngDecoder.kt b/apng_library/src/main/java/oupson/apng/decoder/ApngDecoder.kt index d56fa6a..9dc20fa 100644 --- a/apng_library/src/main/java/oupson/apng/decoder/ApngDecoder.kt +++ b/apng_library/src/main/java/oupson/apng/decoder/ApngDecoder.kt @@ -11,13 +11,11 @@ import android.util.Log import androidx.annotation.RawRes import kotlinx.coroutines.* import oupson.apng.BuildConfig -import oupson.apng.decoder.ApngDecoder.Companion.decodeApng import oupson.apng.drawable.ApngDrawable import oupson.apng.exceptions.BadApngException import oupson.apng.exceptions.BadCRCException import oupson.apng.utils.Loader import oupson.apng.utils.Utils -import oupson.apng.utils.Utils.Companion.isPng import java.io.* import java.net.URL import java.nio.ByteBuffer @@ -27,7 +25,7 @@ import java.util.zip.CRC32 * An APNG Decoder. * Call [decodeApng] */ -class ApngDecoder { +class ApngDecoder(input: InputStream, val config: Config) { class Config( internal var speed: Float = 1f, internal var bitmapConfig: Bitmap.Config = Bitmap.Config.ARGB_8888, @@ -55,6 +53,571 @@ class ApngDecoder { } } + private val inputStream: InputStream? = input + private var result: Result? = null + + /** + * Decode Apng and return a Drawable who can be an [ApngDrawable] if it end successfully. Can also be an [android.graphics.drawable.AnimatedImageDrawable]. + * @param context Context needed for the animation drawable + * @param inStream Input Stream to decode. Will be closed at the end. + * @param config Decoder configuration + * @return [ApngDrawable] if successful and an [AnimatedImageDrawable] if the image decoded is not an APNG but a gif. If it is not an animated image, it is a [Drawable]. + */ + suspend fun decodeApng( + context: Context + ): Drawable = withContext(Dispatchers.Default) { + val inputStream = BufferedInputStream(inputStream) + val bytes = ByteArray(8) + inputStream.mark(8) + withContext(Dispatchers.IO) { + inputStream.read(bytes) + } + + if (Utils.isPng(bytes)) { + var png: ByteArrayOutputStream? = null + var cover: ByteArrayOutputStream? = null + var delay = -1f + var yOffset = -1 + var xOffset = -1 + var plte: ByteArray? = null + var tnrs: ByteArray? = null + var maxWidth = 0 + var maxHeight = 0 + var blendOp: Utils.Companion.BlendOp = Utils.Companion.BlendOp.APNG_BLEND_OP_SOURCE + var disposeOp: Utils.Companion.DisposeOp = + Utils.Companion.DisposeOp.APNG_DISPOSE_OP_NONE + + var ihdrOfApng = ByteArray(0) + + var isApng = false + + val drawable = ApngDrawable().apply { + isOneShot = false + } + + var buffer: Bitmap? = null + + var byteRead: Int + val lengthChunk = ByteArray(4) + do { + val length: Int + val chunk: ByteArray + if (withContext(Dispatchers.IO) { + byteRead = inputStream.read(lengthChunk) + + + if (byteRead != -1) { + length = Utils.uIntFromBytesBigEndian(lengthChunk) + + chunk = ByteArray(length + 8) + byteRead = inputStream.read(chunk) + false + } else { + chunk = ByteArray(0) + true + } + }) { + break + } + + val byteArray = lengthChunk.plus(chunk) + val chunkCRC = Utils.uIntFromBytesBigEndian(byteArray, byteArray.size - 4) + val crc = CRC32() + crc.update(byteArray, 4, byteArray.size - 8) + if (chunkCRC == crc.value.toInt()) { + val name = byteArray.copyOfRange(4, 8) + when { + name.contentEquals(Utils.fcTL) -> { + if (png == null) { + if (config.decodeCoverFrame) { + drawable.coverFrame = cover?.let { + it.write(zeroLength) + // Generate crc for IEND + val crC32 = CRC32() + crC32.update(Utils.IEND, 0, Utils.IEND.size) + it.write(Utils.IEND) + it.write(Utils.uIntToByteArray(crC32.value.toInt())) + + val pngBytes = it.toByteArray() + BitmapFactory.decodeByteArray( + pngBytes, + 0, + pngBytes.size + ) + } + } + cover = null + } else { + // Add IEND body length : 0 + png.write(zeroLength) + // Add IEND + // Generate crc for IEND + val crC32 = CRC32() + crC32.update(Utils.IEND, 0, Utils.IEND.size) + png.write(Utils.IEND) + png.write(Utils.uIntToByteArray(crC32.value.toInt())) + + val btm = Bitmap.createBitmap( + maxWidth, + maxHeight, + Bitmap.Config.ARGB_8888 + ) + + val pngBytes = png.toByteArray() + val decoded = BitmapFactory.decodeByteArray( + pngBytes, + 0, + pngBytes.size + ) + val canvas = Canvas(btm) + canvas.drawBitmap(buffer!!, 0f, 0f, null) + + if (blendOp == Utils.Companion.BlendOp.APNG_BLEND_OP_SOURCE) { + canvas.drawRect( + xOffset.toFloat(), + yOffset.toFloat(), + xOffset + decoded.width.toFloat(), + yOffset + decoded.height.toFloat(), + clearPaint + ) + } + + canvas.drawBitmap( + decoded, + xOffset.toFloat(), + yOffset.toFloat(), + null + ) + + drawable.addFrame( + BitmapDrawable( + context.resources, + if (btm.config != config.bitmapConfig) { + if (BuildConfig.DEBUG) + Log.v( + TAG, + "Bitmap Config : ${btm.config}, Config : $config" + ) + btm.copy(config.bitmapConfig, btm.isMutable) + } else { + btm + } + ), + (delay / config.speed).toInt() + ) + + when (disposeOp) { + Utils.Companion.DisposeOp.APNG_DISPOSE_OP_PREVIOUS -> { + //Do nothings + } + // Add current frame to bitmap buffer + // APNG_DISPOSE_OP_BACKGROUND: the frame's region of the output buffer is to be cleared to fully transparent black before rendering the next frame. + Utils.Companion.DisposeOp.APNG_DISPOSE_OP_BACKGROUND -> { + val res = Bitmap.createBitmap( + maxWidth, + maxHeight, + Bitmap.Config.ARGB_8888 + ) + val can = Canvas(res) + can.drawBitmap(btm, 0f, 0f, null) + can.drawRect( + xOffset.toFloat(), + yOffset.toFloat(), + xOffset + decoded.width.toFloat(), + yOffset + decoded.height.toFloat(), + clearPaint + ) + buffer = res + } + else -> buffer = btm + } + + } + + png = ByteArrayOutputStream(4096) + + // Parse Frame ConTroL chunk + // Get the width of the png + val width = Utils.uIntFromBytesBigEndian( + byteArray, 12 + ) + // Get the height of the png + val height = Utils.uIntFromBytesBigEndian( + byteArray, 16 + ) + + /* + * The `delay_num` and `delay_den` parameters together specify a fraction indicating the time to display the current frame, in seconds. + * If the the value of the numerator is 0 the decoder should render the next frame as quickly as possible, though viewers may impose a reasonable lower bound. + */ + // Get delay numerator + val delayNum = Utils.uShortFromBytesBigEndian( + byteArray, 28 + ).toFloat() + // Get delay denominator + var delayDen = Utils.uShortFromBytesBigEndian( + byteArray, 30 + ).toFloat() + + // If the denominator is 0, it is to be treated as if it were 100 (that is, `delay_num` then specifies 1/100ths of a second). + if (delayDen == 0f) { + delayDen = 100f + } + + delay = (delayNum / delayDen * 1000) + + // Get x and y offsets + xOffset = Utils.uIntFromBytesBigEndian( + byteArray, 20 + ) + yOffset = Utils.uIntFromBytesBigEndian( + byteArray, 24 + ) + blendOp = Utils.decodeBlendOp(byteArray[33].toInt()) + disposeOp = Utils.decodeDisposeOp(byteArray[32].toInt()) + + if (xOffset + width > maxWidth) { + throw BadApngException("`xOffset` + `width` must be <= `IHDR` width") + } else if (yOffset + height > maxHeight) { + throw BadApngException("`yOffset` + `height` must be <= `IHDR` height") + } + + png.write(Utils.pngSignature) + png.write( + generateIhdr( + ihdrOfApng, + width, + height + ) + ) + plte?.let { + png.write(it) + } + tnrs?.let { + png.write(it) + } + + } + name.contentEquals(Utils.IEND) -> { + if (isApng && png != null) { + png.write(zeroLength) + // Add IEND + // Generate crc for IEND + val crC32 = CRC32() + crC32.update(Utils.IEND, 0, Utils.IEND.size) + png.write(Utils.IEND) + png.write(Utils.uIntToByteArray(crC32.value.toInt())) + + val btm = Bitmap.createBitmap( + maxWidth, + maxHeight, + Bitmap.Config.ARGB_8888 + ) + + val pngBytes = png.toByteArray() + val decoded = BitmapFactory.decodeByteArray( + pngBytes, + 0, + pngBytes.size + ) + val canvas = Canvas(btm) + canvas.drawBitmap(buffer!!, 0f, 0f, null) + + if (blendOp == Utils.Companion.BlendOp.APNG_BLEND_OP_SOURCE) { + canvas.drawRect( + xOffset.toFloat(), + yOffset.toFloat(), + xOffset + decoded.width.toFloat(), + yOffset + decoded.height.toFloat(), + clearPaint + ) + } + + canvas.drawBitmap( + decoded, + xOffset.toFloat(), + yOffset.toFloat(), + null + ) + drawable.addFrame( + BitmapDrawable( + context.resources, + if (btm.config != config.bitmapConfig) { + if (BuildConfig.DEBUG) + Log.v( + TAG, + "Bitmap Config : ${btm.config}, Config : $config" + ) + btm.copy(config.bitmapConfig, btm.isMutable) + } else { + btm + } + ), + (delay / config.speed).toInt() + ) + + when (disposeOp) { + Utils.Companion.DisposeOp.APNG_DISPOSE_OP_PREVIOUS -> { + //Do nothings + } + // Add current frame to bitmap buffer + // APNG_DISPOSE_OP_BACKGROUND: the frame's region of the output buffer is to be cleared to fully transparent black before rendering the next frame. + Utils.Companion.DisposeOp.APNG_DISPOSE_OP_BACKGROUND -> { + val res = Bitmap.createBitmap( + maxWidth, + maxHeight, + Bitmap.Config.ARGB_8888 + ) + val can = Canvas(res) + can.drawBitmap(btm, 0f, 0f, null) + can.drawRect( + xOffset.toFloat(), + yOffset.toFloat(), + xOffset + decoded.width.toFloat(), + yOffset + decoded.height.toFloat(), + clearPaint + ) + buffer = res + } + else -> buffer = btm + } + } else { + cover?.let { + it.write(zeroLength) + // Add IEND + // Generate crc for IEND + val crC32 = CRC32() + crC32.update(Utils.IEND, 0, Utils.IEND.size) + it.write(Utils.IEND) + it.write(Utils.uIntToByteArray(crC32.value.toInt())) + withContext(Dispatchers.IO) { + inputStream.close() + } + + val pngBytes = it.toByteArray() + return@withContext BitmapDrawable( + context.resources, + BitmapFactory.decodeByteArray( + pngBytes, + 0, + pngBytes.size + ) + ) + } + } + } + name.contentEquals(Utils.IDAT) -> { + val w = if (png == null) { + if (isApng && !config.decodeCoverFrame) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Ignoring cover frame") + continue + } + if (cover == null) { + cover = ByteArrayOutputStream() + cover.write(Utils.pngSignature) + cover.write( + generateIhdr( + ihdrOfApng, + maxWidth, + maxHeight + ) + ) + } + cover + } else { + png + } + + // Find the chunk length + val bodySize = + Utils.uIntFromBytesBigEndian( + byteArray, 0 + ) + w.write(byteArray.copyOfRange(0, 4)) + + val body = ByteArray(4 + bodySize) + + System.arraycopy(Utils.IDAT, 0, body, 0, 4) + + // Get image bytes + System.arraycopy(byteArray, 8, body, 4, bodySize) + + val crC32 = CRC32() + crC32.update(body, 0, body.size) + w.write(body) + w.write(Utils.uIntToByteArray(crC32.value.toInt())) + } + name.contentEquals(Utils.fdAT) -> { + // Find the chunk length + val bodySize = Utils.uIntFromBytesBigEndian(byteArray, 0) + png?.write(Utils.uIntToByteArray(bodySize - 4)) + + val body = ByteArray(bodySize) + System.arraycopy(Utils.IDAT, 0, body, 0, 4) + + // Get image bytes + System.arraycopy(byteArray, 12, body, 4, bodySize - 4) + + val crC32 = CRC32() + crC32.update(body, 0, body.size) + png?.write(body) + png?.write(Utils.uIntToByteArray(crC32.value.toInt())) + } + name.contentEquals(Utils.plte) -> { + plte = byteArray + } + name.contentEquals(Utils.tnrs) -> { + tnrs = byteArray + } + name.contentEquals(Utils.IHDR) -> { + // Get length of the body of the chunk + val bodySize = Utils.uIntFromBytesBigEndian(byteArray, 0) + // Get the width of the png + maxWidth = Utils.uIntFromBytesBigEndian(byteArray, 8) + // Get the height of the png + maxHeight = Utils.uIntFromBytesBigEndian(byteArray, 12) + ihdrOfApng = byteArray.copyOfRange(4 + 4, 4 + bodySize + 4) + + buffer = Bitmap.createBitmap( + maxWidth, + maxHeight, + Bitmap.Config.ARGB_8888 + ) + } + name.contentEquals(Utils.acTL) -> { // TODO GET NBR REPETITIONS + isApng = true + } + } + } else throw BadCRCException() + } while (byteRead != -1 && isActive) + withContext(Dispatchers.IO) { + inputStream.close() + } + return@withContext drawable + } else { + if (BuildConfig.DEBUG) + Log.i(TAG, "Decoding non APNG stream") + inputStream.reset() + + return@withContext if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val bytesRead: ByteArray + withContext(Dispatchers.IO) { + bytesRead = inputStream.readBytes() + inputStream.close() + } + val buf = ByteBuffer.wrap(bytesRead) + val source = ImageDecoder.createSource(buf) + withContext(Dispatchers.IO) { + ImageDecoder.decodeDrawable(source) + } + } else { + val drawable = Drawable.createFromStream( + inputStream, + null + ) + withContext(Dispatchers.IO) { + inputStream.close() + } + drawable + } + } + } + + suspend fun getDecoded(context: Context): Result { + if (result == null) { + result = kotlin.runCatching { + decodeApng(context) + } + + kotlin.runCatching { + withContext(Dispatchers.IO) { + inputStream?.close() + } + }.onFailure { + return Result.failure(it) + } + } + + return result ?: Result.failure(NullPointerException("result is null")) + } + + /** + * Decode Apng and return a Drawable who can be an [ApngDrawable] if it end successfully. Can also be an [android.graphics.drawable.AnimatedImageDrawable]. + * @param context Context needed for animation drawable. + * @param file File to decode. + * @param config Decoder configuration + * @return [ApngDrawable] if successful and an [AnimatedImageDrawable] if the image decoded is not an APNG but a gif. If it is not an animated image, it is a [Drawable]. + */ + // TODO DOC + constructor(file: File, config: Config = Config()) : this(FileInputStream(file), config) + + /** + * Decode Apng and return a Drawable who can be an [ApngDrawable] if it end successfully. Can also be an [android.graphics.drawable.AnimatedImageDrawable]. + * @param context Context is needed for contentResolver and animation drawable. + * @param uri Uri to open. + * @param config Decoder configuration + * @return [ApngDrawable] if successful and an [AnimatedImageDrawable] if the image decoded is not an APNG but a gif. + */ + // TODO DOC + better + constructor( + context: Context, + uri: Uri, + config: Config = Config() + ) : this(context.contentResolver.openInputStream(uri)!!, config) + + /** + * Decode Apng and return a Drawable who can be an [ApngDrawable] if it end successfully. Can also be an [android.graphics.drawable.AnimatedImageDrawable]. + * @param context Context is needed for contentResolver and animation drawable. + * @param res Resource to decode. + * @param config Decoder configuration + * @return [ApngDrawable] if successful and an [AnimatedImageDrawable] if the image decoded is not an APNG but a gif. + */ + // TODO DOC + constructor( + context: Context, + @RawRes res: Int, + config: Config = Config() + ) : this(context.resources.openRawResource(res), config) + + + /** + * Generate a correct IHDR from the IHDR chunk of the APNG. + * @param ihdrOfApng The IHDR of the APNG. + * @param width The width of the frame. + * @param height The height of the frame. + * @return [ByteArray] The generated IHDR. + */ + private fun generateIhdr(ihdrOfApng: ByteArray, width: Int, height: Int): ByteArray { + val ihdr = + ByteArray(0xD + 4 + 4 + 4) // 0xD (IHDR body length) + 4 (0x0, 0x0, 0x0, 0xD : the chunk length) + 4 : IHDR + 4 : CRC + + // Add chunk body length + System.arraycopy(Utils.uIntToByteArray(0xD), 0, ihdr, 0, 4) + + // We need a body var to know body length and generate crc + val ihdrBody = ByteArray(0xD + 4) // 0xD (IHDR body length) + 4 : IHDR + + // Add IHDR + System.arraycopy(Utils.IHDR, 0, ihdrBody, 0, 4) + + // Add the max width and height + System.arraycopy(Utils.uIntToByteArray(width), 0, ihdrBody, 4, 4) + System.arraycopy(Utils.uIntToByteArray(height), 0, ihdrBody, 8, 4) + + // Add complicated stuff like depth color ... + // If you want correct png you need same parameters. + System.arraycopy(ihdrOfApng, 8, ihdrBody, 12, 5) + + // Generate CRC + val crC32 = CRC32() + crC32.update(ihdrBody, 0, 0xD + 4) + + System.arraycopy(ihdrBody, 0, ihdr, 4, 0xD + 4) + System.arraycopy(Utils.uIntToByteArray(crC32.value.toInt()), 0, ihdr, 0xD + 4 + 4, 4) + return ihdr + } + companion object { private const val TAG = "ApngDecoder" private val zeroLength = byteArrayOf(0x00, 0x00, 0x00, 0x00) @@ -68,544 +631,6 @@ class ApngDecoder { } } - /** - * Decode Apng and return a Drawable who can be an [ApngDrawable] if it end successfully. Can also be an [android.graphics.drawable.AnimatedImageDrawable]. - * @param context Context needed for the animation drawable - * @param inStream Input Stream to decode. Will be closed at the end. - * @param config Decoder configuration - * @return [ApngDrawable] if successful and an [AnimatedImageDrawable] if the image decoded is not an APNG but a gif. If it is not an animated image, it is a [Drawable]. - */ - // TODO DOCUMENT CONFIG - @Suppress( - "MemberVisibilityCanBePrivate", - "BlockingMethodInNonBlockingContext" - ) // BlockingMethodInNonBlockingContext is a warning generated by java @Throws - @JvmStatic - @JvmOverloads - suspend fun decodeApng( - context: Context, - inStream: InputStream, - config: Config = Config() - ): Drawable = withContext(Dispatchers.Default) { - val inputStream = BufferedInputStream(inStream) - val bytes = ByteArray(8) - inputStream.mark(8) - withContext(Dispatchers.IO) { - inputStream.read(bytes) - } - - if (isPng(bytes)) { - var png: ByteArrayOutputStream? = null - var cover: ByteArrayOutputStream? = null - var delay = -1f - var yOffset = -1 - var xOffset = -1 - var plte: ByteArray? = null - var tnrs: ByteArray? = null - var maxWidth = 0 - var maxHeight = 0 - var blendOp: Utils.Companion.BlendOp = Utils.Companion.BlendOp.APNG_BLEND_OP_SOURCE - var disposeOp: Utils.Companion.DisposeOp = - Utils.Companion.DisposeOp.APNG_DISPOSE_OP_NONE - - var ihdrOfApng = ByteArray(0) - - var isApng = false - - val drawable = ApngDrawable().apply { - isOneShot = false - } - - var buffer: Bitmap? = null - - var byteRead: Int - val lengthChunk = ByteArray(4) - do { - val length: Int - val chunk: ByteArray - if (withContext(Dispatchers.IO) { - byteRead = inputStream.read(lengthChunk) - - - if (byteRead != -1) { - length = Utils.uIntFromBytesBigEndian(lengthChunk) - - chunk = ByteArray(length + 8) - byteRead = inputStream.read(chunk) - false - } else { - chunk = ByteArray(0) - true - } - }) { - break - } - - val byteArray = lengthChunk.plus(chunk) - val chunkCRC = Utils.uIntFromBytesBigEndian(byteArray, byteArray.size - 4) - val crc = CRC32() - crc.update(byteArray, 4, byteArray.size - 8) - if (chunkCRC == crc.value.toInt()) { - val name = byteArray.copyOfRange(4, 8) - when { - name.contentEquals(Utils.fcTL) -> { - if (png == null) { - if (config.decodeCoverFrame) { - drawable.coverFrame = cover?.let { - it.write(zeroLength) - // Generate crc for IEND - val crC32 = CRC32() - crC32.update(Utils.IEND, 0, Utils.IEND.size) - it.write(Utils.IEND) - it.write(Utils.uIntToByteArray(crC32.value.toInt())) - - val pngBytes = it.toByteArray() - BitmapFactory.decodeByteArray( - pngBytes, - 0, - pngBytes.size - ) - } - } - cover = null - } else { - // Add IEND body length : 0 - png.write(zeroLength) - // Add IEND - // Generate crc for IEND - val crC32 = CRC32() - crC32.update(Utils.IEND, 0, Utils.IEND.size) - png.write(Utils.IEND) - png.write(Utils.uIntToByteArray(crC32.value.toInt())) - - val btm = Bitmap.createBitmap( - maxWidth, - maxHeight, - Bitmap.Config.ARGB_8888 - ) - - val pngBytes = png.toByteArray() - val decoded = BitmapFactory.decodeByteArray( - pngBytes, - 0, - pngBytes.size - ) - val canvas = Canvas(btm) - canvas.drawBitmap(buffer!!, 0f, 0f, null) - - if (blendOp == Utils.Companion.BlendOp.APNG_BLEND_OP_SOURCE) { - canvas.drawRect( - xOffset.toFloat(), - yOffset.toFloat(), - xOffset + decoded.width.toFloat(), - yOffset + decoded.height.toFloat(), - clearPaint - ) - } - - canvas.drawBitmap( - decoded, - xOffset.toFloat(), - yOffset.toFloat(), - null - ) - - drawable.addFrame( - BitmapDrawable( - context.resources, - if (btm.config != config.bitmapConfig) { - if (BuildConfig.DEBUG) - Log.v( - TAG, - "Bitmap Config : ${btm.config}, Config : $config" - ) - btm.copy(config.bitmapConfig, btm.isMutable) - } else { - btm - } - ), - (delay / config.speed).toInt() - ) - - when (disposeOp) { - Utils.Companion.DisposeOp.APNG_DISPOSE_OP_PREVIOUS -> { - //Do nothings - } - // Add current frame to bitmap buffer - // APNG_DISPOSE_OP_BACKGROUND: the frame's region of the output buffer is to be cleared to fully transparent black before rendering the next frame. - Utils.Companion.DisposeOp.APNG_DISPOSE_OP_BACKGROUND -> { - val res = Bitmap.createBitmap( - maxWidth, - maxHeight, - Bitmap.Config.ARGB_8888 - ) - val can = Canvas(res) - can.drawBitmap(btm, 0f, 0f, null) - can.drawRect( - xOffset.toFloat(), - yOffset.toFloat(), - xOffset + decoded.width.toFloat(), - yOffset + decoded.height.toFloat(), - clearPaint - ) - buffer = res - } - else -> buffer = btm - } - - } - - png = ByteArrayOutputStream(4096) - - // Parse Frame ConTroL chunk - // Get the width of the png - val width = Utils.uIntFromBytesBigEndian( - byteArray, 12 - ) - // Get the height of the png - val height = Utils.uIntFromBytesBigEndian( - byteArray, 16 - ) - - /* - * The `delay_num` and `delay_den` parameters together specify a fraction indicating the time to display the current frame, in seconds. - * If the the value of the numerator is 0 the decoder should render the next frame as quickly as possible, though viewers may impose a reasonable lower bound. - */ - // Get delay numerator - val delayNum = Utils.uShortFromBytesBigEndian( - byteArray, 28 - ).toFloat() - // Get delay denominator - var delayDen = Utils.uShortFromBytesBigEndian( - byteArray, 30 - ).toFloat() - - // If the denominator is 0, it is to be treated as if it were 100 (that is, `delay_num` then specifies 1/100ths of a second). - if (delayDen == 0f) { - delayDen = 100f - } - - delay = (delayNum / delayDen * 1000) - - // Get x and y offsets - xOffset = Utils.uIntFromBytesBigEndian( - byteArray, 20 - ) - yOffset = Utils.uIntFromBytesBigEndian( - byteArray, 24 - ) - blendOp = Utils.decodeBlendOp(byteArray[33].toInt()) - disposeOp = Utils.decodeDisposeOp(byteArray[32].toInt()) - - if (xOffset + width > maxWidth) { - throw BadApngException("`xOffset` + `width` must be <= `IHDR` width") - } else if (yOffset + height > maxHeight) { - throw BadApngException("`yOffset` + `height` must be <= `IHDR` height") - } - - png.write(Utils.pngSignature) - png.write( - generateIhdr( - ihdrOfApng, - width, - height - ) - ) - plte?.let { - png.write(it) - } - tnrs?.let { - png.write(it) - } - - } - name.contentEquals(Utils.IEND) -> { - if (isApng && png != null) { - png.write(zeroLength) - // Add IEND - // Generate crc for IEND - val crC32 = CRC32() - crC32.update(Utils.IEND, 0, Utils.IEND.size) - png.write(Utils.IEND) - png.write(Utils.uIntToByteArray(crC32.value.toInt())) - - val btm = Bitmap.createBitmap( - maxWidth, - maxHeight, - Bitmap.Config.ARGB_8888 - ) - - val pngBytes = png.toByteArray() - val decoded = BitmapFactory.decodeByteArray( - pngBytes, - 0, - pngBytes.size - ) - val canvas = Canvas(btm) - canvas.drawBitmap(buffer!!, 0f, 0f, null) - - if (blendOp == Utils.Companion.BlendOp.APNG_BLEND_OP_SOURCE) { - canvas.drawRect( - xOffset.toFloat(), - yOffset.toFloat(), - xOffset + decoded.width.toFloat(), - yOffset + decoded.height.toFloat(), - clearPaint - ) - } - - canvas.drawBitmap( - decoded, - xOffset.toFloat(), - yOffset.toFloat(), - null - ) - drawable.addFrame( - BitmapDrawable( - context.resources, - if (btm.config != config.bitmapConfig) { - if (BuildConfig.DEBUG) - Log.v( - TAG, - "Bitmap Config : ${btm.config}, Config : $config" - ) - btm.copy(config.bitmapConfig, btm.isMutable) - } else { - btm - } - ), - (delay / config.speed).toInt() - ) - - when (disposeOp) { - Utils.Companion.DisposeOp.APNG_DISPOSE_OP_PREVIOUS -> { - //Do nothings - } - // Add current frame to bitmap buffer - // APNG_DISPOSE_OP_BACKGROUND: the frame's region of the output buffer is to be cleared to fully transparent black before rendering the next frame. - Utils.Companion.DisposeOp.APNG_DISPOSE_OP_BACKGROUND -> { - val res = Bitmap.createBitmap( - maxWidth, - maxHeight, - Bitmap.Config.ARGB_8888 - ) - val can = Canvas(res) - can.drawBitmap(btm, 0f, 0f, null) - can.drawRect( - xOffset.toFloat(), - yOffset.toFloat(), - xOffset + decoded.width.toFloat(), - yOffset + decoded.height.toFloat(), - clearPaint - ) - buffer = res - } - else -> buffer = btm - } - } else { - cover?.let { - it.write(zeroLength) - // Add IEND - // Generate crc for IEND - val crC32 = CRC32() - crC32.update(Utils.IEND, 0, Utils.IEND.size) - it.write(Utils.IEND) - it.write(Utils.uIntToByteArray(crC32.value.toInt())) - withContext(Dispatchers.IO) { - inputStream.close() - } - - val pngBytes = it.toByteArray() - return@withContext BitmapDrawable( - context.resources, - BitmapFactory.decodeByteArray( - pngBytes, - 0, - pngBytes.size - ) - ) - } - } - } - name.contentEquals(Utils.IDAT) -> { - val w = if (png == null) { - if (isApng && !config.decodeCoverFrame) { - if (BuildConfig.DEBUG) - Log.d(TAG, "Ignoring cover frame") - continue - } - if (cover == null) { - cover = ByteArrayOutputStream() - cover.write(Utils.pngSignature) - cover.write( - generateIhdr( - ihdrOfApng, - maxWidth, - maxHeight - ) - ) - } - cover - } else { - png - } - - // Find the chunk length - val bodySize = - Utils.uIntFromBytesBigEndian( - byteArray, 0 - ) - w.write(byteArray.copyOfRange(0, 4)) - - val body = ByteArray(4 + bodySize) - - System.arraycopy(Utils.IDAT, 0, body, 0, 4) - - // Get image bytes - System.arraycopy(byteArray, 8, body, 4, bodySize) - - val crC32 = CRC32() - crC32.update(body, 0, body.size) - w.write(body) - w.write(Utils.uIntToByteArray(crC32.value.toInt())) - } - name.contentEquals(Utils.fdAT) -> { - // Find the chunk length - val bodySize = Utils.uIntFromBytesBigEndian(byteArray, 0) - png?.write(Utils.uIntToByteArray(bodySize - 4)) - - val body = ByteArray(bodySize) - System.arraycopy(Utils.IDAT, 0, body, 0, 4) - - // Get image bytes - System.arraycopy(byteArray, 12, body, 4, bodySize - 4) - - val crC32 = CRC32() - crC32.update(body, 0, body.size) - png?.write(body) - png?.write(Utils.uIntToByteArray(crC32.value.toInt())) - } - name.contentEquals(Utils.plte) -> { - plte = byteArray - } - name.contentEquals(Utils.tnrs) -> { - tnrs = byteArray - } - name.contentEquals(Utils.IHDR) -> { - // Get length of the body of the chunk - val bodySize = Utils.uIntFromBytesBigEndian(byteArray, 0) - // Get the width of the png - maxWidth = Utils.uIntFromBytesBigEndian(byteArray, 8) - // Get the height of the png - maxHeight = Utils.uIntFromBytesBigEndian(byteArray, 12) - ihdrOfApng = byteArray.copyOfRange(4 + 4, 4 + bodySize + 4) - - buffer = Bitmap.createBitmap( - maxWidth, - maxHeight, - Bitmap.Config.ARGB_8888 - ) - } - name.contentEquals(Utils.acTL) -> { // TODO GET NBR REPETITIONS - isApng = true - } - } - } else throw BadCRCException() - } while (byteRead != -1 && isActive) - withContext(Dispatchers.IO) { - inputStream.close() - } - return@withContext drawable - } else { - if (BuildConfig.DEBUG) - Log.i(TAG, "Decoding non APNG stream") - inputStream.reset() - - return@withContext if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - val bytesRead: ByteArray - withContext(Dispatchers.IO) { - bytesRead = inputStream.readBytes() - inputStream.close() - } - val buf = ByteBuffer.wrap(bytesRead) - val source = ImageDecoder.createSource(buf) - withContext(Dispatchers.IO) { - ImageDecoder.decodeDrawable(source) - } - } else { - val drawable = Drawable.createFromStream( - inputStream, - null - ) - withContext(Dispatchers.IO) { - inputStream.close() - } - drawable - } - } - } - - /** - * Decode Apng and return a Drawable who can be an [ApngDrawable] if it end successfully. Can also be an [android.graphics.drawable.AnimatedImageDrawable]. - * @param context Context needed for animation drawable. - * @param file File to decode. - * @param config Decoder configuration - * @return [ApngDrawable] if successful and an [AnimatedImageDrawable] if the image decoded is not an APNG but a gif. If it is not an animated image, it is a [Drawable]. - */ - @Suppress("unused", "BlockingMethodInNonBlockingContext") - @JvmStatic - // TODO DOCUMENT - suspend fun decodeApng( - context: Context, - file: File, - config: Config = Config() - ): Drawable = - decodeApng( - context, - withContext(Dispatchers.IO) { FileInputStream(file) }, config - ) - - /** - * Decode Apng and return a Drawable who can be an [ApngDrawable] if it end successfully. Can also be an [android.graphics.drawable.AnimatedImageDrawable]. - * @param context Context is needed for contentResolver and animation drawable. - * @param uri Uri to open. - * @param config Decoder configuration - * @return [ApngDrawable] if successful and an [AnimatedImageDrawable] if the image decoded is not an APNG but a gif. - */ - @Suppress("unused") - @JvmStatic - suspend fun decodeApng( - context: Context, - uri: Uri, - config: Config = Config() - ): Drawable { - val inputStream = context.contentResolver.openInputStream(uri)!! - return decodeApng( - context, - inputStream, - config - ) - } - - /** - * Decode Apng and return a Drawable who can be an [ApngDrawable] if it end successfully. Can also be an [android.graphics.drawable.AnimatedImageDrawable]. - * @param context Context is needed for contentResolver and animation drawable. - * @param res Resource to decode. - * @param config Decoder configuration - * @return [ApngDrawable] if successful and an [AnimatedImageDrawable] if the image decoded is not an APNG but a gif. - */ - @Suppress("unused") - @JvmStatic - suspend fun decodeApng( - context: Context, - @RawRes res: Int, - config: Config = Config() - ): Drawable = - decodeApng( - context, - context.resources.openRawResource(res), - config - ) /** * Decode Apng and return a Drawable who can be an [ApngDrawable] if it end successfully. Can also be an [android.graphics.drawable.AnimatedImageDrawable]. @@ -616,56 +641,15 @@ class ApngDecoder { */ @Suppress("unused", "BlockingMethodInNonBlockingContext") @JvmStatic - suspend fun decodeApng( - context: Context, + suspend fun constructFromUrl( url: URL, config: Config = Config() ) = withContext(Dispatchers.IO) { - decodeApng( - context, + ApngDecoder( ByteArrayInputStream(Loader.load(url)), config ) } - - - - /** - * Generate a correct IHDR from the IHDR chunk of the APNG. - * @param ihdrOfApng The IHDR of the APNG. - * @param width The width of the frame. - * @param height The height of the frame. - * @return [ByteArray] The generated IHDR. - */ - private fun generateIhdr(ihdrOfApng: ByteArray, width: Int, height: Int): ByteArray { - val ihdr = - ByteArray(0xD + 4 + 4 + 4) // 0xD (IHDR body length) + 4 (0x0, 0x0, 0x0, 0xD : the chunk length) + 4 : IHDR + 4 : CRC - - // Add chunk body length - System.arraycopy(Utils.uIntToByteArray(0xD), 0, ihdr, 0, 4) - - // We need a body var to know body length and generate crc - val ihdrBody = ByteArray(0xD + 4) // 0xD (IHDR body length) + 4 : IHDR - - // Add IHDR - System.arraycopy(Utils.IHDR, 0, ihdrBody, 0, 4) - - // Add the max width and height - System.arraycopy(Utils.uIntToByteArray(width), 0, ihdrBody, 4, 4) - System.arraycopy(Utils.uIntToByteArray(height), 0, ihdrBody, 8, 4) - - // Add complicated stuff like depth color ... - // If you want correct png you need same parameters. - System.arraycopy(ihdrOfApng, 8, ihdrBody, 12, 5) - - // Generate CRC - val crC32 = CRC32() - crC32.update(ihdrBody, 0, 0xD + 4) - - System.arraycopy(ihdrBody, 0, ihdr, 4, 0xD + 4) - System.arraycopy(Utils.uIntToByteArray(crC32.value.toInt()), 0, ihdr, 0xD + 4 + 4, 4) - return ihdr - } } } \ No newline at end of file diff --git a/apng_library/src/main/java/oupson/apng/decoder/ApngLoader.kt b/apng_library/src/main/java/oupson/apng/decoder/ApngLoader.kt index bd05ac7..a670415 100644 --- a/apng_library/src/main/java/oupson/apng/decoder/ApngLoader.kt +++ b/apng_library/src/main/java/oupson/apng/decoder/ApngLoader.kt @@ -10,8 +10,6 @@ import android.widget.ImageView import androidx.annotation.RawRes import kotlinx.coroutines.* import oupson.apng.drawable.ApngDrawable -import oupson.apng.utils.Loader -import java.io.ByteArrayInputStream import java.io.File import java.io.FileInputStream import java.io.FileNotFoundException @@ -29,7 +27,7 @@ class ApngLoader(parent: Job? = null) { * Function called when something gone wrong. * @param error The problem. */ - fun onError(error: Exception) + fun onError(error: Throwable) } private val job = SupervisorJob(parent) @@ -52,23 +50,26 @@ class ApngLoader(parent: Job? = null) { file: File, imageView: ImageView, config: ApngDecoder.Config = ApngDecoder.Config() - ): Drawable { - val drawable = - ApngDecoder.decodeApng( - context, + ): Result { + val result = + ApngDecoder( withContext(Dispatchers.IO) { FileInputStream(file) }, config - ) - withContext(Dispatchers.Main) { - imageView.setImageDrawable(drawable) - (drawable as? AnimationDrawable)?.start() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - (drawable as? AnimatedImageDrawable)?.start() + ).getDecoded(context) + + if (result.isSuccess) { + withContext(Dispatchers.Main) { + val drawable = result.getOrNull() + imageView.setImageDrawable(drawable) + (drawable as? AnimationDrawable)?.start() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + (drawable as? AnimatedImageDrawable)?.start() + } } } - return drawable + return result } /** @@ -83,25 +84,27 @@ class ApngLoader(parent: Job? = null) { uri: Uri, imageView: ImageView, config: ApngDecoder.Config = ApngDecoder.Config() - ): Drawable { + ): Result { val inputStream = withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) } - ?: throw FileNotFoundException("Failed to load $uri") // TODO Better err ? - val drawable = - ApngDecoder.decodeApng( - context, + ?: throw FileNotFoundException("Failed to load $uri") // TODO Result + val result = + ApngDecoder( inputStream, config - ) - withContext(Dispatchers.Main) { - imageView.setImageDrawable(drawable) - (drawable as? AnimationDrawable)?.start() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - (drawable as? AnimatedImageDrawable)?.start() + ).getDecoded(context) + + if (result.isSuccess) { + withContext(Dispatchers.Main) { + val drawable = result.getOrNull() + imageView.setImageDrawable(drawable) + (drawable as? AnimationDrawable)?.start() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + (drawable as? AnimatedImageDrawable)?.start() + } } } - - return drawable + return result } /** @@ -115,21 +118,26 @@ class ApngLoader(parent: Job? = null) { context: Context, @RawRes res: Int, imageView: ImageView, config: ApngDecoder.Config = ApngDecoder.Config() - ): Drawable { - val drawable = - ApngDecoder.decodeApng( - context, - context.resources.openRawResource(res), + ): Result { + val result = + ApngDecoder( + withContext(Dispatchers.IO) { + context.resources.openRawResource(res) + }, config - ) - withContext(Dispatchers.Main) { - imageView.setImageDrawable(drawable) - (drawable as? AnimationDrawable)?.start() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - (drawable as? AnimatedImageDrawable)?.start() + ).getDecoded(context) + + if (result.isSuccess) { + withContext(Dispatchers.Main) { + val drawable = result.getOrNull() + imageView.setImageDrawable(drawable) + (drawable as? AnimationDrawable)?.start() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + (drawable as? AnimatedImageDrawable)?.start() + } } } - return drawable + return result } /** @@ -144,26 +152,20 @@ class ApngLoader(parent: Job? = null) { url: URL, imageView: ImageView, config: ApngDecoder.Config = ApngDecoder.Config() - ): Drawable { - - val drawable = ApngDecoder.decodeApng( - context, - ByteArrayInputStream( - Loader.load( - url - ) - ), - config - ) - withContext(Dispatchers.Main) { - imageView.setImageDrawable(drawable) - (drawable as? AnimationDrawable)?.start() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - (drawable as? AnimatedImageDrawable)?.start() + ): Result { + val result = ApngDecoder.constructFromUrl(url, config).getDecoded(context) + if (result.isSuccess) { + withContext(Dispatchers.Main) { + val drawable = result.getOrNull() + imageView.setImageDrawable(drawable) + (drawable as? AnimationDrawable)?.start() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + (drawable as? AnimatedImageDrawable)?.start() + } } } - return drawable + return result } /** @@ -179,7 +181,7 @@ class ApngLoader(parent: Job? = null) { string: String, imageView: ImageView, config: ApngDecoder.Config = ApngDecoder.Config() - ): Drawable { + ): Result { return if (string.startsWith("http://") || string.startsWith("https://")) { decodeApngInto( context, @@ -198,21 +200,25 @@ class ApngLoader(parent: Job? = null) { config ) } else if (string.startsWith("file://android_asset/")) { - val drawable = - ApngDecoder.decodeApng( - context, - context.assets.open(string.replace("file:///android_asset/", "")), - - config - ) - withContext(Dispatchers.Main) { - imageView.setImageDrawable(drawable) - (drawable as? AnimationDrawable)?.start() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - (drawable as? AnimatedImageDrawable)?.start() + val inputStream = kotlin.runCatching { + withContext(Dispatchers.IO) { + context.assets.open(string.replace("file:///android_asset/", "")) + } + }.onFailure { + return Result.failure(it) + } + val result = ApngDecoder(inputStream.getOrThrow(), config).getDecoded(context) + if (result.isSuccess) { + withContext(Dispatchers.Main) { + val drawable = result.getOrNull() + imageView.setImageDrawable(drawable) + (drawable as? AnimationDrawable)?.start() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + (drawable as? AnimatedImageDrawable)?.start() + } } } - drawable + result } else { throw Exception("Cannot open string") } @@ -228,7 +234,7 @@ class ApngLoader(parent: Job? = null) { * @param callback [ApngLoader.Callback] to handle success and error. * @param config Decoder configuration */ - @Suppress("unused", "BlockingMethodInNonBlockingContext") + @Suppress("unused") @JvmOverloads fun decodeApngAsyncInto( context: Context, @@ -238,11 +244,13 @@ class ApngLoader(parent: Job? = null) { config: ApngDecoder.Config = ApngDecoder.Config() ) = coroutineScope.launch(Dispatchers.Default) { - try { - val drawable = decodeApngInto(context, file, imageView, config) - callback?.onSuccess(drawable) - } catch (e: Exception) { - callback?.onError(e) + val drawable = decodeApngInto(context, file, imageView, config) + withContext(Dispatchers.Main) { + if (drawable.isSuccess) { + callback?.onSuccess(drawable.getOrNull()!!) + } else { + callback?.onError(drawable.exceptionOrNull()!!) + } } } @@ -264,11 +272,13 @@ class ApngLoader(parent: Job? = null) { callback: Callback? = null, config: ApngDecoder.Config = ApngDecoder.Config() ) = coroutineScope.launch(Dispatchers.Default) { - try { - val drawable = decodeApngInto(context, uri, imageView, config) - callback?.onSuccess(drawable) - } catch (e: Exception) { - callback?.onError(e) + val drawable = decodeApngInto(context, uri, imageView, config) + withContext(Dispatchers.Main) { + if (drawable.isSuccess) { + callback?.onSuccess(drawable.getOrNull()!!) + } else { + callback?.onError(drawable.exceptionOrNull()!!) + } } } @@ -288,11 +298,13 @@ class ApngLoader(parent: Job? = null) { callback: Callback? = null, config: ApngDecoder.Config = ApngDecoder.Config() ) = coroutineScope.launch(Dispatchers.Default) { - try { - val drawable = decodeApngInto(context, res, imageView, config) - callback?.onSuccess(drawable) - } catch (e: Exception) { - callback?.onError(e) + val drawable = decodeApngInto(context, res, imageView, config) + withContext(Dispatchers.Main) { + if (drawable.isSuccess) { + callback?.onSuccess(drawable.getOrNull()!!) + } else { + callback?.onError(drawable.exceptionOrNull()!!) + } } } @@ -304,7 +316,7 @@ class ApngLoader(parent: Job? = null) { * @param callback [ApngLoader.Callback] to handle success and error. * @param config Decoder configuration */ - @Suppress("unused", "BlockingMethodInNonBlockingContext") + @Suppress("unused") @JvmOverloads fun decodeApngAsyncInto( context: Context, @@ -313,11 +325,13 @@ class ApngLoader(parent: Job? = null) { callback: Callback? = null, config: ApngDecoder.Config = ApngDecoder.Config() ) = coroutineScope.launch(Dispatchers.Default) { - try { - val drawable = decodeApngInto(context, url, imageView, config) - callback?.onSuccess(drawable) - } catch (e: Exception) { - callback?.onError(e) + val drawable = decodeApngInto(context, url, imageView, config) + withContext(Dispatchers.Main) { + if (drawable.isSuccess) { + callback?.onSuccess(drawable.getOrNull()!!) + } else { + callback?.onError(drawable.exceptionOrNull()!!) + } } } @@ -339,12 +353,12 @@ class ApngLoader(parent: Job? = null) { config: ApngDecoder.Config = ApngDecoder.Config() ) = coroutineScope.launch(Dispatchers.Default) { - try { - val drawable = decodeApngInto(context, string, imageView, config) - callback?.onSuccess(drawable) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - callback?.onError(e) + val drawable = decodeApngInto(context, string, imageView, config) + withContext(Dispatchers.Main) { + if (drawable.isSuccess) { + callback?.onSuccess(drawable.getOrNull()!!) + } else { + callback?.onError(drawable.exceptionOrNull()!!) } } } diff --git a/app-test/src/main/java/oupson/apngcreator/activities/ViewerActivity.kt b/app-test/src/main/java/oupson/apngcreator/activities/ViewerActivity.kt index e7ba8d1..715335b 100644 --- a/app-test/src/main/java/oupson/apngcreator/activities/ViewerActivity.kt +++ b/app-test/src/main/java/oupson/apngcreator/activities/ViewerActivity.kt @@ -60,7 +60,7 @@ class ViewerActivity : AppCompatActivity() { viewerImageView, callback = object : ApngLoader.Callback { override fun onSuccess(drawable: Drawable) {} - override fun onError(error: Exception) { + override fun onError(error: Throwable) { Log.e("ViewerActivity", "Error when loading file", error) } }, diff --git a/app-test/src/main/java/oupson/apngcreator/fragments/ApngDecoderFragment.kt b/app-test/src/main/java/oupson/apngcreator/fragments/ApngDecoderFragment.kt index 4edb6e8..ccd7037 100644 --- a/app-test/src/main/java/oupson/apngcreator/fragments/ApngDecoderFragment.kt +++ b/app-test/src/main/java/oupson/apngcreator/fragments/ApngDecoderFragment.kt @@ -56,7 +56,7 @@ class ApngDecoderFragment : Fragment() { ) } - override fun onError(error: Exception) { + override fun onError(error: Throwable) { Log.e(TAG, "onError : $error") } }) diff --git a/app-test/src/main/java/oupson/apngcreator/fragments/JavaFragment.java b/app-test/src/main/java/oupson/apngcreator/fragments/JavaFragment.java index 49db007..d3e3e34 100644 --- a/app-test/src/main/java/oupson/apngcreator/fragments/JavaFragment.java +++ b/app-test/src/main/java/oupson/apngcreator/fragments/JavaFragment.java @@ -52,7 +52,7 @@ public class JavaFragment extends Fragment { } @Override - public void onError(@NotNull Exception error) { + public void onError(@NotNull Throwable error) { Log.e(TAG, "Error : " + error.toString()); } }, new ApngDecoder.Config().setIsDecodingCoverFrame(false)); From 31c7529d45258456ed94967fcb39e6edaa3d618b Mon Sep 17 00:00:00 2001 From: Oupson Date: Fri, 25 Jun 2021 13:50:27 +0200 Subject: [PATCH 3/4] Work on result --- .../java/oupson/apng/decoder/ApngDecoder.kt | 864 +++++++++--------- .../java/oupson/apng/decoder/ApngLoader.kt | 41 +- 2 files changed, 454 insertions(+), 451 deletions(-) diff --git a/apng_library/src/main/java/oupson/apng/decoder/ApngDecoder.kt b/apng_library/src/main/java/oupson/apng/decoder/ApngDecoder.kt index 9dc20fa..ff3a7a0 100644 --- a/apng_library/src/main/java/oupson/apng/decoder/ApngDecoder.kt +++ b/apng_library/src/main/java/oupson/apng/decoder/ApngDecoder.kt @@ -53,7 +53,7 @@ class ApngDecoder(input: InputStream, val config: Config) { } } - private val inputStream: InputStream? = input + private var inputStream: InputStream? = input private var result: Result? = null /** @@ -65,470 +65,474 @@ class ApngDecoder(input: InputStream, val config: Config) { */ suspend fun decodeApng( context: Context - ): Drawable = withContext(Dispatchers.Default) { - val inputStream = BufferedInputStream(inputStream) - val bytes = ByteArray(8) - inputStream.mark(8) - withContext(Dispatchers.IO) { - inputStream.read(bytes) - } - - if (Utils.isPng(bytes)) { - var png: ByteArrayOutputStream? = null - var cover: ByteArrayOutputStream? = null - var delay = -1f - var yOffset = -1 - var xOffset = -1 - var plte: ByteArray? = null - var tnrs: ByteArray? = null - var maxWidth = 0 - var maxHeight = 0 - var blendOp: Utils.Companion.BlendOp = Utils.Companion.BlendOp.APNG_BLEND_OP_SOURCE - var disposeOp: Utils.Companion.DisposeOp = - Utils.Companion.DisposeOp.APNG_DISPOSE_OP_NONE - - var ihdrOfApng = ByteArray(0) - - var isApng = false - - val drawable = ApngDrawable().apply { - isOneShot = false - } - - var buffer: Bitmap? = null - - var byteRead: Int - val lengthChunk = ByteArray(4) - do { - val length: Int - val chunk: ByteArray - if (withContext(Dispatchers.IO) { - byteRead = inputStream.read(lengthChunk) - - - if (byteRead != -1) { - length = Utils.uIntFromBytesBigEndian(lengthChunk) - - chunk = ByteArray(length + 8) - byteRead = inputStream.read(chunk) - false - } else { - chunk = ByteArray(0) - true - } - }) { - break + ): Result = + kotlin.runCatching { + withContext(Dispatchers.Default) { + val inputStream = BufferedInputStream(inputStream) + val bytes = ByteArray(8) + inputStream.mark(8) + withContext(Dispatchers.IO) { + inputStream.read(bytes) } - val byteArray = lengthChunk.plus(chunk) - val chunkCRC = Utils.uIntFromBytesBigEndian(byteArray, byteArray.size - 4) - val crc = CRC32() - crc.update(byteArray, 4, byteArray.size - 8) - if (chunkCRC == crc.value.toInt()) { - val name = byteArray.copyOfRange(4, 8) - when { - name.contentEquals(Utils.fcTL) -> { - if (png == null) { - if (config.decodeCoverFrame) { - drawable.coverFrame = cover?.let { - it.write(zeroLength) + if (Utils.isPng(bytes)) { + var png: ByteArrayOutputStream? = null + var cover: ByteArrayOutputStream? = null + var delay = -1f + var yOffset = -1 + var xOffset = -1 + var plte: ByteArray? = null + var tnrs: ByteArray? = null + var maxWidth = 0 + var maxHeight = 0 + var blendOp: Utils.Companion.BlendOp = + Utils.Companion.BlendOp.APNG_BLEND_OP_SOURCE + var disposeOp: Utils.Companion.DisposeOp = + Utils.Companion.DisposeOp.APNG_DISPOSE_OP_NONE + + var ihdrOfApng = ByteArray(0) + + var isApng = false + + val drawable = ApngDrawable().apply { + isOneShot = false + } + + var buffer: Bitmap? = null + + var byteRead: Int + val lengthChunk = ByteArray(4) + do { + val length: Int + val chunk: ByteArray + if (withContext(Dispatchers.IO) { + byteRead = inputStream.read(lengthChunk) + + + if (byteRead != -1) { + length = Utils.uIntFromBytesBigEndian(lengthChunk) + + chunk = ByteArray(length + 8) + byteRead = inputStream.read(chunk) + false + } else { + chunk = ByteArray(0) + true + } + }) { + break + } + + val byteArray = lengthChunk.plus(chunk) + val chunkCRC = Utils.uIntFromBytesBigEndian(byteArray, byteArray.size - 4) + val crc = CRC32() + crc.update(byteArray, 4, byteArray.size - 8) + if (chunkCRC == crc.value.toInt()) { + val name = byteArray.copyOfRange(4, 8) + when { + name.contentEquals(Utils.fcTL) -> { + if (png == null) { + if (config.decodeCoverFrame) { + drawable.coverFrame = cover?.let { + it.write(zeroLength) + // Generate crc for IEND + val crC32 = CRC32() + crC32.update(Utils.IEND, 0, Utils.IEND.size) + it.write(Utils.IEND) + it.write(Utils.uIntToByteArray(crC32.value.toInt())) + + val pngBytes = it.toByteArray() + BitmapFactory.decodeByteArray( + pngBytes, + 0, + pngBytes.size + ) + } + } + cover = null + } else { + // Add IEND body length : 0 + png.write(zeroLength) + // Add IEND // Generate crc for IEND val crC32 = CRC32() crC32.update(Utils.IEND, 0, Utils.IEND.size) - it.write(Utils.IEND) - it.write(Utils.uIntToByteArray(crC32.value.toInt())) + png.write(Utils.IEND) + png.write(Utils.uIntToByteArray(crC32.value.toInt())) - val pngBytes = it.toByteArray() - BitmapFactory.decodeByteArray( - pngBytes, - 0, - pngBytes.size - ) - } - } - cover = null - } else { - // Add IEND body length : 0 - png.write(zeroLength) - // Add IEND - // Generate crc for IEND - val crC32 = CRC32() - crC32.update(Utils.IEND, 0, Utils.IEND.size) - png.write(Utils.IEND) - png.write(Utils.uIntToByteArray(crC32.value.toInt())) - - val btm = Bitmap.createBitmap( - maxWidth, - maxHeight, - Bitmap.Config.ARGB_8888 - ) - - val pngBytes = png.toByteArray() - val decoded = BitmapFactory.decodeByteArray( - pngBytes, - 0, - pngBytes.size - ) - val canvas = Canvas(btm) - canvas.drawBitmap(buffer!!, 0f, 0f, null) - - if (blendOp == Utils.Companion.BlendOp.APNG_BLEND_OP_SOURCE) { - canvas.drawRect( - xOffset.toFloat(), - yOffset.toFloat(), - xOffset + decoded.width.toFloat(), - yOffset + decoded.height.toFloat(), - clearPaint - ) - } - - canvas.drawBitmap( - decoded, - xOffset.toFloat(), - yOffset.toFloat(), - null - ) - - drawable.addFrame( - BitmapDrawable( - context.resources, - if (btm.config != config.bitmapConfig) { - if (BuildConfig.DEBUG) - Log.v( - TAG, - "Bitmap Config : ${btm.config}, Config : $config" - ) - btm.copy(config.bitmapConfig, btm.isMutable) - } else { - btm - } - ), - (delay / config.speed).toInt() - ) - - when (disposeOp) { - Utils.Companion.DisposeOp.APNG_DISPOSE_OP_PREVIOUS -> { - //Do nothings - } - // Add current frame to bitmap buffer - // APNG_DISPOSE_OP_BACKGROUND: the frame's region of the output buffer is to be cleared to fully transparent black before rendering the next frame. - Utils.Companion.DisposeOp.APNG_DISPOSE_OP_BACKGROUND -> { - val res = Bitmap.createBitmap( + val btm = Bitmap.createBitmap( maxWidth, maxHeight, Bitmap.Config.ARGB_8888 ) - val can = Canvas(res) - can.drawBitmap(btm, 0f, 0f, null) - can.drawRect( + + val pngBytes = png.toByteArray() + val decoded = BitmapFactory.decodeByteArray( + pngBytes, + 0, + pngBytes.size + ) + val canvas = Canvas(btm) + canvas.drawBitmap(buffer!!, 0f, 0f, null) + + if (blendOp == Utils.Companion.BlendOp.APNG_BLEND_OP_SOURCE) { + canvas.drawRect( + xOffset.toFloat(), + yOffset.toFloat(), + xOffset + decoded.width.toFloat(), + yOffset + decoded.height.toFloat(), + clearPaint + ) + } + + canvas.drawBitmap( + decoded, xOffset.toFloat(), yOffset.toFloat(), - xOffset + decoded.width.toFloat(), - yOffset + decoded.height.toFloat(), - clearPaint + null ) - buffer = res + + drawable.addFrame( + BitmapDrawable( + context.resources, + if (btm.config != config.bitmapConfig) { + if (BuildConfig.DEBUG) + Log.v( + TAG, + "Bitmap Config : ${btm.config}, Config : $config" + ) + btm.copy(config.bitmapConfig, btm.isMutable) + } else { + btm + } + ), + (delay / config.speed).toInt() + ) + + when (disposeOp) { + Utils.Companion.DisposeOp.APNG_DISPOSE_OP_PREVIOUS -> { + //Do nothings + } + // Add current frame to bitmap buffer + // APNG_DISPOSE_OP_BACKGROUND: the frame's region of the output buffer is to be cleared to fully transparent black before rendering the next frame. + Utils.Companion.DisposeOp.APNG_DISPOSE_OP_BACKGROUND -> { + val res = Bitmap.createBitmap( + maxWidth, + maxHeight, + Bitmap.Config.ARGB_8888 + ) + val can = Canvas(res) + can.drawBitmap(btm, 0f, 0f, null) + can.drawRect( + xOffset.toFloat(), + yOffset.toFloat(), + xOffset + decoded.width.toFloat(), + yOffset + decoded.height.toFloat(), + clearPaint + ) + buffer = res + } + else -> buffer = btm + } + } - else -> buffer = btm - } - } + png = ByteArrayOutputStream(4096) - png = ByteArrayOutputStream(4096) + // Parse Frame ConTroL chunk + // Get the width of the png + val width = Utils.uIntFromBytesBigEndian( + byteArray, 12 + ) + // Get the height of the png + val height = Utils.uIntFromBytesBigEndian( + byteArray, 16 + ) - // Parse Frame ConTroL chunk - // Get the width of the png - val width = Utils.uIntFromBytesBigEndian( - byteArray, 12 - ) - // Get the height of the png - val height = Utils.uIntFromBytesBigEndian( - byteArray, 16 - ) - - /* + /* * The `delay_num` and `delay_den` parameters together specify a fraction indicating the time to display the current frame, in seconds. * If the the value of the numerator is 0 the decoder should render the next frame as quickly as possible, though viewers may impose a reasonable lower bound. */ - // Get delay numerator - val delayNum = Utils.uShortFromBytesBigEndian( - byteArray, 28 - ).toFloat() - // Get delay denominator - var delayDen = Utils.uShortFromBytesBigEndian( - byteArray, 30 - ).toFloat() + // Get delay numerator + val delayNum = Utils.uShortFromBytesBigEndian( + byteArray, 28 + ).toFloat() + // Get delay denominator + var delayDen = Utils.uShortFromBytesBigEndian( + byteArray, 30 + ).toFloat() - // If the denominator is 0, it is to be treated as if it were 100 (that is, `delay_num` then specifies 1/100ths of a second). - if (delayDen == 0f) { - delayDen = 100f - } - - delay = (delayNum / delayDen * 1000) - - // Get x and y offsets - xOffset = Utils.uIntFromBytesBigEndian( - byteArray, 20 - ) - yOffset = Utils.uIntFromBytesBigEndian( - byteArray, 24 - ) - blendOp = Utils.decodeBlendOp(byteArray[33].toInt()) - disposeOp = Utils.decodeDisposeOp(byteArray[32].toInt()) - - if (xOffset + width > maxWidth) { - throw BadApngException("`xOffset` + `width` must be <= `IHDR` width") - } else if (yOffset + height > maxHeight) { - throw BadApngException("`yOffset` + `height` must be <= `IHDR` height") - } - - png.write(Utils.pngSignature) - png.write( - generateIhdr( - ihdrOfApng, - width, - height - ) - ) - plte?.let { - png.write(it) - } - tnrs?.let { - png.write(it) - } - - } - name.contentEquals(Utils.IEND) -> { - if (isApng && png != null) { - png.write(zeroLength) - // Add IEND - // Generate crc for IEND - val crC32 = CRC32() - crC32.update(Utils.IEND, 0, Utils.IEND.size) - png.write(Utils.IEND) - png.write(Utils.uIntToByteArray(crC32.value.toInt())) - - val btm = Bitmap.createBitmap( - maxWidth, - maxHeight, - Bitmap.Config.ARGB_8888 - ) - - val pngBytes = png.toByteArray() - val decoded = BitmapFactory.decodeByteArray( - pngBytes, - 0, - pngBytes.size - ) - val canvas = Canvas(btm) - canvas.drawBitmap(buffer!!, 0f, 0f, null) - - if (blendOp == Utils.Companion.BlendOp.APNG_BLEND_OP_SOURCE) { - canvas.drawRect( - xOffset.toFloat(), - yOffset.toFloat(), - xOffset + decoded.width.toFloat(), - yOffset + decoded.height.toFloat(), - clearPaint - ) - } - - canvas.drawBitmap( - decoded, - xOffset.toFloat(), - yOffset.toFloat(), - null - ) - drawable.addFrame( - BitmapDrawable( - context.resources, - if (btm.config != config.bitmapConfig) { - if (BuildConfig.DEBUG) - Log.v( - TAG, - "Bitmap Config : ${btm.config}, Config : $config" - ) - btm.copy(config.bitmapConfig, btm.isMutable) - } else { - btm - } - ), - (delay / config.speed).toInt() - ) - - when (disposeOp) { - Utils.Companion.DisposeOp.APNG_DISPOSE_OP_PREVIOUS -> { - //Do nothings + // If the denominator is 0, it is to be treated as if it were 100 (that is, `delay_num` then specifies 1/100ths of a second). + if (delayDen == 0f) { + delayDen = 100f } - // Add current frame to bitmap buffer - // APNG_DISPOSE_OP_BACKGROUND: the frame's region of the output buffer is to be cleared to fully transparent black before rendering the next frame. - Utils.Companion.DisposeOp.APNG_DISPOSE_OP_BACKGROUND -> { - val res = Bitmap.createBitmap( + + delay = (delayNum / delayDen * 1000) + + // Get x and y offsets + xOffset = Utils.uIntFromBytesBigEndian( + byteArray, 20 + ) + yOffset = Utils.uIntFromBytesBigEndian( + byteArray, 24 + ) + blendOp = Utils.decodeBlendOp(byteArray[33].toInt()) + disposeOp = Utils.decodeDisposeOp(byteArray[32].toInt()) + + if (xOffset + width > maxWidth) { + throw BadApngException("`xOffset` + `width` must be <= `IHDR` width") + } else if (yOffset + height > maxHeight) { + throw BadApngException("`yOffset` + `height` must be <= `IHDR` height") + } + + png.write(Utils.pngSignature) + png.write( + generateIhdr( + ihdrOfApng, + width, + height + ) + ) + plte?.let { + png.write(it) + } + tnrs?.let { + png.write(it) + } + + } + name.contentEquals(Utils.IEND) -> { + if (isApng && png != null) { + png.write(zeroLength) + // Add IEND + // Generate crc for IEND + val crC32 = CRC32() + crC32.update(Utils.IEND, 0, Utils.IEND.size) + png.write(Utils.IEND) + png.write(Utils.uIntToByteArray(crC32.value.toInt())) + + val btm = Bitmap.createBitmap( maxWidth, maxHeight, Bitmap.Config.ARGB_8888 ) - val can = Canvas(res) - can.drawBitmap(btm, 0f, 0f, null) - can.drawRect( - xOffset.toFloat(), - yOffset.toFloat(), - xOffset + decoded.width.toFloat(), - yOffset + decoded.height.toFloat(), - clearPaint - ) - buffer = res - } - else -> buffer = btm - } - } else { - cover?.let { - it.write(zeroLength) - // Add IEND - // Generate crc for IEND - val crC32 = CRC32() - crC32.update(Utils.IEND, 0, Utils.IEND.size) - it.write(Utils.IEND) - it.write(Utils.uIntToByteArray(crC32.value.toInt())) - withContext(Dispatchers.IO) { - inputStream.close() - } - val pngBytes = it.toByteArray() - return@withContext BitmapDrawable( - context.resources, - BitmapFactory.decodeByteArray( + val pngBytes = png.toByteArray() + val decoded = BitmapFactory.decodeByteArray( pngBytes, 0, pngBytes.size ) - ) - } - } - } - name.contentEquals(Utils.IDAT) -> { - val w = if (png == null) { - if (isApng && !config.decodeCoverFrame) { - if (BuildConfig.DEBUG) - Log.d(TAG, "Ignoring cover frame") - continue - } - if (cover == null) { - cover = ByteArrayOutputStream() - cover.write(Utils.pngSignature) - cover.write( - generateIhdr( - ihdrOfApng, - maxWidth, - maxHeight + val canvas = Canvas(btm) + canvas.drawBitmap(buffer!!, 0f, 0f, null) + + if (blendOp == Utils.Companion.BlendOp.APNG_BLEND_OP_SOURCE) { + canvas.drawRect( + xOffset.toFloat(), + yOffset.toFloat(), + xOffset + decoded.width.toFloat(), + yOffset + decoded.height.toFloat(), + clearPaint + ) + } + + canvas.drawBitmap( + decoded, + xOffset.toFloat(), + yOffset.toFloat(), + null ) + drawable.addFrame( + BitmapDrawable( + context.resources, + if (btm.config != config.bitmapConfig) { + if (BuildConfig.DEBUG) + Log.v( + TAG, + "Bitmap Config : ${btm.config}, Config : $config" + ) + btm.copy(config.bitmapConfig, btm.isMutable) + } else { + btm + } + ), + (delay / config.speed).toInt() + ) + + when (disposeOp) { + Utils.Companion.DisposeOp.APNG_DISPOSE_OP_PREVIOUS -> { + //Do nothings + } + // Add current frame to bitmap buffer + // APNG_DISPOSE_OP_BACKGROUND: the frame's region of the output buffer is to be cleared to fully transparent black before rendering the next frame. + Utils.Companion.DisposeOp.APNG_DISPOSE_OP_BACKGROUND -> { + val res = Bitmap.createBitmap( + maxWidth, + maxHeight, + Bitmap.Config.ARGB_8888 + ) + val can = Canvas(res) + can.drawBitmap(btm, 0f, 0f, null) + can.drawRect( + xOffset.toFloat(), + yOffset.toFloat(), + xOffset + decoded.width.toFloat(), + yOffset + decoded.height.toFloat(), + clearPaint + ) + buffer = res + } + else -> buffer = btm + } + } else { + cover?.let { + it.write(zeroLength) + // Add IEND + // Generate crc for IEND + val crC32 = CRC32() + crC32.update(Utils.IEND, 0, Utils.IEND.size) + it.write(Utils.IEND) + it.write(Utils.uIntToByteArray(crC32.value.toInt())) + withContext(Dispatchers.IO) { + inputStream.close() + } + + val pngBytes = it.toByteArray() + return@withContext BitmapDrawable( + context.resources, + BitmapFactory.decodeByteArray( + pngBytes, + 0, + pngBytes.size + ) + ) + } + } + } + name.contentEquals(Utils.IDAT) -> { + val w = if (png == null) { + if (isApng && !config.decodeCoverFrame) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Ignoring cover frame") + continue + } + if (cover == null) { + cover = ByteArrayOutputStream() + cover.write(Utils.pngSignature) + cover.write( + generateIhdr( + ihdrOfApng, + maxWidth, + maxHeight + ) + ) + } + cover + } else { + png + } + + // Find the chunk length + val bodySize = + Utils.uIntFromBytesBigEndian( + byteArray, 0 + ) + w.write(byteArray.copyOfRange(0, 4)) + + val body = ByteArray(4 + bodySize) + + System.arraycopy(Utils.IDAT, 0, body, 0, 4) + + // Get image bytes + System.arraycopy(byteArray, 8, body, 4, bodySize) + + val crC32 = CRC32() + crC32.update(body, 0, body.size) + w.write(body) + w.write(Utils.uIntToByteArray(crC32.value.toInt())) + } + name.contentEquals(Utils.fdAT) -> { + // Find the chunk length + val bodySize = Utils.uIntFromBytesBigEndian(byteArray, 0) + png?.write(Utils.uIntToByteArray(bodySize - 4)) + + val body = ByteArray(bodySize) + System.arraycopy(Utils.IDAT, 0, body, 0, 4) + + // Get image bytes + System.arraycopy(byteArray, 12, body, 4, bodySize - 4) + + val crC32 = CRC32() + crC32.update(body, 0, body.size) + png?.write(body) + png?.write(Utils.uIntToByteArray(crC32.value.toInt())) + } + name.contentEquals(Utils.plte) -> { + plte = byteArray + } + name.contentEquals(Utils.tnrs) -> { + tnrs = byteArray + } + name.contentEquals(Utils.IHDR) -> { + // Get length of the body of the chunk + val bodySize = Utils.uIntFromBytesBigEndian(byteArray, 0) + // Get the width of the png + maxWidth = Utils.uIntFromBytesBigEndian(byteArray, 8) + // Get the height of the png + maxHeight = Utils.uIntFromBytesBigEndian(byteArray, 12) + ihdrOfApng = byteArray.copyOfRange(4 + 4, 4 + bodySize + 4) + + buffer = Bitmap.createBitmap( + maxWidth, + maxHeight, + Bitmap.Config.ARGB_8888 ) } - cover - } else { - png + name.contentEquals(Utils.acTL) -> { // TODO GET NBR REPETITIONS + isApng = true + } } - - // Find the chunk length - val bodySize = - Utils.uIntFromBytesBigEndian( - byteArray, 0 - ) - w.write(byteArray.copyOfRange(0, 4)) - - val body = ByteArray(4 + bodySize) - - System.arraycopy(Utils.IDAT, 0, body, 0, 4) - - // Get image bytes - System.arraycopy(byteArray, 8, body, 4, bodySize) - - val crC32 = CRC32() - crC32.update(body, 0, body.size) - w.write(body) - w.write(Utils.uIntToByteArray(crC32.value.toInt())) - } - name.contentEquals(Utils.fdAT) -> { - // Find the chunk length - val bodySize = Utils.uIntFromBytesBigEndian(byteArray, 0) - png?.write(Utils.uIntToByteArray(bodySize - 4)) - - val body = ByteArray(bodySize) - System.arraycopy(Utils.IDAT, 0, body, 0, 4) - - // Get image bytes - System.arraycopy(byteArray, 12, body, 4, bodySize - 4) - - val crC32 = CRC32() - crC32.update(body, 0, body.size) - png?.write(body) - png?.write(Utils.uIntToByteArray(crC32.value.toInt())) - } - name.contentEquals(Utils.plte) -> { - plte = byteArray - } - name.contentEquals(Utils.tnrs) -> { - tnrs = byteArray - } - name.contentEquals(Utils.IHDR) -> { - // Get length of the body of the chunk - val bodySize = Utils.uIntFromBytesBigEndian(byteArray, 0) - // Get the width of the png - maxWidth = Utils.uIntFromBytesBigEndian(byteArray, 8) - // Get the height of the png - maxHeight = Utils.uIntFromBytesBigEndian(byteArray, 12) - ihdrOfApng = byteArray.copyOfRange(4 + 4, 4 + bodySize + 4) - - buffer = Bitmap.createBitmap( - maxWidth, - maxHeight, - Bitmap.Config.ARGB_8888 - ) - } - name.contentEquals(Utils.acTL) -> { // TODO GET NBR REPETITIONS - isApng = true - } + } else throw BadCRCException() + } while (byteRead != -1 && isActive) + withContext(Dispatchers.IO) { + inputStream.close() } - } else throw BadCRCException() - } while (byteRead != -1 && isActive) - withContext(Dispatchers.IO) { - inputStream.close() - } - return@withContext drawable - } else { - if (BuildConfig.DEBUG) - Log.i(TAG, "Decoding non APNG stream") - inputStream.reset() + drawable + } else { + if (BuildConfig.DEBUG) + Log.i(TAG, "Decoding non APNG stream") + inputStream.reset() - return@withContext if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - val bytesRead: ByteArray - withContext(Dispatchers.IO) { - bytesRead = inputStream.readBytes() - inputStream.close() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val bytesRead: ByteArray + withContext(Dispatchers.IO) { + bytesRead = inputStream.readBytes() + inputStream.close() + } + val buf = ByteBuffer.wrap(bytesRead) + val source = ImageDecoder.createSource(buf) + withContext(Dispatchers.IO) { + ImageDecoder.decodeDrawable(source) + } + } else { + val drawable = Drawable.createFromStream( + inputStream, + null + ) + withContext(Dispatchers.IO) { + inputStream.close() + } + drawable!! + } } - val buf = ByteBuffer.wrap(bytesRead) - val source = ImageDecoder.createSource(buf) - withContext(Dispatchers.IO) { - ImageDecoder.decodeDrawable(source) - } - } else { - val drawable = Drawable.createFromStream( - inputStream, - null - ) - withContext(Dispatchers.IO) { - inputStream.close() - } - drawable } } - } suspend fun getDecoded(context: Context): Result { if (result == null) { - result = kotlin.runCatching { + result = decodeApng(context) - } + kotlin.runCatching { withContext(Dispatchers.IO) { @@ -537,6 +541,8 @@ class ApngDecoder(input: InputStream, val config: Config) { }.onFailure { return Result.failure(it) } + + inputStream = null } return result ?: Result.failure(NullPointerException("result is null")) @@ -639,17 +645,19 @@ class ApngDecoder(input: InputStream, val config: Config) { * @param config Decoder configuration * @return [ApngDrawable] if successful and an [AnimatedImageDrawable] if the image decoded is not an APNG but a gif. */ - @Suppress("unused", "BlockingMethodInNonBlockingContext") + @Suppress("unused") @JvmStatic suspend fun constructFromUrl( url: URL, config: Config = Config() - ) = - withContext(Dispatchers.IO) { - ApngDecoder( - ByteArrayInputStream(Loader.load(url)), - config - ) + ): Result = + kotlin.runCatching { + withContext(Dispatchers.IO) { + ApngDecoder( + ByteArrayInputStream(Loader.load(url)), + config + ) + } } } } \ No newline at end of file diff --git a/apng_library/src/main/java/oupson/apng/decoder/ApngLoader.kt b/apng_library/src/main/java/oupson/apng/decoder/ApngLoader.kt index a670415..20d46eb 100644 --- a/apng_library/src/main/java/oupson/apng/decoder/ApngLoader.kt +++ b/apng_library/src/main/java/oupson/apng/decoder/ApngLoader.kt @@ -153,7 +153,9 @@ class ApngLoader(parent: Job? = null) { imageView: ImageView, config: ApngDecoder.Config = ApngDecoder.Config() ): Result { - val result = ApngDecoder.constructFromUrl(url, config).getDecoded(context) + val result = + ApngDecoder.constructFromUrl(url, config).getOrElse { return Result.failure(it) } + .getDecoded(context) if (result.isSuccess) { withContext(Dispatchers.Main) { val drawable = result.getOrNull() @@ -204,10 +206,10 @@ class ApngLoader(parent: Job? = null) { withContext(Dispatchers.IO) { context.assets.open(string.replace("file:///android_asset/", "")) } - }.onFailure { + }.getOrElse { return Result.failure(it) } - val result = ApngDecoder(inputStream.getOrThrow(), config).getDecoded(context) + val result = ApngDecoder(inputStream, config).getDecoded(context) if (result.isSuccess) { withContext(Dispatchers.Main) { val drawable = result.getOrNull() @@ -246,11 +248,9 @@ class ApngLoader(parent: Job? = null) { coroutineScope.launch(Dispatchers.Default) { val drawable = decodeApngInto(context, file, imageView, config) withContext(Dispatchers.Main) { - if (drawable.isSuccess) { - callback?.onSuccess(drawable.getOrNull()!!) - } else { - callback?.onError(drawable.exceptionOrNull()!!) - } + drawable + .onSuccess { callback?.onSuccess(it) } + .onFailure { callback?.onError(it) } } } @@ -274,11 +274,9 @@ class ApngLoader(parent: Job? = null) { ) = coroutineScope.launch(Dispatchers.Default) { val drawable = decodeApngInto(context, uri, imageView, config) withContext(Dispatchers.Main) { - if (drawable.isSuccess) { - callback?.onSuccess(drawable.getOrNull()!!) - } else { - callback?.onError(drawable.exceptionOrNull()!!) - } + drawable + .onSuccess { callback?.onSuccess(it) } + .onFailure { callback?.onError(it) } } } @@ -300,11 +298,9 @@ class ApngLoader(parent: Job? = null) { ) = coroutineScope.launch(Dispatchers.Default) { val drawable = decodeApngInto(context, res, imageView, config) withContext(Dispatchers.Main) { - if (drawable.isSuccess) { - callback?.onSuccess(drawable.getOrNull()!!) - } else { - callback?.onError(drawable.exceptionOrNull()!!) - } + drawable + .onSuccess { callback?.onSuccess(it) } + .onFailure { callback?.onError(it) } } } @@ -327,14 +323,13 @@ class ApngLoader(parent: Job? = null) { ) = coroutineScope.launch(Dispatchers.Default) { val drawable = decodeApngInto(context, url, imageView, config) withContext(Dispatchers.Main) { - if (drawable.isSuccess) { - callback?.onSuccess(drawable.getOrNull()!!) - } else { - callback?.onError(drawable.exceptionOrNull()!!) - } + drawable + .onSuccess { callback?.onSuccess(it) } + .onFailure { callback?.onError(it) } } } + /** * Load Apng into an imageView, asynchronously. * @param context Context needed for decoding the image and creating the animation drawable. From 21f95126885136ccf1d46af89db4f0460f7ae3aa Mon Sep 17 00:00:00 2001 From: Oupson Date: Fri, 25 Jun 2021 16:34:25 +0200 Subject: [PATCH 4/4] Work on app --- apng_library/build.gradle | 4 +- apng_library/src/main/AndroidManifest.xml | 1 + .../java/oupson/apng/decoder/ApngDecoder.kt | 15 +- .../java/oupson/apng/decoder/ApngLoader.kt | 133 ++++++------- .../java/oupson/apng/encoder/ApngEncoder.kt | 2 +- .../src/main/java/oupson/apng/utils/Loader.kt | 9 +- .../src/main/java/oupson/apng/utils/Utils.kt | 11 +- app-test/build.gradle | 15 +- .../apngcreator/activities/CreatorActivity.kt | 177 +++++++++--------- .../apngcreator/activities/MainActivity.kt | 116 ++++++++---- .../apngcreator/activities/ViewerActivity.kt | 35 ++-- .../apngcreator/adapter/ImageAdapter.kt | 22 ++- .../apngcreator/fragments/KotlinFragment.kt | 9 +- app-test/src/main/res/layout/dialog_delay.xml | 8 +- .../main/res/layout/fragment_apng_decoder.xml | 3 +- .../src/main/res/layout/fragment_java.xml | 3 +- .../src/main/res/layout/fragment_kotlin.xml | 4 +- app-test/src/main/res/layout/list_image.xml | 3 +- build.gradle | 3 +- 19 files changed, 323 insertions(+), 250 deletions(-) diff --git a/apng_library/build.gradle b/apng_library/build.gradle index 038ac45..655d275 100644 --- a/apng_library/build.gradle +++ b/apng_library/build.gradle @@ -1,6 +1,5 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' android { @@ -29,8 +28,7 @@ android { } dependencies { - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3' - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0' implementation 'androidx.appcompat:appcompat:1.3.0' diff --git a/apng_library/src/main/AndroidManifest.xml b/apng_library/src/main/AndroidManifest.xml index a49ba4d..e7377fa 100644 --- a/apng_library/src/main/AndroidManifest.xml +++ b/apng_library/src/main/AndroidManifest.xml @@ -1,2 +1,3 @@ + diff --git a/apng_library/src/main/java/oupson/apng/decoder/ApngDecoder.kt b/apng_library/src/main/java/oupson/apng/decoder/ApngDecoder.kt index ff3a7a0..ef8dad0 100644 --- a/apng_library/src/main/java/oupson/apng/decoder/ApngDecoder.kt +++ b/apng_library/src/main/java/oupson/apng/decoder/ApngDecoder.kt @@ -71,6 +71,7 @@ class ApngDecoder(input: InputStream, val config: Config) { val inputStream = BufferedInputStream(inputStream) val bytes = ByteArray(8) inputStream.mark(8) + withContext(Dispatchers.IO) { inputStream.read(bytes) } @@ -108,7 +109,6 @@ class ApngDecoder(input: InputStream, val config: Config) { if (withContext(Dispatchers.IO) { byteRead = inputStream.read(lengthChunk) - if (byteRead != -1) { length = Utils.uIntFromBytesBigEndian(lengthChunk) @@ -149,6 +149,7 @@ class ApngDecoder(input: InputStream, val config: Config) { ) } } + cover?.close() cover = null } else { // Add IEND body length : 0 @@ -234,9 +235,9 @@ class ApngDecoder(input: InputStream, val config: Config) { } else -> buffer = btm } - } + png?.close() png = ByteArrayOutputStream(4096) // Parse Frame ConTroL chunk @@ -318,6 +319,7 @@ class ApngDecoder(input: InputStream, val config: Config) { ) val pngBytes = png.toByteArray() + png.close() val decoded = BitmapFactory.decodeByteArray( pngBytes, 0, @@ -393,11 +395,14 @@ class ApngDecoder(input: InputStream, val config: Config) { crC32.update(Utils.IEND, 0, Utils.IEND.size) it.write(Utils.IEND) it.write(Utils.uIntToByteArray(crC32.value.toInt())) + withContext(Dispatchers.IO) { inputStream.close() } val pngBytes = it.toByteArray() + it.close() + return@withContext BitmapDrawable( context.resources, BitmapFactory.decodeByteArray( @@ -530,16 +535,14 @@ class ApngDecoder(input: InputStream, val config: Config) { suspend fun getDecoded(context: Context): Result { if (result == null) { - result = - decodeApng(context) - + result = decodeApng(context) kotlin.runCatching { withContext(Dispatchers.IO) { inputStream?.close() } }.onFailure { - return Result.failure(it) + this.result = Result.failure(it) } inputStream = null diff --git a/apng_library/src/main/java/oupson/apng/decoder/ApngLoader.kt b/apng_library/src/main/java/oupson/apng/decoder/ApngLoader.kt index 20d46eb..3c46fb4 100644 --- a/apng_library/src/main/java/oupson/apng/decoder/ApngLoader.kt +++ b/apng_library/src/main/java/oupson/apng/decoder/ApngLoader.kt @@ -10,9 +10,9 @@ import android.widget.ImageView import androidx.annotation.RawRes import kotlinx.coroutines.* import oupson.apng.drawable.ApngDrawable +import oupson.apng.utils.Utils.Companion.mapResult import java.io.File import java.io.FileInputStream -import java.io.FileNotFoundException import java.net.URL class ApngLoader(parent: Job? = null) { @@ -50,18 +50,18 @@ class ApngLoader(parent: Job? = null) { file: File, imageView: ImageView, config: ApngDecoder.Config = ApngDecoder.Config() - ): Result { - val result = + ): Result = + kotlin.runCatching { + withContext(Dispatchers.IO) { + FileInputStream(file) + } + }.mapResult { input -> ApngDecoder( - withContext(Dispatchers.IO) { - FileInputStream(file) - }, + input, config ).getDecoded(context) - - if (result.isSuccess) { + }.onSuccess { drawable -> withContext(Dispatchers.Main) { - val drawable = result.getOrNull() imageView.setImageDrawable(drawable) (drawable as? AnimationDrawable)?.start() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -69,8 +69,6 @@ class ApngLoader(parent: Job? = null) { } } } - return result - } /** * Load Apng into an imageView. @@ -84,19 +82,16 @@ class ApngLoader(parent: Job? = null) { uri: Uri, imageView: ImageView, config: ApngDecoder.Config = ApngDecoder.Config() - ): Result { - val inputStream = - withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) } - ?: throw FileNotFoundException("Failed to load $uri") // TODO Result - val result = + ): Result = + kotlin.runCatching { + withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) }!! + }.mapResult { inputStream -> ApngDecoder( inputStream, config ).getDecoded(context) - - if (result.isSuccess) { + }.onSuccess { drawable -> withContext(Dispatchers.Main) { - val drawable = result.getOrNull() imageView.setImageDrawable(drawable) (drawable as? AnimationDrawable)?.start() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -104,8 +99,7 @@ class ApngLoader(parent: Job? = null) { } } } - return result - } + /** * Load Apng into an imageView. @@ -118,27 +112,23 @@ class ApngLoader(parent: Job? = null) { context: Context, @RawRes res: Int, imageView: ImageView, config: ApngDecoder.Config = ApngDecoder.Config() - ): Result { - val result = - ApngDecoder( - withContext(Dispatchers.IO) { - context.resources.openRawResource(res) - }, - config - ).getDecoded(context) - - if (result.isSuccess) { - withContext(Dispatchers.Main) { - val drawable = result.getOrNull() - imageView.setImageDrawable(drawable) - (drawable as? AnimationDrawable)?.start() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - (drawable as? AnimatedImageDrawable)?.start() + ): Result = + ApngDecoder( + withContext(Dispatchers.IO) { + context.resources.openRawResource(res) + }, + config + ).getDecoded(context) + .onSuccess { drawable -> + withContext(Dispatchers.Main) { + imageView.setImageDrawable(drawable) + (drawable as? AnimationDrawable)?.start() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + (drawable as? AnimatedImageDrawable)?.start() + } } } - } - return result - } + /** * Load Apng into an imageView, asynchronously. @@ -152,23 +142,18 @@ class ApngLoader(parent: Job? = null) { url: URL, imageView: ImageView, config: ApngDecoder.Config = ApngDecoder.Config() - ): Result { - val result = - ApngDecoder.constructFromUrl(url, config).getOrElse { return Result.failure(it) } - .getDecoded(context) - if (result.isSuccess) { - withContext(Dispatchers.Main) { - val drawable = result.getOrNull() - imageView.setImageDrawable(drawable) - (drawable as? AnimationDrawable)?.start() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - (drawable as? AnimatedImageDrawable)?.start() + ): Result = + ApngDecoder.constructFromUrl(url, config).getOrElse { return Result.failure(it) } + .getDecoded(context) + .onSuccess { drawable -> + withContext(Dispatchers.Main) { + imageView.setImageDrawable(drawable) + (drawable as? AnimationDrawable)?.start() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + (drawable as? AnimatedImageDrawable)?.start() + } } } - } - - return result - } /** * Load Apng into an imageView, asynchronously. @@ -185,12 +170,16 @@ class ApngLoader(parent: Job? = null) { config: ApngDecoder.Config = ApngDecoder.Config() ): Result { return if (string.startsWith("http://") || string.startsWith("https://")) { - decodeApngInto( - context, - URL(string), - imageView, - config - ) + kotlin.runCatching { URL(string) } + .mapResult { url -> + decodeApngInto( + context, + url, + imageView, + config + ) + } + } else if (File(string).exists()) { var pathToLoad = if (string.startsWith("content://")) string else "file://$string" @@ -202,25 +191,21 @@ class ApngLoader(parent: Job? = null) { config ) } else if (string.startsWith("file://android_asset/")) { - val inputStream = kotlin.runCatching { + kotlin.runCatching { withContext(Dispatchers.IO) { context.assets.open(string.replace("file:///android_asset/", "")) } - }.getOrElse { - return Result.failure(it) } - val result = ApngDecoder(inputStream, config).getDecoded(context) - if (result.isSuccess) { - withContext(Dispatchers.Main) { - val drawable = result.getOrNull() - imageView.setImageDrawable(drawable) - (drawable as? AnimationDrawable)?.start() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - (drawable as? AnimatedImageDrawable)?.start() + .mapResult { inputStream -> ApngDecoder(inputStream, config).getDecoded(context) } + .onSuccess { drawable -> + withContext(Dispatchers.Main) { + imageView.setImageDrawable(drawable) + (drawable as? AnimationDrawable)?.start() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + (drawable as? AnimatedImageDrawable)?.start() + } } } - } - result } else { throw Exception("Cannot open string") } diff --git a/apng_library/src/main/java/oupson/apng/encoder/ApngEncoder.kt b/apng_library/src/main/java/oupson/apng/encoder/ApngEncoder.kt index 113de2b..66c9d0a 100644 --- a/apng_library/src/main/java/oupson/apng/encoder/ApngEncoder.kt +++ b/apng_library/src/main/java/oupson/apng/encoder/ApngEncoder.kt @@ -291,7 +291,7 @@ class ApngEncoder( ) { if (currentFrame == 0) { if (btm.width != width || btm.height != height) - throw InvalidFrameSizeException( + throw InvalidFrameSizeException( // TODO btm.width, btm.height, width, diff --git a/apng_library/src/main/java/oupson/apng/utils/Loader.kt b/apng_library/src/main/java/oupson/apng/utils/Loader.kt index 775008b..ddfe8af 100644 --- a/apng_library/src/main/java/oupson/apng/utils/Loader.kt +++ b/apng_library/src/main/java/oupson/apng/utils/Loader.kt @@ -24,8 +24,11 @@ class Loader { val connection = url.openConnection() as HttpURLConnection connection.useCaches = true connection.connect() + + val inputStream = connection.inputStream + if (connection.responseCode == 200) { - val input = BufferedInputStream(connection.inputStream) + val input = BufferedInputStream(inputStream) val output = ByteArrayOutputStream() var bytesRead: Int val buffer = ByteArray(4096) @@ -36,9 +39,13 @@ class Loader { } while (bytesRead != -1) input.close() output.close() + + inputStream.close() connection.disconnect() + output.toByteArray() } else { + inputStream.close() connection.disconnect() throw Exception("Error when downloading file : ${connection.responseCode}") } diff --git a/apng_library/src/main/java/oupson/apng/utils/Utils.kt b/apng_library/src/main/java/oupson/apng/utils/Utils.kt index 10d861f..d8de186 100644 --- a/apng_library/src/main/java/oupson/apng/utils/Utils.kt +++ b/apng_library/src/main/java/oupson/apng/utils/Utils.kt @@ -208,7 +208,7 @@ class Utils { (bytes[1] and 0xFF)) // TODO DOCUMENT AND TEST - fun uShortFromBytesBigEndian(bytes: ByteArray, offset : Int = 0): Int = + fun uShortFromBytesBigEndian(bytes: ByteArray, offset: Int = 0): Int = (((bytes[offset].toInt() and 0xFF) shl 8) or (bytes[offset + 1].toInt() and 0xFF)) @@ -330,5 +330,14 @@ class Utils { } return result } + + suspend fun Result.mapResult(block: suspend (T) -> Result): Result { + return this.fold( + onSuccess = { + block.invoke(it) + }, + onFailure = { Result.failure(it) } + ) + } } } \ No newline at end of file diff --git a/app-test/build.gradle b/app-test/build.gradle index ce736ef..0beeee8 100644 --- a/app-test/build.gradle +++ b/app-test/build.gradle @@ -1,6 +1,5 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' android { compileSdkVersion 30 @@ -24,29 +23,35 @@ android { buildTypes { release { - minifyEnabled false + minifyEnabled true + shrinkResources true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } productFlavors { } + + buildFeatures { + viewBinding = true + } + } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0' implementation 'androidx.appcompat:appcompat:1.3.0' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'com.google.android.material:material:1.3.0' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test:runner:1.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' - implementation 'com.squareup.picasso:picasso:2.71828' + implementation("io.coil-kt:coil:1.2.2") implementation project(":apng_library") // implementation fileTree(include: ['*.aar'], dir: 'libs') diff --git a/app-test/src/main/java/oupson/apngcreator/activities/CreatorActivity.kt b/app-test/src/main/java/oupson/apngcreator/activities/CreatorActivity.kt index 50427ea..8b6dde6 100644 --- a/app-test/src/main/java/oupson/apngcreator/activities/CreatorActivity.kt +++ b/app-test/src/main/java/oupson/apngcreator/activities/CreatorActivity.kt @@ -14,18 +14,18 @@ import android.util.Log import android.view.Menu import android.view.MenuItem import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.FileProvider +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar -import kotlinx.android.synthetic.main.activity_creator.* import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import oupson.apng.encoder.ApngEncoder @@ -33,6 +33,7 @@ import oupson.apng.utils.Utils import oupson.apngcreator.BuildConfig import oupson.apngcreator.R import oupson.apngcreator.adapter.ImageAdapter +import oupson.apngcreator.databinding.ActivityCreatorBinding import oupson.apngcreator.dialogs.DelayInputDialog import java.io.File import java.io.FileOutputStream @@ -42,8 +43,6 @@ import kotlin.collections.ArrayList class CreatorActivity : AppCompatActivity() { companion object { - private const val PICK_IMAGE = 1 - private const val WRITE_REQUEST_CODE = 2 private const val TAG = "CreatorActivity" private const val CREATION_CHANNEL_ID = @@ -57,35 +56,97 @@ class CreatorActivity : AppCompatActivity() { private var nextImageId: Long = 0 + private var binding: ActivityCreatorBinding? = null + + private val pickLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val data: Intent? = result.data + + if (data?.clipData != null) { + for (i in 0 until data.clipData!!.itemCount) { + items.add(Triple(data.clipData!!.getItemAt(i).uri, 1000, nextImageId++)) + } + adapter?.notifyDataSetChanged() + } else if (data?.data != null) { + items.add(Triple(data.data!!, 1000, nextImageId++)) + adapter?.notifyDataSetChanged() + } + } + } + + private val writeLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val data: Intent? = result.data + + if (data?.data != null) { + if (BuildConfig.DEBUG) + Log.i(TAG, "Intent data : ${data.data}") + + val builder = NotificationCompat.Builder(this, CREATION_CHANNEL_ID).apply { + setContentTitle(getString(R.string.create_notification_title)) + setContentText( + this@CreatorActivity.resources.getQuantityString( + R.plurals.create_notification_description, + 0, + 0, + items.size + ) + ) + setSmallIcon(R.drawable.ic_create_white_24dp) + priority = NotificationCompat.PRIORITY_LOW + } + lifecycleScope.launch(Dispatchers.IO) { + val out = contentResolver.openOutputStream(data.data!!) ?: return@launch + saveToOutputStream( + items.map { Pair(it.first, it.second) }, + out, + builder = builder + ) + out.close() + + if (binding != null) { + withContext(Dispatchers.Main) { + Snackbar.make( + binding!!.imageRecyclerView, + R.string.done, + Snackbar.LENGTH_SHORT + ).show() + } + } + } + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + binding = ActivityCreatorBinding.inflate(layoutInflater) - setContentView(R.layout.activity_creator) + setContentView(binding?.root) - fabAddImage.setOnClickListener { + binding?.fabAddImage?.setOnClickListener { val getIntent = Intent(Intent.ACTION_GET_CONTENT) getIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) getIntent.type = "image/*" - startActivityForResult( - getIntent, - PICK_IMAGE - ) + pickLauncher.launch(getIntent) } - adapter = ImageAdapter(this, items) + adapter = ImageAdapter(this, items, lifecycleScope) adapter?.setHasStableIds(true) - imageRecyclerView.layoutManager = LinearLayoutManager(this) - imageRecyclerView.setHasFixedSize(true) - imageRecyclerView.itemAnimator = object : DefaultItemAnimator() { + binding?.imageRecyclerView?.layoutManager = LinearLayoutManager(this) + binding?.imageRecyclerView?.setHasFixedSize(true) + binding?.imageRecyclerView?.itemAnimator = object : DefaultItemAnimator() { override fun canReuseUpdatedViewHolder(viewHolder: RecyclerView.ViewHolder): Boolean { return true } } - imageRecyclerView.setItemViewCacheSize(20) + binding?.imageRecyclerView?.setItemViewCacheSize(20) if (adapter != null) - ItemTouchHelper(SwipeToDeleteCallback(adapter!!)).attachToRecyclerView(imageRecyclerView) + ItemTouchHelper(SwipeToDeleteCallback(adapter!!)).attachToRecyclerView(binding?.imageRecyclerView) adapter?.clickListener = { position -> DelayInputDialog(object : DelayInputDialog.InputSenderDialogListener { @@ -104,8 +165,8 @@ class CreatorActivity : AppCompatActivity() { }, items[position].second).show(supportFragmentManager, null) } - setSupportActionBar(creatorBottomAppBar) - imageRecyclerView.adapter = adapter + setSupportActionBar(binding?.creatorBottomAppBar) + binding?.imageRecyclerView?.adapter = adapter supportActionBar?.setDisplayHomeAsUpEnabled(true) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -148,7 +209,7 @@ class CreatorActivity : AppCompatActivity() { priority = NotificationCompat.PRIORITY_LOW } - GlobalScope.launch(Dispatchers.IO) { + lifecycleScope.launch(Dispatchers.IO) { val randomFileName = UUID.randomUUID().toString() val f = File(filesDir, "images/$randomFileName.png").apply { if (!exists()) { @@ -205,7 +266,7 @@ class CreatorActivity : AppCompatActivity() { setSmallIcon(R.drawable.ic_create_white_24dp) priority = NotificationCompat.PRIORITY_LOW } - GlobalScope.launch(Dispatchers.IO) { + lifecycleScope.launch(Dispatchers.IO) { val randomFileName = UUID.randomUUID().toString() val f = File(filesDir, "images/$randomFileName.png").apply { if (!exists()) { @@ -272,7 +333,10 @@ class CreatorActivity : AppCompatActivity() { type = "image/png" putExtra(Intent.EXTRA_TITLE, "${items[0].first.lastPathSegment}.png") } - startActivityForResult(intent, WRITE_REQUEST_CODE) + + writeLauncher.launch( + intent + ) } true } @@ -409,7 +473,9 @@ class CreatorActivity : AppCompatActivity() { } } - encoder.writeEnd() + withContext(Dispatchers.IO) { + encoder.writeEnd() + } if (builder != null) { @@ -423,71 +489,14 @@ class CreatorActivity : AppCompatActivity() { } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - when (requestCode) { - PICK_IMAGE -> { - if (resultCode == Activity.RESULT_OK) { - if (data?.clipData != null) { - for (i in 0 until data.clipData!!.itemCount) { - items.add(Triple(data.clipData!!.getItemAt(i).uri, 1000, nextImageId++)) - } - adapter?.notifyDataSetChanged() - } else if (data?.data != null) { - items.add(Triple(data.data!!, 1000, nextImageId++)) - adapter?.notifyDataSetChanged() - } - } - } - WRITE_REQUEST_CODE -> { - if (resultCode == Activity.RESULT_OK) { - if (data?.data != null) { - if (BuildConfig.DEBUG) - Log.i(TAG, "Intent data : ${data.data}") - - val builder = NotificationCompat.Builder(this, CREATION_CHANNEL_ID).apply { - setContentTitle(getString(R.string.create_notification_title)) - setContentText( - this@CreatorActivity.resources.getQuantityString( - R.plurals.create_notification_description, - 0, - 0, - items.size - ) - ) - setSmallIcon(R.drawable.ic_create_white_24dp) - priority = NotificationCompat.PRIORITY_LOW - } - GlobalScope.launch(Dispatchers.IO) { - - val out = contentResolver.openOutputStream(data.data!!) ?: return@launch - saveToOutputStream( - items.map { Pair(it.first, it.second) }, - out, - builder = builder - ) - out.close() - - withContext(Dispatchers.Main) { - Snackbar.make( - imageRecyclerView, - R.string.done, - Snackbar.LENGTH_SHORT - ).show() - } - } - } - } - } - } - } - override fun onDestroy() { super.onDestroy() - val deleteResult = File(filesDir, "images").deleteRecursively() - if (BuildConfig.DEBUG) - Log.v(TAG, "Deleted images dir : $deleteResult") + lifecycleScope.launch(Dispatchers.IO) { + val deleteResult = File(filesDir, "images").deleteRecursively() + if (BuildConfig.DEBUG) + Log.v(TAG, "Deleted images dir : $deleteResult") + } } inner class SwipeToDeleteCallback(private val adapter: ImageAdapter) : diff --git a/app-test/src/main/java/oupson/apngcreator/activities/MainActivity.kt b/app-test/src/main/java/oupson/apngcreator/activities/MainActivity.kt index 7b49170..d7ac924 100644 --- a/app-test/src/main/java/oupson/apngcreator/activities/MainActivity.kt +++ b/app-test/src/main/java/oupson/apngcreator/activities/MainActivity.kt @@ -4,19 +4,25 @@ import android.annotation.SuppressLint import android.content.Intent import android.net.http.HttpResponseCache import android.os.Bundle +import android.os.StrictMode +import android.os.StrictMode.ThreadPolicy +import android.os.StrictMode.VmPolicy import android.util.Log import android.view.MenuItem import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity import androidx.core.view.GravityCompat +import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomappbar.BottomAppBarTopEdgeTreatment import com.google.android.material.shape.CutCornerTreatment import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.shape.ShapePath -import kotlinx.android.synthetic.main.activity_main.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import oupson.apngcreator.BuildConfig import oupson.apngcreator.R +import oupson.apngcreator.databinding.ActivityMainBinding import oupson.apngcreator.fragments.ApngDecoderFragment import oupson.apngcreator.fragments.JavaFragment import oupson.apngcreator.fragments.KotlinFragment @@ -28,38 +34,67 @@ class MainActivity : AppCompatActivity() { private const val TAG = "MainActivity" } + private var binding: ActivityMainBinding? = null + override fun onCreate(savedInstanceState: Bundle?) { + if (BuildConfig.DEBUG) { + StrictMode.setThreadPolicy( + ThreadPolicy.Builder() + .detectDiskReads() + .detectDiskWrites() + .detectAll() + .penaltyLog() + .build() + ) + StrictMode.setVmPolicy( + VmPolicy.Builder() + .detectLeakedSqlLiteObjects() + .detectLeakedClosableObjects() + .penaltyLog() + + .build() + ) + } super.onCreate(savedInstanceState) if (BuildConfig.DEBUG) - Log.v(TAG, "supportFragmentManager.fragments.size : ${supportFragmentManager.fragments.size}") + Log.v( + TAG, + "supportFragmentManager.fragments.size : ${supportFragmentManager.fragments.size}" + ) - setContentView(R.layout.activity_main) + binding = ActivityMainBinding.inflate(layoutInflater) - setSupportActionBar(bottomAppBar) + + setContentView(binding?.root) + + setSupportActionBar(binding?.bottomAppBar) setUpBottomAppBarShapeAppearance() val httpCacheSize = 10 * 1024 * 1024.toLong() // 10 MiB - val httpCacheDir = File(cacheDir, "http") - HttpResponseCache.install(httpCacheDir, httpCacheSize) + lifecycleScope.launch(Dispatchers.IO) { + val httpCacheDir = File(cacheDir, "http") + HttpResponseCache.install(httpCacheDir, httpCacheSize) + } - fabCreate.setOnClickListener { + binding?.fabCreate?.setOnClickListener { startActivity(Intent(this, CreatorActivity::class.java)) } - val drawerToggle = ActionBarDrawerToggle(this, drawer_layout, bottomAppBar, + val drawerToggle = ActionBarDrawerToggle( + this, binding?.drawerLayout, binding?.bottomAppBar, R.string.open, R.string.close ) - drawer_layout.addDrawerListener(drawerToggle) + binding?.drawerLayout?.addDrawerListener(drawerToggle) drawerToggle.syncState() var selected = 0 - navigationView.setNavigationItemSelectedListener { menuItem : MenuItem -> - when(menuItem.itemId) { + binding?.navigationView?.setNavigationItemSelectedListener { menuItem: MenuItem -> + when (menuItem.itemId) { R.id.menu_kotlin_fragment -> { if (selected != 0) { supportFragmentManager.beginTransaction().apply { @@ -98,20 +133,21 @@ class MainActivity : AppCompatActivity() { } } - drawer_layout.closeDrawer(GravityCompat.START) + binding?.drawerLayout?.closeDrawer(GravityCompat.START) return@setNavigationItemSelectedListener true } if (intent.hasExtra("fragment") && supportFragmentManager.fragments.size == 0) { - when(intent.getStringExtra("fragment")) { + when (intent.getStringExtra("fragment")) { "kotlin" -> { supportFragmentManager.beginTransaction().apply { add( R.id.fragment_container, - KotlinFragment.newInstance(), "KotlinFragment") + KotlinFragment.newInstance(), "KotlinFragment" + ) }.commit() - navigationView.setCheckedItem(R.id.menu_kotlin_fragment) + binding?.navigationView?.setCheckedItem(R.id.menu_kotlin_fragment) selected = 0 } "java" -> { @@ -121,7 +157,7 @@ class MainActivity : AppCompatActivity() { JavaFragment() ) }.commit() - navigationView.setCheckedItem(R.id.menu_java_fragment) + binding?.navigationView?.setCheckedItem(R.id.menu_java_fragment) selected = 1 } "apng_decoder" -> { @@ -131,7 +167,7 @@ class MainActivity : AppCompatActivity() { ApngDecoderFragment.newInstance() ) }.commit() - navigationView.setCheckedItem(R.id.menu_apng_decoder_fragment) + binding?.navigationView?.setCheckedItem(R.id.menu_apng_decoder_fragment) selected = 2 } } @@ -139,35 +175,41 @@ class MainActivity : AppCompatActivity() { supportFragmentManager.beginTransaction().apply { add( R.id.fragment_container, - KotlinFragment.newInstance(), "KotlinFragment") + KotlinFragment.newInstance(), "KotlinFragment" + ) }.commit() - navigationView.setCheckedItem(R.id.menu_kotlin_fragment) + binding?.navigationView?.setCheckedItem(R.id.menu_kotlin_fragment) } } override fun onStop() { super.onStop() - HttpResponseCache.getInstalled()?.flush() + lifecycleScope.launch(Dispatchers.IO) { + HttpResponseCache.getInstalled()?.flush() + } } private fun setUpBottomAppBarShapeAppearance() { - val fabShapeAppearanceModel: ShapeAppearanceModel = fabCreate.shapeAppearanceModel - val cutCornersFab = - (fabShapeAppearanceModel.bottomLeftCorner is CutCornerTreatment - && fabShapeAppearanceModel.bottomRightCorner is CutCornerTreatment) - val topEdge = - if (cutCornersFab) BottomAppBarCutCornersTopEdge( - bottomAppBar.fabCradleMargin, - bottomAppBar.fabCradleRoundedCornerRadius, - bottomAppBar.cradleVerticalOffset - ) else BottomAppBarTopEdgeTreatment( - bottomAppBar.fabCradleMargin, - bottomAppBar.fabCradleRoundedCornerRadius, - bottomAppBar.cradleVerticalOffset - ) - val babBackground = bottomAppBar.background as MaterialShapeDrawable - babBackground.shapeAppearanceModel = - babBackground.shapeAppearanceModel.toBuilder().setTopEdge(topEdge).build() + if (binding != null) { + val fabShapeAppearanceModel: ShapeAppearanceModel = + binding!!.fabCreate.shapeAppearanceModel + val cutCornersFab = + (fabShapeAppearanceModel.bottomLeftCorner is CutCornerTreatment + && fabShapeAppearanceModel.bottomRightCorner is CutCornerTreatment) + val topEdge = + if (cutCornersFab) BottomAppBarCutCornersTopEdge( + binding!!.bottomAppBar.fabCradleMargin, + binding!!.bottomAppBar.fabCradleRoundedCornerRadius, + binding!!.bottomAppBar.cradleVerticalOffset + ) else BottomAppBarTopEdgeTreatment( + binding!!.bottomAppBar.fabCradleMargin, + binding!!.bottomAppBar.fabCradleRoundedCornerRadius, + binding!!.bottomAppBar.cradleVerticalOffset + ) + val babBackground = binding!!.bottomAppBar.background as MaterialShapeDrawable + babBackground.shapeAppearanceModel = + babBackground.shapeAppearanceModel.toBuilder().setTopEdge(topEdge).build() + } } diff --git a/app-test/src/main/java/oupson/apngcreator/activities/ViewerActivity.kt b/app-test/src/main/java/oupson/apngcreator/activities/ViewerActivity.kt index 715335b..aaad23d 100644 --- a/app-test/src/main/java/oupson/apngcreator/activities/ViewerActivity.kt +++ b/app-test/src/main/java/oupson/apngcreator/activities/ViewerActivity.kt @@ -9,16 +9,19 @@ import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat -import kotlinx.android.synthetic.main.activity_viewer.* import oupson.apng.decoder.ApngDecoder import oupson.apng.decoder.ApngLoader -import oupson.apngcreator.R +import oupson.apngcreator.databinding.ActivityViewerBinding class ViewerActivity : AppCompatActivity() { private var apngLoader: ApngLoader? = null + private var binding: ActivityViewerBinding? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_viewer) + binding = ActivityViewerBinding.inflate(layoutInflater) + + setContentView(binding?.root) this.apngLoader = ApngLoader() @@ -54,18 +57,20 @@ class ViewerActivity : AppCompatActivity() { private fun load() { val uri = intent.data ?: return - apngLoader?.decodeApngAsyncInto( - this, - uri, - viewerImageView, - callback = object : ApngLoader.Callback { - override fun onSuccess(drawable: Drawable) {} - override fun onError(error: Throwable) { - Log.e("ViewerActivity", "Error when loading file", error) - } - }, - ApngDecoder.Config(decodeCoverFrame = false) - ) + + if (binding != null) + apngLoader?.decodeApngAsyncInto( + this, + uri, + binding!!.viewerImageView, + callback = object : ApngLoader.Callback { + override fun onSuccess(drawable: Drawable) {} + override fun onError(error: Throwable) { + Log.e("ViewerActivity", "Error when loading file", error) + } + }, + ApngDecoder.Config(decodeCoverFrame = false) + ) } override fun onRequestPermissionsResult( diff --git a/app-test/src/main/java/oupson/apngcreator/adapter/ImageAdapter.kt b/app-test/src/main/java/oupson/apngcreator/adapter/ImageAdapter.kt index 7acf900..e0cb56e 100644 --- a/app-test/src/main/java/oupson/apngcreator/adapter/ImageAdapter.kt +++ b/app-test/src/main/java/oupson/apngcreator/adapter/ImageAdapter.kt @@ -10,21 +10,25 @@ import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import oupson.apngcreator.R -class ImageAdapter(private val context : Context, private val list : List>) : RecyclerView.Adapter() { - inner class ImageHolder(view : View) : RecyclerView.ViewHolder(view) { - val imageView : ImageView? = view.findViewById(R.id.listImageView) - val textDelay : TextView? = view.findViewById(R.id.textDelay) - val positionTextView : TextView? = view.findViewById(R.id.position_textView) - val nameTextView : TextView? = view.findViewById(R.id.name_textView) +class ImageAdapter( + private val context: Context, + private val list: List>, + private val scope: CoroutineScope +) : RecyclerView.Adapter() { + inner class ImageHolder(view: View) : RecyclerView.ViewHolder(view) { + val imageView: ImageView? = view.findViewById(R.id.listImageView) + val textDelay: TextView? = view.findViewById(R.id.textDelay) + val positionTextView: TextView? = view.findViewById(R.id.position_textView) + val nameTextView: TextView? = view.findViewById(R.id.name_textView) } - var clickListener : ((position : Int) -> Unit)? = null + var clickListener: ((position: Int) -> Unit)? = null override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageHolder { val inflater = LayoutInflater.from(parent.context) @@ -36,7 +40,7 @@ class ImageAdapter(private val context : Context, private val list : List - + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:viewBindingIgnore="true"> + tools:context=".fragments.ApngDecoderFragment" + tools:viewBindingIgnore="true"> + tools:context=".fragments.JavaFragment" + tools:viewBindingIgnore="true"> + android:layout_height="match_parent" + tools:viewBindingIgnore="true"> + android:orientation="horizontal" + tools:viewBindingIgnore="true">