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