tauri-plugin-android-fs 28.2.1

Android file system API for Tauri.
Documentation
package com.plugin.android_fs

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.graphics.Point
import android.media.MediaMetadataRetriever
import android.media.ThumbnailUtils
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import android.util.Size
import java.io.File
import java.io.FileNotFoundException
import java.io.OutputStream
import kotlin.math.ceil
import kotlin.math.min


class AFThumbnails private constructor() { companion object {

    /**
     * @param format: "jpeg", "png", "webp"
     * @param quality: 0 ~ 100
     */
    fun <T, O: OutputStream>loadThumbnail(
        fileUri: AFUri,
        preferredSize: Size,
        format: String,
        quality: Int,
        output: () -> O,
        useThumbnail: (O) -> T,
        ctx: Context
    ): T? {

        var thumbnail: Bitmap? = null
        try {
            thumbnail = fetchThumbnail(fileUri, preferredSize, ctx)
            if (thumbnail == null) {
                return null
            }

            val rw = preferredSize.width
            val rh = preferredSize.height
            val w = thumbnail.width
            val h = thumbnail.height
            if ((w > (rw * 2) + 3 || h > (rh * 2) + 3)) {
                val ratio = minOf(rw.toFloat() / w, rh.toFloat() / h)
                val newThumbnail = Bitmap.createScaledBitmap(
                    thumbnail,
                    (w * ratio).toInt(),
                    (h * ratio).toInt(),
                    false
                )
                val oldThumbnail = thumbnail
                thumbnail = newThumbnail

                // Bitmap.createScaledBitmap は元の Bitmap を返すことがある。
                if (oldThumbnail !== thumbnail) {
                    oldThumbnail.recycle()
                }
            }

            val compressFormat = when (format.lowercase()) {
                "jpeg", "jpg" -> Bitmap.CompressFormat.JPEG
                "png" -> Bitmap.CompressFormat.PNG
                "webp" -> {
                    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
                        Bitmap.CompressFormat.WEBP
                    }
                    else {
                        Bitmap.CompressFormat.WEBP_LOSSY
                    }
                }
                else -> throw Exception("Illegal format: $format")
            }

            val t = output().use {
                if (!thumbnail.compress(compressFormat, quality.coerceIn(0, 100), it)) {
                    throw Exception("Failed to compress bitmap")
                }
                it.flush()
                useThumbnail(it)
            }

            return t
        }
        finally {
            thumbnail?.recycle()
        }
    }
}}


private fun fetchThumbnail(
    fileUri: AFUri,
    preferredSize: Size,
    ctx: Context
): Bitmap? {

    val uri = Uri.parse(fileUri.uri)

    when (true) {
        (uri.scheme == "file") -> {
            fetchThumbnailFromFile(File(uri.path!!), preferredSize)?.let {
                return it
            }
        }
        (uri.scheme == "content") -> {
            if (fileUri.documentTopTreeUri != null || DocumentsContract.isDocumentUri(ctx, uri)) {
                fetchThumbnailFromDocumentFile(uri, preferredSize, ctx)?.let {
                    return it
                }
            }
            else {
                fetchThumbnailFromContentFile(uri, preferredSize, ctx)?.let {
                    return it
                }
            }

            val mimeType = AFUtils.getFileMimeType(fileUri, ctx)
            if (mimeType.startsWith("video/")) {
                createVideoThumbnailWithoutResizeFromContentFile(uri, preferredSize, ctx)?.let {
                    return it
                }
            }
            else if (mimeType.startsWith("image/")) {
                createImageThumbnailWithoutResizeFromContentFile(uri, preferredSize, ctx)?.let {
                    return it
                }
            }
        }
        else -> {}
    }

    return null
}


private fun fetchThumbnailFromContentFile(
    fileUri: Uri,
    preferredSize: Size,
    ctx: Context
): Bitmap? {

    try {
        // Q は Android 10
        if (Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT) {
            return ctx.contentResolver.loadThumbnail(
                fileUri,
                preferredSize,
                null
            )
        }
    }
    catch (e: FileNotFoundException) {
        throw FileNotFoundException("file not found: $fileUri")
    }
    catch (ignore: Exception) {}

    return null
}

private fun fetchThumbnailFromDocumentFile(
    fileUri: Uri,
    preferredSize: Size,
    ctx: Context
): Bitmap? {

    try {
        return DocumentsContract.getDocumentThumbnail(
            ctx.contentResolver,
            fileUri,
            Point(preferredSize.width, preferredSize.height),
            null
        )
    }
    catch (e: FileNotFoundException) {
        throw FileNotFoundException("file not found: $fileUri")
    }
    catch (_: Exception) {}

    return null
}

private fun fetchThumbnailFromFile(file: File, preferredSize: Size): Bitmap? {
    val mimeType = AFUtils.guessFileMimeTypeFromExtension(file)

    try {
        if (Build.VERSION_CODES.Q <= Build.VERSION.SDK_INT) {
            when {
                mimeType.startsWith("image/") -> return ThumbnailUtils.createImageThumbnail(
                    file,
                    preferredSize,
                    null
                )
                mimeType.startsWith("video/") -> return ThumbnailUtils.createVideoThumbnail(
                    file,
                    preferredSize,
                    null
                )
                mimeType.startsWith("audio/") -> return ThumbnailUtils.createAudioThumbnail(
                    file,
                    preferredSize,
                    null
                )
            }
        }
    }
    catch (e: FileNotFoundException) {
        throw FileNotFoundException("file not found: ${file.path}")
    }
    catch (_: Exception) {}

    return null
}


// https://developer.android.com/social-and-messaging/guides/media-thumbnails?hl=ja#create-thumbnail
// を改変したもの (Apache 2.0 ライセンス)
private fun createImageThumbnailWithoutResizeFromContentFile(uri: Uri, size: Size, context: Context): Bitmap?{
    try {
        // P は Android 9
        if (Build.VERSION_CODES.P <= Build.VERSION.SDK_INT) {
            val source = ImageDecoder.createSource(context.contentResolver, uri)

            return ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
                val widthSample = ceil(info.size.width / size.width.toDouble()).toInt()
                val heightSample = ceil(info.size.height / size.height.toDouble()).toInt()
                val sample = min(widthSample, heightSample)
                if (sample > 1) decoder.setTargetSampleSize(sample)
            }
        }

        val options = context.contentResolver.openInputStream(uri)?.use {
            val options = BitmapFactory.Options()
            options.inJustDecodeBounds = true
            BitmapFactory.decodeStream(it, null, options)
            options
        } ?: return null

        if ( options.outHeight != 0 ) {
            val widthSample = options.outWidth / size.width
            val heightSample = options.outHeight / size.height
            val sample = min(widthSample, heightSample)
            if (sample > 1) {
                options.inSampleSize = sample
            }
            options.inJustDecodeBounds = false
            val decodeStream = context.contentResolver.openInputStream(uri)
            val bitmap =  BitmapFactory.decodeStream(decodeStream, null, options)
            decodeStream?.close()
            return bitmap
        }
    }
    catch (_: Exception) {}

    return null
}

// https://developer.android.com/social-and-messaging/guides/media-thumbnails?hl=ja#create-thumbnail
// を改変したもの (Apache 2.0 ライセンス)
private fun createVideoThumbnailWithoutResizeFromContentFile(
    uri: Uri,
    preferredSize: Size,
    ctx: Context
): Bitmap? {

    val width = preferredSize.width
    val height = preferredSize.height

    try {
        MediaMetadataRetriever().use { mediaMetadataRetriever ->
            mediaMetadataRetriever.setDataSource(ctx, uri)
            val thumbnailBytes = mediaMetadataRetriever.embeddedPicture
            thumbnailBytes?.let {

                return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                    ImageDecoder.decodeBitmap(ImageDecoder.createSource(it))
                } else {
                    BitmapFactory.decodeByteArray(it, 0, it.size)
                }
            }

            val vw = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toFloat()
            val vh = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toFloat()
            if (vw != null && vh != null && (width < vw || height < vh)) {
                val wr = width.toFloat() / vw
                val hr = height.toFloat() / vh
                val ratio = min(wr, hr)

                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                    return mediaMetadataRetriever.getScaledFrameAtTime(
                        -1,
                        MediaMetadataRetriever.OPTION_PREVIOUS_SYNC,
                        (vw * ratio).toInt(),
                        (vh * ratio).toInt()
                    )
                }
            }

            return mediaMetadataRetriever.frameAtTime
        }
    }
    catch (e: FileNotFoundException) {
        throw FileNotFoundException("file not found: $uri")
    }
    catch (_: Exception) {}

    return null
}