From 3079570cdcd34bf69861ff251382ec8fe8547eca Mon Sep 17 00:00:00 2001 From: Dmitri Livotov Date: Thu, 19 Jun 2025 21:05:13 +0500 Subject: [PATCH 1/2] implement notification capability check --- .../capability/KmpCapabilities.android.kt | 3 + .../LocalNotificationsCapability.android.kt | 126 ++++++++++++++++++ .../oskitkmp/capability/KmpCapabilities.kt | 3 + .../capability/KmpCapabilities.ios.kt | 3 + .../capability/LocalNotifications.ios.kt | 31 +++++ .../capability/KmpCapabilities.jvm.kt | 3 + .../LocalNotificationsCapability.jvm.kt | 31 +++++ .../capability/KmpCapabilities.wasm.kt | 3 + .../capability/LocalNotifications.wasm.kt.kt | 31 +++++ 9 files changed, 234 insertions(+) create mode 100644 src/androidMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotificationsCapability.android.kt create mode 100644 src/iosMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotifications.ios.kt create mode 100644 src/jvmMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotificationsCapability.jvm.kt create mode 100644 src/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotifications.wasm.kt.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..ade84290 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 createPlatformLocalNotificationsCapability(): IKmpCapability = + LocalNotificationsKmpCapability() + 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/LocalNotificationsCapability.android.kt b/src/androidMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotificationsCapability.android.kt new file mode 100644 index 00000000..2f074583 --- /dev/null +++ b/src/androidMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotificationsCapability.android.kt @@ -0,0 +1,126 @@ +package com.outsidesource.oskitkmp.capability + +import android.Manifest +import android.app.NotificationManager +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.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.launch +import kotlinx.coroutines.withContext + +internal class LocalNotificationsKmpCapability : IInitializableKmpCapability, IKmpCapability { + + private var context: KmpCapabilityContext? = null + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + private var permissionResultLauncher: ActivityResultLauncher? = null + private val permissionResultFlow = MutableSharedFlow(extraBufferCapacity = 1) + private var hasRequestedPermissions: Boolean = false + + private val permission: String? = if (Build.VERSION.SDK_INT >= 33) { + Manifest.permission.POST_NOTIFICATIONS + } else { + null + } + + override val hasPermissions: Boolean = permission != null + override val hasEnablableService: Boolean = false + override val supportsRequestEnable: Boolean = false + override val supportsOpenAppSettingsScreen: Boolean = true + override val supportsOpenServiceSettingsScreen: Boolean = false + + 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.RequestPermission()) { + scope.launch { + permissionResultFlow.emit(Unit) + } + } + } + + override suspend fun queryStatus(): CapabilityStatus { + val activity = context?.activity ?: return CapabilityStatus.Unknown + + if (permission != null) { + val granted = ContextCompat.checkSelfPermission(activity, permission) == + PackageManager.PERMISSION_GRANTED + + if (!granted) { + val reason = if (hasRequestedPermissions) { + NoPermissionReason.DeniedPermanently + } else { + NoPermissionReason.NotRequested + } + return CapabilityStatus.NoPermission(reason) + } + } + + val manager = ContextCompat.getSystemService(activity, NotificationManager::class.java) + if (manager?.areNotificationsEnabled() != true) { + return CapabilityStatus.NotEnabled + } + + return CapabilityStatus.Ready + } + + override suspend fun requestPermissions(): Outcome { + try { + context?.activity ?: return Outcome.Error(KmpCapabilitiesError.Uninitialized) + + if (permission != null) { + withContext(Dispatchers.Main) { + permissionResultLauncher?.launch(permission) + } + permissionResultFlow.firstOrNull() + hasRequestedPermissions = true + } + + return Outcome.Ok(queryStatus()) + } catch (e: Exception) { + return Outcome.Error(e) + } + } + + override suspend fun requestEnable(): Outcome { + return Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) + } + + override suspend fun openServiceSettingsScreen(): Outcome { + return Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) + } + + override suspend fun openAppSettingsScreen(): Outcome { + return internalOpenAppSettingsScreen(context) + } +} diff --git a/src/commonMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.kt b/src/commonMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.kt index 0f913e94..cac81d8f 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 createPlatformLocalNotificationsCapability(): IKmpCapability /** * [KmpCapabilities] allows querying and requesting of permissions and enablement of certain platform capabilities. @@ -45,11 +46,13 @@ class KmpCapabilities( * NSLocationWhenInUseUsageDescription */ val location: IKmpCapability = createPlatformLocationCapability(locationFlags) + val localNotifications: IKmpCapability = createPlatformLocalNotificationsCapability() fun init(context: KmpCapabilityContext) { this.context = context (bluetooth as? IInitializableKmpCapability)?.init(context) (location as? IInitializableKmpCapability)?.init(context) + (localNotifications as? IInitializableKmpCapability)?.init(context) } } 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..c0008fcf 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 createPlatformLocalNotificationsCapability(): IKmpCapability = + LocalNotificationsKmpCapability() + internal actual suspend fun internalOpenAppSettingsScreen( context: KmpCapabilityContext?, ): Outcome = withContext(Dispatchers.Main) { diff --git a/src/iosMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotifications.ios.kt b/src/iosMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotifications.ios.kt new file mode 100644 index 00000000..0272d416 --- /dev/null +++ b/src/iosMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotifications.ios.kt @@ -0,0 +1,31 @@ +package com.outsidesource.oskitkmp.capability + +import com.outsidesource.oskitkmp.outcome.Outcome +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class LocalNotificationsKmpCapability() : 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..e56fdd1f 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 createPlatformLocalNotificationsCapability(): IKmpCapability = + LocalNotificationsKmpCapability() + internal actual suspend fun internalOpenAppSettingsScreen( context: KmpCapabilityContext?, ): Outcome = Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) diff --git a/src/jvmMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotificationsCapability.jvm.kt b/src/jvmMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotificationsCapability.jvm.kt new file mode 100644 index 00000000..0272d416 --- /dev/null +++ b/src/jvmMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotificationsCapability.jvm.kt @@ -0,0 +1,31 @@ +package com.outsidesource.oskitkmp.capability + +import com.outsidesource.oskitkmp.outcome.Outcome +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class LocalNotificationsKmpCapability() : 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/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.wasm.kt b/src/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/KmpCapabilities.wasm.kt index e25a75cd..e56fdd1f 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 createPlatformLocalNotificationsCapability(): IKmpCapability = + LocalNotificationsKmpCapability() + internal actual suspend fun internalOpenAppSettingsScreen( context: KmpCapabilityContext?, ): Outcome = Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) diff --git a/src/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotifications.wasm.kt.kt b/src/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotifications.wasm.kt.kt new file mode 100644 index 00000000..0272d416 --- /dev/null +++ b/src/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotifications.wasm.kt.kt @@ -0,0 +1,31 @@ +package com.outsidesource.oskitkmp.capability + +import com.outsidesource.oskitkmp.outcome.Outcome +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class LocalNotificationsKmpCapability() : 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) +} From a94adce457780720fc57a257a34b1305477cbbd2 Mon Sep 17 00:00:00 2001 From: Dmitri Livotov Date: Wed, 25 Jun 2025 20:36:38 +0500 Subject: [PATCH 2/2] implement notification capability check --- .../capability/LocalNotifications.wasm.kt.kt | 67 ++++++++++++++++--- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/src/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotifications.wasm.kt.kt b/src/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotifications.wasm.kt.kt index 0272d416..1f7386cd 100644 --- a/src/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotifications.wasm.kt.kt +++ b/src/wasmJsMain/kotlin/com/outsidesource/oskitkmp/capability/LocalNotifications.wasm.kt.kt @@ -1,24 +1,62 @@ package com.outsidesource.oskitkmp.capability import com.outsidesource.oskitkmp.outcome.Outcome +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.w3c.notifications.Notification +import org.w3c.notifications.NotificationPermission +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine -class LocalNotificationsKmpCapability() : IInitializableKmpCapability, IKmpCapability { - override fun init(context: KmpCapabilityContext) {} +private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - override val status: Flow = flow { emit(queryStatus()) } - override val hasPermissions: Boolean = false +class LocalNotificationsKmpCapability : IInitializableKmpCapability, IKmpCapability { + + private val localStatusFlow = MutableStateFlow( + if (!hardwareSupportsCapability()) { + CapabilityStatus.Unsupported() + } else { + CapabilityStatus.Unknown + }, + ) + + override val status: Flow = localStatusFlow + + override val hasPermissions: Boolean = true 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 fun init(context: KmpCapabilityContext) { + scope.launch { + if (!hardwareSupportsCapability()) return@launch - override suspend fun requestPermissions(): Outcome = - Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) + val permission = Notification.permission + localStatusFlow.value = mapPermissionToCapabilityStatus(permission) + } + } + + override suspend fun queryStatus(): CapabilityStatus { + if (!hardwareSupportsCapability()) return CapabilityStatus.Unsupported() + return mapPermissionToCapabilityStatus(Notification.permission) + } + + override suspend fun requestPermissions(): Outcome { + if (!hardwareSupportsCapability()) return Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) + + return suspendCoroutine { continuation -> + Notification.requestPermission { permission -> + val status = mapPermissionToCapabilityStatus(permission) + localStatusFlow.value = status + continuation.resume(Outcome.Ok(status)) + } + } + } override suspend fun requestEnable(): Outcome = Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) @@ -28,4 +66,15 @@ class LocalNotificationsKmpCapability() : IInitializableKmpCapability, IKmpCapab override suspend fun openAppSettingsScreen(): Outcome = Outcome.Error(KmpCapabilitiesError.UnsupportedOperation) + + private fun mapPermissionToCapabilityStatus(permission: NotificationPermission): CapabilityStatus { + return when (permission.toString()) { + "granted" -> CapabilityStatus.Ready + "default" -> CapabilityStatus.NoPermission(NoPermissionReason.NotRequested) + "denied" -> CapabilityStatus.NoPermission(NoPermissionReason.DeniedPermanently) + else -> CapabilityStatus.Unknown + } + } } + +private fun hardwareSupportsCapability(): Boolean = js("""typeof Notification !== undefined""")