panpf/zoomimage

File descriptor from content URI isn't closed when disposed

Closed this issue · 8 comments

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:

  • Android
  • Desktop

Versions

  • zoomimage version: 1.1.0-alpha01
  • Kotlin version: 2.0.0-RC1
  • Compose version(s)):1.6.8

Running Devices

  • Samsung Galaxy A12; One UI Core 4.1; arm64

Code sample

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

Reproduction step

  • Create an image with subsampling using a content URI
  • Zoom
  • Unzoom

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)

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

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)

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
        )
    }
}

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?

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 #29 (comment)

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.

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

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.

This is good news