Skip to content

Commit d79ab5d

Browse files
authored
feat: 유니버셜 링크 추가 및 공유 기능 강화 (#103)
* feat: 유니버셜링크 추가 * feat: 카카오톡 공유, 유니버셜 링크 공유 기능 추가 * feat: 딥링크 및 공유기능 강화 * feat(1.2.10): 버전 업데이트
1 parent 9ff0e41 commit d79ab5d

17 files changed

Lines changed: 903 additions & 25 deletions

KillingPart.xcodeproj/project.pbxproj

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
12F7A0012F3F010000A00001 /* KakaoSDKCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 12F7A0112F3F010000A00001 /* KakaoSDKCommon */; };
1717
12F7A0022F3F010000A00001 /* KakaoSDKAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 12F7A0122F3F010000A00001 /* KakaoSDKAuth */; };
1818
12F7A0032F3F010000A00001 /* KakaoSDKUser in Frameworks */ = {isa = PBXBuildFile; productRef = 12F7A0132F3F010000A00001 /* KakaoSDKUser */; };
19+
12F7A0042F3F010000A00001 /* KakaoSDKShare in Frameworks */ = {isa = PBXBuildFile; productRef = 12F7A0142F3F010000A00001 /* KakaoSDKShare */; };
20+
12F7A0052F3F010000A00001 /* KakaoSDKTemplate in Frameworks */ = {isa = PBXBuildFile; productRef = 12F7A0152F3F010000A00001 /* KakaoSDKTemplate */; };
1921
13A100032FF5A00000A1B001 /* AmplitudeUnified in Frameworks */ = {isa = PBXBuildFile; productRef = 13A100022FF5A00000A1B001 /* AmplitudeUnified */; };
2022
/* End PBXBuildFile section */
2123

@@ -82,6 +84,8 @@
8284
files = (
8385
12F7A0012F3F010000A00001 /* KakaoSDKCommon in Frameworks */,
8486
12F7A0022F3F010000A00001 /* KakaoSDKAuth in Frameworks */,
87+
12F7A0042F3F010000A00001 /* KakaoSDKShare in Frameworks */,
88+
12F7A0052F3F010000A00001 /* KakaoSDKTemplate in Frameworks */,
8589
13A100032FF5A00000A1B001 /* AmplitudeUnified in Frameworks */,
8690
1260BBC82FA88B900006BF01 /* GoogleSignInSwift in Frameworks */,
8791
1224698D2FA765FE00A6EF76 /* FirebaseMessaging in Frameworks */,
@@ -153,6 +157,8 @@
153157
12F7A0112F3F010000A00001 /* KakaoSDKCommon */,
154158
12F7A0122F3F010000A00001 /* KakaoSDKAuth */,
155159
12F7A0132F3F010000A00001 /* KakaoSDKUser */,
160+
12F7A0142F3F010000A00001 /* KakaoSDKShare */,
161+
12F7A0152F3F010000A00001 /* KakaoSDKTemplate */,
156162
1224698A2FA765FE00A6EF76 /* FirebaseCore */,
157163
1224698C2FA765FE00A6EF76 /* FirebaseMessaging */,
158164
1260BBC52FA88B900006BF01 /* GoogleSignIn */,
@@ -452,7 +458,7 @@
452458
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
453459
CODE_SIGN_ENTITLEMENTS = KillingPart/KillingPart.entitlements;
454460
CODE_SIGN_STYLE = Automatic;
455-
CURRENT_PROJECT_VERSION = 44;
461+
CURRENT_PROJECT_VERSION = 45;
456462
DEAD_CODE_STRIPPING = YES;
457463
DEVELOPMENT_TEAM = GQ89YG5G9R;
458464
ENABLE_APP_SANDBOX = YES;
@@ -477,7 +483,7 @@
477483
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
478484
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
479485
MACOSX_DEPLOYMENT_TARGET = 14.0;
480-
MARKETING_VERSION = 1.2.9;
486+
MARKETING_VERSION = 1.2.10;
481487
PRODUCT_BUNDLE_IDENTIFIER = com.killingpoint.killingpart;
482488
PRODUCT_NAME = "$(TARGET_NAME)";
483489
REGISTER_APP_GROUPS = YES;
@@ -497,7 +503,7 @@
497503
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
498504
CODE_SIGN_ENTITLEMENTS = KillingPart/KillingPart.entitlements;
499505
CODE_SIGN_STYLE = Automatic;
500-
CURRENT_PROJECT_VERSION = 44;
506+
CURRENT_PROJECT_VERSION = 45;
501507
DEAD_CODE_STRIPPING = YES;
502508
DEVELOPMENT_TEAM = GQ89YG5G9R;
503509
ENABLE_APP_SANDBOX = YES;
@@ -522,7 +528,7 @@
522528
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
523529
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
524530
MACOSX_DEPLOYMENT_TARGET = 14.0;
525-
MARKETING_VERSION = 1.2.9;
531+
MARKETING_VERSION = 1.2.10;
526532
PRODUCT_BUNDLE_IDENTIFIER = com.killingpoint.killingpart;
527533
PRODUCT_NAME = "$(TARGET_NAME)";
528534
REGISTER_APP_GROUPS = YES;
@@ -741,6 +747,16 @@
741747
package = 12F7A0212F3F010000A00001 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */;
742748
productName = KakaoSDKUser;
743749
};
750+
12F7A0142F3F010000A00001 /* KakaoSDKShare */ = {
751+
isa = XCSwiftPackageProductDependency;
752+
package = 12F7A0212F3F010000A00001 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */;
753+
productName = KakaoSDKShare;
754+
};
755+
12F7A0152F3F010000A00001 /* KakaoSDKTemplate */ = {
756+
isa = XCSwiftPackageProductDependency;
757+
package = 12F7A0212F3F010000A00001 /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */;
758+
productName = KakaoSDKTemplate;
759+
};
744760
13A100022FF5A00000A1B001 /* AmplitudeUnified */ = {
745761
isa = XCSwiftPackageProductDependency;
746762
package = 13A100012FF5A00000A1B001 /* XCRemoteSwiftPackageReference "AmplitudeUnified-Swift" */;

KillingPart/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<string>Editor</string>
1616
<key>CFBundleURLSchemes</key>
1717
<array>
18+
<string>killingpart</string>
1819
<string>kakao$(KAKAO_NATIVE_APP_KEY)</string>
1920
<string>com.googleusercontent.apps.619615909665-c9j3gs1vqmvi64rhb9i9ikg6dnpdghc0</string>
2021
</array>
@@ -28,6 +29,7 @@
2829
<array>
2930
<string>instagram-stories</string>
3031
<string>kakaokompassauth</string>
32+
<string>kakaolink</string>
3133
</array>
3234
<key>MUSIC_BASE_URL</key>
3335
<string>$(MUSIC_BASE_URL)</string>

KillingPart/KillingPart.entitlements

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
<array>
99
<string>Default</string>
1010
</array>
11+
<key>com.apple.developer.associated-domains</key>
12+
<array>
13+
<string>applinks:killingpart.com</string>
14+
</array>
1115
<key>com.apple.developer.aps-environment</key>
1216
<string>development</string>
1317
</dict>

KillingPart/KillingPartApp.swift

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,22 @@ struct KillingPartApp: App {
2222

2323
var body: some Scene {
2424
WindowGroup {
25-
RootFlowView()
26-
.onOpenURL { url in
27-
if GIDSignIn.sharedInstance.handle(url) {
28-
return
29-
}
30-
31-
if AuthApi.isKakaoTalkLoginUrl(url) {
32-
_ = AuthController.handleOpenUrl(url: url)
33-
}
34-
}
25+
RootFlowView(authURLHandler: handleAuthURL)
3526
}
3627
}
3728

29+
private func handleAuthURL(_ url: URL) -> Bool {
30+
if GIDSignIn.sharedInstance.handle(url) {
31+
return true
32+
}
33+
34+
if AuthApi.isKakaoTalkLoginUrl(url) {
35+
return AuthController.handleOpenUrl(url: url)
36+
}
37+
38+
return false
39+
}
40+
3841
private func configureKakaoSDK() {
3942
let appKey = (Bundle.main.object(forInfoDictionaryKey: "KAKAO_NATIVE_APP_KEY") as? String ?? "")
4043
.trimmingCharacters(in: .whitespacesAndNewlines)
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import Foundation
2+
3+
struct DeepLinkRequest: Equatable, Hashable, Identifiable {
4+
let id: UUID
5+
let route: DeepLinkRoute
6+
7+
init(route: DeepLinkRoute, id: UUID = UUID()) {
8+
self.id = id
9+
self.route = route
10+
}
11+
}
12+
13+
enum DeepLinkRoute: Equatable, Hashable {
14+
case socialDiary(diaryId: Int)
15+
16+
init?(url: URL) {
17+
guard
18+
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
19+
let scheme = components.scheme?.lowercased()
20+
else {
21+
return nil
22+
}
23+
24+
let pathComponents: [String]
25+
switch scheme {
26+
case "https":
27+
guard components.host?.lowercased() == DeepLinkURLBuilder.host else {
28+
return nil
29+
}
30+
pathComponents = Self.pathComponents(from: components.path)
31+
case DeepLinkURLBuilder.customScheme:
32+
let hostComponents = components.host.map { [$0] } ?? []
33+
pathComponents = hostComponents + Self.pathComponents(from: components.path)
34+
case let kakaoScheme where kakaoScheme.hasPrefix("kakao"):
35+
guard let route = Self.kakaoLinkRoute(from: components) else {
36+
return nil
37+
}
38+
self = route
39+
return
40+
default:
41+
return nil
42+
}
43+
44+
guard let route = Self.route(from: pathComponents) else {
45+
return nil
46+
}
47+
self = route
48+
}
49+
50+
private static func route(from pathComponents: [String]) -> DeepLinkRoute? {
51+
guard
52+
pathComponents.count == 2,
53+
pathComponents[0].caseInsensitiveCompare("diaries") == .orderedSame,
54+
let diaryId = Int(pathComponents[1]),
55+
diaryId > 0
56+
else {
57+
return nil
58+
}
59+
60+
return .socialDiary(diaryId: diaryId)
61+
}
62+
63+
private static func kakaoLinkRoute(from components: URLComponents) -> DeepLinkRoute? {
64+
guard components.host?.caseInsensitiveCompare("kakaolink") == .orderedSame else {
65+
return nil
66+
}
67+
68+
let queryValues = queryValues(from: components)
69+
guard
70+
queryValues["route"]?.caseInsensitiveCompare("diary") == .orderedSame,
71+
let diaryIDText = queryValues["diaryId"],
72+
let diaryId = Int(diaryIDText),
73+
diaryId > 0
74+
else {
75+
return nil
76+
}
77+
78+
return .socialDiary(diaryId: diaryId)
79+
}
80+
81+
private static func queryValues(from components: URLComponents) -> [String: String] {
82+
var values: [String: String] = [:]
83+
84+
for item in components.queryItems ?? [] {
85+
guard let value = item.value else { continue }
86+
values[item.name] = value
87+
88+
for nestedItem in queryItems(fromQueryString: value) {
89+
guard let nestedValue = nestedItem.value else { continue }
90+
values[nestedItem.name] = nestedValue
91+
}
92+
}
93+
94+
return values
95+
}
96+
97+
private static func queryItems(fromQueryString queryString: String) -> [URLQueryItem] {
98+
guard queryString.contains("=") else { return [] }
99+
100+
var components = URLComponents()
101+
components.percentEncodedQuery = queryString
102+
return components.queryItems ?? []
103+
}
104+
105+
private static func pathComponents(from path: String) -> [String] {
106+
path
107+
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
108+
.split(separator: "/")
109+
.map(String.init)
110+
}
111+
}
112+
113+
enum DeepLinkURLBuilder {
114+
static let host = "killingpart.com"
115+
static let customScheme = "killingpart"
116+
117+
static func kakaoDiaryExecutionParams(diaryId: Int) -> [String: String]? {
118+
guard diaryId > 0 else { return nil }
119+
120+
return [
121+
"route": "diary",
122+
"diaryId": "\(diaryId)"
123+
]
124+
}
125+
126+
static func diaryURL(diaryId: Int) -> URL? {
127+
guard diaryId > 0 else { return nil }
128+
129+
var components = URLComponents()
130+
components.scheme = "https"
131+
components.host = host
132+
components.path = "/diaries/\(diaryId)"
133+
return components.url
134+
}
135+
136+
static func customDiaryURL(diaryId: Int) -> URL? {
137+
guard diaryId > 0 else { return nil }
138+
139+
var components = URLComponents()
140+
components.scheme = customScheme
141+
components.host = "diaries"
142+
components.path = "/\(diaryId)"
143+
return components.url
144+
}
145+
}

KillingPart/ViewModels/AppViewModel.swift

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ final class AppViewModel: ObservableObject {
4444
@Published var isResolvingPostLoginFlow = false
4545
@Published private(set) var isSplashReadyToFinish = false
4646
@Published private(set) var mainStartupPayload: MainStartupPayload?
47+
@Published private(set) var activeDeepLinkRequest: DeepLinkRequest?
4748

4849
let loginViewModel: LoginViewModel
4950

@@ -58,6 +59,7 @@ final class AppViewModel: ObservableObject {
5859
private var sessionExpiredObserver: NSObjectProtocol?
5960
private var splashPreparationTask: Task<Void, Never>?
6061
private var preparedPostSplashStep: AppFlowStep?
62+
private var pendingDeepLinkRequest: DeepLinkRequest?
6163

6264
init(
6365
authenticationService: AuthenticationServicing = AuthenticationService(),
@@ -97,7 +99,7 @@ final class AppViewModel: ObservableObject {
9799
queue: .main
98100
) { [weak self] _ in
99101
Task { @MainActor [weak self] in
100-
self?.logout()
102+
self?.resetSession(preservePendingDeepLink: self?.pendingDeepLinkRequest != nil)
101103
}
102104
}
103105
}
@@ -139,6 +141,35 @@ final class AppViewModel: ObservableObject {
139141
)
140142
}
141143

144+
func handleDeepLink(_ url: URL) -> Bool {
145+
guard let route = DeepLinkRoute(url: url) else {
146+
return false
147+
}
148+
149+
pendingDeepLinkRequest = DeepLinkRequest(route: route)
150+
activeDeepLinkRequest = nil
151+
152+
if currentStep == .splash {
153+
prepareSplashIfNeeded()
154+
return true
155+
}
156+
157+
guard tokenStore.hasSessionTokens else {
158+
currentStep = .login
159+
return true
160+
}
161+
162+
Task { @MainActor [weak self] in
163+
await self?.resolvePostLoginFlow()
164+
}
165+
return true
166+
}
167+
168+
func consumeDeepLinkRequest(_ request: DeepLinkRequest) {
169+
guard activeDeepLinkRequest == request else { return }
170+
activeDeepLinkRequest = nil
171+
}
172+
142173
private func prepareSplash() async {
143174
defer { splashPreparationTask = nil }
144175

@@ -209,13 +240,21 @@ final class AppViewModel: ObservableObject {
209240
}
210241

211242
func logout() {
243+
resetSession(preservePendingDeepLink: false)
244+
}
245+
246+
private func resetSession(preservePendingDeepLink: Bool) {
212247
splashPreparationTask?.cancel()
213248
splashPreparationTask = nil
214249
preparedPostSplashStep = nil
215250
isSplashReadyToFinish = false
216251
loginViewModel.resetState()
217252
setupFlowViewModel = nil
218253
mainStartupPayload = nil
254+
activeDeepLinkRequest = nil
255+
if !preservePendingDeepLink {
256+
pendingDeepLinkRequest = nil
257+
}
219258
updatePrompt = nil
220259
currentStep = .login
221260
}
@@ -295,6 +334,13 @@ final class AppViewModel: ObservableObject {
295334
currentStep = .main
296335
schedulePushPermissionRequest()
297336
}
337+
activatePendingDeepLinkRouteIfNeeded()
338+
}
339+
340+
private func activatePendingDeepLinkRouteIfNeeded() {
341+
guard let pendingDeepLinkRequest else { return }
342+
activeDeepLinkRequest = pendingDeepLinkRequest
343+
self.pendingDeepLinkRequest = nil
298344
}
299345

300346
private func makeMainStartupPayload() async -> MainStartupPayload {

0 commit comments

Comments
 (0)