tauri-plugin-android-fs 28.2.1

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

import android.annotation.SuppressLint
import android.app.Activity
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ShareCompat
import app.tauri.annotation.InvokeArg
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger

class AFNotification {

    companion object {
        private val notificationIdCounter = AtomicInteger(0)
        private const val CHANNEL_ID = "progress_notification_channel"

        @Volatile
        private var isNotifiedProgressChannel = false

        private val notifications = ConcurrentHashMap.newKeySet<Int>()

        @Volatile
        private var notificationQueueManager: NotificationQueueManager? = null

        @InvokeArg
        enum class ProgressNotificationIconType {
            Download,
            Upload,
            Save,
            App,
        }

        private fun getIcon(i: ProgressNotificationIconType, ctx: Context): Int {
            return when (i) {
                ProgressNotificationIconType.Download -> android.R.drawable.stat_sys_download_done
                ProgressNotificationIconType.Upload -> android.R.drawable.stat_sys_upload_done
                ProgressNotificationIconType.Save -> android.R.drawable.ic_menu_save
                ProgressNotificationIconType.App -> {
                    ctx.applicationInfo.icon
                        .takeIf { it != 0 }
                        ?: android.R.drawable.sym_def_app_icon
                }
            }
        }

        private fun nextNotificationId(): Int {
            return notificationIdCounter.incrementAndGet()
        }

        @Synchronized
        fun initProgressNotificationManager(scope: CoroutineScope, ctx: Context) {
            if (!isNotifiedProgressChannel) {
                if (Build.VERSION_CODES.O <= Build.VERSION.SDK_INT) {
                    val name = "Progress Notification"
                    val description = "Notifies the progress and completion"
                    val importance = NotificationManager.IMPORTANCE_LOW
                    val channel = NotificationChannel(CHANNEL_ID, name, importance)
                    channel.description = description

                    val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
                    notificationManager.createNotificationChannel(channel)
                }
                isNotifiedProgressChannel = true
            }

            if (notificationQueueManager == null) {
                notificationQueueManager = NotificationQueueManager(scope)
            }
        }

        @SuppressLint("MissingPermission")
        suspend fun startProgressNotification(
            iconType: ProgressNotificationIconType,
            title: String?,
            text: String?,
            subText: String?,
            progressMax: Int?,
            progress: Int?,
            ctx: Context,
        ): Int {

            val id = nextNotificationId()
            notifications.add(id)

            notificationQueueManager?.add(id, NotificationEventType.Progress) {
                val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
                    .setSmallIcon(getIcon(iconType, ctx))
                    .setContentTitle(title.takeIf { !title.isNullOrEmpty() } ?: ctx.getString(android.R.string.unknownName))
                    .setCategory(NotificationCompat.CATEGORY_PROGRESS)
                    .setOngoing(false)
                    .setOnlyAlertOnce(true)

                if (progressMax != null && progress != null) {
                    builder.setProgress(progressMax, progress, false)
                }
                else {
                    builder.setProgress(0, 0, true)
                }

                if (!text.isNullOrEmpty()) {
                    builder.setContentText(text)
                }
                if (!subText.isNullOrEmpty()) {
                    builder.setSubText(subText)
                }

                NotificationManagerCompat.from(ctx).notify(id, builder.build())
            }

            return id
        }

        @SuppressLint("MissingPermission")
        suspend fun updateProgressNotification(
            id: Int,
            iconType: ProgressNotificationIconType,
            title: String?,
            text: String?,
            subText: String?,
            progressMax: Int?,
            progress: Int?,
            ctx: Context,
        ) {

            if (!notifications.contains(id)) {
                return
            }

            notificationQueueManager?.add(id, NotificationEventType.Progress) {
                if (!notifications.contains(id)) return@add

                val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
                    .setSmallIcon(getIcon(iconType, ctx))
                    .setContentTitle(title.takeIf { !title.isNullOrEmpty() } ?: ctx.getString(android.R.string.unknownName))
                    .setCategory(NotificationCompat.CATEGORY_PROGRESS)
                    .setOngoing(false)
                    .setOnlyAlertOnce(true)

                if (progressMax != null && progress != null) {
                    builder.setProgress(progressMax, progress, false)
                }
                else {
                    builder.setProgress(0, 0, true)
                }

                if (!text.isNullOrEmpty()) {
                    builder.setContentText(text)
                }
                if (!subText.isNullOrEmpty()) {
                    builder.setSubText(subText)
                }

                if (!notifications.contains(id)) return@add
                NotificationManagerCompat.from(ctx).notify(id, builder.build())
            }
        }

        @SuppressLint("MissingPermission")
        suspend fun finishProgressNotification(
            id: Int,
            iconType: ProgressNotificationIconType,
            title: String?,
            text: String?,
            subText: String?,
            error: Boolean,
            shareSrc: AFUri?,
            ctx: Context,
        ) {

            notifications.remove(id)

            notificationQueueManager?.add(id, NotificationEventType.ProgressFinish) {
                val icon = when (error) {
                    true -> android.R.drawable.stat_notify_error
                    else -> getIcon(iconType, ctx)
                }

                val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
                    .setSmallIcon(icon)
                    .setContentTitle(title.takeIf { !title.isNullOrEmpty() } ?: ctx.getString(android.R.string.unknownName))
                    .setOnlyAlertOnce(true)
                    .setOngoing(false)
                    .setAutoCancel(true)

                if (shareSrc != null) {
                    try {
                        val uri = Uri.parse(shareSrc.uri)
                        if (uri.scheme == "content") {
                            val mimeType = AFUtils.getFileMimeType(shareSrc, ctx)
                            val intentChooser = ShareCompat.IntentBuilder(ctx)
                                .setType(mimeType)
                                .setStream(uri)
                                .createChooserIntent().apply {
                                    addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                                    if (ctx is Activity) {
                                        putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(ctx.componentName))
                                    }
                                }

                            builder.setContentIntent(PendingIntent.getActivity(
                                ctx,
                                id,
                                intentChooser,
                                PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
                            ))
                        }
                    }
                    catch (ignore: Exception) { }
                }

                if (!text.isNullOrEmpty()) {
                    builder.setContentText(text)
                }
                if (!subText.isNullOrEmpty()) {
                    builder.setSubText(subText)
                }

                if (error) {
                    builder.setCategory(NotificationCompat.CATEGORY_ERROR)
                }

                NotificationManagerCompat.from(ctx).notify(id, builder.build())
            }
        }

        suspend fun cancelNotification(id: Int, ctx: Context) {
            notifications.remove(id)
            notificationQueueManager?.cancel(id)
            NotificationManagerCompat.from(ctx).cancel(id)
        }

        suspend fun cancelAllNotifications(ctx: Context) {
            notifications.clear()
            notificationQueueManager?.cancelAll()
            NotificationManagerCompat.from(ctx).cancelAll()
        }
    }
}


private enum class NotificationEventType {
    Progress,
    ProgressFinish,
}

/**
 * Android ではパッケージ単位での通知の送信・更新にレート制限があるため、
 * 優先度と遅延付きの Queue で通知を処理する。
 * https://saket.me/android-7-nougat-rate-limiting-notifications/
 */
private class NotificationQueueManager(scope: CoroutineScope) {
    private val queue = Channel<Pair<Int, NotificationEventType>>(Channel.UNLIMITED)
    private val notifications = HashMap<Pair<Int, NotificationEventType>, () -> Unit>()
    private val lock = Mutex()

    init {
        scope.launch {
            for (key in queue) {
                try {
                    val notificationTask = lock.withLock {
                        val notificationId = key.first
                        val notificationType = key.second
                        val notificationTask = notifications.remove(key) ?: return@withLock null

                        // Progress イベントは優先度が低いので必要に応じてスキップする。
                        if (notificationType == NotificationEventType.Progress) {
                            // 同じ通知に対して Finish イベントがあればそれを使用する。
                            val finishKey = Pair(notificationId, NotificationEventType.ProgressFinish)
                            val finishNotificationTask = notifications.remove(finishKey)
                            if (finishNotificationTask != null) {
                                return@withLock finishNotificationTask
                            }
                        }

                        notificationTask
                    } ?: continue

                    notificationTask()
                }
                catch (ignore: Exception) {}

                delay(1000)
            }
        }
    }

    suspend fun add(
        notificationId: Int,
        notificationType: NotificationEventType,
        notificationTask: () -> Unit
    ) {

        lock.withLock {
            val key = Pair(notificationId, notificationType)

            if (notifications.put(key, notificationTask) == null) {
                queue.send(key)
            }
        }
    }

    suspend fun cancelAll() {
        lock.withLock {
            notifications.clear()
        }
    }

    suspend fun cancel(id: Int) {
        lock.withLock {
            for (t in NotificationEventType.values()) {
                notifications.remove(Pair(id, t))
            }
        }
    }
}