From 967f567ffc690601e2ff6f27afedf4c4bd95e254 Mon Sep 17 00:00:00 2001 From: Dmitri Livotov Date: Mon, 9 Jun 2025 22:12:22 +0500 Subject: [PATCH 1/2] WIP: Add support for requesting/querying permissions for Storage --- .../capability/KmpCapabilities.android.kt | 3 + .../capability/StorageCapability.android.kt | 131 ++++++++++++++++++ .../oskitkmp/capability/KmpCapabilities.kt | 12 ++ .../capability/KmpCapabilities.ios.kt | 3 + .../capability/StorageCapability.ios.kt | 34 +++++ .../capability/KmpCapabilities.jvm.kt | 3 + .../capability/StorageCapability.jvm.kt | 38 +++++ .../capability/KmpCapabilities.wasm.kt | 3 + .../capability/StorageCapability.wasm.kt | 34 +++++ 9 files changed, 261 insertions(+) create mode 100644 src/androidMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.android.kt create mode 100644 src/iosMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.ios.kt create mode 100644 src/jvmMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.jvm.kt create mode 100644 src/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.wasm.kt diff --git a/src/androidMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.android.kt b/src/androidMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.android.kt index 4f9f3665..ca9625f9 100644 --- a/src/androidMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.android.kt +++ b/src/androidMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.android.kt @@ -16,6 +16,9 @@ internal actual fun createPlatformBluetoothCapability(flags: Array): IKmpCapability = LocationKmpCapability(flags) +internal actual fun createPlatformStorageCapability(flags: Array): IKmpCapability = + StorageKmpCapability(flags) + internal actual suspend fun internalOpenAppSettingsScreen(context: KmpCapabilityContext?): Outcome { try { val activity = context?.activity ?: return Outcome.Error(KmpCapabilitiesError.Uninitialized) diff --git a/src/androidMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.android.kt b/src/androidMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.android.kt new file mode 100644 index 00000000..753ba6d8 --- /dev/null +++ b/src/androidMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.android.kt @@ -0,0 +1,131 @@ +package com.outsidesource.oskitkmp.capability + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import com.outsidesource.oskitkmp.outcome.Outcome +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +internal class StorageKmpCapability( + private val flags: Array, +) : IInitializableKmpCapability, IKmpCapability { + + + private var context: KmpCapabilityContext? = null + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private var permissionResultLauncher: ActivityResultLauncher>? = null + private val permissionsResultFlow = + MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + private var hasRequestedPermissions: Boolean = false + + private val permissions = run { + val result = mutableListOf() + + flags.forEach { flag -> + when (flag) { + StorageCapabilityFlags.ReadExternal -> { + if (Build.VERSION.SDK_INT < 33) { + result += Manifest.permission.READ_EXTERNAL_STORAGE + } + } + + StorageCapabilityFlags.WriteExternal -> { + result += Manifest.permission.WRITE_EXTERNAL_STORAGE + } + + StorageCapabilityFlags.ReadMedia -> { + if (Build.VERSION.SDK_INT >= 33) { + result += Manifest.permission.READ_MEDIA_AUDIO + result += Manifest.permission.READ_MEDIA_IMAGES + result += Manifest.permission.READ_MEDIA_VIDEO + } + } + + StorageCapabilityFlags.WriteMedia -> Unit + } + } + + result + }.toTypedArray() + + override val status: Flow = callbackFlow { + val activity = context?.activity ?: return@callbackFlow + launch { + activity.lifecycle.currentStateFlow.collect { + if (it == Lifecycle.State.RESUMED) { + send(queryStatus()) + } + } + } + + send(queryStatus()) + awaitClose {} + }.distinctUntilChanged() + + override fun init(context: KmpCapabilityContext) { + this.context = context + + permissionResultLauncher = context.activity + .registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + scope.launch { permissionsResultFlow.emit(Unit) } + } + } + + override val hasPermissions: Boolean = permissions.isNotEmpty() + override val hasEnablableService: Boolean = false + override val supportsRequestEnable: Boolean = false + override val supportsOpenAppSettingsScreen: Boolean = true + override val supportsOpenServiceSettingsScreen: Boolean = false + + override suspend fun queryStatus(): CapabilityStatus { + val activity = context?.activity ?: return CapabilityStatus.Unknown + + val hasAuthorization = permissions + .all { ContextCompat.checkSelfPermission(activity, it) == PackageManager.PERMISSION_GRANTED } + + if (!hasAuthorization) { + val reason = NoPermissionReason.NotRequested + return CapabilityStatus.NoPermission(reason) + } + + return CapabilityStatus.Ready + } + + override suspend fun requestPermissions(): Outcome { + try { + context?.activity ?: return Outcome.Error(KmpCapabilitiesError.Uninitialized) + withContext(Dispatchers.Main) { + permissionResultLauncher?.launch(permissions) + } + permissionsResultFlow.firstOrNull() + return Outcome.Ok(queryStatus()) + } catch (e: Exception) { + return Outcome.Error(Unit) + } + } + + override suspend fun requestEnable(): Outcome = Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) + + override suspend fun openServiceSettingsScreen(): Outcome = + Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) + + override suspend fun openAppSettingsScreen(): Outcome = internalOpenAppSettingsScreen(context) + +} \ No newline at end of file diff --git a/src/commonMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.kt b/src/commonMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.kt index 0f913e94..79437319 100644 --- a/src/commonMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.kt +++ b/src/commonMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.kt @@ -12,6 +12,7 @@ internal interface ICapabilityContextScope { internal expect suspend fun internalOpenAppSettingsScreen(context: KmpCapabilityContext?): Outcome internal expect fun createPlatformBluetoothCapability(flags: Array): IKmpCapability internal expect fun createPlatformLocationCapability(flags: Array): IKmpCapability +internal expect fun createPlatformStorageCapability(flags: Array): IKmpCapability /** * [KmpCapabilities] allows querying and requesting of permissions and enablement of certain platform capabilities. @@ -27,6 +28,7 @@ internal expect fun createPlatformLocationCapability(flags: Array = emptyArray(), locationFlags: Array = emptyArray(), + storageFlags: Array = emptyArray(), ) { private var context: KmpCapabilityContext? = null @@ -46,10 +48,13 @@ class KmpCapabilities( */ val location: IKmpCapability = createPlatformLocationCapability(locationFlags) + val storage: IKmpCapability = createPlatformStorageCapability(storageFlags) + fun init(context: KmpCapabilityContext) { this.context = context (bluetooth as? IInitializableKmpCapability)?.init(context) (location as? IInitializableKmpCapability)?.init(context) + (storage as? IInitializableKmpCapability)?.init(context) } } @@ -73,6 +78,13 @@ enum class LocationCapabilityFlags { FineLocation, } +enum class StorageCapabilityFlags { + ReadExternal, + WriteExternal, + ReadMedia, + WriteMedia, +} + interface IInitializableKmpCapability : IKmpCapability { fun init(context: KmpCapabilityContext) } diff --git a/src/iosMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.ios.kt b/src/iosMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.ios.kt index 33d9746f..dd6a5c46 100644 --- a/src/iosMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.ios.kt +++ b/src/iosMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.ios.kt @@ -15,6 +15,9 @@ internal actual fun createPlatformBluetoothCapability(flags: Array): IKmpCapability = LocationKmpCapability(flags) +internal actual fun createPlatformStorageCapability(flags: Array): IKmpCapability = + StorageKmpCapability(flags) + internal actual suspend fun internalOpenAppSettingsScreen( context: KmpCapabilityContext?, ): Outcome = withContext(Dispatchers.Main) { diff --git a/src/iosMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.ios.kt b/src/iosMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.ios.kt new file mode 100644 index 00000000..72d6c057 --- /dev/null +++ b/src/iosMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.ios.kt @@ -0,0 +1,34 @@ +package com.outsidesource.oskitkmp.capability + +import com.outsidesource.oskitkmp.outcome.Outcome +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +internal class StorageKmpCapability( + private val flags: Array, +) : IInitializableKmpCapability, IKmpCapability { + + override fun init(context: KmpCapabilityContext) {} + + override val status: Flow = flow { emit(queryStatus()) } + override val hasPermissions: Boolean = false + override val hasEnablableService: Boolean = false + override val supportsRequestEnable: Boolean = false + override val supportsOpenAppSettingsScreen: Boolean = false + override val supportsOpenServiceSettingsScreen: Boolean = false + + override suspend fun queryStatus(): CapabilityStatus = + CapabilityStatus.Unsupported(UnsupportedReason.NotImplemented) + + override suspend fun requestPermissions(): Outcome = + Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) + + override suspend fun requestEnable(): Outcome = + Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) + + override suspend fun openServiceSettingsScreen(): Outcome = + Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) + + override suspend fun openAppSettingsScreen(): Outcome = + Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) +} diff --git a/src/jvmMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.jvm.kt b/src/jvmMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.jvm.kt index e25a75cd..22c53abd 100644 --- a/src/jvmMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.jvm.kt +++ b/src/jvmMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.jvm.kt @@ -10,6 +10,9 @@ internal actual fun createPlatformBluetoothCapability(flags: Array): IKmpCapability = LocationKmpCapability(flags) +internal actual fun createPlatformStorageCapability(flags: Array): IKmpCapability = + StorageKmpCapability(flags) + internal actual suspend fun internalOpenAppSettingsScreen( context: KmpCapabilityContext?, ): Outcome = Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) diff --git a/src/jvmMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.jvm.kt b/src/jvmMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.jvm.kt new file mode 100644 index 00000000..03414ee3 --- /dev/null +++ b/src/jvmMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.jvm.kt @@ -0,0 +1,38 @@ +package com.outsidesource.oskitkmp.capability + +import com.outsidesource.oskitkmp.outcome.Outcome +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class StorageKmpCapability( + private val flags: Array, +) : IInitializableKmpCapability, IKmpCapability { + + private var context: KmpCapabilityContext? = null + + override fun init(context: KmpCapabilityContext) { + this.context = context + } + + override val status: Flow = flow { emit(queryStatus()) } + override val hasPermissions: Boolean = false + override val hasEnablableService: Boolean = false + override val supportsRequestEnable: Boolean = false + override val supportsOpenAppSettingsScreen: Boolean = false + override val supportsOpenServiceSettingsScreen: Boolean = false + + override suspend fun queryStatus(): CapabilityStatus = + CapabilityStatus.Unsupported(UnsupportedReason.NotImplemented) + + override suspend fun requestPermissions(): Outcome = + Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) + + override suspend fun requestEnable(): Outcome = + Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) + + override suspend fun openServiceSettingsScreen(): Outcome = + Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) + + override suspend fun openAppSettingsScreen(): Outcome = + Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) +} diff --git a/src/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.wasm.kt b/src/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.wasm.kt index e25a75cd..22c53abd 100644 --- a/src/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.wasm.kt +++ b/src/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.wasm.kt @@ -10,6 +10,9 @@ internal actual fun createPlatformBluetoothCapability(flags: Array): IKmpCapability = LocationKmpCapability(flags) +internal actual fun createPlatformStorageCapability(flags: Array): IKmpCapability = + StorageKmpCapability(flags) + internal actual suspend fun internalOpenAppSettingsScreen( context: KmpCapabilityContext?, ): Outcome = Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) diff --git a/src/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.wasm.kt b/src/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.wasm.kt new file mode 100644 index 00000000..961dc5f4 --- /dev/null +++ b/src/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.wasm.kt @@ -0,0 +1,34 @@ +package com.outsidesource.oskitkmp.capability + +import com.outsidesource.oskitkmp.outcome.Outcome +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class StorageKmpCapability( + private val flags: Array, +) : IInitializableKmpCapability, IKmpCapability { + override val status: Flow = flow { emit(queryStatus()) } + + override val hasPermissions: Boolean = false + override val hasEnablableService: Boolean = false + override val supportsRequestEnable: Boolean = false + override val supportsOpenAppSettingsScreen: Boolean = false + override val supportsOpenServiceSettingsScreen: Boolean = false + + override fun init(context: KmpCapabilityContext) {} + + override suspend fun queryStatus(): CapabilityStatus = + CapabilityStatus.Unsupported(UnsupportedReason.NotImplemented) + + override suspend fun requestPermissions(): Outcome = + Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) + + override suspend fun requestEnable(): Outcome = + Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) + + override suspend fun openServiceSettingsScreen(): Outcome = + Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) + + override suspend fun openAppSettingsScreen(): Outcome = + Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) +} From 4a5481a21a6831750f2616f6567377c0eefcaeb2 Mon Sep 17 00:00:00 2001 From: Dmitri Livotov Date: Tue, 10 Jun 2025 18:52:51 +0500 Subject: [PATCH 2/2] Add support for requesting/querying permissions for Storage --- .../capability/StorageCapability.android.kt | 60 +++++++++++++------ .../capability/StorageCapability.jvm.kt | 6 +- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/src/androidMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.android.kt b/src/androidMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.android.kt index 753ba6d8..4e1eae81 100644 --- a/src/androidMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.android.kt +++ b/src/androidMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.android.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -26,7 +25,6 @@ internal class StorageKmpCapability( private val flags: Array, ) : IInitializableKmpCapability, IKmpCapability { - private var context: KmpCapabilityContext? = null private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private var permissionResultLauncher: ActivityResultLauncher>? = null @@ -41,29 +39,45 @@ internal class StorageKmpCapability( flags.forEach { flag -> when (flag) { StorageCapabilityFlags.ReadExternal -> { - if (Build.VERSION.SDK_INT < 33) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { result += Manifest.permission.READ_EXTERNAL_STORAGE } } StorageCapabilityFlags.WriteExternal -> { - result += Manifest.permission.WRITE_EXTERNAL_STORAGE + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + result += Manifest.permission.WRITE_EXTERNAL_STORAGE + } } StorageCapabilityFlags.ReadMedia -> { - if (Build.VERSION.SDK_INT >= 33) { - result += Manifest.permission.READ_MEDIA_AUDIO - result += Manifest.permission.READ_MEDIA_IMAGES - result += Manifest.permission.READ_MEDIA_VIDEO + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> { + result += Manifest.permission.READ_MEDIA_AUDIO + result += Manifest.permission.READ_MEDIA_IMAGES + result += Manifest.permission.READ_MEDIA_VIDEO + result += Manifest.permission.ACCESS_MEDIA_LOCATION + } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> { + result += Manifest.permission.READ_EXTERNAL_STORAGE + result += Manifest.permission.ACCESS_MEDIA_LOCATION + } + else -> { + result += Manifest.permission.READ_EXTERNAL_STORAGE + } } } - StorageCapabilityFlags.WriteMedia -> Unit + StorageCapabilityFlags.WriteMedia -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + result += Manifest.permission.ACCESS_MEDIA_LOCATION + } + } } } - result - }.toTypedArray() + result.distinct().toTypedArray() + } override val status: Flow = callbackFlow { val activity = context?.activity ?: return@callbackFlow @@ -83,8 +97,14 @@ internal class StorageKmpCapability( this.context = context permissionResultLauncher = context.activity - .registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { - scope.launch { permissionsResultFlow.emit(Unit) } + .registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results -> + scope.launch { + permissionsResultFlow.emit(Unit) + + if (results.all { it.value }) { + hasRequestedPermissions = false + } + } } } @@ -101,7 +121,11 @@ internal class StorageKmpCapability( .all { ContextCompat.checkSelfPermission(activity, it) == PackageManager.PERMISSION_GRANTED } if (!hasAuthorization) { - val reason = NoPermissionReason.NotRequested + val reason = if (hasRequestedPermissions) { + NoPermissionReason.DeniedPermanently + } else { + NoPermissionReason.NotRequested + } return CapabilityStatus.NoPermission(reason) } @@ -111,6 +135,7 @@ internal class StorageKmpCapability( override suspend fun requestPermissions(): Outcome { try { context?.activity ?: return Outcome.Error(KmpCapabilitiesError.Uninitialized) + hasRequestedPermissions = true withContext(Dispatchers.Main) { permissionResultLauncher?.launch(permissions) } @@ -121,11 +146,12 @@ internal class StorageKmpCapability( } } - override suspend fun requestEnable(): Outcome = Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) + override suspend fun requestEnable(): Outcome = Outcome.Error( + KmpCapabilitiesError.UnsupportedOperation, + ) override suspend fun openServiceSettingsScreen(): Outcome = Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) override suspend fun openAppSettingsScreen(): Outcome = internalOpenAppSettingsScreen(context) - -} \ No newline at end of file +} diff --git a/src/jvmMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.jvm.kt b/src/jvmMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.jvm.kt index 03414ee3..3ca61edd 100644 --- a/src/jvmMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.jvm.kt +++ b/src/jvmMain/kotlin/com/outsidesource/oskitkmp/capability/StorageCapability.jvm.kt @@ -8,11 +8,7 @@ class StorageKmpCapability( private val flags: Array, ) : IInitializableKmpCapability, IKmpCapability { - private var context: KmpCapabilityContext? = null - - override fun init(context: KmpCapabilityContext) { - this.context = context - } + override fun init(context: KmpCapabilityContext) {} override val status: Flow = flow { emit(queryStatus()) } override val hasPermissions: Boolean = false