Initial migrate to ImageDecoder Java api, and downgrade minsdk to 28
This commit is contained in:
1 parent
fbdab389c4
commit
54d11dea95
18 files changed
+266
-862
No files matched your search
@@ -27,7 +27,7 @@ class UriArchiveAccessor(ctx: Context, uri: Uri) {
|
||||
}
|
||||
|
||||
private external fun openArchive(fd: Int, size: Long): Int
|
||||
external fun extractToAddr(index: Int): Long
|
||||
external fun extractToMemfd(index: Int): Int
|
||||
external fun extractToFd(index: Int, fd: Int)
|
||||
external fun getFilename(index: Int): String
|
||||
external fun needPassword(): Boolean
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package com.hippo.drawable;
|
||||
|
||||
import android.graphics.drawable.AnimatedImageDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.util.Log;
|
||||
|
||||
@@ -25,7 +26,6 @@ import com.hippo.conaco.Conaco;
|
||||
import com.hippo.conaco.ConacoTask;
|
||||
import com.hippo.conaco.Unikery;
|
||||
import com.hippo.image.ImageBitmap;
|
||||
import com.hippo.image.ImageDrawable;
|
||||
import com.hippo.widget.ObservedTextView;
|
||||
|
||||
public class UnikeryDrawable extends WrapDrawable implements Unikery<ImageBitmap>,
|
||||
@@ -37,6 +37,8 @@ public class UnikeryDrawable extends WrapDrawable implements Unikery<ImageBitmap
|
||||
private int mTaskId = Unikery.INVALID_ID;
|
||||
private String mUrl;
|
||||
|
||||
private ImageBitmap imageBitmap;
|
||||
|
||||
public UnikeryDrawable(ObservedTextView textView, Conaco<ImageBitmap> conaco) {
|
||||
mTextView = textView;
|
||||
mTextView.setOnWindowAttachListener(this);
|
||||
@@ -62,11 +64,11 @@ public class UnikeryDrawable extends WrapDrawable implements Unikery<ImageBitmap
|
||||
}
|
||||
|
||||
private void clearDrawable() {
|
||||
Drawable drawable = getDrawable();
|
||||
if (drawable instanceof ImageDrawable) {
|
||||
((ImageDrawable) drawable).recycle();
|
||||
}
|
||||
setDrawable(null);
|
||||
if (imageBitmap != null) {
|
||||
imageBitmap.release();
|
||||
imageBitmap = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -123,9 +125,9 @@ public class UnikeryDrawable extends WrapDrawable implements Unikery<ImageBitmap
|
||||
|
||||
@Override
|
||||
public boolean onGetValue(@NonNull ImageBitmap value, int source) {
|
||||
ImageDrawable drawable;
|
||||
Drawable drawable;
|
||||
try {
|
||||
drawable = new ImageDrawable(value);
|
||||
drawable = value.getDrawable();
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "The ImageBitmap is recycled", e);
|
||||
return false;
|
||||
@@ -134,7 +136,9 @@ public class UnikeryDrawable extends WrapDrawable implements Unikery<ImageBitmap
|
||||
clearDrawable();
|
||||
|
||||
setDrawable(drawable);
|
||||
drawable.start();
|
||||
imageBitmap = value;
|
||||
if (drawable instanceof AnimatedImageDrawable animatedImageDrawable)
|
||||
animatedImageDrawable.start();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -492,9 +492,6 @@ public class EhApplication extends SceneApplication {
|
||||
Log.i(TAG, "Native memory: " + FileUtils.humanReadableByteCount(
|
||||
Debug.getNativeHeapAllocatedSize(), false));
|
||||
}
|
||||
if (DEBUG_PRINT_IMAGE_COUNT) {
|
||||
Log.i(TAG, "Image count: " + Image.getImageCount());
|
||||
}
|
||||
SimpleHandler.getInstance().postDelayed(this, DEBUG_PRINT_INTERVAL);
|
||||
}
|
||||
}.run();
|
||||
|
||||
@@ -36,7 +36,7 @@ public class ImageBitmapHelper implements ValueHelper<ImageBitmap> {
|
||||
try {
|
||||
isPipe.obtain();
|
||||
FileInputStream is = (FileInputStream) isPipe.open();
|
||||
return ImageBitmap.decode(is.getFD());
|
||||
return ImageBitmap.decode(is);
|
||||
} catch (OutOfMemoryError | IOException e) {
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
@@ -19,6 +19,7 @@ package com.hippo.ehviewer.gallery;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Handler;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.os.Process;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -35,6 +36,8 @@ import com.hippo.yorozuya.FileUtils;
|
||||
import com.hippo.yorozuya.thread.PVLock;
|
||||
import com.hippo.yorozuya.thread.PriorityThread;
|
||||
|
||||
import java.io.FileDescriptor;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Iterator;
|
||||
@@ -51,7 +54,7 @@ public class ArchiveGalleryProvider extends GalleryProvider2 {
|
||||
public static PVLock pv = new PVLock(0);
|
||||
private final UriArchiveAccessor archiveAccessor;
|
||||
private final Stack<Integer> requests = new Stack<>();
|
||||
private final LinkedHashMap<Integer, Long> streams = new LinkedHashMap<>();
|
||||
private final LinkedHashMap<Integer, Integer> streams = new LinkedHashMap<>();
|
||||
private Thread archiveThread;
|
||||
private Thread[] decodeThread = new Thread[] {
|
||||
new Thread(new DecodeTask()),
|
||||
@@ -298,10 +301,10 @@ public class ArchiveGalleryProvider extends GalleryProvider2 {
|
||||
}
|
||||
}
|
||||
|
||||
long addr = archiveAccessor.extractToAddr(index);
|
||||
int fd = archiveAccessor.extractToMemfd(index);
|
||||
|
||||
synchronized (streams) {
|
||||
streams.put(index, addr);
|
||||
streams.put(index, fd);
|
||||
streams.notify();
|
||||
}
|
||||
}
|
||||
@@ -313,7 +316,7 @@ public class ArchiveGalleryProvider extends GalleryProvider2 {
|
||||
public void run() {
|
||||
while (!Thread.currentThread().isInterrupted()) {
|
||||
int index;
|
||||
Long addr;
|
||||
int fd;
|
||||
synchronized (streams) {
|
||||
if (streams.isEmpty()) {
|
||||
try {
|
||||
@@ -324,14 +327,15 @@ public class ArchiveGalleryProvider extends GalleryProvider2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
Iterator<Map.Entry<Integer, Long>> iterator = streams.entrySet().iterator();
|
||||
Map.Entry<Integer, Long> entry = iterator.next();
|
||||
Iterator<Map.Entry<Integer, Integer>> iterator = streams.entrySet().iterator();
|
||||
Map.Entry<Integer, Integer> entry = iterator.next();
|
||||
iterator.remove();
|
||||
index = entry.getKey();
|
||||
addr = entry.getValue();
|
||||
fd = entry.getValue();
|
||||
}
|
||||
|
||||
Image image = Image.decodeAddr(addr, true);
|
||||
Image image;
|
||||
image = Image.decode(new FileInputStream(ParcelFileDescriptor.adoptFd(fd).getFileDescriptor()), false); // we take ownership here since this memfd is anon fd
|
||||
if (image != null) {
|
||||
notifyPageSucceed(index, image);
|
||||
} else {
|
||||
|
||||
@@ -259,7 +259,7 @@ public class DirGalleryProvider extends GalleryProvider2 implements Runnable {
|
||||
InputStream is = null;
|
||||
try {
|
||||
is = files[index].openInputStream();
|
||||
Image image = Image.decode(((FileInputStream) is).getFD(), true);
|
||||
Image image = Image.decode((FileInputStream) is, false);
|
||||
mDecodingIndex.lazySet(GalleryPageView.INVALID_INDEX);
|
||||
if (image != null) {
|
||||
notifyPageSucceed(index, image);
|
||||
|
||||
@@ -1678,9 +1678,7 @@ public final class SpiderQueen implements Runnable {
|
||||
|
||||
if (is != null) {
|
||||
try {
|
||||
image = Image.decode(((FileInputStream) is).getFD(), true);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
image = Image.decode(((FileInputStream) is), false);
|
||||
} finally {
|
||||
try {
|
||||
is.close();
|
||||
|
||||
@@ -57,7 +57,6 @@ public abstract class GalleryProvider {
|
||||
public void stop() {
|
||||
OSUtils.checkMainLoop();
|
||||
mImageCache.evictAll();
|
||||
Image.lazyEvictAll();
|
||||
}
|
||||
|
||||
public void setGLRoot(GLRoot glRoot) {
|
||||
@@ -246,7 +245,7 @@ public abstract class GalleryProvider {
|
||||
}
|
||||
|
||||
public void add(Integer key, ImageWrapper value) {
|
||||
if (value.getFormat() != Image.FORMAT_ANIMATED)
|
||||
if (!value.getAnimated())
|
||||
if (value.obtain())
|
||||
put(key, value);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,6 @@ public class ImageMovableTextTexture extends ImageSpriteTexture {
|
||||
}
|
||||
|
||||
Image image = Image.create(bitmap);
|
||||
bitmap.recycle();
|
||||
if (image == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ public class ImageTexture implements Texture, Animatable {
|
||||
}
|
||||
|
||||
boolean end = mReleased.get() || mImage.isImageRecycled() || mNeedRelease.get() ||
|
||||
(mImage.getFormat() == 0) || mRunning.get();
|
||||
(!mImage.getAnimated()) || mRunning.get();
|
||||
|
||||
synchronized (mImage) {
|
||||
mImageBusy = false;
|
||||
@@ -595,7 +595,7 @@ public class ImageTexture implements Texture, Animatable {
|
||||
// Release image
|
||||
mImageBusy = false;
|
||||
// Check need release, frameCount <= 1
|
||||
if (mNeedRelease.get() || mImage.getFormat() == 0) {
|
||||
if (mNeedRelease.get() || !mImage.getAnimated()) {
|
||||
mAnimateRunnable = null;
|
||||
return;
|
||||
}
|
||||
@@ -617,7 +617,7 @@ public class ImageTexture implements Texture, Animatable {
|
||||
mImageBusy = true;
|
||||
}
|
||||
|
||||
mImage.advance();
|
||||
mImage.start();
|
||||
long delay = mImage.getDelay();
|
||||
long time = System.nanoTime();
|
||||
if (-1L != lastDelay) {
|
||||
|
||||
@@ -102,14 +102,14 @@ public class ImageWrapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* @see Image#getFormat()
|
||||
* @see Image#getAnimated()
|
||||
*/
|
||||
public int getFormat() {
|
||||
return mImage.getFormat();
|
||||
public Boolean getAnimated() {
|
||||
return mImage.getAnimated();
|
||||
}
|
||||
|
||||
/**
|
||||
* @see Image#getFormat()
|
||||
* @see Image#getAnimated()
|
||||
*/
|
||||
public int getWidth() {
|
||||
return mCut.width();
|
||||
@@ -123,12 +123,12 @@ public class ImageWrapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* @see Image#render(int, int, Bitmap, int, int, int, int, boolean, int)
|
||||
* @see Image#render(int, int, Bitmap, int, int, int, int)
|
||||
*/
|
||||
public void render(int srcX, int srcY, Bitmap dst, int dstX, int dstY,
|
||||
int width, int height, boolean fillBlank, int defaultColor) {
|
||||
mImage.render(srcX + mCut.left, srcY + mCut.top, dst, dstX, dstY,
|
||||
width, height, fillBlank, defaultColor);
|
||||
width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,10 +139,10 @@ public class ImageWrapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* @see Image#advance()
|
||||
* @see Image#start()
|
||||
*/
|
||||
public void advance() {
|
||||
mImage.advance();
|
||||
public void start() {
|
||||
mImage.start();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,267 +1,186 @@
|
||||
/*
|
||||
* Copyright 2016 Hippo Seven
|
||||
* Copyright 2022 Tarsin Norbin
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* This file is part of EhViewer
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* EhViewer is free software: you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
* EhViewer is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with EhViewer.
|
||||
* If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.hippo.image
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import com.hippo.Native.getFd
|
||||
import java.io.FileDescriptor
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* The `Image` is a image which stored pixel data in native heap
|
||||
*/
|
||||
class Image private constructor(nativePtr: Long) {
|
||||
private var mNativePtr: Long
|
||||
private var needRecycle = false
|
||||
private var nativeProcessCnt = 0
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ImageDecoder
|
||||
import android.graphics.ImageDecoder.ALLOCATOR_DEFAULT
|
||||
import android.graphics.ImageDecoder.ALLOCATOR_SOFTWARE
|
||||
import android.graphics.ImageDecoder.Source
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.AnimatedImageDrawable
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.core.graphics.component1
|
||||
import androidx.core.graphics.component2
|
||||
import androidx.core.graphics.component3
|
||||
import androidx.core.graphics.component4
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import java.io.FileInputStream
|
||||
import java.nio.channels.FileChannel
|
||||
|
||||
class Image private constructor(source: Source?, drawable: Drawable? = null,
|
||||
val hardware: Boolean = true
|
||||
) {
|
||||
internal var mObtainedDrawable: Drawable?
|
||||
private var mBitmap: Bitmap? = null
|
||||
init {
|
||||
if (needEvictAll) evictAll()
|
||||
mNativePtr = nativePtr
|
||||
imageList.add(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the format of the image
|
||||
*/
|
||||
val format: Int
|
||||
get() {
|
||||
checkRecycledAndLock()
|
||||
val ret = nativeGetFormat(mNativePtr)
|
||||
releaseNativeLock()
|
||||
return ret
|
||||
mObtainedDrawable = null
|
||||
source?.let {
|
||||
mObtainedDrawable = ImageDecoder.decodeDrawable(source) { decoder: ImageDecoder, _, _ ->
|
||||
decoder.allocator = if (hardware) ALLOCATOR_DEFAULT else ALLOCATOR_SOFTWARE
|
||||
// Sadly we must use software memory since we need copy it to tile buffer, fuck glgallery
|
||||
// Idk it will cause how much performance regression
|
||||
} // Should we lazy decode it?
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the width of the image
|
||||
*/
|
||||
val width: Int
|
||||
get() {
|
||||
checkRecycledAndLock()
|
||||
val ret = nativeGetWidth(mNativePtr)
|
||||
releaseNativeLock()
|
||||
return ret
|
||||
if (mObtainedDrawable == null) {
|
||||
mObtainedDrawable = drawable!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the height of the image
|
||||
*/
|
||||
val height: Int
|
||||
get() {
|
||||
checkRecycledAndLock()
|
||||
val ret = nativeGetHeight(mNativePtr)
|
||||
releaseNativeLock()
|
||||
return ret
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun checkRecycledAndLock() {
|
||||
check(mNativePtr != 0L) { "The image is recycled." }
|
||||
nativeProcessCnt++
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun releaseNativeLock() {
|
||||
nativeProcessCnt--
|
||||
if (needRecycle) recycle()
|
||||
}
|
||||
val animated = mObtainedDrawable is AnimatedImageDrawable
|
||||
val width = mObtainedDrawable!!.intrinsicWidth
|
||||
val height = mObtainedDrawable!!.intrinsicHeight
|
||||
val isRecycled = mObtainedDrawable == null
|
||||
|
||||
/**
|
||||
* Render the image to `Bitmap`
|
||||
*/
|
||||
fun render(
|
||||
srcX: Int, srcY: Int, dst: Bitmap, dstX: Int, dstY: Int,
|
||||
width: Int, height: Int, fillBlank: Boolean, defaultColor: Int
|
||||
) {
|
||||
checkRecycledAndLock()
|
||||
nativeRender(mNativePtr, srcX, srcY, dst, dstX, dstY, width, height)
|
||||
releaseNativeLock()
|
||||
}
|
||||
var started = false
|
||||
|
||||
/**
|
||||
* Call `glTexImage2D` for init is true and
|
||||
* call `glTexSubImage2D` for init is false.
|
||||
* width * height must <= 512 * 512 or do nothing
|
||||
*/
|
||||
fun texImage(init: Boolean, offsetX: Int, offsetY: Int, width: Int, height: Int) {
|
||||
checkRecycledAndLock()
|
||||
nativeTexImage(mNativePtr, init, offsetX, offsetY, width, height)
|
||||
releaseNativeLock()
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to next frame. Do nothing for non-animation image
|
||||
*/
|
||||
fun advance() {
|
||||
checkRecycledAndLock()
|
||||
nativeAdvance(mNativePtr)
|
||||
releaseNativeLock()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current frame delay. 0 for non-animation image
|
||||
*/
|
||||
val delay: Int
|
||||
get() {
|
||||
checkRecycledAndLock()
|
||||
val delay = nativeGetDelay(mNativePtr)
|
||||
releaseNativeLock()
|
||||
return if (delay <= 10) 100 else delay
|
||||
}
|
||||
|
||||
/**
|
||||
* Return is the image opaque
|
||||
*/
|
||||
val isOpaque: Boolean
|
||||
get() {
|
||||
checkRecycledAndLock()
|
||||
val ret = nativeIsOpaque(mNativePtr)
|
||||
releaseNativeLock()
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* Free the native object associated with this image.
|
||||
* It must be called when the image will not be used.
|
||||
* The image can't be used after this method is called.
|
||||
*/
|
||||
@Synchronized
|
||||
fun recycle() {
|
||||
if (mNativePtr != 0L) {
|
||||
if (nativeProcessCnt != 0) {
|
||||
needRecycle = true
|
||||
return
|
||||
}
|
||||
nativeRecycle(mNativePtr)
|
||||
mNativePtr = 0
|
||||
imageList.remove(this)
|
||||
if (mObtainedDrawable is AnimatedImageDrawable) {
|
||||
(mObtainedDrawable as AnimatedImageDrawable?)?.stop()
|
||||
}
|
||||
if (mObtainedDrawable is BitmapDrawable) {
|
||||
(mObtainedDrawable as BitmapDrawable?)?.bitmap?.recycle()
|
||||
}
|
||||
mObtainedDrawable?.callback = null
|
||||
mObtainedDrawable = null
|
||||
mBitmap?.recycle()
|
||||
mBitmap = null
|
||||
}
|
||||
|
||||
private fun prepareBitmap() {
|
||||
if (mBitmap != null) return
|
||||
mBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
|
||||
private fun updateBitmap() {
|
||||
prepareBitmap()
|
||||
val (oldLeft, oldTop, oldRight, oldBottom) = mObtainedDrawable!!.bounds
|
||||
mObtainedDrawable!!.setBounds(0, 0, width, height)
|
||||
mObtainedDrawable!!.draw(Canvas(mBitmap!!))
|
||||
mObtainedDrawable!!.setBounds(oldLeft, oldTop, oldRight, oldBottom)
|
||||
}
|
||||
|
||||
fun render(
|
||||
srcX: Int, srcY: Int, dst: Bitmap, dstX: Int, dstY: Int,
|
||||
width: Int, height: Int
|
||||
) {
|
||||
check(!hardware) { "Hardware buffer cannot be used in glgallery" }
|
||||
val bitmap: Bitmap = if (animated) {
|
||||
updateBitmap()
|
||||
mBitmap!!
|
||||
} else {
|
||||
mObtainedDrawable!!.toBitmap()
|
||||
}
|
||||
nativeRender(
|
||||
bitmap,
|
||||
srcX,
|
||||
srcY,
|
||||
dst,
|
||||
dstX,
|
||||
dstY,
|
||||
width,
|
||||
height
|
||||
)
|
||||
}
|
||||
|
||||
fun texImage(init: Boolean, offsetX: Int, offsetY: Int, width: Int, height: Int) {
|
||||
check(!hardware) { "Hardware buffer cannot be used in glgallery" }
|
||||
val bitmap: Bitmap = if (animated) {
|
||||
updateBitmap()
|
||||
mBitmap!!
|
||||
} else {
|
||||
mObtainedDrawable!!.toBitmap()
|
||||
}
|
||||
nativeTexImage(
|
||||
bitmap,
|
||||
init,
|
||||
offsetX,
|
||||
offsetY,
|
||||
width,
|
||||
height
|
||||
)
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (!started) {
|
||||
(mObtainedDrawable as AnimatedImageDrawable?)?.start()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this image has been recycled.
|
||||
*/
|
||||
val isRecycled: Boolean
|
||||
get() = mNativePtr == 0L
|
||||
val delay: Int
|
||||
get() {
|
||||
if (animated)
|
||||
return 100
|
||||
return 0
|
||||
}
|
||||
|
||||
val isOpaque: Boolean
|
||||
get() {
|
||||
return mObtainedDrawable?.opacity == PixelFormat.OPAQUE
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FORMAT_NORMAL = 0
|
||||
const val FORMAT_ANIMATED = 1
|
||||
private val imageList = ArrayList<Image>()
|
||||
private var needEvictAll = false
|
||||
private fun newFromAddr(addr: Long): Image? {
|
||||
return if (addr == 0L) null else Image(addr)
|
||||
@JvmStatic
|
||||
fun decode(stream: FileInputStream, hardware: Boolean = true): Image {
|
||||
val src = ImageDecoder.createSource(
|
||||
stream.channel.map(
|
||||
FileChannel.MapMode.READ_ONLY, 0,
|
||||
stream.available().toLong()
|
||||
)
|
||||
)
|
||||
return Image(src, hardware = hardware)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun lazyEvictAll() {
|
||||
needEvictAll = true
|
||||
fun create(bitmap: Bitmap): Image {
|
||||
return Image(null, bitmap.toDrawable(Resources.getSystem()), false)
|
||||
}
|
||||
|
||||
private fun evictAll() {
|
||||
ArrayList(imageList).forEach(
|
||||
Consumer { obj: Image -> obj.recycle() })
|
||||
needEvictAll = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode image from `InputStream`
|
||||
*/
|
||||
@JvmStatic
|
||||
fun decode(fd: FileDescriptor?, partially: Boolean): Image? {
|
||||
return newFromAddr(nativeDecode(getFd(fd)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode image from `InputStream`
|
||||
*/
|
||||
@JvmStatic
|
||||
fun decode(fd: Int, partially: Boolean): Image? {
|
||||
return newFromAddr(nativeDecode(fd))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode image from `InputStream`
|
||||
*/
|
||||
@JvmStatic
|
||||
fun decodeAddr(addr: Long, partially: Boolean): Image? {
|
||||
return newFromAddr(nativeDecodeAddr(addr))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a plain image from Bitmap
|
||||
*/
|
||||
@JvmStatic
|
||||
fun create(bitmap: Bitmap): Image? {
|
||||
return newFromAddr(nativeCreate(bitmap))
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all un-recycled `Image` instance count.
|
||||
* It is useful for debug.
|
||||
*/
|
||||
@JvmStatic
|
||||
val imageCount: Int
|
||||
get() = imageList.size
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeDecode(fd: Int): Long
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeDecodeAddr(addr: Long): Long
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeCreate(bitmap: Bitmap): Long
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeRender(
|
||||
nativePtr: Long,
|
||||
bitmap: Bitmap,
|
||||
srcX: Int, srcY: Int, dst: Bitmap, dstX: Int, dstY: Int,
|
||||
width: Int, height: Int
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeTexImage(
|
||||
nativePtr: Long,
|
||||
bitmap: Bitmap,
|
||||
init: Boolean,
|
||||
offsetX: Int,
|
||||
offsetY: Int,
|
||||
width: Int,
|
||||
height: Int
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeAdvance(nativePtr: Long)
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeGetDelay(nativePtr: Long): Int
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeIsOpaque(nativePtr: Long): Boolean
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeRecycle(nativePtr: Long)
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeGetFormat(nativePtr: Long): Int
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeGetWidth(nativePtr: Long): Int
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeGetHeight(nativePtr: Long): Int
|
||||
}
|
||||
}
|
||||
@@ -1,82 +1,32 @@
|
||||
/*
|
||||
* Copyright 2016 Hippo Seven
|
||||
* Copyright 2022 Tarsin Norbin
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* This file is part of EhViewer
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* EhViewer is free software: you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
* EhViewer is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with EhViewer.
|
||||
* If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.hippo.image
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import java.io.FileDescriptor
|
||||
import java.lang.ref.WeakReference
|
||||
import android.graphics.drawable.Drawable
|
||||
import java.io.FileInputStream
|
||||
|
||||
/**
|
||||
* A image with [Image] for data and [Bitmap] for render.
|
||||
*/
|
||||
class ImageBitmap : Animatable, Runnable {
|
||||
private val mBitmap: Bitmap
|
||||
|
||||
/**
|
||||
* Return the format of the image
|
||||
*/
|
||||
val format: Int
|
||||
|
||||
/**
|
||||
* Return image is opaque
|
||||
*/
|
||||
val isOpaque: Boolean
|
||||
private val mCallbackSet: MutableSet<WeakReference<Callback>> = LinkedHashSet()
|
||||
private var mImage: Image? = null
|
||||
class ImageBitmap private constructor(image: Image) {
|
||||
private var mImage: Image? = image
|
||||
private var mReferences = 0
|
||||
private var mAnimationReferences = 0
|
||||
private var mRunning = false
|
||||
|
||||
private constructor(image: Image) {
|
||||
val width = image.width
|
||||
val height = image.height
|
||||
mBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
image.render(0, 0, mBitmap, 0, 0, width, height, false, 0)
|
||||
format = image.format
|
||||
isOpaque = image.isOpaque
|
||||
if (format == 1) {
|
||||
// For animated image, save image object
|
||||
mImage = image
|
||||
} else {
|
||||
// Free the image
|
||||
image.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private constructor(bitmap: Bitmap) {
|
||||
format = Image.FORMAT_NORMAL
|
||||
mBitmap = bitmap
|
||||
isOpaque = !bitmap.hasAlpha()
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain the image bitmap
|
||||
*
|
||||
* @return false for the image is recycled and obtain failed
|
||||
*/
|
||||
@Synchronized
|
||||
fun obtain(): Boolean {
|
||||
return if (mBitmap.isRecycled) {
|
||||
return if (mImage?.isRecycled == true) {
|
||||
false
|
||||
} else {
|
||||
++mReferences
|
||||
@@ -84,220 +34,31 @@ class ImageBitmap : Animatable, Runnable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the image bitmap
|
||||
*/
|
||||
@Synchronized
|
||||
fun release() {
|
||||
--mReferences
|
||||
if (mReferences <= 0 && !mBitmap.isRecycled) {
|
||||
mBitmap.recycle()
|
||||
if (mImage != null) {
|
||||
mImage!!.recycle()
|
||||
}
|
||||
if (mReferences <= 0 && !mImage!!.isRecycled) {
|
||||
mImage!!.recycle()
|
||||
mImage = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a callback for invalidating
|
||||
*/
|
||||
fun addCallback(callback: Callback) {
|
||||
val iterator = mCallbackSet.iterator()
|
||||
var c: Callback?
|
||||
while (iterator.hasNext()) {
|
||||
c = iterator.next().get()
|
||||
if (c == null) {
|
||||
// Remove from the set if the reference has been cleared or
|
||||
// it can't be used.
|
||||
iterator.remove()
|
||||
} else if (c === callback) {
|
||||
return
|
||||
}
|
||||
}
|
||||
mCallbackSet.add(WeakReference(callback))
|
||||
fun getDrawable(): Drawable {
|
||||
check(obtain()) { "Recycled!" }
|
||||
return mImage!!.mObtainedDrawable as Drawable
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a callback
|
||||
*/
|
||||
fun removeCallback(callback: Callback) {
|
||||
val iterator = mCallbackSet.iterator()
|
||||
var c: Callback?
|
||||
while (iterator.hasNext()) {
|
||||
c = iterator.next().get()
|
||||
if (c == null) {
|
||||
// Remove from the set if the reference has been cleared or
|
||||
// it can't be used.
|
||||
iterator.remove()
|
||||
} else if (c === callback) {
|
||||
iterator.remove()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return image width
|
||||
*/
|
||||
val width: Int
|
||||
get() = mBitmap.width
|
||||
get() = mImage!!.width
|
||||
|
||||
/**
|
||||
* Return image height
|
||||
*/
|
||||
val height: Int
|
||||
get() = mBitmap.height
|
||||
|
||||
/**
|
||||
* Return image is animated
|
||||
*/
|
||||
val isAnimated: Boolean
|
||||
get() = mImage != null
|
||||
|
||||
/**
|
||||
* Draw image to canvas
|
||||
*/
|
||||
fun draw(canvas: Canvas, left: Float, top: Float, paint: Paint?) {
|
||||
if (!mBitmap.isRecycled) {
|
||||
canvas.drawBitmap(mBitmap, left, top, paint)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw image to canvas
|
||||
*/
|
||||
fun draw(canvas: Canvas, src: Rect?, dst: Rect, paint: Paint?) {
|
||||
if (!mBitmap.isRecycled) {
|
||||
canvas.drawBitmap(mBitmap, src, dst, paint)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw image to canvas
|
||||
*/
|
||||
fun draw(canvas: Canvas, src: Rect?, dst: RectF, paint: Paint?) {
|
||||
if (!mBitmap.isRecycled) {
|
||||
canvas.drawBitmap(mBitmap, src, dst, paint)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `start()` and `stop()` is a pair
|
||||
*/
|
||||
override fun start() {
|
||||
mAnimationReferences++
|
||||
if (mBitmap.isRecycled || mImage == null || mRunning) {
|
||||
return
|
||||
}
|
||||
mRunning = true
|
||||
HANDLER.postDelayed(this, Math.max(0, mImage!!.delay).toLong())
|
||||
}
|
||||
|
||||
/**
|
||||
* `start()` and `stop()` is a pair
|
||||
*/
|
||||
override fun stop() {
|
||||
mAnimationReferences--
|
||||
if (mAnimationReferences <= 0) {
|
||||
mRunning = false
|
||||
HANDLER.removeCallbacks(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun isRunning(): Boolean {
|
||||
return mRunning
|
||||
}
|
||||
|
||||
private fun notifyUpdate(): Boolean {
|
||||
var hasCallback = false
|
||||
val iterator = mCallbackSet.iterator()
|
||||
var callback: Callback?
|
||||
while (iterator.hasNext()) {
|
||||
callback = iterator.next().get()
|
||||
if (callback != null) {
|
||||
// Render bitmap int the first time
|
||||
if (!hasCallback) {
|
||||
hasCallback = true
|
||||
mImage!!.render(0, 0, mBitmap, 0, 0, mImage!!.width, mImage!!.height, false, 0)
|
||||
}
|
||||
callback.invalidateImage(this)
|
||||
} else {
|
||||
// Remove from the set if the reference has been cleared or
|
||||
// it can't be used.
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
return hasCallback
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
// Check recycled
|
||||
if (mBitmap.isRecycled || mImage == null) {
|
||||
mRunning = false
|
||||
return
|
||||
}
|
||||
mImage!!.advance()
|
||||
if (notifyUpdate()) {
|
||||
if (mRunning) {
|
||||
HANDLER.postDelayed(this, Math.max(0, mImage!!.delay).toLong())
|
||||
}
|
||||
} else {
|
||||
mRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun invalidateImage(who: ImageBitmap?)
|
||||
}
|
||||
get() = mImage!!.height
|
||||
|
||||
companion object {
|
||||
private val HANDLER = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* Decode `InputStream`, then create image.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun decode(fd: FileDescriptor): ImageBitmap? {
|
||||
val image = Image.decode(fd, false)
|
||||
return if (image != null) {
|
||||
create(image)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create `ImageBitmap` from `Image`.
|
||||
* It is not recommended. Use [.decode] if you can.
|
||||
*
|
||||
* @param image the image should not be used before and
|
||||
* it must not be recycled. And the image should
|
||||
* not be used directly anymore.
|
||||
*/
|
||||
fun create(image: Image): ImageBitmap? {
|
||||
return if (!image.isRecycled) {
|
||||
try {
|
||||
ImageBitmap(image)
|
||||
} catch (e: OutOfMemoryError) {
|
||||
image.recycle()
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create `ImageBitmap` from `Bitmap`.
|
||||
*
|
||||
* @param bitmap the bitmap should not be recycled
|
||||
*/
|
||||
fun create(bitmap: Bitmap): ImageBitmap? {
|
||||
return if (!bitmap.isRecycled) {
|
||||
ImageBitmap(bitmap)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
fun decode(stream: FileInputStream): ImageBitmap {
|
||||
val image = Image.decode(stream)
|
||||
return ImageBitmap(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
* Copyright 2016 Hippo Seven
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.hippo.image
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.graphics.drawable.Drawable
|
||||
|
||||
/**
|
||||
* A drawable to draw [ImageBitmap]
|
||||
*/
|
||||
class ImageDrawable(imageBitmap: ImageBitmap) : Drawable(), Animatable, ImageBitmap.Callback {
|
||||
private val mImageBitmap: ImageBitmap
|
||||
private val mPaint: Paint
|
||||
|
||||
init {
|
||||
if (!imageBitmap.obtain()) {
|
||||
throw Exception()
|
||||
}
|
||||
mImageBitmap = imageBitmap
|
||||
mPaint = Paint(Paint.FILTER_BITMAP_FLAG)
|
||||
|
||||
// Add callback
|
||||
imageBitmap.addCallback(this)
|
||||
}
|
||||
|
||||
fun recycle() {
|
||||
mImageBitmap.removeCallback(this)
|
||||
mImageBitmap.release()
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
mImageBitmap.draw(canvas, null, bounds, mPaint)
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) {
|
||||
mPaint.alpha = alpha
|
||||
}
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) {
|
||||
mPaint.colorFilter = colorFilter
|
||||
}
|
||||
|
||||
override fun getOpacity(): Int {
|
||||
return if (mImageBitmap.isOpaque) PixelFormat.OPAQUE else PixelFormat.UNKNOWN
|
||||
}
|
||||
|
||||
override fun getIntrinsicWidth(): Int {
|
||||
return mImageBitmap.width
|
||||
}
|
||||
|
||||
override fun getIntrinsicHeight(): Int {
|
||||
return mImageBitmap.height
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
mImageBitmap.start()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
mImageBitmap.stop()
|
||||
}
|
||||
|
||||
override fun isRunning(): Boolean {
|
||||
return mImageBitmap.isRunning
|
||||
}
|
||||
|
||||
override fun invalidateImage(who: ImageBitmap?) {
|
||||
invalidateSelf()
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.drawable.Animatable;
|
||||
import android.graphics.drawable.AnimatedImageDrawable;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.graphics.drawable.TransitionDrawable;
|
||||
@@ -39,7 +40,6 @@ import com.hippo.drawable.PreciselyClipDrawable;
|
||||
import com.hippo.ehviewer.EhApplication;
|
||||
import com.hippo.ehviewer.R;
|
||||
import com.hippo.image.ImageBitmap;
|
||||
import com.hippo.image.ImageDrawable;
|
||||
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
@@ -63,6 +63,7 @@ public class LoadImageView extends FixedAspectImageView implements Unikery<Image
|
||||
private int mRetryType;
|
||||
private boolean mFailed;
|
||||
private boolean mLoadFromDrawable = false;
|
||||
private ImageBitmap imageBitmap;
|
||||
|
||||
public LoadImageView(Context context) {
|
||||
super(context);
|
||||
@@ -130,10 +131,9 @@ public class LoadImageView extends FixedAspectImageView implements Unikery<Image
|
||||
}
|
||||
}
|
||||
|
||||
private ImageDrawable getImageDrawable() {
|
||||
private Drawable getImageDrawable() {
|
||||
Drawable drawable = getDrawable();
|
||||
if (drawable instanceof TransitionDrawable) {
|
||||
TransitionDrawable transitionDrawable = (TransitionDrawable) drawable;
|
||||
if (drawable instanceof TransitionDrawable transitionDrawable) {
|
||||
if (transitionDrawable.getNumberOfLayers() == 2) {
|
||||
drawable = transitionDrawable.getDrawable(1);
|
||||
}
|
||||
@@ -141,22 +141,17 @@ public class LoadImageView extends FixedAspectImageView implements Unikery<Image
|
||||
if (drawable instanceof PreciselyClipDrawable) {
|
||||
drawable = ((PreciselyClipDrawable) drawable).getWrappedDrawable();
|
||||
}
|
||||
if (drawable instanceof ImageDrawable) {
|
||||
return (ImageDrawable) drawable;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return drawable;
|
||||
}
|
||||
|
||||
private void clearDrawable() {
|
||||
// Recycle ImageDrawable
|
||||
ImageDrawable imageDrawable = getImageDrawable();
|
||||
if (imageDrawable != null) {
|
||||
imageDrawable.recycle();
|
||||
}
|
||||
|
||||
// Set drawable null
|
||||
setImageDrawable(null);
|
||||
// Recycle ImageDrawable
|
||||
if (imageBitmap != null) {
|
||||
imageBitmap.release();
|
||||
imageBitmap = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void clearRetry() {
|
||||
@@ -274,7 +269,7 @@ public class LoadImageView extends FixedAspectImageView implements Unikery<Image
|
||||
public boolean onGetValue(@NonNull ImageBitmap value, int source) {
|
||||
Drawable drawable;
|
||||
try {
|
||||
drawable = new ImageDrawable(value);
|
||||
drawable = value.getDrawable();
|
||||
} catch (Exception e) {
|
||||
// The image might be recycled because it is removed from memory cache.
|
||||
Log.d(TAG, "The image is recycled", e);
|
||||
@@ -298,6 +293,7 @@ public class LoadImageView extends FixedAspectImageView implements Unikery<Image
|
||||
} else {
|
||||
setImageDrawable(drawable);
|
||||
}
|
||||
imageBitmap = value;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -327,25 +323,25 @@ public class LoadImageView extends FixedAspectImageView implements Unikery<Image
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
ImageDrawable drawable = getImageDrawable();
|
||||
if (drawable != null) {
|
||||
drawable.start();
|
||||
Drawable drawable = getImageDrawable();
|
||||
if (drawable instanceof AnimatedImageDrawable animatedImageDrawable) {
|
||||
animatedImageDrawable.start();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
ImageDrawable drawable = getImageDrawable();
|
||||
if (drawable != null) {
|
||||
drawable.stop();
|
||||
Drawable drawable = getImageDrawable();
|
||||
if (drawable instanceof AnimatedImageDrawable animatedImageDrawable) {
|
||||
animatedImageDrawable.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunning() {
|
||||
ImageDrawable drawable = getImageDrawable();
|
||||
if (drawable != null) {
|
||||
return drawable.isRunning();
|
||||
Drawable drawable = getImageDrawable();
|
||||
if (drawable instanceof AnimatedImageDrawable animatedImageDrawable) {
|
||||
return animatedImageDrawable.isRunning();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user