Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions OneSignalSDK/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ buildscript {
kotlinxSerializationJsonVersion = '1.6.3'
kotestVersion = '5.8.0'
ioMockVersion = '1.13.2'
// JUnit 4 for the Java interop tests. The vintage engine runs them on the JUnit Platform
// that Kotest already uses, so its version tracks that platform (5.x) rather than JUnit 4.
junit4Version = '4.13.2'
junitVintageVersion = '5.8.2'
// AndroidX Lifecycle and Activity versions
lifecycleVersion = '2.6.2'
activityVersion = '1.7.2'
Expand Down
6 changes: 6 additions & 0 deletions OneSignalSDK/onesignal/core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ dependencies {

// com.tdunning:json is needed for non-Robolectric tests.
testImplementation("com.tdunning:json:$tdunningJsonForTest")

// The Java interop tests are the one place we assert what a Java caller sees, so they have to
// be written in Java. Robolectric only ships a JUnit 4 runner, and this module runs on the
// JUnit Platform for Kotest, so the vintage engine is what lets both live in one source set.
testImplementation("junit:junit:$junit4Version")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:$junitVintageVersion")
}

apply from: '../detekt.gradle'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ import java.util.function.Consumer
import kotlin.coroutines.Continuation
import kotlin.coroutines.CoroutineContext

/**
* Receives the outcome of a coroutine on behalf of a Java caller.
*
* A Kotlin `fun interface` rather than a `java.util.function.Consumer` so that it is usable on the
* SDK's whole supported range. `Consumer` only exists from API 24, which is what confines
* [Continue.with] to `@RequiresApi(N)`; this one is a plain interface and works from API 21. Java
* still writes it as a lambda either way.
*/
fun interface ContinueCallback<R> {
/**
* Called once the coroutine has completed, whether it succeeded or failed.
*
* @param result The outcome of the coroutine, to continue processing from.
*/
fun onFinished(result: ContinueResult<R>)
}

/**
* The result provided by [Continue.with] when the Java user wants to inspect the results
* of a Kotlin coroutine completing.
Expand Down Expand Up @@ -74,6 +91,59 @@ object Continue {
}
}

/**
* The same callback bridge as [with], usable on every API level the SDK supports.
*
* ```java
* OneSignal.getConsentGivenSuspend(Continue.callback(r -> {
* if (r.isSuccess()) render(r.getData());
* else Log.e("app", "could not read consent", r.getThrowable());
* }));
* ```
*
* Prefer this over [with]: the two behave identically, but [with] takes a
* `java.util.function.Consumer`, which does not exist below API 24 and is not desugared here, so
* on API 21 through 23 it fails at runtime rather than at compile time.
*
* @param onFinished Called when the coroutine has completed, with its [ContinueResult].
* @param context The optional coroutine context to run [onFinished] under. Defaults to the main
* thread, matching [with].
*/
@JvmOverloads
@JvmStatic
fun <R> callback(
onFinished: ContinueCallback<R>,
context: CoroutineContext = Dispatchers.Main,
): Continuation<R> {
return object : Continuation<R> {
override val context: CoroutineContext
get() = context

override fun resumeWith(result: Result<R>) {
onFinished.onFinished(ContinueResult(result.isSuccess, result.getOrNull(), result.exceptionOrNull()))
}
}
}

/**
* Bridges a suspending call into a [Future], for Java callers that would rather collect the
* answer than be called back.
*
* ```java
* FutureContinuation<Boolean> consent = Continue.future();
* OneSignal.getConsentGivenSuspend(consent);
* boolean given = consent.get(); // off the main thread
* ```
*
* Each instance backs one call. [FutureContinuation.get] refuses to run on the main thread, so
* reach for [callback] when the answer is needed there.
*
* Unlike [with] and [callback] this takes no context, because there would be nothing for it to
* govern — see [FutureContinuation.context].
*/
@JvmStatic
fun <R> future(): FutureContinuation<R> = FutureContinuation()

/**
* Allows java code to indicate they have no follow-up to a Kotlin coroutine.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package com.onesignal

import com.onesignal.common.AndroidUtils
import com.onesignal.common.exceptions.MainThreadException
import kotlinx.coroutines.Dispatchers
import java.util.concurrent.CancellationException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.ExecutionException
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import java.util.concurrent.atomic.AtomicReference
import kotlin.coroutines.Continuation
import kotlin.coroutines.CoroutineContext

/**
* A [Continuation] that is also a [Future], letting Java call a suspending function and collect the
* answer later:
*
* ```java
* FutureContinuation<Boolean> consent = Continue.future();
* OneSignal.getConsentGivenSuspend(consent);
*
* // ...on any thread that is not the main thread:
* boolean given = consent.get();
* ```
*
* Obtain one from [Continue.future]. Each instance backs a single call: it can be resumed once, so
* a second call needs a second instance.
*
* The value is whatever the suspending function returns, handed back unchanged. This bridge
* deliberately does not wrap it in a result type of its own — a suspending function that reports
* failure by returning something is left to say so in its own vocabulary, and one that reports
* failure by throwing surfaces here the way [Future] already specifies.
*
* What [get] guards is the blocking wait, not the call. A suspending function runs on the thread
* that started it until it first suspends, so invoking one from the main thread still does that much
* work there — no continuation can change that. The SDK's suspending APIs change dispatchers almost
* immediately, so in practice the work leaves the main thread right away.
*/
class FutureContinuation<R> internal constructor() : Continuation<R>, Future<R> {
/**
* Fixed at [Dispatchers.Unconfined], so the call resumes on whichever thread finished the work.
* Resuming records a value and releases a latch, so there is no user code here to place on a
* particular thread — dispatching elsewhere would only add latency, and dispatching to a busy
* main thread would add an unbounded amount of it.
*/
override val context: CoroutineContext
get() = Dispatchers.Unconfined

private val completed = CountDownLatch(1)

/**
* The one outcome, claimed by compare-and-set.
*
* A single slot rather than separate value and failure fields so that resuming once is enforced
* rather than merely asked for, and so that two resumes cannot interleave into a state that is
* neither of the results which arrived.
*/
private val outcome = AtomicReference<Result<R>?>(null)

/**
* @throws IllegalStateException if this instance has already been resumed. Accepting a second
* result quietly would leave the [Future] reporting an outcome belonging to some other call,
* which is worse than failing where the mistake was made.
*/
override fun resumeWith(result: Result<R>) {
check(outcome.compareAndSet(null, result)) {
"This FutureContinuation has already been resumed. Each one backs a single call, so use " +
"a fresh Continue.future() for every suspending call."
}
completed.countDown()
}

/**
* Blocks until the call completes and returns its value.
*
* @throws MainThreadException if called on the main thread. Blocking there is the stall this
* bridge exists to avoid, so it is refused rather than logged. Use [Continue.callback] when the
* answer is needed on the main thread.
* @throws ExecutionException if the call threw. The original throwable is the [Throwable.cause].
* @throws CancellationException if the call was cancelled.
* @throws InterruptedException if the waiting thread is interrupted.
*/
@Throws(InterruptedException::class, ExecutionException::class)
override fun get(): R {
refuseMainThread()
completed.await()
return valueOrThrow()
}

/**
* Blocks for at most [timeout] and returns the call's value.
*
* Throws as [get] does, plus [TimeoutException] if the call had not completed in time. A
* timeout leaves the underlying call running — this bridge has no handle on it, see [cancel].
*/
@Throws(InterruptedException::class, ExecutionException::class, TimeoutException::class)
override fun get(
timeout: Long,
unit: TimeUnit,
): R {
refuseMainThread()
if (!completed.await(timeout, unit)) {
throw TimeoutException("OneSignal call did not complete within $timeout ${unit.name.lowercase()}.")
}
return valueOrThrow()
}

override fun isDone(): Boolean = completed.count == 0L

override fun isCancelled(): Boolean = outcome.get()?.exceptionOrNull() is CancellationException

/**
* Always returns `false`: this continuation is handed to a suspending function that the caller
* started directly, so there is no job here to cancel. Cancellation that happens upstream is
* still reported — it arrives as a [CancellationException] and surfaces through [get] and
* [isCancelled].
*/
override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false

private fun valueOrThrow(): R {
// Only reached once the latch has opened, which cannot happen before the slot is filled.
val result = outcome.get()!!
val thrown = result.exceptionOrNull() ?: return result.getOrThrow()
// Future specifies CancellationException directly and everything else wrapped, which also
// keeps a cancelled call from being mistaken for a failed one.
if (thrown is CancellationException) throw thrown
throw ExecutionException(thrown)
}

// Refused whether or not the value has already arrived. Allowing the already-complete case
// would make the same line throw or not depending on timing, which is a worse contract than a
// rule that always holds.
private fun refuseMainThread() {
if (AndroidUtils.isRunningOnMainThread()) {
throw MainThreadException(
"Blocking on a OneSignal call from the main thread is not allowed. " +
"Call get() from a background thread, or use Continue.callback() to be notified instead.",
)
}
}
}
Loading
Loading