From c78f7417abd6501eb934157a7f5e59648cd0c66d Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 29 Oct 2026 15:44:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(event):=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=94=A5=EB=A7=81=ED=81=AC=EC=99=80=20One?= =?UTF-8?q?Link=EB=A5=BC=20=EC=97=B0=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive/Sources/App/AppDeepLinkHandler.swift | 115 ++++- SodaLive/Sources/App/AppDelegate.swift | 8 +- SodaLive/Sources/App/AppState.swift | 10 +- work/tests/AppDeepLinkGiftDetailCheck.swift | 15 +- work/tests/EventDeepLinkCheck.swift | 432 ++++++++++++++++++ work/tests/check-app-deep-link-gift-detail.sh | 7 +- work/tests/check-event-deep-link.sh | 18 + 7 files changed, 595 insertions(+), 10 deletions(-) create mode 100644 work/tests/EventDeepLinkCheck.swift create mode 100644 work/tests/check-event-deep-link.sh diff --git a/SodaLive/Sources/App/AppDeepLinkHandler.swift b/SodaLive/Sources/App/AppDeepLinkHandler.swift index 00bd2927..75691356 100644 --- a/SodaLive/Sources/App/AppDeepLinkHandler.swift +++ b/SodaLive/Sources/App/AppDeepLinkHandler.swift @@ -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) } diff --git a/SodaLive/Sources/App/AppDelegate.swift b/SodaLive/Sources/App/AppDelegate.swift index 48a3bb88..dd38ab06 100644 --- a/SodaLive/Sources/App/AppDelegate.swift +++ b/SodaLive/Sources/App/AppDelegate.swift @@ -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 } diff --git a/SodaLive/Sources/App/AppState.swift b/SodaLive/Sources/App/AppState.swift index 7a39eba0..333d5aee 100644 --- a/SodaLive/Sources/App/AppState.swift +++ b/SodaLive/Sources/App/AppState.swift @@ -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 diff --git a/work/tests/AppDeepLinkGiftDetailCheck.swift b/work/tests/AppDeepLinkGiftDetailCheck.swift index 5705fdcb..2c117032 100644 --- a/work/tests/AppDeepLinkGiftDetailCheck.swift +++ b/work/tests/AppDeepLinkGiftDetailCheck.swift @@ -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 } } diff --git a/work/tests/EventDeepLinkCheck.swift b/work/tests/EventDeepLinkCheck.swift new file mode 100644 index 00000000..7c8ea78d --- /dev/null +++ b/work/tests/EventDeepLinkCheck.swift @@ -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 diff --git a/work/tests/check-app-deep-link-gift-detail.sh b/work/tests/check-app-deep-link-gift-detail.sh index 5cfd403e..ee7e6f72 100755 --- a/work/tests/check-app-deep-link-gift-detail.sh +++ b/work/tests/check-app-deep-link-gift-detail.sh @@ -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" diff --git a/work/tests/check-event-deep-link.sh b/work/tests/check-event-deep-link.sh new file mode 100644 index 00000000..68db93d3 --- /dev/null +++ b/work/tests/check-event-deep-link.sh @@ -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" "$@"