Skip to content

Commit

Permalink
Merge pull request #45 from ankushg/feat/supportedTarget
Browse files Browse the repository at this point in the history
Create intermediate sourceset to share between Apple and non-Apple targets
  • Loading branch information
rickclephas authored Feb 19, 2022
2 parents cfe0201 + 7de411f commit 823ec70
Show file tree
Hide file tree
Showing 16 changed files with 89 additions and 54 deletions.
10 changes: 8 additions & 2 deletions kmp-nativecoroutines-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,18 @@ kotlin {
implementation(Dependencies.Kotlinx.atomicfu)
}
}
val appleMain by creating {
val nativeCoroutinesMain by creating {
dependsOn(commonMain)
}
val appleTest by creating {
val nativeCoroutinesTest by creating {
dependsOn(commonTest)
}
val appleMain by creating {
dependsOn(nativeCoroutinesMain)
}
val appleTest by creating {
dependsOn(nativeCoroutinesTest)
}
listOf(
macosX64, macosArm64,
iosArm64, iosX64, iosSimulatorArm64,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.rickclephas.kmp.nativecoroutines

import kotlin.native.concurrent.freeze

actual fun <T> T.freeze(): T = this.freeze()
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import platform.Foundation.NSError
import platform.Foundation.NSLocalizedDescriptionKey
import kotlin.native.concurrent.freeze

actual typealias NativeError = NSError

/**
* Converts a [Throwable] to a [NSError].
*
Expand All @@ -15,12 +17,12 @@ import kotlin.native.concurrent.freeze
* The Kotlin throwable can be retrieved from the [NSError.userInfo] with the key `KotlinException`.
*/
@OptIn(UnsafeNumber::class)
internal fun Throwable.asNSError(): NSError {
internal actual fun Throwable.asNativeError(): NativeError {
val userInfo = mutableMapOf<Any?, Any>()
userInfo["KotlinException"] = this.freeze()
val message = message
if (message != null) {
userInfo[NSLocalizedDescriptionKey] = message
}
return NSError.errorWithDomain("KotlinException", 0.convert(), userInfo)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,26 @@ package com.rickclephas.kmp.nativecoroutines

import kotlinx.cinterop.UnsafeNumber
import kotlinx.cinterop.convert
import kotlin.native.concurrent.isFrozen
import kotlin.test.*

class NSErrorTests {

@Test
fun `ensure frozen`() {
val exception = RandomException()
assertFalse(exception.isFrozen, "Exception shouldn't be frozen yet")
val nsError = exception.asNSError()
assertTrue(nsError.isFrozen, "NSError should be frozen")
assertTrue(exception.isFrozen, "Exception should be frozen")
}
internal actual val NativeError.kotlinCause
get() = this.userInfo["KotlinException"] as? Throwable

class NSErrorTests {
@Test
@OptIn(UnsafeNumber::class)
fun `ensure NSError domain and code are correct`() {
val exception = RandomException()
val nsError = exception.asNSError()
val nsError = exception.asNativeError()
assertEquals("KotlinException", nsError.domain, "Incorrect NSError domain")
assertEquals(0.convert(), nsError.code, "Incorrect NSError code")
}

@Test
fun `ensure localizedDescription is set to message`() {
val exception = RandomException()
val nsError = exception.asNSError()
val nsError = exception.asNativeError()
assertEquals(exception.message, nsError.localizedDescription,
"Localized description isn't set to message")
}

@Test
fun `ensure exception is part of user info`() {
val exception = RandomException()
val nsError = exception.asNSError()
assertSame(exception, nsError.userInfo["KotlinException"], "Exception isn't part of the user info")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.rickclephas.kmp.nativecoroutines

internal expect fun <T> T.freeze(): T
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package com.rickclephas.kmp.nativecoroutines

import kotlin.native.concurrent.freeze

/**
* A callback with a single argument.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.rickclephas.kmp.nativecoroutines

import kotlinx.coroutines.Job
import kotlin.native.concurrent.freeze

/**
* A function that cancels the coroutines [Job].
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.rickclephas.kmp.nativecoroutines

/**
* Represents an error in a way that the specific platform is able to handle
*/
expect class NativeError

/**
* Converts a [Throwable] to a [NativeError].
*/
internal expect fun Throwable.asNativeError(): NativeError
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,14 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import platform.Foundation.NSError
import kotlin.native.concurrent.freeze

/**
* A function that collects a [Flow] via callbacks.
*
* The function takes an `onItem` and `onComplete` callback
* and returns a cancellable that can be used to cancel the collection.
*/
typealias NativeFlow<T> = (onItem: NativeCallback<T>, onComplete: NativeCallback<NSError?>) -> NativeCancellable
typealias NativeFlow<T> = (onItem: NativeCallback<T>, onComplete: NativeCallback<NativeError?>) -> NativeCancellable

/**
* Creates a [NativeFlow] for this [Flow].
Expand All @@ -25,7 +23,7 @@ typealias NativeFlow<T> = (onItem: NativeCallback<T>, onComplete: NativeCallback
*/
fun <T> Flow<T>.asNativeFlow(scope: CoroutineScope? = null): NativeFlow<T> {
val coroutineScope = scope ?: defaultCoroutineScope
return (collect@{ onItem: NativeCallback<T>, onComplete: NativeCallback<NSError?> ->
return (collect@{ onItem: NativeCallback<T>, onComplete: NativeCallback<NativeError?> ->
val job = coroutineScope.launch {
try {
collect { onItem(it) }
Expand All @@ -35,13 +33,13 @@ fun <T> Flow<T>.asNativeFlow(scope: CoroutineScope? = null): NativeFlow<T> {
// this is required since the job could be cancelled before it is started
throw e
} catch (e: Throwable) {
onComplete(e.asNSError())
onComplete(e.asNativeError())
}
}
job.invokeOnCompletion { cause ->
// Only handle CancellationExceptions, all other exceptions should be handled inside the job
if (cause !is CancellationException) return@invokeOnCompletion
onComplete(cause.asNSError())
onComplete(cause.asNativeError())
}
return@collect job.asNativeCancellable()
}).freeze()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,14 @@ package com.rickclephas.kmp.nativecoroutines
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import platform.Foundation.NSError
import kotlin.native.concurrent.freeze

/**
* A function that awaits a suspend function via callbacks.
*
* The function takes an `onResult` and `onError` callback
* and returns a cancellable that can be used to cancel the suspend function.
*/
typealias NativeSuspend<T> = (onResult: NativeCallback<T>, onError: NativeCallback<NSError>) -> NativeCancellable
typealias NativeSuspend<T> = (onResult: NativeCallback<T>, onError: NativeCallback<NativeError>) -> NativeCancellable

/**
* Creates a [NativeSuspend] for the provided suspend [block].
Expand All @@ -22,7 +20,7 @@ typealias NativeSuspend<T> = (onResult: NativeCallback<T>, onError: NativeCallba
*/
fun <T> nativeSuspend(scope: CoroutineScope? = null, block: suspend () -> T): NativeSuspend<T> {
val coroutineScope = scope ?: defaultCoroutineScope
return (collect@{ onResult: NativeCallback<T>, onError: NativeCallback<NSError> ->
return (collect@{ onResult: NativeCallback<T>, onError: NativeCallback<NativeError> ->
val job = coroutineScope.launch {
try {
onResult(block())
Expand All @@ -31,13 +29,13 @@ fun <T> nativeSuspend(scope: CoroutineScope? = null, block: suspend () -> T): Na
// this is required since the job could be cancelled before it is started
throw e
} catch (e: Throwable) {
onError(e.asNSError())
onError(e.asNativeError())
}
}
job.invokeOnCompletion { cause ->
// Only handle CancellationExceptions, all other exceptions should be handled inside the job
if (cause !is CancellationException) return@invokeOnCompletion
onError(cause.asNSError())
onError(cause.asNativeError())
}
return@collect job.asNativeCancellable()
}).freeze()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.rickclephas.kmp.nativecoroutines

import kotlin.native.concurrent.isFrozen
import kotlin.test.*

/**
* Get the [Throwable] that is represented by the given [NativeError]
*/
internal expect val NativeError.kotlinCause: Throwable?

class NativeErrorTests {
@Test
fun `ensure frozen`() {
val exception = RandomException()
assertFalse(exception.isFrozen, "Exception shouldn't be frozen yet")
val nsError = exception.asNativeError()
assertTrue(nsError.isFrozen, "NSError should be frozen")
assertTrue(exception.isFrozen, "Exception should be frozen")
}

@Test
fun `ensure exception is part of user info`() {
val exception = RandomException()
val nsError = exception.asNativeError()
assertSame(exception, nsError.kotlinCause, "Exception isn't part of the NativeError")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@ class NativeFlowTests {

@Test
fun `ensure frozen`() {
val flow = flow<RandomValue> { }
val flow = flow<RandomValue> { }
assertFalse(flow.isFrozen, "Flow shouldn't be frozen yet")
val nativeFlow = flow.asNativeFlow()
assertTrue(nativeFlow.isFrozen, "NativeFlow should be frozen")
assertTrue(flow.isFrozen, "Flow should be frozen")
}

@Test
fun `ensure completion callback is invoked`() = runBlocking {
val flow = flow<RandomValue> { }
fun `ensure completion callback is invoked`() = kotlinx.coroutines.runBlocking {
val flow = flow<RandomValue> { }
val job = Job()
val nativeFlow = flow.asNativeFlow(CoroutineScope(job))
val completionCount = atomic(0)
Expand All @@ -33,15 +33,15 @@ class NativeFlowTests {
}

@Test
fun `ensure exceptions are received as errors`() = runBlocking {
fun `ensure exceptions are received as errors`() = kotlinx.coroutines.runBlocking {
val exception = RandomException()
val flow = flow<RandomValue> { throw exception }
val job = Job()
val nativeFlow = flow.asNativeFlow(CoroutineScope(job))
val completionCount = atomic(0)
nativeFlow({ _, _ -> }, { error, _ ->
assertNotNull(error, "Flow should complete with an error")
val kotlinException = error.userInfo["KotlinException"]
val kotlinException = error.kotlinCause
assertSame(exception, kotlinException, "Kotlin exception should be the same exception")
completionCount.incrementAndGet()
})
Expand All @@ -50,7 +50,7 @@ class NativeFlowTests {
}

@Test
fun `ensure values are received`() = runBlocking {
fun `ensure values are received`() = kotlinx.coroutines.runBlocking {
val values = listOf(RandomValue(), RandomValue(), RandomValue(), RandomValue())
val flow = flow { values.forEach { emit(it) } }
val job = Job()
Expand All @@ -61,18 +61,22 @@ class NativeFlowTests {
receivedValueCount.incrementAndGet()
}, { _, _ -> })
job.children.forEach { it.join() } // Waits for the collection to complete
assertEquals(values.size, receivedValueCount.value, "Item callback should be called for every value")
assertEquals(
values.size,
receivedValueCount.value,
"Item callback should be called for every value"
)
}

@Test
fun `ensure collection is cancelled`() = runBlocking {
fun `ensure collection is cancelled`() = kotlinx.coroutines.runBlocking {
val flow = MutableSharedFlow<RandomValue>()
val job = Job()
val nativeFlow = flow.asNativeFlow(CoroutineScope(job))
val completionCount = atomic(0)
val cancel = nativeFlow({ _, _ -> }, { error, _ ->
assertNotNull(error, "Flow should complete with an error")
val exception = error.userInfo["KotlinException"]
val exception = error.kotlinCause
assertIs<CancellationException>(exception, "Error should contain CancellationException")
completionCount.incrementAndGet()
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.coroutines.cancellation.CancellationException
import kotlin.native.concurrent.isFrozen
import kotlin.test.*
Expand All @@ -31,7 +30,7 @@ class NativeSuspendTests {
}

@Test
fun `ensure correct result is received`() = runBlocking {
fun `ensure correct result is received`() = kotlinx.coroutines.runBlocking {
val value = RandomValue()
val job = Job()
val nativeSuspend = nativeSuspend(CoroutineScope(job)) { delayAndReturn(100, value) }
Expand All @@ -49,7 +48,7 @@ class NativeSuspendTests {
}

@Test
fun `ensure exceptions are received as errors`() = runBlocking {
fun `ensure exceptions are received as errors`() = kotlinx.coroutines.runBlocking {
val exception = RandomException()
val job = Job()
val nativeSuspend = nativeSuspend(CoroutineScope(job)) { delayAndThrow(100, exception) }
Expand All @@ -59,7 +58,7 @@ class NativeSuspendTests {
receivedResultCount.incrementAndGet()
}, { error, _ ->
assertNotNull(error, "Function should complete with an error")
val kotlinException = error.userInfo["KotlinException"]
val kotlinException = error.kotlinCause
assertSame(exception, kotlinException, "Kotlin exception should be the same exception")
receivedErrorCount.incrementAndGet()
})
Expand All @@ -69,7 +68,7 @@ class NativeSuspendTests {
}

@Test
fun `ensure function is cancelled`() = runBlocking {
fun `ensure function is cancelled`() = kotlinx.coroutines.runBlocking {
val job = Job()
val nativeSuspend = nativeSuspend(CoroutineScope(job)) { delayAndReturn(5_000, RandomValue()) }
val receivedResultCount = atomic(0)
Expand All @@ -78,7 +77,7 @@ class NativeSuspendTests {
receivedResultCount.incrementAndGet()
}, { error, _ ->
assertNotNull(error, "Function should complete with an error")
val exception = error.userInfo["KotlinException"]
val exception = error.kotlinCause
assertIs<CancellationException>(exception, "Error should contain CancellationException")
receivedErrorCount.incrementAndGet()
})
Expand Down

0 comments on commit 823ec70

Please sign in to comment.