From 44909d1251765a8947fc813a7efd14b85e79b0e4 Mon Sep 17 00:00:00 2001 From: SkyD666 Date: Sat, 24 Aug 2024 14:25:11 +0800 Subject: [PATCH] [feature|optimize|build] Added GIF support on the reading page; optimize image download code on the reading page; update dependencies --- app/build.gradle.kts | 16 +- .../main/java/com/skyd/anivu/config/Const.kt | 4 + .../main/java/com/skyd/anivu/ext/ArrayExt.kt | 102 +++++++ .../java/com/skyd/anivu/ext/ContextExt.kt | 19 +- .../java/com/skyd/anivu/ext/DrawableExt.kt | 32 +- .../main/java/com/skyd/anivu/ext/FileExt.kt | 40 +-- .../anivu/ext/content/ContentResolverExt.kt | 19 ++ .../anivu/ext/content/ContentValuesExt.kt | 19 ++ .../model/repository/ArticleRepository.kt | 31 -- .../anivu/model/repository/ReadRepository.kt | 39 +++ .../com/skyd/anivu/ui/component/AniVuImage.kt | 15 +- .../skyd/anivu/ui/component/html/HtmlText.kt | 3 + .../anivu/ui/component/html/ImageGetter.kt | 30 +- .../anivu/ui/component/html/ImgTagHandler.kt | 8 +- .../skyd/anivu/ui/screen/read/ReadScreen.kt | 15 +- .../anivu/ui/screen/read/ReadViewModel.kt | 2 +- .../anivu/util/image/ImageFormatChecker.kt | 28 ++ .../anivu/util/image/format/FormatStandard.kt | 280 ++++++++++++++++++ .../anivu/util/image/format/ImageFormat.kt | 61 ++++ app/src/main/res/values-zh-rCN/strings.xml | 4 +- app/src/main/res/values/strings.xml | 4 +- 21 files changed, 654 insertions(+), 117 deletions(-) create mode 100644 app/src/main/java/com/skyd/anivu/ext/ArrayExt.kt create mode 100644 app/src/main/java/com/skyd/anivu/ext/content/ContentResolverExt.kt create mode 100644 app/src/main/java/com/skyd/anivu/ext/content/ContentValuesExt.kt create mode 100644 app/src/main/java/com/skyd/anivu/util/image/ImageFormatChecker.kt create mode 100644 app/src/main/java/com/skyd/anivu/util/image/format/FormatStandard.kt create mode 100644 app/src/main/java/com/skyd/anivu/util/image/format/ImageFormat.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0bc2ae58..df7d855d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,7 +22,7 @@ android { minSdk = 24 targetSdk = 35 versionCode = 22 - versionName = "2.1-alpha19" + versionName = "2.1-alpha21" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -163,13 +163,13 @@ dependencies { implementation("androidx.constraintlayout:constraintlayout-compose:1.1.0-alpha14") implementation("androidx.navigation:navigation-compose:2.7.7") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.4") - implementation("androidx.compose.ui:ui:1.7.0-beta07") - implementation("androidx.compose.material:material:1.7.0-beta07") - implementation("androidx.compose.material3:material3:1.3.0-beta05") - implementation("androidx.compose.material3:material3-window-size-class:1.3.0-beta05") - implementation("androidx.compose.material3.adaptive:adaptive:1.0.0-beta04") - implementation("androidx.compose.material3.adaptive:adaptive-layout:1.0.0-beta04") - implementation("androidx.compose.material3.adaptive:adaptive-navigation:1.0.0-beta04") + implementation("androidx.compose.ui:ui:1.7.0-rc01") + implementation("androidx.compose.material:material:1.7.0-rc01") + implementation("androidx.compose.material3:material3:1.3.0-rc01") + implementation("androidx.compose.material3:material3-window-size-class:1.3.0-rc01") + implementation("androidx.compose.material3.adaptive:adaptive:1.0.0-rc01") + implementation("androidx.compose.material3.adaptive:adaptive-layout:1.0.0-rc01") + implementation("androidx.compose.material3.adaptive:adaptive-navigation:1.0.0-rc01") implementation("androidx.compose.material:material-icons-extended:1.6.8") implementation("com.materialkolor:material-kolor:1.7.0") implementation("androidx.room:room-runtime:2.6.1") diff --git a/app/src/main/java/com/skyd/anivu/config/Const.kt b/app/src/main/java/com/skyd/anivu/config/Const.kt index 28fd14c2..b07f0ec6 100644 --- a/app/src/main/java/com/skyd/anivu/config/Const.kt +++ b/app/src/main/java/com/skyd/anivu/config/Const.kt @@ -46,6 +46,10 @@ object Const { .apply { if (!exists()) mkdirs() } val PICTURES_DIR = appContext.getExternalFilesDir(Environment.DIRECTORY_PICTURES)!! + val ANIVU_PICTURES_DIR = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + "AniVu" + ).apply { if (!exists()) mkdirs() } val INTERNAL_STORAGE: String = Environment.getExternalStorageDirectory().absolutePath } \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ext/ArrayExt.kt b/app/src/main/java/com/skyd/anivu/ext/ArrayExt.kt new file mode 100644 index 00000000..8cc03b75 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/ext/ArrayExt.kt @@ -0,0 +1,102 @@ +package com.skyd.anivu.ext + +import kotlin.math.max + +/** + * Boyer–Moore string-search algorithm + * + * Returns the index within this string of the first occurrence of the + * specified substring. If it is not a substring, return -1. + * + * There is no Galil because it only generates one match. + * + * @param needle The target string to search + * @return The start index of the substring + */ +fun Array.indexOf(needle: Array): Int { + if (needle.isEmpty()) { + return 0 + } + val charTable = makeCharTable(needle) + val offsetTable = makeOffsetTable(needle) + var i = needle.size - 1 + var j: Int + while (i < this.size) { + j = needle.size - 1 + while (needle[j] == this[i]) { + if (j == 0) return i + i-- + j-- + } + // i += needle.length - j; // For naive method + i += max(offsetTable[needle.size - 1 - j], charTable[this[i]]!!) + } + return -1 +} + +/** + * Makes the jump table based on the mismatched character information. + * (bad-character rule) + */ +private fun makeCharTable(needle: Array): HashMap { + val table = hashMapOf() + for (i in needle) { + table[i] = needle.size + } + for (i in needle.indices) { + table[needle[i]] = needle.size - 1 - i + } + return table +} + +/** + * Makes the jump table based on the scan offset which mismatch occurs. + * (good suffix rule) + */ +private fun makeOffsetTable(needle: Array): IntArray { + val table = IntArray(needle.size) + var lastPrefixPosition = needle.size + for (i in needle.size downTo 1) { + if (isPrefix(needle, i)) { + lastPrefixPosition = i + } + table[needle.size - i] = lastPrefixPosition - i + needle.size + } + for (i in 0 until needle.size - 1) { + val slen = suffixLength(needle, i) + table[slen] = needle.size - 1 - i + slen + } + return table +} + +/** + * Is needle[p:end] a prefix of needle? + */ +private fun isPrefix(needle: Array, p: Int): Boolean { + var i = p + var j = 0 + while (i < needle.size) { + if (needle[i] != needle[j]) { + return false + } + i++ + j++ + } + return true +} + +/** + * Returns the maximum length of the substring ends at p and is a suffix. + * (good-suffix rule) + */ +private fun suffixLength(needle: Array, p: Int): Int { + var len = 0 + var i = p + var j = needle.size - 1 + while (i >= 0 && needle[i] == needle[j]) { + len += 1 + --i + --j + } + return len +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ext/ContextExt.kt b/app/src/main/java/com/skyd/anivu/ext/ContextExt.kt index 76c71e0d..1e55f99e 100644 --- a/app/src/main/java/com/skyd/anivu/ext/ContextExt.kt +++ b/app/src/main/java/com/skyd/anivu/ext/ContextExt.kt @@ -8,9 +8,14 @@ import android.content.pm.PackageManager import android.content.res.Configuration import android.graphics.Point import android.os.Build +import android.os.Build.VERSION.SDK_INT import android.view.Window import androidx.core.content.ContextCompat import androidx.core.content.pm.PackageInfoCompat +import coil.ImageLoader +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder +import coil.decode.SvgDecoder val Context.activity: Activity get() { @@ -66,7 +71,7 @@ fun Context.screenWidth(includeVirtualKey: Boolean): Int { fun Context.getAppVersionName(): String { var appVersionName = "" try { - val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val packageInfo = if (SDK_INT >= Build.VERSION_CODES.TIRAMISU) { packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0L)) } else { packageManager.getPackageInfo(packageName, 0) @@ -105,4 +110,16 @@ fun Context.getAppName(): String? { fun Context.inDarkMode(): Boolean { return (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES +} + +fun Context.imageLoaderBuilder(): ImageLoader.Builder { + return ImageLoader.Builder(this) + .components { + if (SDK_INT >= 28) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + add(SvgDecoder.Factory()) + } } \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ext/DrawableExt.kt b/app/src/main/java/com/skyd/anivu/ext/DrawableExt.kt index f3d5a897..64213169 100644 --- a/app/src/main/java/com/skyd/anivu/ext/DrawableExt.kt +++ b/app/src/main/java/com/skyd/anivu/ext/DrawableExt.kt @@ -1,32 +1,28 @@ package com.skyd.anivu.ext -import android.content.ContentValues import android.content.Context import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable -import android.os.Environment -import android.provider.MediaStore +import android.os.Build +import com.skyd.anivu.config.Const +import com.skyd.anivu.ext.content.saveToGallery import kotlin.random.Random fun BitmapDrawable.saveToGallery( context: Context, filename: String = System.currentTimeMillis().toString() + "_" + Random.nextInt(), ): Boolean { - val contentResolver = context.contentResolver - - val values = ContentValues().apply { - put(MediaStore.Images.Media.DISPLAY_NAME, "$filename.png") - put(MediaStore.Images.Media.MIME_TYPE, "image/png") - put( - MediaStore.Images.Media.RELATIVE_PATH, - Environment.DIRECTORY_PICTURES + "/" + context.getAppName() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + context.contentResolver.saveToGallery( + fileNameWithExt = "$filename.png", + mimetype = "image/png", + output = { outputStream -> + bitmap?.compress(Bitmap.CompressFormat.PNG, 100, outputStream) == true + } ) + } else { + Const.ANIVU_PICTURES_DIR.outputStream().use { outputStream -> + bitmap?.compress(Bitmap.CompressFormat.PNG, 100, outputStream) == true + } } - - val uri = contentResolver - .insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return false - contentResolver.openOutputStream(uri)?.use { outputStream -> - val out = bitmap?.compress(Bitmap.CompressFormat.PNG, 100, outputStream) - return out == true - } ?: return false } \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ext/FileExt.kt b/app/src/main/java/com/skyd/anivu/ext/FileExt.kt index b14d1412..56e5b4e3 100644 --- a/app/src/main/java/com/skyd/anivu/ext/FileExt.kt +++ b/app/src/main/java/com/skyd/anivu/ext/FileExt.kt @@ -1,16 +1,15 @@ package com.skyd.anivu.ext -import android.content.ContentValues import android.content.Context import android.net.Uri import android.os.Build -import android.os.Environment import android.provider.DocumentsContract -import android.provider.MediaStore import android.webkit.MimeTypeMap import androidx.core.content.FileProvider import androidx.core.net.toUri import com.skyd.anivu.R +import com.skyd.anivu.config.Const.ANIVU_PICTURES_DIR +import com.skyd.anivu.ext.content.saveToGallery import com.skyd.anivu.ui.component.showToast import java.io.File @@ -38,30 +37,23 @@ fun File.getMimeType(): String? { return type } -fun File.savePictureToMediaStore(context: Context, autoDelete: Boolean = true) { +fun File.savePictureToMediaStore( + context: Context, + mimetype: String? = null, + fileName: String = name, + autoDelete: Boolean = true, +) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val contentValues = ContentValues() - contentValues.put( - MediaStore.Images.Media.RELATIVE_PATH, - "${Environment.DIRECTORY_PICTURES}/AniVu", - ) - contentValues.put(MediaStore.Images.Media.DISPLAY_NAME, name) - val uri = context.contentResolver.insert( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - contentValues - )!! - context.contentResolver.openOutputStream(uri)?.use { output -> - inputStream().use { input -> - input.copyTo(output) + context.contentResolver.saveToGallery( + fileNameWithExt = fileName, + mimetype = mimetype, + output = { output -> + inputStream().use { input -> input.copyTo(output) } + true } - } - } else { - val dir = File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), - "AniVu" ) - if (!dir.exists()) dir.mkdirs() - this.copyTo(File(dir, name)) + } else { + this.copyTo(File(ANIVU_PICTURES_DIR, fileName)) } context.getString(R.string.save_picture_to_media_store_saved).showToast() if (autoDelete) delete() diff --git a/app/src/main/java/com/skyd/anivu/ext/content/ContentResolverExt.kt b/app/src/main/java/com/skyd/anivu/ext/content/ContentResolverExt.kt new file mode 100644 index 00000000..8374780d --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/ext/content/ContentResolverExt.kt @@ -0,0 +1,19 @@ +package com.skyd.anivu.ext.content + +import android.content.ContentResolver +import android.content.ContentValues +import android.provider.MediaStore +import java.io.OutputStream + +fun ContentResolver.saveToGallery( + fileNameWithExt: String, + mimetype: String? = null, + output: (OutputStream) -> Boolean, +): Boolean { + val contentValues = ContentValues().gallery(fileNameWithExt, mimetype) + val uri = insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues + ) ?: return false + return openOutputStream(uri)?.use(output) ?: false +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ext/content/ContentValuesExt.kt b/app/src/main/java/com/skyd/anivu/ext/content/ContentValuesExt.kt new file mode 100644 index 00000000..e3f2c109 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/ext/content/ContentValuesExt.kt @@ -0,0 +1,19 @@ +package com.skyd.anivu.ext.content + +import android.content.ContentValues +import android.os.Environment +import android.provider.MediaStore + +fun ContentValues.gallery( + fileNameWithExt: String, + mimetype: String? = null, +) = apply { + put(MediaStore.Images.Media.DISPLAY_NAME, fileNameWithExt) + if (mimetype != null) { + put(MediaStore.Images.Media.MIME_TYPE, mimetype) + } + put( + MediaStore.Images.Media.RELATIVE_PATH, + Environment.DIRECTORY_PICTURES + "/AniVu" + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/model/repository/ArticleRepository.kt b/app/src/main/java/com/skyd/anivu/model/repository/ArticleRepository.kt index 5f91b84d..dee21f1e 100644 --- a/app/src/main/java/com/skyd/anivu/model/repository/ArticleRepository.kt +++ b/app/src/main/java/com/skyd/anivu/model/repository/ArticleRepository.kt @@ -1,20 +1,12 @@ package com.skyd.anivu.model.repository import android.database.DatabaseUtils -import android.graphics.drawable.BitmapDrawable import android.os.Parcelable import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.sqlite.db.SimpleSQLiteQuery -import coil.Coil -import coil.request.ErrorResult -import coil.request.ImageRequest -import coil.request.SuccessResult -import com.skyd.anivu.appContext import com.skyd.anivu.base.BaseRepository -import com.skyd.anivu.ext.saveToGallery -import com.skyd.anivu.ext.validateFileName import com.skyd.anivu.model.bean.ARTICLE_TABLE_NAME import com.skyd.anivu.model.bean.ArticleBean import com.skyd.anivu.model.bean.ArticleWithFeed @@ -34,7 +26,6 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.parcelize.Parcelize import javax.inject.Inject -import kotlin.random.Random @Parcelize sealed class ArticleSort(open val asc: Boolean) : Parcelable { @@ -142,28 +133,6 @@ class ArticleRepository @Inject constructor( }.flowOn(Dispatchers.IO) } - fun downloadImage(url: String, title: String?): Flow { - return flow { - val request = ImageRequest.Builder(appContext) - .data(url) - .build() - when (val result = Coil.imageLoader(appContext).execute(request)) { - is ErrorResult -> throw result.throwable - is SuccessResult -> { - check( - (result.drawable as? BitmapDrawable)?.saveToGallery( - context = appContext, - filename = (title.orEmpty() - .ifEmpty { url.substringAfterLast('/') } + - "_" + Random.nextInt()).validateFileName(), - ) ?: false - ) { "saveToGallery failed" } - } - } - emit(Unit) - }.flowOn(Dispatchers.IO) - } - companion object { fun genSql( feedUrls: List, diff --git a/app/src/main/java/com/skyd/anivu/model/repository/ReadRepository.kt b/app/src/main/java/com/skyd/anivu/model/repository/ReadRepository.kt index 44247579..194d9604 100644 --- a/app/src/main/java/com/skyd/anivu/model/repository/ReadRepository.kt +++ b/app/src/main/java/com/skyd/anivu/model/repository/ReadRepository.kt @@ -1,13 +1,24 @@ package com.skyd.anivu.model.repository +import coil.request.CachePolicy +import coil.request.ErrorResult +import coil.request.ImageRequest +import coil.request.SuccessResult +import com.skyd.anivu.appContext import com.skyd.anivu.base.BaseRepository +import com.skyd.anivu.ext.imageLoaderBuilder +import com.skyd.anivu.ext.savePictureToMediaStore +import com.skyd.anivu.ext.validateFileName import com.skyd.anivu.model.bean.ArticleWithEnclosureBean import com.skyd.anivu.model.db.dao.ArticleDao +import com.skyd.anivu.util.image.ImageFormatChecker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import javax.inject.Inject +import kotlin.random.Random class ReadRepository @Inject constructor( private val articleDao: ArticleDao, @@ -17,4 +28,32 @@ class ReadRepository @Inject constructor( .filterNotNull() .flowOn(Dispatchers.IO) } + + fun downloadImage(url: String, title: String?): Flow { + return flow { + val request = ImageRequest.Builder(appContext) + .data(url) + .diskCachePolicy(CachePolicy.ENABLED) + .build() + val imageLoader = appContext.imageLoaderBuilder().build() + when (val result = imageLoader.execute(request)) { + is ErrorResult -> throw result.throwable + is SuccessResult -> { + imageLoader.diskCache!!.openSnapshot(url).use { snapshot -> + val imageFile = snapshot!!.data.toFile() + val format = imageFile.inputStream().use { ImageFormatChecker.check(it) } + imageFile.savePictureToMediaStore( + context = appContext, + mimetype = format.toMimeType(), + fileName = (title.orEmpty().ifEmpty { + url.substringAfterLast('/') + } + "_" + Random.nextInt()).validateFileName() + format.toString(), + autoDelete = false, + ) + } + } + } + emit(Unit) + }.flowOn(Dispatchers.IO) + } } \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/component/AniVuImage.kt b/app/src/main/java/com/skyd/anivu/ui/component/AniVuImage.kt index e75a3739..338b8cf9 100644 --- a/app/src/main/java/com/skyd/anivu/ui/component/AniVuImage.kt +++ b/app/src/main/java/com/skyd/anivu/ui/component/AniVuImage.kt @@ -1,6 +1,5 @@ package com.skyd.anivu.ui.component -import android.os.Build.VERSION.SDK_INT import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -10,10 +9,8 @@ import androidx.compose.ui.platform.LocalContext import coil.EventListener import coil.ImageLoader import coil.compose.AsyncImage -import coil.decode.GifDecoder -import coil.decode.ImageDecoderDecoder -import coil.decode.SvgDecoder import coil.request.ImageRequest +import com.skyd.anivu.ext.imageLoaderBuilder @Composable @@ -45,15 +42,7 @@ fun AniVuImage( fun rememberAniVuImageLoader(listener: EventListener? = null): ImageLoader { val context = LocalContext.current return remember(context) { - ImageLoader.Builder(context) - .components { - if (SDK_INT >= 28) { - add(ImageDecoderDecoder.Factory()) - } else { - add(GifDecoder.Factory()) - } - add(SvgDecoder.Factory()) - } + context.imageLoaderBuilder() .run { if (listener != null) eventListener(listener) else this } .build() } diff --git a/app/src/main/java/com/skyd/anivu/ui/component/html/HtmlText.kt b/app/src/main/java/com/skyd/anivu/ui/component/html/HtmlText.kt index 978d2343..b509c9b4 100644 --- a/app/src/main/java/com/skyd/anivu/ui/component/html/HtmlText.kt +++ b/app/src/main/java/com/skyd/anivu/ui/component/html/HtmlText.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY import androidx.core.text.parseAsHtml +import androidx.lifecycle.compose.LocalLifecycleOwner @Composable fun HtmlText( @@ -25,6 +26,7 @@ fun HtmlText( onImageClick: ((String) -> Unit)? = null, ) { val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current val textColor = LocalContentColor.current var componentWidth by remember { mutableIntStateOf(0) } AndroidView( @@ -45,6 +47,7 @@ fun HtmlText( htmlFlags, imageGetter = ImageGetter( context = context, + lifecycleOwner = lifecycleOwner, maxWidth = { componentWidth }, onSuccess = { _, _ -> textView.text = textView.text diff --git a/app/src/main/java/com/skyd/anivu/ui/component/html/ImageGetter.kt b/app/src/main/java/com/skyd/anivu/ui/component/html/ImageGetter.kt index 00c5a34e..f641e9d3 100644 --- a/app/src/main/java/com/skyd/anivu/ui/component/html/ImageGetter.kt +++ b/app/src/main/java/com/skyd/anivu/ui/component/html/ImageGetter.kt @@ -1,20 +1,24 @@ package com.skyd.anivu.ui.component.html import android.content.Context +import android.graphics.drawable.AnimatedImageDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.DrawableWrapper +import android.os.Build import android.text.Html import androidx.appcompat.content.res.AppCompatResources -import coil.Coil -import coil.ImageLoader +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import coil.drawable.ScaleDrawable import coil.request.ErrorResult import coil.request.ImageRequest import coil.request.SuccessResult import com.skyd.anivu.R +import com.skyd.anivu.ext.imageLoaderBuilder class ImageGetter( private val context: Context, - private val imageLoader: ImageLoader = Coil.imageLoader(context), + private val lifecycleOwner: LifecycleOwner, private val maxWidth: () -> Int, private val onSuccess: (ImageRequest, SuccessResult) -> Unit, private val onError: (ImageRequest, ErrorResult) -> Unit = { _, _ -> }, @@ -55,6 +59,7 @@ class ImageGetter( .error(R.drawable.ic_error_24) .listener( onSuccess = { request, result -> + preProcessDrawable(result.drawable) setAndResizeDrawable(result.drawable) onSuccess(request, result) }, @@ -68,11 +73,28 @@ class ImageGetter( .build() // 返回占位符,直到图片加载完成 - imageLoader.enqueue(request) + context.imageLoaderBuilder().build().enqueue(request) return drawable } + private fun preProcessDrawable(drawable: Drawable) { + if (drawable is ScaleDrawable) { + val child = drawable.child + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && child is AnimatedImageDrawable) { + lifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) = child.start() + override fun onPause(owner: LifecycleOwner) = child.stop() + override fun onDestroy(owner: LifecycleOwner) { + lifecycleOwner.lifecycle.removeObserver(this) + } + } + ) + } + } + } + inner class ImageGetterDrawable( drawable: Drawable, ) : DrawableWrapper(drawable) diff --git a/app/src/main/java/com/skyd/anivu/ui/component/html/ImgTagHandler.kt b/app/src/main/java/com/skyd/anivu/ui/component/html/ImgTagHandler.kt index f10e3fce..ed50aada 100644 --- a/app/src/main/java/com/skyd/anivu/ui/component/html/ImgTagHandler.kt +++ b/app/src/main/java/com/skyd/anivu/ui/component/html/ImgTagHandler.kt @@ -12,11 +12,11 @@ internal class ImgTagHandler(private val onClick: (String) -> Unit) : TagHandler override fun handleTag(opening: Boolean, tag: String, output: Editable, xmlReader: XMLReader) { if (tag.equals("img", ignoreCase = true)) { val len = output.length - val images = output.getSpans(0, len, ImageSpan::class.java) + val images = output.getSpans(len - 1, len, ImageSpan::class.java) val imgURL = images.firstOrNull()?.source ?: return output.setSpan( ClickableImage(imgURL, onClick), - 0, + len - 1, len, Spanned.SPAN_INCLUSIVE_EXCLUSIVE, ) @@ -28,7 +28,5 @@ internal class ClickableImage( private val url: String, private val onClick: (String) -> Unit, ) : ClickableSpan() { - override fun onClick(widget: View) { - onClick(url) - } + override fun onClick(widget: View) = onClick(url) } \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/read/ReadScreen.kt b/app/src/main/java/com/skyd/anivu/ui/screen/read/ReadScreen.kt index 862353a8..55411b3d 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/read/ReadScreen.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/read/ReadScreen.kt @@ -56,7 +56,6 @@ import com.skyd.anivu.ui.component.AniVuFloatingActionButton import com.skyd.anivu.ui.component.AniVuIconButton import com.skyd.anivu.ui.component.AniVuTopBar import com.skyd.anivu.ui.component.AniVuTopBarStyle -import com.skyd.anivu.ui.component.Toast import com.skyd.anivu.ui.component.dialog.WaitingDialog import com.skyd.anivu.ui.component.html.HtmlText import com.skyd.anivu.ui.screen.article.enclosure.EnclosureBottomSheet @@ -196,11 +195,7 @@ fun ReadScreen(articleId: String, viewModel: ReadViewModel = hiltViewModel()) { is ReadEvent.DownloadImageResultEvent.Failed -> snackbarHostState.showSnackbarWithLaunchedEffect(message = event.msg, key1 = event) - is ReadEvent.DownloadImageResultEvent.Success -> Toast( - event, - text = stringResource(R.string.read_screen_download_image_success), - ) - + is ReadEvent.DownloadImageResultEvent.Success, null -> Unit } @@ -289,7 +284,11 @@ private fun ImageBottomSheet( ) { val context = LocalContext.current ModalBottomSheet(onDismissRequest = onDismissRequest) { - Column(modifier = Modifier.padding(horizontal = 12.dp)) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp), + ) { ImageBottomSheetItem( icon = Icons.Outlined.Download, title = stringResource(id = R.string.read_screen_download_image), @@ -315,7 +314,7 @@ private fun ImageBottomSheetItem( .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .clickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = 16.dp), + .padding(16.dp), ) { Icon(imageVector = icon, contentDescription = null) Spacer(modifier = Modifier.width(20.dp)) diff --git a/app/src/main/java/com/skyd/anivu/ui/screen/read/ReadViewModel.kt b/app/src/main/java/com/skyd/anivu/ui/screen/read/ReadViewModel.kt index 26f2c27b..45cf3f04 100644 --- a/app/src/main/java/com/skyd/anivu/ui/screen/read/ReadViewModel.kt +++ b/app/src/main/java/com/skyd/anivu/ui/screen/read/ReadViewModel.kt @@ -107,7 +107,7 @@ class ReadViewModel @Inject constructor( } }, filterIsInstance().flatMapConcat { intent -> - articleRepo.downloadImage(intent.url, intent.title).map { + readRepo.downloadImage(intent.url, intent.title).map { ReadPartialStateChange.DownloadImage.Success(intent.url) }.startWith(ReadPartialStateChange.LoadingDialog.Show).catchMap { ReadPartialStateChange.DownloadImage.Failed(it.message.toString()) diff --git a/app/src/main/java/com/skyd/anivu/util/image/ImageFormatChecker.kt b/app/src/main/java/com/skyd/anivu/util/image/ImageFormatChecker.kt new file mode 100644 index 00000000..e5cae0c0 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/util/image/ImageFormatChecker.kt @@ -0,0 +1,28 @@ +package com.skyd.anivu.util.image + +import com.skyd.anivu.util.image.format.FormatStandard.Companion.formatStandards +import com.skyd.anivu.util.image.format.ImageFormat +import java.io.InputStream + +object ImageFormatChecker { + fun check(tested: InputStream): ImageFormat { + var readByteArray: ByteArray? = null + formatStandards.forEach { + val result = it.check(tested, readByteArray) + readByteArray = result.second + if (result.first) { + return it.format + } + } + return ImageFormat.UNDEFINED + } + + fun check(tested: ByteArray): ImageFormat { + formatStandards.forEach { + if (it.check(tested)) { + return it.format + } + } + return ImageFormat.UNDEFINED + } +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/util/image/format/FormatStandard.kt b/app/src/main/java/com/skyd/anivu/util/image/format/FormatStandard.kt new file mode 100644 index 00000000..d353b982 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/util/image/format/FormatStandard.kt @@ -0,0 +1,280 @@ +package com.skyd.anivu.util.image.format + +import com.skyd.anivu.ext.indexOf +import com.skyd.anivu.util.image.format.FormatStandardUtil.baseCheck +import java.io.InputStream + + +sealed class FormatStandard( + val format: ImageFormat, + val requiredByteArraySize: Int +) { + companion object { + val formatStandards by lazy { + arrayOf( + ApngFormat, // Should be before PngFormat + PngFormat, + JpgFormat, + GifFormat, + BmpFormat, + WebpFormat, + HeifFormat, + HeicFormat, + SvgFormat, + ) + } + } + + abstract fun check(tested: ByteArray): Boolean + + fun check(tested: InputStream, readByteArray: ByteArray?): Pair { + require(requiredByteArraySize > 0) + val delta = requiredByteArraySize - (readByteArray?.size ?: 0) + val buffer: ByteArray = if (delta > 0) { + val newBuffer = ByteArray(delta) + tested.read(newBuffer) + if (readByteArray == null) { + newBuffer + } else { + readByteArray + newBuffer + } + } else { + // 当 requiredByteArraySize > 0 时,这里 readByteArray 一定不为 null + readByteArray!! + } + return check(buffer) to buffer + } + + data object ApngFormat : FormatStandard( + format = ImageFormat.APNG, + requiredByteArraySize = 41, + ) { + override fun check(tested: ByteArray): Boolean { + // Return false if the image is not png + if (tested.size < 12 || !PngFormat.check(tested)) { + return false + } + // Get IHDR length, in fact it should be decimal 13 + var ihdrLength = 0 + for (i in 8..11) { + ihdrLength = ihdrLength shl 8 or tested[i].toInt() + } + + /** + * 8: PNG format + * 4: 4 bytes to store the length of the next part (IHDR) + * 4: Chunk Type (IHDR) + * 13: IHDR length (ihdrLength) + * 4: CRC32 + * 4: 4 bytes to store the length of the next part (acTL) + */ + val startIndex = 8 + 4 + 4 + /*13*/ ihdrLength + 4 + 4 + return baseCheck( + byteArrayOf(0x61, 0x63, 0x54, 0x4C), + tested.copyOfRange(startIndex, startIndex + 4) + ) + } + } + + data object PngFormat : FormatStandard( + format = ImageFormat.PNG, + requiredByteArraySize = 8, + ) { + private val PNG_FORMAT_DATA = byteArrayOf( + 0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + ) + + override fun check(tested: ByteArray): Boolean = baseCheck( + standard = PNG_FORMAT_DATA, + tested = tested, + ) + } + + data object JpgFormat : FormatStandard( + format = ImageFormat.JPG, + requiredByteArraySize = 3, + ) { + override fun check(tested: ByteArray): Boolean = baseCheck( + standard = byteArrayOf( + 0xFF.toByte(), + 0xD8.toByte(), + 0xFF.toByte(), + ), + tested = tested, + ) + } + + + data object GifFormat : FormatStandard( + format = ImageFormat.GIF, + requiredByteArraySize = 6, + ) { + override fun check(tested: ByteArray): Boolean = baseCheck( + // GIF87a + standard = byteArrayOf(0x47, 0x49, 0x46, 0x38, 0x37, 0x61), + tested = tested, + ) or baseCheck( + // GIF89a + standard = byteArrayOf(0x47, 0x49, 0x46, 0x38, 0x39, 0x61), + tested = tested, + ) + } + + data object BmpFormat : FormatStandard( + format = ImageFormat.BMP, + requiredByteArraySize = 28, + ) { + /** + * Offset: 00 + * 用于标识BMP和DIB文件的魔数,一般为0x42 0x4D,即ASCII的BM。 + * 以下为可能的取值: + * BM – Windows 3.1x, 95, NT, ... etc. + * BA – OS/2 struct Bitmap Array + * CI – OS/2 struct Color Icon + * CP – OS/2 const Color Pointer + * IC – OS/2 struct Icon + * PT – OS/2 Pointer + */ + private val firstFieldArray = arrayOf( + byteArrayOf(0x42, 0x4D), + byteArrayOf(0x42, 0x41), + byteArrayOf(0x43, 0x49), + byteArrayOf(0x43, 0x50), + byteArrayOf(0x49, 0x43), + byteArrayOf(0x50, 0x54), + ) + + override fun check(tested: ByteArray): Boolean { + var firstFieldResult = false + for (firstField in firstFieldArray) { + var r = false + for (i in firstField.indices) { + if (firstField[i] != tested[i]) { + continue + } else if (i == 1) { + r = true + } + } + firstFieldResult = r + if (r) { + break + } + } + return firstFieldResult && + // Offset: 1A == 0x01 && Offset: 1B == 0x00 + tested[26] == 0x01.toByte() && + tested[27] == 0x00.toByte() + } + } + + data object WebpFormat : FormatStandard( + format = ImageFormat.WEBP, + requiredByteArraySize = 12, + ) { + private val standard = byteArrayOf( + 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, + ) + + override fun check(tested: ByteArray): Boolean { + if (standard.size > tested.size) { + return false + } + + for (i in standard.indices) { + if (standard[i] == 0x00.toByte()) { + continue + } else if (standard[i] != tested[i]) { + return false + } + } + + return true + } + } + + /** + * https://github.com/nokiatech/heif/issues/74 + * https://zh.wikipedia.org/wiki/%E9%AB%98%E6%95%88%E7%8E%87%E5%9B%BE%E5%83%8F%E6%96%87%E4%BB%B6%E6%A0%BC%E5%BC%8F# + */ + data object HeifFormat : FormatStandard( + format = ImageFormat.HEIF, + requiredByteArraySize = 12, + ) { + private val ftyp = byteArrayOf(0x66, 0x74, 0x79, 0x70) + private val mif1 = byteArrayOf(0x6d, 0x69, 0x66, 0x31) + private val msf1 = byteArrayOf(0x6d, 0x73, 0x66, 0x31) + private val m = arrayOf(mif1, msf1) + + override fun check(tested: ByteArray): Boolean { + for (i in 4..7) { + if (tested[i] != ftyp[i - 4]) return false + } + + m.forEach { + if (baseCheck(it, tested.copyOfRange(8, 12))) { + return true + } + } + return false + } + } + + data object HeicFormat : FormatStandard( + format = ImageFormat.HEIC, + requiredByteArraySize = 12, + ) { + private val ftyp = byteArrayOf(0x66, 0x74, 0x79, 0x70) + private val he = byteArrayOf(0x68, 0x65) + private val icIxIsVcVx = arrayOf( + byteArrayOf(0x69, 0x63), + byteArrayOf(0x69, 0x78), + byteArrayOf(0x69, 0x73), + byteArrayOf(0x76, 0x63), + byteArrayOf(0x76, 0x78), + ) + + override fun check(tested: ByteArray): Boolean { + for (i in 4..7) { + if (tested[i] != ftyp[i - 4]) return false + } + for (i in 8..9) { + if (tested[i] != he[i - 8]) return false + } + icIxIsVcVx.forEach { + if (baseCheck(it, tested.copyOfRange(10, 12))) { + return true + } + } + return false + } + } + + data object SvgFormat : FormatStandard( + format = ImageFormat.SVG, + requiredByteArraySize = 1024, + ) { + override fun check(tested: ByteArray): Boolean { + if (tested[0] != '<'.code.toByte()) return false + if (tested.toTypedArray().indexOf(" tested.size) { + return false + } + + for (i in standard.indices) { + if (standard[i] != tested[i]) { + return false + } + } + + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/skyd/anivu/util/image/format/ImageFormat.kt b/app/src/main/java/com/skyd/anivu/util/image/format/ImageFormat.kt new file mode 100644 index 00000000..930c3744 --- /dev/null +++ b/app/src/main/java/com/skyd/anivu/util/image/format/ImageFormat.kt @@ -0,0 +1,61 @@ +package com.skyd.anivu.util.image.format + +enum class ImageFormat { + JPG, + PNG, + APNG, + GIF, + WEBP, + BMP, + HEIF, + HEIC, + SVG, + UNDEFINED; + + override fun toString(): String { + return when (this) { + JPG -> ".jpg" + PNG -> ".png" + APNG -> ".apng" + GIF -> ".gif" + WEBP -> ".webp" + BMP -> ".bmp" + HEIF -> ".heif" + HEIC -> ".heic" + SVG -> ".svg" + UNDEFINED -> "" + } + } + + fun toMimeType(): String { + return when (this) { + JPG -> "image/jpg" + PNG -> "image/png" + APNG -> "image/apng" + GIF -> "image/gif" + WEBP -> "image/webp" + BMP -> "image/bmp" + HEIF -> "image/heif" + HEIC -> "image/heic" + SVG -> "image/svg+xml" + UNDEFINED -> "image/*" + } + } + + companion object { + fun fromMimeType(mimeType: String): ImageFormat { + return when (mimeType) { + "image/jpg" -> JPG + "image/apng" -> APNG + "image/png" -> PNG + "image/gif" -> GIF + "image/webp" -> WEBP + "image/bmp" -> BMP + "image/heif" -> HEIF + "image/heic" -> HEIC + "image/svg+xml" -> SVG + else -> UNDEFINED + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f719fa70..3672e29b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -317,8 +317,8 @@ 快,但不精确 精确,但慢 进度调整选项 - 在浏览器中打开 - 下载图片 + 在浏览器中打开图片 + 保存到相册 保存成功 已读 %d 项 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 81e63d08..fcdefd77 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -324,8 +324,8 @@ Fast, but not precise Precise, but slow Seek option - Open in browser - Download image + Open image in browser + Save to gallery Save success Read %d item