diff --git a/android/airship-framework-proxy/src/main/java/com/urbanairship/android/framework/proxy/AirshipListener.kt b/android/airship-framework-proxy/src/main/java/com/urbanairship/android/framework/proxy/AirshipListener.kt index fa1de86..ff8cf92 100644 --- a/android/airship-framework-proxy/src/main/java/com/urbanairship/android/framework/proxy/AirshipListener.kt +++ b/android/airship-framework-proxy/src/main/java/com/urbanairship/android/framework/proxy/AirshipListener.kt @@ -86,6 +86,8 @@ internal class AirshipListener( } override fun onNotificationOpened(notificationInfo: NotificationInfo): Boolean { + LaunchDeepLinkTracker.shared().onNotificationOpened(notificationInfo.message, isAppForegrounded) + eventEmitter.addEvent( NotificationResponseEvent(notificationInfo, null) ) diff --git a/android/airship-framework-proxy/src/main/java/com/urbanairship/android/framework/proxy/BaseAutopilot.kt b/android/airship-framework-proxy/src/main/java/com/urbanairship/android/framework/proxy/BaseAutopilot.kt index d467973..7fbd3ad 100644 --- a/android/airship-framework-proxy/src/main/java/com/urbanairship/android/framework/proxy/BaseAutopilot.kt +++ b/android/airship-framework-proxy/src/main/java/com/urbanairship/android/framework/proxy/BaseAutopilot.kt @@ -14,6 +14,8 @@ import com.urbanairship.UALog import com.urbanairship.Predicate import com.urbanairship.android.framework.proxy.Utils.getNamedResource import com.urbanairship.android.framework.proxy.events.EventEmitter +import com.urbanairship.app.ApplicationListener +import com.urbanairship.app.GlobalActivityMonitor import com.urbanairship.android.framework.proxy.events.NotificationStatusEvent import com.urbanairship.android.framework.proxy.events.PendingEmbeddedUpdated import com.urbanairship.android.framework.proxy.proxies.AirshipProxy @@ -60,6 +62,20 @@ public abstract class BaseAutopilot : Autopilot() { Airship.push.notificationListener = airshipListener Airship.deepLinkListener = airshipListener + val activityMonitor = GlobalActivityMonitor.shared(context.applicationContext) + if (activityMonitor.isAppForegrounded) { + LaunchDeepLinkTracker.shared().markLaunchResolved() + } else { + activityMonitor.addApplicationListener(object : ApplicationListener { + override fun onForeground(milliseconds: Long) { + LaunchDeepLinkTracker.shared().markLaunchResolved() + activityMonitor.removeApplicationListener(this) + } + + override fun onBackground(milliseconds: Long) {} + }) + } + dispatcher.launch { PendingEmbedded.pending.collect { EventEmitter.shared().addEvent( diff --git a/android/airship-framework-proxy/src/main/java/com/urbanairship/android/framework/proxy/LaunchDeepLinkTracker.kt b/android/airship-framework-proxy/src/main/java/com/urbanairship/android/framework/proxy/LaunchDeepLinkTracker.kt new file mode 100644 index 0000000..c77d391 --- /dev/null +++ b/android/airship-framework-proxy/src/main/java/com/urbanairship/android/framework/proxy/LaunchDeepLinkTracker.kt @@ -0,0 +1,73 @@ +/* Copyright Airship and Contributors */ + +package com.urbanairship.android.framework.proxy + +import com.urbanairship.push.PushMessage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.getAndUpdate + +/** + * Tracks the deep link that launched the app from a notification tap. + * + * The stash is consume-once and expires after a short staleness window so a + * JS reload can't replay an old launch link. + */ +public class LaunchDeepLinkTracker internal constructor( + private val clock: () -> Long = { System.currentTimeMillis() } +) { + + private data class Stash(val deepLink: String, val timeMs: Long) + + private val stash = MutableStateFlow(null) + private val launchResolved = MutableStateFlow(false) + + /** + * Called when a notification is opened, before the SDK runs the + * notification's actions. Resolves the launch, stashing the payload's + * deep link when the app was not already foregrounded. + */ + internal fun onNotificationOpened(message: PushMessage, isAppForegrounded: Boolean) { + if (!isAppForegrounded) { + DEEP_LINK_ACTION_KEYS.firstNotNullOfOrNull { message.actions[it]?.string }?.let { + stash.value = Stash(it, clock()) + } + } + launchResolved.value = true + } + + /** + * Resolves the launch without a deep link. Called once the app is + * foregrounded so normal launches resolve null without waiting. + */ + internal fun markLaunchResolved() { + launchResolved.value = true + } + + /** + * Returns the deep link that launched the app, or null. Consumes the value. + */ + public suspend fun takeLaunchDeepLink(): String? { + take()?.let { return it } + launchResolved.first { it } + return take() + } + + private fun take(): String? { + val current = stash.getAndUpdate { null } ?: return null + return if (clock() - current.timeMs <= MAX_STASH_AGE_MS) current.deepLink else null + } + + public companion object { + private val DEEP_LINK_ACTION_KEYS = listOf("^d", "deep_link_action") + private const val MAX_STASH_AGE_MS = 10_000L + + private val sharedInstance = LaunchDeepLinkTracker() + + /** + * Shared tracker instance. + */ + @JvmStatic + public fun shared(): LaunchDeepLinkTracker = sharedInstance + } +} diff --git a/android/airship-framework-proxy/src/main/java/com/urbanairship/android/framework/proxy/proxies/AirshipProxy.kt b/android/airship-framework-proxy/src/main/java/com/urbanairship/android/framework/proxy/proxies/AirshipProxy.kt index 4e4b186..fcedfbe 100644 --- a/android/airship-framework-proxy/src/main/java/com/urbanairship/android/framework/proxy/proxies/AirshipProxy.kt +++ b/android/airship-framework-proxy/src/main/java/com/urbanairship/android/framework/proxy/proxies/AirshipProxy.kt @@ -7,6 +7,7 @@ import android.content.Context import com.urbanairship.Airship import com.urbanairship.Autopilot import com.urbanairship.actions.DefaultActionRunner +import com.urbanairship.android.framework.proxy.LaunchDeepLinkTracker import com.urbanairship.android.framework.proxy.ProxyConfig import com.urbanairship.android.framework.proxy.ProxyStore import com.urbanairship.UALog @@ -107,6 +108,15 @@ public class AirshipProxy( return Airship.isFlyingOrTakingOff } + /** + * Returns the deep link that launched the app from a notification tap, + * or null if the app was not launched by a deep-link-carrying tap. + * One-shot: the value is consumed on read. + */ + public suspend fun getLaunchDeepLink(): String? { + return LaunchDeepLinkTracker.shared().takeLaunchDeepLink() + } + public companion object { @SuppressLint("StaticFieldLeak") @Volatile diff --git a/android/airship-framework-proxy/src/test/java/com/urbanairship/android/framework/proxy/LaunchDeepLinkTrackerTest.kt b/android/airship-framework-proxy/src/test/java/com/urbanairship/android/framework/proxy/LaunchDeepLinkTrackerTest.kt new file mode 100644 index 0000000..49ce8a6 --- /dev/null +++ b/android/airship-framework-proxy/src/test/java/com/urbanairship/android/framework/proxy/LaunchDeepLinkTrackerTest.kt @@ -0,0 +1,100 @@ +package com.urbanairship.android.framework.proxy + +import androidx.core.os.bundleOf +import com.urbanairship.push.PushMessage +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +public class LaunchDeepLinkTrackerTest { + + private fun message(deepLink: String? = null, key: String = "^d"): PushMessage { + val extras = if (deepLink != null) { + bundleOf(PushMessage.EXTRA_ACTIONS to """{"$key":"$deepLink"}""") + } else { + bundleOf() + } + return PushMessage(extras) + } + + @Test + public fun testTapWithDeepLinkStashes(): Unit = runTest { + val tracker = LaunchDeepLinkTracker() + tracker.onNotificationOpened(message("myapp://home"), isAppForegrounded = false) + assertEquals("myapp://home", tracker.takeLaunchDeepLink()) + } + + @Test + public fun testConsumeOnce(): Unit = runTest { + val tracker = LaunchDeepLinkTracker() + tracker.onNotificationOpened(message("myapp://home"), isAppForegrounded = false) + tracker.takeLaunchDeepLink() + assertNull(tracker.takeLaunchDeepLink()) + } + + @Test + public fun testLongActionNameKey(): Unit = runTest { + val tracker = LaunchDeepLinkTracker() + tracker.onNotificationOpened( + message("myapp://home", key = "deep_link_action"), + isAppForegrounded = false + ) + assertEquals("myapp://home", tracker.takeLaunchDeepLink()) + } + + @Test + public fun testForegroundTapSkipsStash(): Unit = runTest { + val tracker = LaunchDeepLinkTracker() + tracker.onNotificationOpened(message("myapp://home"), isAppForegrounded = true) + assertNull(tracker.takeLaunchDeepLink()) + } + + @Test + public fun testTapWithoutDeepLinkResolvesNull(): Unit = runTest { + val tracker = LaunchDeepLinkTracker() + tracker.onNotificationOpened(message(), isAppForegrounded = false) + assertNull(tracker.takeLaunchDeepLink()) + } + + @Test + public fun testWaiterResolvedByLaterTap(): Unit = runTest { + val tracker = LaunchDeepLinkTracker() + val pending = async { tracker.takeLaunchDeepLink() } + yield() + tracker.onNotificationOpened(message("myapp://home"), isAppForegrounded = false) + assertEquals("myapp://home", pending.await()) + } + + @Test + public fun testWaiterResolvedNullOnLaunchResolved(): Unit = runTest { + val tracker = LaunchDeepLinkTracker() + val pending = async { tracker.takeLaunchDeepLink() } + yield() + tracker.markLaunchResolved() + assertNull(pending.await()) + } + + @Test + public fun testStaleStashReturnsNull(): Unit = runTest { + var now = 0L + val tracker = LaunchDeepLinkTracker(clock = { now }) + tracker.onNotificationOpened(message("myapp://home"), isAppForegrounded = false) + now = 10_001L + assertNull(tracker.takeLaunchDeepLink()) + } + + @Test + public fun testFreshStashReturnsLink(): Unit = runTest { + var now = 0L + val tracker = LaunchDeepLinkTracker(clock = { now }) + tracker.onNotificationOpened(message("myapp://home"), isAppForegrounded = false) + now = 9_999L + assertEquals("myapp://home", tracker.takeLaunchDeepLink()) + } +} diff --git a/ios/AirshipFrameworkProxy.xcodeproj/project.pbxproj b/ios/AirshipFrameworkProxy.xcodeproj/project.pbxproj index 4b93bf5..20f567c 100644 --- a/ios/AirshipFrameworkProxy.xcodeproj/project.pbxproj +++ b/ios/AirshipFrameworkProxy.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 46696F40AC1D351E957ABCAD /* LaunchDeepLinkTrackerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46696F3FAC1D351E957ABCAC /* LaunchDeepLinkTrackerTest.swift */; }; 6E07689629FC48450014E2A9 /* AttributeOperationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E07689529FC48450014E2A9 /* AttributeOperationTest.swift */; }; 6E07689D2A0038F80014E2A9 /* ScopedSubscriptionListOperationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E07689C2A0038F80014E2A9 /* ScopedSubscriptionListOperationTest.swift */; }; 6E0D94F12A7D52F400781BC7 /* AirshipFeatureFlagManagerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0D94F02A7D52F400781BC7 /* AirshipFeatureFlagManagerProxy.swift */; }; @@ -25,6 +26,7 @@ 6E142EA2296F852300F71E23 /* AirshipContactProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E142E95296F852300F71E23 /* AirshipContactProxy.swift */; }; 6E142EA3296F852300F71E23 /* AirshipProxyEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E142E96296F852300F71E23 /* AirshipProxyEvent.swift */; }; 6E142EA4296F852300F71E23 /* AirshipPreferenceCenterProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E142E97296F852300F71E23 /* AirshipPreferenceCenterProxy.swift */; }; + 46696F41AC1D351E957ABCAE /* LaunchDeepLinkTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46696F42AC1D351E957ABCAF /* LaunchDeepLinkTracker.swift */; }; 6E142EBA296F85CD00F71E23 /* AirshipDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E142E66296E3DEB00F71E23 /* AirshipDelegate.swift */; }; 6E142EBB296F85CD00F71E23 /* AirshipExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E142E65296E3DEB00F71E23 /* AirshipExtensions.swift */; }; 6E142EBC296F85CD00F71E23 /* AttributeOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E142E67296E3DEB00F71E23 /* AttributeOperation.swift */; }; @@ -56,10 +58,10 @@ 6ED117D72CA7760A00C41C56 /* UAirshipFrameworkProxyLoader.m in Sources */ = {isa = PBXBuildFile; fileRef = 6ED117A92CA74F9100C41C56 /* UAirshipFrameworkProxyLoader.m */; }; 6ED117D82CA7761300C41C56 /* UAirshipFrameworkProxyLoader.h in Headers */ = {isa = PBXBuildFile; fileRef = 6ED117A82CA74F9100C41C56 /* UAirshipFrameworkProxyLoader.h */; }; 6EFB83DA2979BE7F0008BEB5 /* ScopedSubscriptionListOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EFB83D92979BE7F0008BEB5 /* ScopedSubscriptionListOperation.swift */; }; - AA000001303011110000E001 /* EmailRegistrationProxyOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000001303011110000F001 /* EmailRegistrationProxyOptions.swift */; }; - AA000002303011110000E001 /* SMSRegistrationProxyOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002303011110000F001 /* SMSRegistrationProxyOptions.swift */; }; 842981FF2AA10D6600456BDB /* TagOperationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 842981FE2AA10D6600456BDB /* TagOperationTest.swift */; }; 8443CF342A97ADB3000589B8 /* TagOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8443CF332A97ADB3000589B8 /* TagOperation.swift */; }; + AA000001303011110000E001 /* EmailRegistrationProxyOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000001303011110000F001 /* EmailRegistrationProxyOptions.swift */; }; + AA000002303011110000E001 /* SMSRegistrationProxyOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002303011110000F001 /* SMSRegistrationProxyOptions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -73,6 +75,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 46696F3FAC1D351E957ABCAC /* LaunchDeepLinkTrackerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchDeepLinkTrackerTest.swift; sourceTree = ""; }; 6E07689529FC48450014E2A9 /* AttributeOperationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributeOperationTest.swift; sourceTree = ""; }; 6E07689C2A0038F80014E2A9 /* ScopedSubscriptionListOperationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScopedSubscriptionListOperationTest.swift; sourceTree = ""; }; 6E0D94F02A7D52F400781BC7 /* AirshipFeatureFlagManagerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipFeatureFlagManagerProxy.swift; sourceTree = ""; }; @@ -82,6 +85,7 @@ 6E142E59296E3A8800F71E23 /* FeatureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureTests.swift; sourceTree = ""; }; 6E142E64296E3DEB00F71E23 /* SubscriptionListOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionListOperation.swift; sourceTree = ""; }; 6E142E65296E3DEB00F71E23 /* AirshipExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipExtensions.swift; sourceTree = ""; }; + 46696F42AC1D351E957ABCAF /* LaunchDeepLinkTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchDeepLinkTracker.swift; sourceTree = ""; }; 6E142E66296E3DEB00F71E23 /* AirshipDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipDelegate.swift; sourceTree = ""; }; 6E142E67296E3DEB00F71E23 /* AttributeOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributeOperation.swift; sourceTree = ""; }; 6E142E68296E3DEB00F71E23 /* ProxyStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProxyStore.swift; sourceTree = ""; }; @@ -118,10 +122,10 @@ 6ED117B12CA75D0F00C41C56 /* AirshipPluginLoaderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipPluginLoaderProtocol.swift; sourceTree = ""; }; 6ED117B42CA75D2300C41C56 /* AirshipPluginExtenderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipPluginExtenderProtocol.swift; sourceTree = ""; }; 6EFB83D92979BE7F0008BEB5 /* ScopedSubscriptionListOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScopedSubscriptionListOperation.swift; sourceTree = ""; }; - AA000001303011110000F001 /* EmailRegistrationProxyOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailRegistrationProxyOptions.swift; sourceTree = ""; }; - AA000002303011110000F001 /* SMSRegistrationProxyOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMSRegistrationProxyOptions.swift; sourceTree = ""; }; 842981FE2AA10D6600456BDB /* TagOperationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagOperationTest.swift; sourceTree = ""; }; 8443CF332A97ADB3000589B8 /* TagOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagOperation.swift; sourceTree = ""; }; + AA000001303011110000F001 /* EmailRegistrationProxyOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailRegistrationProxyOptions.swift; sourceTree = ""; }; + AA000002303011110000F001 /* SMSRegistrationProxyOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMSRegistrationProxyOptions.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -178,6 +182,7 @@ 6E142E96296F852300F71E23 /* AirshipProxyEvent.swift */, 6E142E8D296F852300F71E23 /* AirshipProxyEventEmitter.swift */, 6E142E66296E3DEB00F71E23 /* AirshipDelegate.swift */, + 46696F42AC1D351E957ABCAF /* LaunchDeepLinkTracker.swift */, 6E142E65296E3DEB00F71E23 /* AirshipExtensions.swift */, 6E142E6C296E3DEB00F71E23 /* ProxyConfig.swift */, 6E142E68296E3DEB00F71E23 /* ProxyStore.swift */, @@ -206,6 +211,7 @@ 6E07689C2A0038F80014E2A9 /* ScopedSubscriptionListOperationTest.swift */, 842981FE2AA10D6600456BDB /* TagOperationTest.swift */, 6E1EEE762BD2D34B00B45A87 /* AirshipProxyEventEmitterTest.swift */, + 46696F3FAC1D351E957ABCAC /* LaunchDeepLinkTrackerTest.swift */, ); path = AirshipFrameworkProxyTests; sourceTree = ""; @@ -390,6 +396,7 @@ buildActionMask = 2147483647; files = ( 6E142EBA296F85CD00F71E23 /* AirshipDelegate.swift in Sources */, + 46696F41AC1D351E957ABCAE /* LaunchDeepLinkTracker.swift in Sources */, 6E142EBB296F85CD00F71E23 /* AirshipExtensions.swift in Sources */, 6E142EBC296F85CD00F71E23 /* AttributeOperation.swift in Sources */, 6E142EBD296F85CD00F71E23 /* ProxyConfig.swift in Sources */, @@ -441,6 +448,7 @@ 6E142E5A296E3A8800F71E23 /* FeatureTests.swift in Sources */, 6E1EEE772BD2D34C00B45A87 /* AirshipProxyEventEmitterTest.swift in Sources */, 6E07689629FC48450014E2A9 /* AttributeOperationTest.swift in Sources */, + 46696F40AC1D351E957ABCAD /* LaunchDeepLinkTrackerTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/AirshipFrameworkProxy/AirshipDelegate.swift b/ios/AirshipFrameworkProxy/AirshipDelegate.swift index e3e677d..2a1c276 100644 --- a/ios/AirshipFrameworkProxy/AirshipDelegate.swift +++ b/ios/AirshipFrameworkProxy/AirshipDelegate.swift @@ -95,6 +95,11 @@ extension AirshipDelegate: PushNotificationDelegate { @MainActor func receivedNotificationResponse(_ notificationResponse: UNNotificationResponse) async { + LaunchDeepLinkTracker.shared.onNotificationResponse( + userInfo: notificationResponse.notification.request.content.userInfo, + isDefaultAction: notificationResponse.actionIdentifier == UNNotificationDefaultActionIdentifier + ) + do { if (notificationResponse.actionIdentifier != UNNotificationDismissActionIdentifier) { self.eventEmitter.addEvent( diff --git a/ios/AirshipFrameworkProxy/LaunchDeepLinkTracker.swift b/ios/AirshipFrameworkProxy/LaunchDeepLinkTracker.swift new file mode 100644 index 0000000..b66ec47 --- /dev/null +++ b/ios/AirshipFrameworkProxy/LaunchDeepLinkTracker.swift @@ -0,0 +1,85 @@ +/* Copyright Airship and Contributors */ + +import Foundation + +#if canImport(AirshipKit) +import AirshipKit +#elseif canImport(AirshipCore) +import AirshipCore +#endif + +/// Tracks the deep link that launched the app from a notification tap. +/// +/// The stash is consume-once and expires after `maxStashAge` so a JS reload +/// or a stale foreground tap can't replay an old launch link. +@MainActor +final class LaunchDeepLinkTracker { + + static let shared = LaunchDeepLinkTracker() + + private static let deepLinkActionKeys = ["^d", "deep_link_action"] + private static let maxStashAge: TimeInterval = 10.0 + + private var stash: (deepLink: String, date: Date)? + private var launchResolved = false + private var waiters: [CheckedContinuation] = [] + + private let dateProvider: () -> Date + + init(dateProvider: @escaping () -> Date = { Date() }) { + self.dateProvider = dateProvider + } + + /// Called from the notification response handler with the tapped + /// notification's payload. A default-action tap resolves the launch, + /// stashing the payload's deep link if present. + func onNotificationResponse( + userInfo: [AnyHashable: Any], + isDefaultAction: Bool + ) { + guard isDefaultAction else { return } + let deepLink = Self.deepLinkActionKeys + .compactMap { userInfo[$0] as? String } + .first + if let deepLink { + stash = (deepLink, dateProvider()) + } + launchResolved = true + resolveWaiters() + } + + /// Resolves the launch without a deep link. Called once the app becomes + /// active so normal launches resolve nil without waiting. + func onLaunchResolved() { + launchResolved = true + resolveWaiters() + } + + /// Returns the deep link that launched the app, or nil. Consumes the value. + func takeLaunchDeepLink() async -> String? { + if let deepLink = takeFreshStash() { + return deepLink + } + if launchResolved { + return nil + } + return await withCheckedContinuation { waiters.append($0) } + } + + private func takeFreshStash() -> String? { + guard let stash else { return nil } + self.stash = nil + guard dateProvider().timeIntervalSince(stash.date) <= Self.maxStashAge else { + return nil + } + return stash.deepLink + } + + private func resolveWaiters() { + guard !waiters.isEmpty else { return } + let result = takeFreshStash() + let pending = waiters + waiters = [] + pending.forEach { $0.resume(returning: result) } + } +} diff --git a/ios/AirshipFrameworkProxyTests/FeatureTests.swift b/ios/AirshipFrameworkProxyTests/FeatureTests.swift index f4ee428..e2a4ae9 100644 --- a/ios/AirshipFrameworkProxyTests/FeatureTests.swift +++ b/ios/AirshipFrameworkProxyTests/FeatureTests.swift @@ -6,7 +6,7 @@ import AirshipFeatureFlags class FeatureTests: XCTestCase { func testFeaturesEncoding() throws { - let data = try AirshipJSONUtils.data(["push", "analytics"], options:.fragmentsAllowed) + let data = try JSONEncoder().encode(["push", "analytics"]) let fromJson = try JSONDecoder().decode(AirshipFeature.self, from: data) XCTAssertEqual([.push, .analytics], fromJson) @@ -17,19 +17,19 @@ class FeatureTests: XCTestCase { } func testFeaturesEncodingAll() throws { - let data = try AirshipJSONUtils.data(["all"], options:.fragmentsAllowed) + let data = try JSONEncoder().encode(["all"]) let fromJson = try JSONDecoder().decode(AirshipFeature.self, from: data) XCTAssertEqual(AirshipFeature.all, fromJson) } func testFeaturesEncodingNone() throws { - let data = try AirshipJSONUtils.data(["none"], options:.fragmentsAllowed) + let data = try JSONEncoder().encode(["none"]) let fromJson = try JSONDecoder().decode(AirshipFeature.self, from: data) XCTAssertEqual([], fromJson) } func testFeaturesEncodingEmpty() throws { - let data = try AirshipJSONUtils.data([], options:.fragmentsAllowed) + let data = try JSONEncoder().encode([String]()) let fromJson = try JSONDecoder().decode(AirshipFeature.self, from: data) XCTAssertEqual([], fromJson) } diff --git a/ios/AirshipFrameworkProxyTests/LaunchDeepLinkTrackerTest.swift b/ios/AirshipFrameworkProxyTests/LaunchDeepLinkTrackerTest.swift new file mode 100644 index 0000000..2f683a1 --- /dev/null +++ b/ios/AirshipFrameworkProxyTests/LaunchDeepLinkTrackerTest.swift @@ -0,0 +1,109 @@ +/* Copyright Airship and Contributors */ + +import XCTest +@testable import AirshipFrameworkProxy + +@MainActor +final class LaunchDeepLinkTrackerTest: XCTestCase { + + func testTapWithDeepLinkStashes() async { + let tracker = LaunchDeepLinkTracker() + tracker.onNotificationResponse( + userInfo: ["^d": "myapp://home"], + isDefaultAction: true + ) + let result = await tracker.takeLaunchDeepLink() + XCTAssertEqual(result, "myapp://home") + } + + func testConsumeOnce() async { + let tracker = LaunchDeepLinkTracker() + tracker.onNotificationResponse( + userInfo: ["^d": "myapp://home"], + isDefaultAction: true + ) + _ = await tracker.takeLaunchDeepLink() + let second = await tracker.takeLaunchDeepLink() + XCTAssertNil(second) + } + + func testLongActionNameKey() async { + let tracker = LaunchDeepLinkTracker() + tracker.onNotificationResponse( + userInfo: ["deep_link_action": "myapp://home"], + isDefaultAction: true + ) + let result = await tracker.takeLaunchDeepLink() + XCTAssertEqual(result, "myapp://home") + } + + func testTapWithoutDeepLinkResolvesNil() async { + let tracker = LaunchDeepLinkTracker() + tracker.onNotificationResponse(userInfo: [:], isDefaultAction: true) + let result = await tracker.takeLaunchDeepLink() + XCTAssertNil(result) + } + + func testNonDefaultActionIgnored() async { + let tracker = LaunchDeepLinkTracker() + tracker.onNotificationResponse( + userInfo: ["^d": "myapp://home"], + isDefaultAction: false + ) + tracker.onLaunchResolved() + let result = await tracker.takeLaunchDeepLink() + XCTAssertNil(result) + } + + func testWaiterResolvedByLaterTap() async { + let tracker = LaunchDeepLinkTracker() + let task = Task { await tracker.takeLaunchDeepLink() } + await Task.yield() + tracker.onNotificationResponse( + userInfo: ["^d": "myapp://home"], + isDefaultAction: true + ) + let result = await task.value + XCTAssertEqual(result, "myapp://home") + } + + func testWaiterResolvedNilOnLaunchResolved() async { + let tracker = LaunchDeepLinkTracker() + let task = Task { await tracker.takeLaunchDeepLink() } + await Task.yield() + tracker.onLaunchResolved() + let result = await task.value + XCTAssertNil(result) + } + + func testAfterResolvedReturnsNilImmediately() async { + let tracker = LaunchDeepLinkTracker() + tracker.onLaunchResolved() + let result = await tracker.takeLaunchDeepLink() + XCTAssertNil(result) + } + + func testStaleStashReturnsNil() async { + var now = Date() + let tracker = LaunchDeepLinkTracker(dateProvider: { now }) + tracker.onNotificationResponse( + userInfo: ["^d": "myapp://home"], + isDefaultAction: true + ) + now = now.addingTimeInterval(11.0) + let result = await tracker.takeLaunchDeepLink() + XCTAssertNil(result) + } + + func testStashStillFreshReturnsLink() async { + var now = Date() + let tracker = LaunchDeepLinkTracker(dateProvider: { now }) + tracker.onNotificationResponse( + userInfo: ["^d": "myapp://home"], + isDefaultAction: true + ) + now = now.addingTimeInterval(9.0) + let result = await tracker.takeLaunchDeepLink() + XCTAssertEqual(result, "myapp://home") + } +}