panpf / zoomimage

ZoomImage is an gesture zoom viewing of images library specially designed for Compose Multiplatform and Android View. Supported scale, pan, locate, rotation, and super-large image subsampling.
Apache License 2.0
340 stars 19 forks source link

File descriptor from content URI isn't closed when disposed #29

Closed theskyblockman closed 4 months ago

theskyblockman commented 4 months ago

Describe the bug

.close isn't called (or .use isn't used) when the file descriptor (here from a content URI) is not needed anymore when using supersampling.

Affected platforms

Select of the platforms below:

Versions

Running Devices

Code sample

// Create a ZoomImage with a zoomState and set the subsampling
zoomState.subsampling.setImageSource(Uri.parse("content://..."))

Reproduction step

Error/stacktrace

Details

StrictMode policy violation: android.os.strictmode.LeakedClosableViolation: A resource was acquired at attached stack trace but never released. See java.io.Closeable for information on avoiding resource leaks. at android.os.StrictMode$AndroidCloseGuardReporter.report(StrictMode.java:1987) at dalvik.system.CloseGuard.warnIfOpen(CloseGuard.java:336) at android.os.ParcelFileDescriptor.finalize(ParcelFileDescriptor.java:1069) at java.lang.Daemons$FinalizerDaemon.doFinalize(Daemons.java:339) at java.lang.Daemons$FinalizerDaemon.processReference(Daemons.java:324) at java.lang.Daemons$FinalizerDaemon.runInternal(Daemons.java:300) at java.lang.Daemons$Daemon.run(Daemons.java:145) at java.lang.Thread.run(Thread.java:1012) Caused by: java.lang.Throwable: Explicit termination method 'close' not called at dalvik.system.CloseGuard.openWithCallSite(CloseGuard.java:288) at dalvik.system.CloseGuard.open(CloseGuard.java:257) at android.os.ParcelFileDescriptor.(ParcelFileDescriptor.java:206) at android.os.ParcelFileDescriptor$2.createFromParcel(ParcelFileDescriptor.java:1129) at android.os.ParcelFileDescriptor$2.createFromParcel(ParcelFileDescriptor.java:1120) at android.os.storage.IStorageManager$Stub$Proxy.openProxyFileDescriptor(IStorageManager.java:3577) at android.os.storage.StorageManager.openProxyFileDescriptor(StorageManager.java:2141) at android.os.storage.StorageManager.openProxyFileDescriptor(StorageManager.java:2202) at fr.theskyblockman.lifechest.vault.EncryptedContentProvider.openFile(EncryptedContentProvider.kt:159) at android.content.ContentProvider.openAssetFile(ContentProvider.java:2138) at android.content.ContentProvider.openTypedAssetFile(ContentProvider.java:2314) at android.content.ContentProvider.openTypedAssetFile(ContentProvider.java:2381) at android.content.ContentProvider$Transport.openTypedAssetFile(ContentProvider.java:562) at android.content.ContentResolver.openTypedAssetFileDescriptor(ContentResolver.java:2034) at android.content.ContentResolver.openAssetFileDescriptor(ContentResolver.java:1849) at android.content.ContentResolver.openInputStream(ContentResolver.java:1525) at com.github.panpf.zoomimage.subsampling.ContentImageSource$openSource$2.invokeSuspend(AndroidImageSource.kt:101) at com.github.panpf.zoomimage.subsampling.ContentImageSource$openSource$2.invoke(Unknown Source:8) at com.github.panpf.zoomimage.subsampling.ContentImageSource$openSource$2.invoke(Unknown Source:4) at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturn(Undispatched.kt:61) at kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:163) at kotlinx.coroutines.BuildersKt.withContext(Unknown Source:1) at com.github.panpf.zoomimage.subsampling.ContentImageSource.openSource-IoAF18A(AndroidImageSource.kt:99) at com.github.panpf.zoomimage.subsampling.internal.BitmapFactoryDecodeHelper$getOrCreateDecoder$2.invokeSuspend(BitmapFactoryDecodeHelper.kt:80) at com.github.panpf.zoomimage.subsampling.internal.BitmapFactoryDecodeHelper$getOrCreateDecoder$2.invoke(Unknown Source:8) at com.github.panpf.zoomimage.subsampling.internal.BitmapFactoryDecodeHelper$getOrCreateDecoder$2.invoke(Unknown Source:4) at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturn(Undispatched.kt:61) at kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:163) at kotlinx.coroutines.BuildersKt.withContext(Unknown Source:1) at com.github.panpf.zoomimage.subsampling.internal.BitmapFactoryDecodeHelper.getOrCreateDecoder(BitmapFactoryDecodeHelper.kt:79) at com.github.panpf.zoomimage.subsampling.internal.BitmapFactoryDecodeHelper.access$getOrCreateDecoder(BitmapFactoryDecodeHelper.kt:25) at com.github.panpf.zoomimage.subsampling.internal.BitmapFactoryDecodeHelper$decodeRegion$2.invokeSuspend(BitmapFactoryDecodeHelper.kt:47) at com.github.panpf.zoomimage.subsampling.internal.BitmapFactoryDecodeHelper$decodeRegion$2.invoke(Unknown Source:8) at com.github.panpf.zoomimage.subsampling.internal.BitmapFactoryDecodeHelper$decodeRegion$2.invoke(Unknown Source:4) at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturn(Undispatched.kt:61) at kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:163) at kotlinx.coroutines.BuildersKt.withContext(Unknown Source:1) at com.github.panpf.zoomimage.subsampling.internal.BitmapFactoryDecodeHelper.decodeRegion(BitmapFactoryDecodeHelper.kt:39) at com.github.panpf.zoomimage.subsampling.internal.TileDecoder$decode$tileBitmap$1.invokeSuspend(TileDecoder.kt:57) at com.github.panpf.zoomimage.subsampling.internal.TileDecoder$decode$tileBitmap$1.invoke(Unknown Source:8) at com.github.panpf.zoomimage.subsampling.internal.TileDecoder$decode$tileBitmap$1.invoke(Unknown Source:4) at com.github.panpf.zoomimage.subsampling.internal.TileDecoder$useDecoder$2.invokeSuspend(TileDecoder.kt:75) at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33) at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:104) at kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:111) at kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:99) at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584) at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:811) at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:715) at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:702) at com.github.panpf.zoomimage.subsampling.internal.TileDecoder$decode$tileBitmap$1.invoke(Unknown Source:8) at com.github.panpf.zoomimage.subsampling.internal.TileDecoder$decode$tileBitmap$1.invoke(Unknown Source:4) at com.github.panpf.zoomimage.subsampling.internal.TileDecoder$useDecoder$2.invokeSuspend(TileDecoder.kt:75) at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33) at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:104) at kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:111) at kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:99) at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:584) at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:811) at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:715) at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:702)

panpf commented 4 months ago

This is indeed turned off by use, so this may be a false positive of StrictMode

https://github.com/panpf/zoomimage/blob/6bf40159f1dbc1e21ba8dd9b28900349c4c3fa30/zoomimage-core/src/androidMain/kotlin/com/github/panpf/zoomimage/subsampling/internal/BitmapFactoryDecodeHelper.kt#L80

theskyblockman commented 4 months ago

This seems quite strange, I have never seen a false positive from StrictMode before.

I use my own ContentProvider so I could listen for when a file is opened and closed, when I initialized the ZoomImage I see a giant wall of events (the fd attached to the URI is opened more than 75 times in less than 300ms!) which could be (and probably is) the cause of StrictMode's error, I believe this should be treated a little bit better, here I use ProxyFileDescriptor so the OS is under a bit more strain than with a normal content URI but this seems abusive to not keep a ParcelFileDescriptor around as it does not store any state anyways (I do, but only for optimization)

theskyblockman commented 4 months ago

For convenience I also add the Composable I use with ZoomImage:

@Composable
fun InteractiveImage(
    modifier: Modifier = Modifier,
    bitmap: ImageBitmap,
    uri: Uri?,
    subsample: Boolean = true,
    fileName: String,
    isFullscreen: Boolean,
    contentScale: ContentScale = ContentScale.Fit,
    onClick: () -> Unit,
) {
    val context = LocalContext.current
    val zoomState: ZoomState = rememberZoomState()
    LaunchedEffect(context, zoomState, uri, subsample) {
        if (subsample) {
            zoomState.subsampling.setImageSource(ImageSource.fromContent(context, uri!!))
        }
    }

    val painter = remember {
        BitmapPainter(bitmap)
    }

    Box(
        contentAlignment = Alignment.Center,
        modifier = if (isFullscreen) Modifier.background(Color.Black) else Modifier
    ) {
        ZoomImage(
            painter = painter,
            contentDescription = stringResource(R.string.image_alt_text, fileName),
            modifier = modifier
                .fillMaxSize(),
            contentScale = contentScale,
            onTap = {
                onClick()
            },
            zoomState = zoomState
        )
    }
}
panpf commented 4 months ago

I have reproduced the problem of opening files multiple times in a short period of time. This is caused by the failure of concurrency control. I am working hard to fix this problem.

I have not reproduced the LeakedClosableViolation problem reported by StrictMode on the simulator of API 31. Can you give me more precise development environment information or give me a sample code that can be reproduced on the simulator?

theskyblockman commented 4 months ago

I use my own content URIs which depending on the context of my app enables me to read a file I encrypted earlier. In my class who's implementing ContentProvider I essentially have this method (some values need to be tweaked to make it work in another environment) :

override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
    if (mode != "r") {
        throw UnsupportedOperationException("Only reading is supported")
    }

    val file = getFile(uri) ?: return null // Set a File object here to test

    val storageManager = context?.getSystemService(Context.STORAGE_SERVICE) as StorageManager?
        ?: throw SecurityException("No storage manager")

    val handlerThread = HandlerThread("BackgroundFileReader")
    handlerThread.start()
    val randomAccessFile =
        RandomAccessFile(file, "r")
    return storageManager.openProxyFileDescriptor(
        ParcelFileDescriptor.MODE_READ_ONLY, EncryptedProxyFileDescriptorCallback(
            file,
            randomAccessFile,
            handlerThread
        ), Handler(handlerThread.looper)
    )
}
class EncryptedProxyFileDescriptorCallback(
    private val file: File,
    private val randomAccessFile: RandomAccessFile,
    private val handlerThread: HandlerThread
) : ProxyFileDescriptorCallback() {
    override fun onGetSize(): Long {
        return file.length()
    }

    override fun onRead(offset: Long, size: Int, data: ByteArray?): Int {
        if (data == null) return if (offset + size > file.size) (file.size - offset).toInt() else size
        randomAccessFile.seek(offset)
        val result = randomAccessFile.read(data, 0, size)

        return result
    }

    init {
        Log.d("EncryptedContentProvider", "Initializing file")
    }

    override fun onRelease() {
        Log.d("EncryptedContentProvider", "Releasing file")
        handlerThread.quitSafely()
        randomAccessFile.close()
    }
}

This is the ContentProvider for the URI I have as an argument in https://github.com/panpf/zoomimage/issues/29#issuecomment-2228105630

I heavily edited the code to accept clear files, normally I run decryption on the relevant part of the file which adds more latency/processing time. I use quite large files (around 5000x8000) to do my tests.

This piece of code is part of a relatively large app running all the latest versions of Kotlin, Jetpack Compose and AGP, also it does not have any networking involved.

panpf commented 4 months ago

Version 1.1.0-alpha03 attempts to optimize this issue, please retest

theskyblockman commented 4 months ago

I have retested with 1.1.0-alpha03, the file descriptor is now only created 4 times when the image is opened and the subsampling is initialized, which is a totally acceptable behavior. Now, the StrictMode error is gone. Bug fixed.

panpf commented 4 months ago

This is good news