diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AndroidUtils.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AndroidUtils.kt index 69ad3c7aa..b8a49e955 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AndroidUtils.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AndroidUtils.kt @@ -285,4 +285,14 @@ object AndroidUtils { } } } + + /** + * Safely finishes the activity only if it's still valid. + * Prevents redundant or unsafe calls to finish(), reducing lifecycle issues or potential leaks. + */ + fun finishSafely(activity: Activity) { + if (!activity.isDestroyed && !activity.isFinishing) { + activity.finish() + } + } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/ThreadUtils.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/ThreadUtils.kt index 1a6b4ff48..504a0e433 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/ThreadUtils.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/ThreadUtils.kt @@ -63,12 +63,35 @@ fun suspendifyOnMain(block: suspend () -> Unit) { fun suspendifyOnThread( priority: Int = -1, block: suspend () -> Unit, +) { + suspendifyOnThread(priority, block, null) +} + +/** + * Allows a non suspending function to create a scope that can + * call suspending functions. This is a nonblocking call, which + * means the scope will run on a background thread. This will + * return immediately!!! Also provides an optional onComplete. + * + * @param priority The priority of the background thread. Default is -1. + * Higher values indicate higher thread priority. + * + * @param block A suspending lambda to be executed on the background thread. + * This is where you put your suspending code. + * + * @param onComplete An optional lambda that will be invoked on the same + * background thread after [block] has finished executing. + * Useful for cleanup or follow-up logic. + **/ +fun suspendifyOnThread( + priority: Int = -1, + block: suspend () -> Unit, + onComplete: (() -> Unit)? = null, ) { thread(priority = priority) { try { - runBlocking { - block() - } + runBlocking { block() } + onComplete?.invoke() } catch (e: Exception) { Logging.error("Exception on thread", e) } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/activities/NotificationOpenedActivityBase.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/activities/NotificationOpenedActivityBase.kt index ab89cec04..b083e6107 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/activities/NotificationOpenedActivityBase.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/activities/NotificationOpenedActivityBase.kt @@ -30,6 +30,7 @@ import android.app.Activity import android.content.Intent import android.os.Bundle import com.onesignal.OneSignal +import com.onesignal.common.AndroidUtils import com.onesignal.common.threading.suspendifyOnThread import com.onesignal.notifications.internal.open.INotificationOpenedProcessor @@ -44,18 +45,26 @@ abstract class NotificationOpenedActivityBase : Activity() { processIntent() } - private fun processIntent() { + internal open fun processIntent() { if (!OneSignal.initWithContext(applicationContext)) { return } - suspendifyOnThread { - val openedProcessor = OneSignal.getService() - openedProcessor.processFromContext(this, intent) - // KEEP: Xiaomi Compatibility: - // Must keep this Activity alive while trampolining, that is - // startActivity() must be called BEFORE finish(), otherwise - // the app is never foregrounded. - finish() - } + suspendifyOnThread( + block = { + val openedProcessor = OneSignal.getService() + openedProcessor.processFromContext(this, intent) + // KEEP: Xiaomi Compatibility: + // Must keep this Activity alive while trampolining, that is + // startActivity() must be called BEFORE finish(), otherwise + // the app is never foregrounded. + }, + onComplete = { + // Safely finish the activity on the main thread after processing is complete. + // This gives the system enough time to complete rendering before closing the Trampoline activity. + runOnUiThread { + AndroidUtils.finishSafely(this) + } + }, + ) } } diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/activities/NotificationOpenedActivityBaseTest.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/activities/NotificationOpenedActivityBaseTest.kt new file mode 100644 index 000000000..6e162131e --- /dev/null +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/activities/NotificationOpenedActivityBaseTest.kt @@ -0,0 +1,57 @@ +package com.onesignal.notifications.activities + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.robolectric.Robolectric +import org.robolectric.shadows.ShadowLooper + +class TestNotificationOpenedActivity : NotificationOpenedActivityBase() { + var wasFinishCalledOnMainThread = false + var wasProcessIntentCalled = false + + override fun finish() { + wasFinishCalledOnMainThread = Looper.myLooper() == Looper.getMainLooper() + super.finish() + } + + override fun getIntent(): Intent { + return Intent().apply { + putExtra("some_key", "some_value") // simulate a valid OneSignal intent + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // This triggers processIntent inside base + } + + override fun processIntent() { + wasProcessIntentCalled = true + super.processIntent() + } +} + +@RobolectricTest +class NotificationOpenedActivityTest : FunSpec({ + test("finishSafely should be called on main thread") { + val controller = Robolectric.buildActivity(TestNotificationOpenedActivity::class.java) + val activity = controller.setup().get() + Handler(Looper.getMainLooper()).post { + activity.finish() + } + ShadowLooper.runUiThreadTasksIncludingDelayedTasks() + activity.wasFinishCalledOnMainThread shouldBe true + } + + test("processIntent should be called during activity setup") { + val controller = Robolectric.buildActivity(TestNotificationOpenedActivity::class.java) + val activity = controller.setup().get() + + activity.wasProcessIntentCalled shouldBe true + } +})