feat(event): 이벤트 상세 딥링크와 OneLink를 연결한다
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
import AppsFlyerLib
|
||||
|
||||
enum AppDeepLinkAction {
|
||||
case live(roomId: Int)
|
||||
case userCreatorChat(roomId: Int)
|
||||
@@ -7,6 +9,7 @@ enum AppDeepLinkAction {
|
||||
case series(seriesId: Int)
|
||||
case giftDetail(applicationNo: String)
|
||||
case community(postId: Int)
|
||||
case eventDetail(eventId: Int)
|
||||
case message
|
||||
case audition
|
||||
}
|
||||
@@ -17,7 +20,14 @@ enum AppDeepLinkSource {
|
||||
}
|
||||
|
||||
enum AppDeepLinkHandler {
|
||||
private static var navigatingEventId: Int?
|
||||
|
||||
static func handle(url: URL, source: AppDeepLinkSource = .external) -> Bool {
|
||||
if isOneLink(url) {
|
||||
AppsFlyerLib.shared().performOnAppAttribution(with: url)
|
||||
return true
|
||||
}
|
||||
|
||||
guard isSupportedScheme(url) else {
|
||||
return false
|
||||
}
|
||||
@@ -26,16 +36,58 @@ enum AppDeepLinkHandler {
|
||||
return false
|
||||
}
|
||||
|
||||
dispatch(action: action, source: source)
|
||||
return true
|
||||
}
|
||||
|
||||
static func handleEventDeepLink(result: DeepLinkResult) -> Bool {
|
||||
guard result.status == .found,
|
||||
let clickEvent = result.deepLink?.clickEvent,
|
||||
clickEvent["deep_link_value"] as? String == "event-detail",
|
||||
let id = eventId(clickEvent["deep_link_sub5"] as? String) else {
|
||||
return false
|
||||
}
|
||||
|
||||
dispatch(action: .eventDetail(eventId: id))
|
||||
return true
|
||||
}
|
||||
|
||||
static func handlePushDeepLink(userInfo: [AnyHashable: Any]) -> Bool {
|
||||
if userInfo["notifly_message_type"] as? String == "push-notification" {
|
||||
if let url = userInfo["url"] as? URL, !url.absoluteString.isEmpty {
|
||||
return true
|
||||
}
|
||||
if let urlString = userInfo["url"] as? String, !urlString.isEmpty {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let deepLinkString = (userInfo["deep_link"] as? String ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !deepLinkString.isEmpty else { return false }
|
||||
|
||||
if let url = URL(string: deepLinkString), isOneLink(url) {
|
||||
AppsFlyerLib.shared().handlePushNotification(userInfo)
|
||||
} else {
|
||||
_ = handle(urlString: deepLinkString)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private static func dispatch(action: AppDeepLinkAction, source: AppDeepLinkSource = .external) {
|
||||
DispatchQueue.main.async {
|
||||
if case .splash = AppState.shared.rootStep {
|
||||
if case .eventDetail(let eventId) = action,
|
||||
case .eventDetail(let pendingId)? = AppState.shared.pendingDeepLinkAction,
|
||||
pendingId == eventId {
|
||||
return
|
||||
}
|
||||
AppState.shared.pendingDeepLinkAction = action
|
||||
return
|
||||
}
|
||||
|
||||
apply(action: action, source: source)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
static func handle(urlString: String, source: AppDeepLinkSource = .external) -> Bool {
|
||||
@@ -78,6 +130,35 @@ enum AppDeepLinkHandler {
|
||||
guard postId > 0 else { return }
|
||||
AppState.shared.setAppStep(step: .creatorChannelCommunityPostDetail(postId: postId))
|
||||
|
||||
case .eventDetail(let eventId):
|
||||
guard eventId > 0, navigatingEventId != eventId else { return }
|
||||
navigatingEventId = eventId
|
||||
AppState.shared.requestExternalNavigation(
|
||||
navigationAction: {
|
||||
guard navigatingEventId == eventId else { return }
|
||||
AppState.shared.setAppStep(
|
||||
step: .eventDetail(eventId: eventId),
|
||||
ifCurrentStep: { currentStep in
|
||||
guard navigatingEventId == eventId else { return false }
|
||||
if case .eventDetail(let currentId) = currentStep {
|
||||
return currentId != eventId
|
||||
}
|
||||
return true
|
||||
},
|
||||
completion: {
|
||||
if navigatingEventId == eventId {
|
||||
navigatingEventId = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
cancelAction: {
|
||||
if navigatingEventId == eventId {
|
||||
navigatingEventId = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
case .message:
|
||||
AppState.shared.setAppStep(step: .message)
|
||||
|
||||
@@ -86,6 +167,26 @@ enum AppDeepLinkHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private static func isOneLink(_ url: URL) -> Bool {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return components.scheme?.lowercased() == "https"
|
||||
&& components.host?.lowercased() == "voiceon.onelink.me"
|
||||
&& components.user == nil && components.password == nil
|
||||
&& (components.port == nil || components.port == 443)
|
||||
}
|
||||
|
||||
private static func eventId(_ identifier: String?) -> Int? {
|
||||
guard let identifier = identifier, !identifier.isEmpty,
|
||||
identifier.utf8.allSatisfy({ $0 >= 48 && $0 <= 57 }),
|
||||
let value = Int64(identifier), value > 0 else {
|
||||
return nil
|
||||
}
|
||||
return Int(exactly: value)
|
||||
}
|
||||
|
||||
private static func isSupportedScheme(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased() else {
|
||||
return false
|
||||
@@ -100,6 +201,16 @@ enum AppDeepLinkHandler {
|
||||
let host = components?.host?.lowercased() ?? ""
|
||||
let pathRoute = url.pathComponents.first { $0 != "/" && !$0.isEmpty }?.lowercased()
|
||||
|
||||
if host == "event-detail" || (host.isEmpty && pathRoute == "event-detail") {
|
||||
let scheme = url.scheme?.lowercased()
|
||||
guard scheme == "voiceon" || scheme == "voiceon-test" else { return nil }
|
||||
let path = url.pathComponents.filter { $0 != "/" && !$0.isEmpty }
|
||||
guard path.count == (host.isEmpty ? 2 : 1), let id = eventId(path.last) else {
|
||||
return nil
|
||||
}
|
||||
return .eventDetail(eventId: id)
|
||||
}
|
||||
|
||||
if host == "community" || (host.isEmpty && pathRoute == "community") {
|
||||
return parsePathStyle(url: url, components: components)
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
AppsFlyerLib.shared().delegate = self
|
||||
AppsFlyerLib.shared().deepLinkDelegate = self
|
||||
AppsFlyerLib.shared().addPushNotificationDeepLinkPath(["deep_link"])
|
||||
}
|
||||
|
||||
private func logUtmInFirebase() {
|
||||
@@ -221,6 +222,7 @@ extension AppDelegate: DeepLinkDelegate {
|
||||
}
|
||||
|
||||
logUtmInFirebase()
|
||||
_ = AppDeepLinkHandler.handleEventDeepLink(result: result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,11 +274,7 @@ extension AppDelegate : UNUserNotificationCenterDelegate {
|
||||
Messaging.messaging().appDidReceiveMessage(userInfo)
|
||||
Notifly.userNotificationCenter(center, didReceive: response)
|
||||
|
||||
let deepLinkString = (userInfo["deep_link"] as? String ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if !deepLinkString.isEmpty {
|
||||
_ = AppDeepLinkHandler.handle(urlString: deepLinkString)
|
||||
if AppDeepLinkHandler.handlePushDeepLink(userInfo: userInfo) {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -110,8 +110,15 @@ class AppState: ObservableObject {
|
||||
routeStepMap[route]
|
||||
}
|
||||
|
||||
func setAppStep(step: AppStep) {
|
||||
func setAppStep(
|
||||
step: AppStep,
|
||||
ifCurrentStep matches: ((AppStep) -> Bool)? = nil,
|
||||
completion: @escaping () -> Void = {}
|
||||
) {
|
||||
DispatchQueue.main.async {
|
||||
defer { completion() }
|
||||
guard matches?(self.appStep) ?? true else { return }
|
||||
|
||||
switch step {
|
||||
case .splash, .main:
|
||||
self.liveDetailSheet = nil
|
||||
@@ -196,6 +203,7 @@ class AppState: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
pendingExternalNavigationCancelAction?()
|
||||
pendingExternalNavigationAction = navigationAction
|
||||
pendingExternalNavigationCancelAction = cancelAction
|
||||
isShowLeaveLiveNavigationDialog = true
|
||||
|
||||
@@ -11,6 +11,7 @@ enum AppStep: Equatable {
|
||||
case message
|
||||
case audition
|
||||
case giftDetail(applicationNo: String)
|
||||
case eventDetail(eventId: Int)
|
||||
}
|
||||
|
||||
enum UserDefaultsKey { case userId }
|
||||
@@ -24,10 +25,22 @@ final class AppState {
|
||||
var rootStep: AppStep = .main
|
||||
var pendingDeepLinkAction: AppDeepLinkAction?
|
||||
var appliedStep: AppStep?
|
||||
var appStep: AppStep { appliedStep ?? rootStep }
|
||||
var isPushRoomFromDeepLink = false
|
||||
var pushRoomId = 0
|
||||
var pendingCommentPostId: Int?
|
||||
func setAppStep(step: AppStep) { appliedStep = step }
|
||||
func setAppStep(
|
||||
step: AppStep,
|
||||
ifCurrentStep matches: ((AppStep) -> Bool)? = nil,
|
||||
completion: @escaping () -> Void = {}
|
||||
) {
|
||||
defer { completion() }
|
||||
guard matches?(appStep) ?? true else { return }
|
||||
appliedStep = step
|
||||
}
|
||||
func requestExternalNavigation(navigationAction: @escaping () -> Void, cancelAction: @escaping () -> Void) {
|
||||
navigationAction()
|
||||
}
|
||||
func setPendingCommunityCommentDeepLink(creatorId: Int, postId: Int) { pendingCommentPostId = postId }
|
||||
func clearPendingCommunityCommentDeepLink() { pendingCommentPostId = nil }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
import Foundation
|
||||
|
||||
#if EVENT_LINK_SDK_DOUBLE
|
||||
public enum DeepLinkStatus { case found, notFound, failure }
|
||||
public struct DeepLink {
|
||||
public let clickEvent: [String: Any]
|
||||
public init(_ clickEvent: [String: Any]) { self.clickEvent = clickEvent }
|
||||
}
|
||||
public struct DeepLinkResult {
|
||||
public let status: DeepLinkStatus
|
||||
public let deepLink: DeepLink?
|
||||
public init(status: DeepLinkStatus, deepLink: DeepLink? = nil) {
|
||||
self.status = status
|
||||
self.deepLink = deepLink
|
||||
}
|
||||
}
|
||||
public final class AppsFlyerLib {
|
||||
private static let instance = AppsFlyerLib()
|
||||
public static func shared() -> AppsFlyerLib { instance }
|
||||
public var resolvedURLs: [URL] = []
|
||||
public var pushPayloads: [[AnyHashable: Any]] = []
|
||||
public func performOnAppAttribution(with url: URL) { resolvedURLs.append(url) }
|
||||
public func handlePushNotification(_ userInfo: [AnyHashable: Any]) { pushPayloads.append(userInfo) }
|
||||
}
|
||||
#else
|
||||
import Combine
|
||||
import AppsFlyerLib
|
||||
|
||||
typealias ObservableObject = Combine.ObservableObject
|
||||
typealias Published = Combine.Published
|
||||
|
||||
enum AppStep {
|
||||
case splash, main, notificationList, message, audition
|
||||
case eventDetail(eventId: Int)
|
||||
case contentDetail(contentId: Int)
|
||||
case seriesDetail(seriesId: Int)
|
||||
case giftDetail(applicationNo: String)
|
||||
case userCreatorChatRoom(roomId: Int)
|
||||
case creatorChannelCommunityPostDetail(postId: Int)
|
||||
case liveDetail(roomId: Int, onClickParticipant: () -> Void, onClickReservation: () -> Void,
|
||||
onClickStart: () -> Void, onClickCancel: () -> Void)
|
||||
}
|
||||
struct EventItem {}
|
||||
enum OrderType { case KEEP }
|
||||
enum MainChatFilter { case all }
|
||||
enum HomeViewModel { enum CurrentTab { case home } }
|
||||
final class ContentPlayManager {
|
||||
static let shared = ContentPlayManager()
|
||||
func stopAudio() {}
|
||||
}
|
||||
final class ContentPlayerPlayManager {
|
||||
static let shared = ContentPlayerPlayManager()
|
||||
func resetPlayer() {}
|
||||
}
|
||||
extension Notification.Name {
|
||||
static let requestLiveRoomQuitForExternalNavigation = Notification.Name("test-live-quit")
|
||||
}
|
||||
let APPSCHEME = "soda"
|
||||
|
||||
@main
|
||||
enum EventDeepLinkCheck {
|
||||
static var assertions = 0
|
||||
static let state = AppState.shared
|
||||
static let sdk = AppsFlyerLib.shared()
|
||||
|
||||
static func check(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||
assertions += 1
|
||||
if !condition() {
|
||||
fputs("FAIL: \(message)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
static func settle() {
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.02))
|
||||
}
|
||||
|
||||
static func reset() {
|
||||
state.cancelExternalNavigation()
|
||||
state.isShowPlayer = false
|
||||
state.pendingDeepLinkAction = nil
|
||||
state.setAppStep(step: .main)
|
||||
settle()
|
||||
sdk.resolvedURLs = []
|
||||
sdk.pushPayloads = []
|
||||
}
|
||||
|
||||
static func isEvent(_ id: Int) -> Bool {
|
||||
if case .eventDetail(let eventId) = state.appStep { return eventId == id }
|
||||
return false
|
||||
}
|
||||
|
||||
static func checkURIs() {
|
||||
for scheme in ["voiceon", "voiceon-test"] {
|
||||
for id in [1, 4_294_967_296, Int.max] {
|
||||
reset()
|
||||
check(AppDeepLinkHandler.handle(urlString: "\(scheme)://event-detail/\(id)"), "event URI accepted: \(scheme), \(id)")
|
||||
settle()
|
||||
check(isEvent(id), "public event detail uses complete 64-bit ID")
|
||||
check(state.navigationPath.count == 1, "one event destination")
|
||||
}
|
||||
}
|
||||
reset()
|
||||
check(AppDeepLinkHandler.handle(urlString: "voiceon:///event-detail/42"), "existing hostless path convention accepted")
|
||||
settle()
|
||||
check(isEvent(42), "hostless event path opens event")
|
||||
|
||||
for root in [AppStep.main, .splash] {
|
||||
for identifier in ["", "abc", "0", "-1", "1.5", "9223372036854775808", "+1", "%20", "%20%31"] {
|
||||
for route in ["voiceon://event-detail/", "voiceon-test:///event-detail/"] {
|
||||
reset()
|
||||
state.setAppStep(step: root)
|
||||
settle()
|
||||
state.pendingDeepLinkAction = .content(contentId: 99)
|
||||
check(!AppDeepLinkHandler.handle(urlString: route + identifier + "?content_id=42&message_id=1"), "invalid reserved event ID cannot fall through: \(identifier)")
|
||||
settle()
|
||||
check(state.navigationPath.isEmpty, "invalid ID preserves current route")
|
||||
if case .content(let contentId)? = state.pendingDeepLinkAction {
|
||||
check(contentId == 99, "invalid ID preserves pending destination")
|
||||
} else { check(false, "invalid ID erased pending destination") }
|
||||
}
|
||||
}
|
||||
}
|
||||
for url in ["voiceon://event/42", "voiceon://?event_id=42", "soda://event-detail/42",
|
||||
"voiceon://event-detail/42/extra?content_id=7", "voiceon://event-detail?message_id=1"] {
|
||||
check(!AppDeepLinkHandler.handle(urlString: url), "event aliases and noncontract scheme rejected")
|
||||
}
|
||||
reset()
|
||||
check(AppDeepLinkHandler.handle(urlString: "soda://content/7"), "existing APPSCHEME route retained")
|
||||
settle()
|
||||
if case .contentDetail(let id) = state.appStep { check(id == 7, "legacy app scheme still navigates") }
|
||||
else { check(false, "legacy app scheme route lost") }
|
||||
print("PASS: exact event URI, 64-bit ID, reserved invalid path and legacy scheme")
|
||||
}
|
||||
|
||||
static func checkOneLinkInputs() {
|
||||
for source in [AppDeepLinkSource.external, .notificationList] {
|
||||
for url in ["https://voiceon.onelink.me/local/synthetic-short",
|
||||
"https://voiceon.onelink.me/local?deep_link_value=event-detail&deep_link_sub5=42",
|
||||
"https://VOICEON.ONELINK.ME:443/local/synthetic-short"] {
|
||||
reset()
|
||||
check(AppDeepLinkHandler.handle(urlString: url, source: source), "OneLink accepted through common ingress")
|
||||
settle()
|
||||
check(sdk.resolvedURLs.map(\.absoluteString) == [url], "original OneLink forwarded to SDK exactly once")
|
||||
check(sdk.pushPayloads.isEmpty, "notification list does not fabricate push")
|
||||
check(state.navigationPath.isEmpty && state.pendingDeepLinkAction == nil, "OneLink query cannot bypass UDL")
|
||||
}
|
||||
}
|
||||
for url in ["http://voiceon.onelink.me/a", "https://voiceon.onelink.me.evil.invalid/a",
|
||||
"https://evilvoiceon.onelink.me/a", "https://voiceon.onelink.me@evil.invalid/a",
|
||||
"https://user@voiceon.onelink.me/a", "https://user:pass@voiceon.onelink.me/a",
|
||||
"https://voiceon.onelink.me:444/a", "https://voiceon.onelink.me./a",
|
||||
"https://other.onelink.me/a", "ftp://voiceon.onelink.me/a"] {
|
||||
reset()
|
||||
check(!AppDeepLinkHandler.handle(urlString: url), "untrusted OneLink rejected: \(url)")
|
||||
settle()
|
||||
check(sdk.resolvedURLs.isEmpty, "rejected URL never sent to SDK")
|
||||
check(state.navigationPath.isEmpty, "rejected URL never navigates")
|
||||
}
|
||||
print("PASS: short/long original SDK delegation, no query bypass, exact HTTPS origin")
|
||||
}
|
||||
|
||||
static func found(_ id: Any, value: String = "event-detail") -> DeepLinkResult {
|
||||
DeepLinkResult(status: .found, deepLink: DeepLink(["deep_link_value": value, "deep_link_sub5": id]))
|
||||
}
|
||||
|
||||
static func checkCallbacksAndPending() {
|
||||
for id in [1, 4_294_967_296, Int.max] {
|
||||
reset()
|
||||
check(AppDeepLinkHandler.handleEventDeepLink(result: found(String(id))), "positive 64-bit UDL ID accepted")
|
||||
settle()
|
||||
check(isEvent(id), "UDL preserves full ID")
|
||||
}
|
||||
reset()
|
||||
state.setAppStep(step: .splash)
|
||||
settle()
|
||||
let data = Data(#"{"deep_link_value":"event-detail","deep_link_sub5":"4294967296","deep_link_sub1":"marketing-pid","deep_link_sub2":"source","deep_link_sub3":"medium","deep_link_sub4":"campaign"}"#.utf8)
|
||||
let decoded = try! JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||
check(AppDeepLinkHandler.handleEventDeepLink(result: DeepLinkResult(status: .found, deepLink: DeepLink(decoded))), "decoded UDL event accepted")
|
||||
settle()
|
||||
check(state.navigationPath.isEmpty, "cold UDL only stores pending action")
|
||||
for result in [DeepLinkResult(status: .notFound), DeepLinkResult(status: .failure),
|
||||
DeepLinkResult(status: .failure, deepLink: DeepLink(decoded)),
|
||||
DeepLinkResult(status: .found),
|
||||
DeepLinkResult(status: .found, deepLink: DeepLink(["deep_link_value": "event-detail"]))] {
|
||||
check(!AppDeepLinkHandler.handleEventDeepLink(result: result), "failure/notFound/missing object ignored without request correlation")
|
||||
}
|
||||
for id: Any in ["", "bad", "0", "-1", "9223372036854775808", "+1", 42] {
|
||||
check(!AppDeepLinkHandler.handleEventDeepLink(result: found(id)), "UDL validates positive string ID")
|
||||
}
|
||||
for value in ["event", "EVENT-DETAIL", " event-detail", "content", "series", "live", "channel"] {
|
||||
check(!AppDeepLinkHandler.handleEventDeepLink(result: found("42", value: value)), "only exact UDL event kind owned by event path")
|
||||
}
|
||||
settle()
|
||||
if case .eventDetail(let id)? = state.pendingDeepLinkAction {
|
||||
check(id == 4_294_967_296, "invalid callbacks preserve valid pending event")
|
||||
} else { check(false, "UDL pending event missing") }
|
||||
check(AppDeepLinkHandler.handle(urlString: "voiceon://event-detail/41"), "later pending event accepted")
|
||||
check(AppDeepLinkHandler.handle(urlString: "voiceon://event-detail/42"), "latest pending event accepted")
|
||||
settle()
|
||||
if case .eventDetail(let id)? = state.pendingDeepLinkAction { check(id == 42, "single pending slot keeps latest event") }
|
||||
else { check(false, "latest pending event missing") }
|
||||
let action = state.consumePendingDeepLinkAction()!
|
||||
check(state.pendingDeepLinkAction == nil, "splash consumes existing single pending slot")
|
||||
state.setAppStep(step: .main)
|
||||
settle()
|
||||
AppDeepLinkHandler.apply(action: action)
|
||||
AppDeepLinkHandler.apply(action: action)
|
||||
settle()
|
||||
check(isEvent(42) && state.navigationPath.count == 1, "public splash apply does not require token and deduplicates")
|
||||
AppDeepLinkHandler.apply(action: .eventDetail(eventId: 0))
|
||||
AppDeepLinkHandler.apply(action: .eventDetail(eventId: -1))
|
||||
settle()
|
||||
check(isEvent(42) && state.navigationPath.count == 1, "invalid action cannot overwrite current event")
|
||||
print("PASS: decoded UDL, early failures, invalid callbacks, public splash pending/latest intent")
|
||||
}
|
||||
|
||||
static func checkNavigationLifecycle() {
|
||||
reset()
|
||||
state.setAppStep(step: .notificationList)
|
||||
settle()
|
||||
for _ in 0..<20 {
|
||||
check(AppDeepLinkHandler.handle(urlString: "voiceon://event-detail/42", source: .notificationList), "burst event accepted")
|
||||
check(AppDeepLinkHandler.handleEventDeepLink(result: found("42")), "burst UDL accepted")
|
||||
}
|
||||
settle()
|
||||
check(isEvent(42) && state.navigationPath.count == 2, "queued and current duplicate delivery opens only one event without resetting warm root")
|
||||
check(AppDeepLinkHandler.handleEventDeepLink(result: found("42")), "current duplicate recognized")
|
||||
settle()
|
||||
check(state.navigationPath.count == 2, "current event not stacked again")
|
||||
state.back()
|
||||
settle()
|
||||
if case .notificationList = state.appStep { check(true, "back returns to notification list") }
|
||||
else { check(false, "warm route reset notification list") }
|
||||
check(AppDeepLinkHandler.handleEventDeepLink(result: found("42")), "same event deliberately reopened after back")
|
||||
settle()
|
||||
check(isEvent(42) && state.navigationPath.count == 2, "completed guard does not permanently block reopen")
|
||||
|
||||
reset()
|
||||
state.isShowPlayer = true
|
||||
for _ in 0..<10 { _ = AppDeepLinkHandler.handle(urlString: "voiceon://event-detail/43") }
|
||||
settle()
|
||||
check(state.isShowLeaveLiveNavigationDialog && state.navigationPath.isEmpty, "live confirmation waits without navigation")
|
||||
state.cancelExternalNavigation()
|
||||
state.isShowPlayer = false
|
||||
settle()
|
||||
check(state.navigationPath.isEmpty, "cancel then live exit cannot apply stale event")
|
||||
_ = AppDeepLinkHandler.handle(urlString: "voiceon://event-detail/43")
|
||||
settle()
|
||||
check(isEvent(43) && state.navigationPath.count == 1, "same event works after cancellation")
|
||||
|
||||
reset()
|
||||
state.isShowPlayer = true
|
||||
_ = AppDeepLinkHandler.handle(urlString: "voiceon://event-detail/44")
|
||||
_ = AppDeepLinkHandler.handle(urlString: "voiceon://event-detail/45")
|
||||
settle()
|
||||
var quitRequests = 0
|
||||
let observer = NotificationCenter.default.addObserver(forName: .requestLiveRoomQuitForExternalNavigation,
|
||||
object: nil, queue: nil) { _ in quitRequests += 1 }
|
||||
state.confirmExternalNavigation()
|
||||
check(quitRequests == 1 && state.navigationPath.isEmpty, "confirmation requests existing live exit before navigation")
|
||||
state.isShowPlayer = false
|
||||
_ = AppDeepLinkHandler.handleEventDeepLink(result: found("45"))
|
||||
settle()
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
check(isEvent(45) && state.navigationPath.count == 1, "latest live intent and queued callback navigate once after exit")
|
||||
|
||||
state.isShowPlayer = true
|
||||
_ = AppDeepLinkHandler.handle(urlString: "voiceon://event-detail/44")
|
||||
settle()
|
||||
_ = AppDeepLinkHandler.handle(urlString: "voiceon://event-detail/45")
|
||||
settle()
|
||||
state.isShowPlayer = false
|
||||
settle()
|
||||
check(isEvent(45) && state.navigationPath.count == 1, "latest intent for already-current event replaces live pending without stacking current event")
|
||||
|
||||
reset()
|
||||
state.isShowPlayer = true
|
||||
_ = AppDeepLinkHandler.handle(urlString: "voiceon://event-detail/46")
|
||||
settle()
|
||||
state.requestExternalNavigation(navigationAction: {
|
||||
state.setAppStep(step: .contentDetail(contentId: 7))
|
||||
}, cancelAction: {})
|
||||
state.isShowPlayer = false
|
||||
settle()
|
||||
check(state.navigationPath.count == 1 && !isEvent(46), "other external navigation replaces pending event")
|
||||
state.back()
|
||||
settle()
|
||||
_ = AppDeepLinkHandler.handle(urlString: "voiceon://event-detail/46")
|
||||
settle()
|
||||
check(isEvent(46), "superseded live event releases in-flight guard for deliberate reopen")
|
||||
print("PASS: real async AppState burst/current/back/reopen, live wait/cancel/confirm/latest intent")
|
||||
}
|
||||
|
||||
static func checkMixedQueuedEvents(repeatLatest: Bool) {
|
||||
reset()
|
||||
state.setAppStep(step: .eventDetail(eventId: 45))
|
||||
settle()
|
||||
let originalPath = state.navigationPath
|
||||
let ids = repeatLatest ? [44, 45, 44] : [44, 45]
|
||||
for id in ids {
|
||||
check(AppDeepLinkHandler.handle(urlString: "voiceon://event-detail/\(id)"), "mixed queued event accepted")
|
||||
}
|
||||
settle()
|
||||
if repeatLatest {
|
||||
check(isEvent(44) && state.navigationPath.count == originalPath.count + 1,
|
||||
"current 45 then queued 44->45->44 appends latest 44 exactly once")
|
||||
state.back()
|
||||
settle()
|
||||
check(isEvent(45), "mixed latest event retains original 45 beneath one new destination")
|
||||
} else {
|
||||
check(isEvent(45) && state.navigationPath == originalPath,
|
||||
"current 45 then queued 44->45 preserves 45 and original stack")
|
||||
}
|
||||
state.back()
|
||||
settle()
|
||||
check(AppDeepLinkHandler.handle(urlString: "voiceon://event-detail/45"), "event reopens after skipped mixed intent")
|
||||
settle()
|
||||
check(isEvent(45), "obsolete/no-op completion releases event guard")
|
||||
print("PASS: atomic mixed queued events \(ids)")
|
||||
}
|
||||
|
||||
static func checkCurrentEventBehindLive() {
|
||||
reset()
|
||||
state.setAppStep(step: .eventDetail(eventId: 45))
|
||||
settle()
|
||||
let originalPath = state.navigationPath
|
||||
state.isShowPlayer = true
|
||||
check(AppDeepLinkHandler.handle(urlString: "voiceon://event-detail/45"), "current event link accepted behind live")
|
||||
settle()
|
||||
check(state.isShowLeaveLiveNavigationDialog, "current event behind live still requests leave confirmation")
|
||||
state.cancelExternalNavigation()
|
||||
settle()
|
||||
check(state.isShowPlayer && state.navigationPath == originalPath, "cancel keeps live and current event stack unchanged")
|
||||
|
||||
check(AppDeepLinkHandler.handle(urlString: "voiceon://event-detail/45"), "same current event can reopen confirmation after cancel")
|
||||
settle()
|
||||
check(state.isShowLeaveLiveNavigationDialog, "cancel released event guard for next live request")
|
||||
var quitRequests = 0
|
||||
let observer = NotificationCenter.default.addObserver(forName: .requestLiveRoomQuitForExternalNavigation,
|
||||
object: nil, queue: nil) { _ in quitRequests += 1 }
|
||||
state.confirmExternalNavigation()
|
||||
check(quitRequests == 1 && state.isShowPlayer && state.navigationPath == originalPath,
|
||||
"current event confirmation requests live exit before any route change")
|
||||
state.isShowPlayer = false
|
||||
settle()
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
check(isEvent(45) && state.navigationPath == originalPath, "confirmed live exit reveals existing event without duplicate push")
|
||||
state.back()
|
||||
settle()
|
||||
_ = AppDeepLinkHandler.handle(urlString: "voiceon://event-detail/45")
|
||||
settle()
|
||||
check(isEvent(45) && state.navigationPath.count == 1, "current event skip on live exit releases guard for later reopen")
|
||||
print("PASS: current event behind live confirm/cancel/reopen without duplicate destination")
|
||||
}
|
||||
|
||||
static func checkPushOwnership() {
|
||||
let https = "https://voiceon.onelink.me/local/synthetic-short"
|
||||
for link in [https, "https://voiceon.onelink.me/local?deep_link_value=event-detail&deep_link_sub5=42"] {
|
||||
reset()
|
||||
let payload: [AnyHashable: Any] = ["deep_link": link, "room_id": "99", "aps": ["alert": "synthetic"], "fixture": "original"]
|
||||
check(AppDeepLinkHandler.handlePushDeepLink(userInfo: payload), "HTTPS push consumed")
|
||||
settle()
|
||||
check(sdk.pushPayloads.count == 1, "HTTPS push SDK invoked once")
|
||||
check(NSDictionary(dictionary: sdk.pushPayloads[0]).isEqual(NSDictionary(dictionary: payload)), "SDK receives actual unchanged userInfo")
|
||||
check(sdk.resolvedURLs.isEmpty && state.navigationPath.isEmpty, "push does not also resolve URL or bypass UDL")
|
||||
check(AppDeepLinkHandler.handleEventDeepLink(result: found("42")), "push UDL accepted")
|
||||
settle()
|
||||
check(isEvent(42), "push UDL enters common event navigation")
|
||||
}
|
||||
reset()
|
||||
check(AppDeepLinkHandler.handlePushDeepLink(userInfo: ["deep_link": " voiceon://event-detail/42 ", "room_id": "99"]), "URI push consumed")
|
||||
settle()
|
||||
check(isEvent(42) && sdk.pushPayloads.isEmpty && sdk.resolvedURLs.isEmpty, "URI push routes directly without SDK")
|
||||
|
||||
for link in ["unknown://other", "https://voiceon.onelink.me.evil.invalid/a", "http://voiceon.onelink.me/a", "https://voiceon.onelink.me:444/a", "voiceon://event-detail/0"] {
|
||||
reset()
|
||||
check(AppDeepLinkHandler.handlePushDeepLink(userInfo: ["deep_link": link, "room_id": "99"]), "unknown nonempty deep_link retains no-legacy-fallback behavior")
|
||||
settle()
|
||||
check(sdk.pushPayloads.isEmpty && sdk.resolvedURLs.isEmpty && state.navigationPath.isEmpty, "unknown push produces no new side effects")
|
||||
}
|
||||
for payload: [AnyHashable: Any] in [["room_id": "99"], ["deep_link": "", "content_id": "7"], ["deep_link": " \n", "channel_id": "8", "message_id": "9"]] {
|
||||
check(!AppDeepLinkHandler.handlePushDeepLink(userInfo: payload), "empty/missing deep_link returns ownership to unchanged legacy flow")
|
||||
}
|
||||
for url: Any in ["voiceon://event-detail/42", https, NSURL(string: https)!, "https://[", " \n"] {
|
||||
reset()
|
||||
check(AppDeepLinkHandler.handlePushDeepLink(userInfo: ["notifly_message_type": "push-notification", "url": url,
|
||||
"deep_link": "voiceon://event-detail/43", "room_id": "99"]), "Notifly URL owns payload including NSURL or malformed nonempty string")
|
||||
settle()
|
||||
check(sdk.pushPayloads.isEmpty && sdk.resolvedURLs.isEmpty && state.navigationPath.isEmpty, "Notifly does not also app-route/AF-route/legacy-route")
|
||||
}
|
||||
reset()
|
||||
check(!AppDeepLinkHandler.handlePushDeepLink(userInfo: ["notifly_message_type": "push-notification", "url": ""]), "empty Notifly URL is not a URL-opening owner")
|
||||
check(!AppDeepLinkHandler.handlePushDeepLink(userInfo: ["notifly_message_type": "other", "url": https]), "other Notifly type does not claim URL ownership")
|
||||
check(AppDeepLinkHandler.handlePushDeepLink(userInfo: ["notifly_message_type": "push-notification", "deep_link": "voiceon://event-detail/42"]), "Notifly marker without URL does not discard standard URI")
|
||||
settle()
|
||||
check(isEvent(42), "marker without URL uses standard URI path")
|
||||
reset()
|
||||
_ = AppDeepLinkHandler.handle(urlString: "voiceon://event-detail/42")
|
||||
settle()
|
||||
check(isEvent(42), "Notifly SDK-opened URI reaches unchanged common ingress")
|
||||
reset()
|
||||
_ = AppDeepLinkHandler.handle(urlString: https)
|
||||
check(sdk.resolvedURLs.map(\.absoluteString) == [https], "Notifly SDK-opened OneLink reaches common UDL resolver")
|
||||
|
||||
reset()
|
||||
state.setAppStep(step: .splash)
|
||||
settle()
|
||||
check(AppDeepLinkHandler.handle(urlString: https, source: .notificationList), "cold notification-list OneLink uses common resolver")
|
||||
check(AppDeepLinkHandler.handleEventDeepLink(result: found("47")), "notification-list decoded event accepted")
|
||||
settle()
|
||||
if case .eventDetail(let id)? = state.pendingDeepLinkAction { check(id == 47, "notification-list UDL honors splash pending gate") }
|
||||
else { check(false, "notification-list UDL lost pending event") }
|
||||
check(state.navigationPath.isEmpty && sdk.pushPayloads.isEmpty, "cold notification list neither navigates early nor fabricates push")
|
||||
print("PASS: production push policy original payload, URI/SDK/Notifly one owner, legacy fallback")
|
||||
}
|
||||
|
||||
static func main() {
|
||||
check(Int.max == 9_223_372_036_854_775_807, "Int must preserve signed 64-bit IDs")
|
||||
let groups = Set(CommandLine.arguments.dropFirst())
|
||||
if groups.isEmpty || groups.contains("uri") { checkURIs() }
|
||||
if groups.isEmpty || groups.contains("onelink") { checkOneLinkInputs() }
|
||||
if groups.isEmpty || groups.contains("callback") { checkCallbacksAndPending() }
|
||||
if groups.isEmpty || groups.contains("lifecycle") { checkNavigationLifecycle() }
|
||||
if groups.isEmpty || groups.contains("mixed-current") { checkMixedQueuedEvents(repeatLatest: false) }
|
||||
if groups.isEmpty || groups.contains("mixed-repeat") { checkMixedQueuedEvents(repeatLatest: true) }
|
||||
if groups.isEmpty || groups.contains("current-live") { checkCurrentEventBehindLive() }
|
||||
if groups.isEmpty || groups.contains("push") { checkPushOwnership() }
|
||||
print("PASS EventDeepLinkCheck assertions=\(assertions)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -5,7 +5,12 @@ root="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
swiftc "$root/SodaLive/Sources/App/AppDeepLinkHandler.swift" \
|
||||
swiftc -swift-version 5 -module-cache-path "$tmp/module-cache" \
|
||||
-D EVENT_LINK_SDK_DOUBLE -emit-module -emit-library -module-name AppsFlyerLib \
|
||||
"$root/work/tests/EventDeepLinkCheck.swift" -o "$tmp/libAppsFlyerLib.dylib"
|
||||
swiftc -swift-version 5 -module-cache-path "$tmp/module-cache" \
|
||||
-I "$tmp" -L "$tmp" -lAppsFlyerLib -Xlinker -rpath -Xlinker "$tmp" \
|
||||
"$root/SodaLive/Sources/App/AppDeepLinkHandler.swift" \
|
||||
"$root/work/tests/AppDeepLinkGiftDetailCheck.swift" \
|
||||
-o "$tmp/check"
|
||||
"$tmp/check"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
TEMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TEMP_DIR"' EXIT
|
||||
|
||||
swiftc -swift-version 5 -module-cache-path "$TEMP_DIR/module-cache" \
|
||||
-D EVENT_LINK_SDK_DOUBLE -emit-module -emit-library -module-name AppsFlyerLib \
|
||||
"$ROOT/work/tests/EventDeepLinkCheck.swift" -o "$TEMP_DIR/libAppsFlyerLib.dylib"
|
||||
swiftc -swift-version 5 -module-cache-path "$TEMP_DIR/module-cache" \
|
||||
-I "$TEMP_DIR" -L "$TEMP_DIR" -lAppsFlyerLib \
|
||||
-Xlinker -rpath -Xlinker "$TEMP_DIR" \
|
||||
"$ROOT/SodaLive/Sources/App/AppDeepLinkHandler.swift" \
|
||||
"$ROOT/SodaLive/Sources/App/AppState.swift" \
|
||||
"$ROOT/work/tests/EventDeepLinkCheck.swift" \
|
||||
-o "$TEMP_DIR/check-event-deep-link"
|
||||
"$TEMP_DIR/check-event-deep-link" "$@"
|
||||
Reference in New Issue
Block a user