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
Expand Up @@ -86,6 +86,8 @@ internal class AirshipListener(
}

override fun onNotificationOpened(notificationInfo: NotificationInfo): Boolean {
LaunchDeepLinkTracker.shared().onNotificationOpened(notificationInfo.message, isAppForegrounded)

eventEmitter.addEvent(
NotificationResponseEvent(notificationInfo, null)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Comment thread
crow marked this conversation as resolved.
activityMonitor.removeApplicationListener(this)
}

override fun onBackground(milliseconds: Long) {}
})
}

dispatcher.launch {
PendingEmbedded.pending.collect {
EventEmitter.shared().addEvent(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Stash?>(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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
}
}
16 changes: 12 additions & 4 deletions ios/AirshipFrameworkProxy.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -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 */; };
Expand Down Expand Up @@ -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 */
Expand All @@ -73,6 +75,7 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
46696F3FAC1D351E957ABCAC /* LaunchDeepLinkTrackerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchDeepLinkTrackerTest.swift; sourceTree = "<group>"; };
6E07689529FC48450014E2A9 /* AttributeOperationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributeOperationTest.swift; sourceTree = "<group>"; };
6E07689C2A0038F80014E2A9 /* ScopedSubscriptionListOperationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScopedSubscriptionListOperationTest.swift; sourceTree = "<group>"; };
6E0D94F02A7D52F400781BC7 /* AirshipFeatureFlagManagerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipFeatureFlagManagerProxy.swift; sourceTree = "<group>"; };
Expand All @@ -82,6 +85,7 @@
6E142E59296E3A8800F71E23 /* FeatureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureTests.swift; sourceTree = "<group>"; };
6E142E64296E3DEB00F71E23 /* SubscriptionListOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionListOperation.swift; sourceTree = "<group>"; };
6E142E65296E3DEB00F71E23 /* AirshipExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipExtensions.swift; sourceTree = "<group>"; };
46696F42AC1D351E957ABCAF /* LaunchDeepLinkTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchDeepLinkTracker.swift; sourceTree = "<group>"; };
6E142E66296E3DEB00F71E23 /* AirshipDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipDelegate.swift; sourceTree = "<group>"; };
6E142E67296E3DEB00F71E23 /* AttributeOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributeOperation.swift; sourceTree = "<group>"; };
6E142E68296E3DEB00F71E23 /* ProxyStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProxyStore.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -118,10 +122,10 @@
6ED117B12CA75D0F00C41C56 /* AirshipPluginLoaderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipPluginLoaderProtocol.swift; sourceTree = "<group>"; };
6ED117B42CA75D2300C41C56 /* AirshipPluginExtenderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipPluginExtenderProtocol.swift; sourceTree = "<group>"; };
6EFB83D92979BE7F0008BEB5 /* ScopedSubscriptionListOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScopedSubscriptionListOperation.swift; sourceTree = "<group>"; };
AA000001303011110000F001 /* EmailRegistrationProxyOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailRegistrationProxyOptions.swift; sourceTree = "<group>"; };
AA000002303011110000F001 /* SMSRegistrationProxyOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMSRegistrationProxyOptions.swift; sourceTree = "<group>"; };
842981FE2AA10D6600456BDB /* TagOperationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagOperationTest.swift; sourceTree = "<group>"; };
8443CF332A97ADB3000589B8 /* TagOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagOperation.swift; sourceTree = "<group>"; };
AA000001303011110000F001 /* EmailRegistrationProxyOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailRegistrationProxyOptions.swift; sourceTree = "<group>"; };
AA000002303011110000F001 /* SMSRegistrationProxyOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMSRegistrationProxyOptions.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -206,6 +211,7 @@
6E07689C2A0038F80014E2A9 /* ScopedSubscriptionListOperationTest.swift */,
842981FE2AA10D6600456BDB /* TagOperationTest.swift */,
6E1EEE762BD2D34B00B45A87 /* AirshipProxyEventEmitterTest.swift */,
46696F3FAC1D351E957ABCAC /* LaunchDeepLinkTrackerTest.swift */,
);
path = AirshipFrameworkProxyTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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;
};
Expand Down
5 changes: 5 additions & 0 deletions ios/AirshipFrameworkProxy/AirshipDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading