Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.onesignal.common.threading

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job

/**
* Provider interface for coroutine dispatchers.
* This allows for proper dependency injection and easier testing.
*/
interface CoroutineDispatcherProvider {
val io: CoroutineDispatcher
val default: CoroutineDispatcher

/**
* Launch a coroutine on the IO dispatcher.
*/
fun launchOnIO(block: suspend () -> Unit): Job

/**
* Launch a coroutine on the Default dispatcher.
*/
fun launchOnDefault(block: suspend () -> Unit): Job
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.onesignal.common.threading

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job

/**
* Production implementation of [CoroutineDispatcherProvider] that uses OneSignalDispatchers.
*
* This delegates to the existing scopes in OneSignalDispatchers to avoid creating duplicate scopes.
* The OneSignalDispatchers already maintains IOScope and DefaultScope with SupervisorJob,
* so we reuse those instead of creating new ones.
*/
class DefaultDispatcherProvider : CoroutineDispatcherProvider {
override val io: CoroutineDispatcher = OneSignalDispatchers.IO
override val default: CoroutineDispatcher = OneSignalDispatchers.Default

override fun launchOnIO(block: suspend () -> Unit): Job {
// Delegate to OneSignalDispatchers which already has IOScope with SupervisorJob
return OneSignalDispatchers.launchOnIO(block)
}

override fun launchOnDefault(block: suspend () -> Unit): Job {
// Delegate to OneSignalDispatchers which already has DefaultScope with SupervisorJob
return OneSignalDispatchers.launchOnDefault(block)
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
package com.onesignal.core.internal.startup

import com.onesignal.common.services.ServiceProvider
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.common.threading.CoroutineDispatcherProvider
import com.onesignal.common.threading.DefaultDispatcherProvider

internal class StartupService(
private val services: ServiceProvider,
private val dispatchers: CoroutineDispatcherProvider = DefaultDispatcherProvider(),
) {
fun bootstrap() {
services.getAllServices<IBootstrapService>().forEach { it.bootstrap() }
}

// schedule to start all startable services using OneSignal dispatcher
// schedule to start all startable services using the provided dispatcher
fun scheduleStart() {
OneSignalDispatchers.launchOnDefault {
dispatchers.launchOnDefault {
services.getAllServices<IStartableService>().forEach { it.start() }
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.onesignal.session.internal.outcomes.impl

import android.content.ContentValues
import com.onesignal.common.threading.OneSignalDispatchers
import com.onesignal.core.internal.database.IDatabaseProvider
import com.onesignal.core.internal.database.impl.OneSignalDbContract
import com.onesignal.debug.internal.logging.Logging
Expand All @@ -9,20 +10,21 @@ import com.onesignal.session.internal.influence.InfluenceChannel
import com.onesignal.session.internal.influence.InfluenceType
import com.onesignal.session.internal.influence.InfluenceType.Companion.fromString
import com.onesignal.session.internal.outcomes.migrations.RemoveInvalidSessionTimeRecords
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONException
import java.util.Locale

internal class OutcomeEventsRepository(
private val _databaseProvider: IDatabaseProvider,
private val ioDispatcher: CoroutineDispatcher = OneSignalDispatchers.IO,
) : IOutcomeEventsRepository {
/**
* Delete event from the DB
*/
override suspend fun deleteOldOutcomeEvent(event: OutcomeEventParams) {
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
_databaseProvider.os.delete(
OutcomeEventsTable.TABLE_NAME,
OutcomeEventsTable.COLUMN_NAME_TIMESTAMP + " = ?",
Expand All @@ -36,7 +38,7 @@ internal class OutcomeEventsRepository(
* For offline mode and contingency of errors
*/
override suspend fun saveOutcomeEvent(eventParams: OutcomeEventParams) {
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
var notificationIds = JSONArray()
var iamIds = JSONArray()
var notificationInfluenceType = InfluenceType.UNATTRIBUTED
Expand Down Expand Up @@ -101,7 +103,7 @@ internal class OutcomeEventsRepository(
*/
override suspend fun getAllEventsToSend(): List<OutcomeEventParams> {
val events: MutableList<OutcomeEventParams> = ArrayList()
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
RemoveInvalidSessionTimeRecords.run(_databaseProvider)
_databaseProvider.os.query(OutcomeEventsTable.TABLE_NAME) { cursor ->
if (cursor.moveToFirst()) {
Expand Down Expand Up @@ -248,7 +250,7 @@ internal class OutcomeEventsRepository(
override suspend fun saveUniqueOutcomeEventParams(eventParams: OutcomeEventParams) {
Logging.debug("OutcomeEventsCache.saveUniqueOutcomeEventParams(eventParams: $eventParams)")

withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
val outcomeName = eventParams.outcomeId
val cachedUniqueOutcomes: MutableList<CachedUniqueOutcome> = ArrayList()
val directBody = eventParams.outcomeSource?.directBody
Expand Down Expand Up @@ -283,7 +285,7 @@ internal class OutcomeEventsRepository(
): List<Influence> {
val uniqueInfluences: MutableList<Influence> = ArrayList()

withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
try {
for (influence in influences) {
val availableInfluenceIds = JSONArray()
Expand Down Expand Up @@ -333,7 +335,7 @@ internal class OutcomeEventsRepository(
val notificationTableName = OneSignalDbContract.NotificationTable.TABLE_NAME
val notificationIdColumnName = OneSignalDbContract.NotificationTable.COLUMN_NAME_NOTIFICATION_ID

withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
val whereStr =
"NOT EXISTS(" +
"SELECT NULL FROM " + notificationTableName + " n " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import com.onesignal.common.services.ServiceBuilder
import com.onesignal.common.services.ServiceProvider
import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.mocks.IOMockHelper
import com.onesignal.mocks.IOMockHelper.awaitIO
import com.onesignal.mocks.TestDispatcherProvider
import io.kotest.assertions.throwables.shouldThrowUnit
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.comparables.shouldBeLessThan
Expand All @@ -14,7 +13,12 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest

@OptIn(ExperimentalCoroutinesApi::class)
class StartupServiceTests : FunSpec({
fun setupServiceProvider(
bootstrapServices: List<IBootstrapService>,
Expand All @@ -27,111 +31,127 @@ class StartupServiceTests : FunSpec({
serviceBuilder.register(reg).provides<IStartableService>()
return serviceBuilder.build()
}

listener(IOMockHelper)
val testDispatcher = StandardTestDispatcher()
val dispatcherProvider = TestDispatcherProvider(testDispatcher)

beforeAny {
Logging.logLevel = LogLevel.NONE
}

test("bootstrap with no IBootstrapService dependencies is a no-op") {
// Given
val startupService = StartupService(setupServiceProvider(listOf(), listOf()))
runTest(testDispatcher.scheduler) {
// Given
val startupService = StartupService(setupServiceProvider(listOf(), listOf()), dispatcherProvider)

// When
startupService.bootstrap()
// When
startupService.bootstrap()

// Then
// Then
}
}

test("bootstrap will call all IBootstrapService dependencies successfully") {
// Given
val mockBootstrapService1 = mockk<IBootstrapService>(relaxed = true)
val mockBootstrapService2 = mockk<IBootstrapService>(relaxed = true)
runTest(testDispatcher.scheduler) {
// Given
val mockBootstrapService1 = mockk<IBootstrapService>(relaxed = true)
val mockBootstrapService2 = mockk<IBootstrapService>(relaxed = true)

val startupService = StartupService(setupServiceProvider(listOf(mockBootstrapService1, mockBootstrapService2), listOf()))
val startupService = StartupService(setupServiceProvider(listOf(mockBootstrapService1, mockBootstrapService2), listOf()), dispatcherProvider)

// When
startupService.bootstrap()
// When
startupService.bootstrap()

// Then
verify(exactly = 1) { mockBootstrapService1.bootstrap() }
verify(exactly = 1) { mockBootstrapService2.bootstrap() }
// Then
verify(exactly = 1) { mockBootstrapService1.bootstrap() }
verify(exactly = 1) { mockBootstrapService2.bootstrap() }
}
}

test("bootstrap will propagate exception when an IBootstrapService throws an exception") {
// Given
val exception = Exception("SOMETHING BAD")

val mockBootstrapService1 = mockk<IBootstrapService>()
every { mockBootstrapService1.bootstrap() } throws exception
val mockBootstrapService2 = spyk<IBootstrapService>()

val startupService = StartupService(setupServiceProvider(listOf(mockBootstrapService1, mockBootstrapService2), listOf()))

// When
val actualException =
shouldThrowUnit<Exception> {
startupService.bootstrap()
}

// Then
actualException shouldBe exception
verify(exactly = 1) { mockBootstrapService1.bootstrap() }
verify(exactly = 0) { mockBootstrapService2.bootstrap() }
runTest(testDispatcher.scheduler) {
// Given
val exception = Exception("SOMETHING BAD")

val mockBootstrapService1 = mockk<IBootstrapService>()
every { mockBootstrapService1.bootstrap() } throws exception
val mockBootstrapService2 = spyk<IBootstrapService>()

val startupService = StartupService(setupServiceProvider(listOf(mockBootstrapService1, mockBootstrapService2), listOf()), dispatcherProvider)

// When
val actualException =
shouldThrowUnit<Exception> {
startupService.bootstrap()
}

// Then
actualException shouldBe exception
verify(exactly = 1) { mockBootstrapService1.bootstrap() }
verify(exactly = 0) { mockBootstrapService2.bootstrap() }
}
}

test("startup will call all IStartableService dependencies successfully after a short delay") {
// Given
val mockStartupService1 = mockk<IStartableService>(relaxed = true)
val mockStartupService2 = mockk<IStartableService>(relaxed = true)

val startupService = StartupService(setupServiceProvider(listOf(), listOf(mockStartupService1, mockStartupService2)))

// When
startupService.scheduleStart()

// Then - wait deterministically for both services to start using IOMockHelper
awaitIO()
verify(exactly = 1) { mockStartupService1.start() }
verify(exactly = 1) { mockStartupService2.start() }
runTest(testDispatcher.scheduler) {
// Given
val mockStartupService1 = mockk<IStartableService>(relaxed = true)
val mockStartupService2 = mockk<IStartableService>(relaxed = true)

val startupService = StartupService(
setupServiceProvider(listOf(), listOf(mockStartupService1, mockStartupService2)),
dispatcherProvider
)

// When
startupService.scheduleStart()

// Then - wait deterministically for both services to start using advanceUntilIdle
advanceUntilIdle()
verify(exactly = 1) { mockStartupService1.start() }
verify(exactly = 1) { mockStartupService2.start() }
}
}

test("scheduleStart does not block main thread") {
// Given
val mockStartableService1 = mockk<IStartableService>(relaxed = true)
val mockStartableService2 = spyk<IStartableService>()
val mockStartableService3 = spyk<IStartableService>()
// Only service1 and service2 are scheduled - service3 is NOT scheduled
val startupService = StartupService(setupServiceProvider(listOf(), listOf(mockStartableService1, mockStartableService2)))

// When - scheduleStart() is async, so it doesn't block
val startTime = System.currentTimeMillis()
startupService.scheduleStart()
val scheduleTime = System.currentTimeMillis() - startTime

// This should execute immediately since scheduleStart() doesn't block
// service3 is NOT part of scheduled services, so this is a direct call
mockStartableService3.start()
val immediateTime = System.currentTimeMillis() - startTime

// Then - verify scheduleStart() returned quickly (non-blocking)
// Should return in < 50ms (proving it doesn't wait for services to start)
scheduleTime shouldBeLessThan 50L
immediateTime shouldBeLessThan 50L

// Verify service3 was called immediately (proving main thread wasn't blocked)
verify(exactly = 1) { mockStartableService3.start() }

// Wait deterministically for async execution using IOMockHelper
awaitIO()

// Verify scheduled services were called
verify(exactly = 1) { mockStartableService1.start() }
verify(exactly = 1) { mockStartableService2.start() }

// The key assertion: scheduleStart() returned immediately without blocking,
// allowing service3.start() to be called synchronously before scheduled services
// complete. This proves scheduleStart() is non-blocking.
runTest(testDispatcher.scheduler) {
// Given
val mockStartableService1 = mockk<IStartableService>(relaxed = true)
val mockStartableService2 = spyk<IStartableService>()
val mockStartableService3 = spyk<IStartableService>()
// Only service1 and service2 are scheduled - service3 is NOT scheduled
val startupService = StartupService(
setupServiceProvider(listOf(), listOf(mockStartableService1, mockStartableService2)),
dispatcherProvider
)

// When - scheduleStart() is async, so it doesn't block
val startTime = System.currentTimeMillis()
startupService.scheduleStart()
val scheduleTime = System.currentTimeMillis() - startTime

// This should execute immediately since scheduleStart() doesn't block
// service3 is NOT part of scheduled services, so this is a direct call
mockStartableService3.start()
val immediateTime = System.currentTimeMillis() - startTime

// Then - verify scheduleStart() returned quickly (non-blocking)
// Should return in < 50ms (proving it doesn't wait for services to start)
scheduleTime shouldBeLessThan 50L
immediateTime shouldBeLessThan 50L

// Verify service3 was called immediately (proving main thread wasn't blocked)
verify(exactly = 1) { mockStartableService3.start() }

// Wait deterministically for async execution using advanceUntilIdle
advanceUntilIdle()

// Verify scheduled services were called
verify(exactly = 1) { mockStartableService1.start() }
verify(exactly = 1) { mockStartableService2.start() }

// The key assertion: scheduleStart() returned immediately without blocking,
// allowing service3.start() to be called synchronously before scheduled services
// complete. This proves scheduleStart() is non-blocking.
}
}
})
Loading
Loading