From 7d7b6e13f6bb94a59e354bac8807a217b76588c5 Mon Sep 17 00:00:00 2001 From: shinhong_park Date: Wed, 2 Oct 2024 18:56:05 +0900 Subject: [PATCH] Implement friend request via Kakao (#306) * Implement friend request via Kakao * Apply SwiftFormat changes * Fix as reviews * Apply SwiftFormat changes * Bump ReactNativeKit version * Close modal before sending request * Merge from master * Add sleep --------- Co-authored-by: shp7724 --- SNUTT-2022/SNUTT.xcodeproj/project.pbxproj | 37 +++++- .../xcshareddata/swiftpm/Package.resolved | 8 +- .../xcshareddata/xcschemes/SNUTT.xcscheme | 2 +- SNUTT-2022/SNUTT/AppState/AppState.swift | 1 + .../SNUTT/AppState/DeepLinkHandler.swift | 7 ++ .../SNUTT/AppState/States/FriendState.swift | 6 + SNUTT-2022/SNUTT/Info.plist | 29 ++--- .../SNUTT/Repositories/NetworkUtils.swift | 10 +- .../SNUTT/Services/FriendsService.swift | 14 +-- .../SNUTT/ViewModels/FriendsViewModel.swift | 105 ++++++++++++++++++ SNUTT-2022/SNUTT/Views/AppDelegate.swift | 3 + .../SNUTT/Views/Scenes/FriendsScene.swift | 21 +++- 12 files changed, 195 insertions(+), 48 deletions(-) create mode 100644 SNUTT-2022/SNUTT/AppState/States/FriendState.swift diff --git a/SNUTT-2022/SNUTT.xcodeproj/project.pbxproj b/SNUTT-2022/SNUTT.xcodeproj/project.pbxproj index 31d078058..2449e1bb1 100644 --- a/SNUTT-2022/SNUTT.xcodeproj/project.pbxproj +++ b/SNUTT-2022/SNUTT.xcodeproj/project.pbxproj @@ -270,6 +270,10 @@ CE6CA91429E3CFB4004E92B1 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = BE6D893228EFD662000607A6 /* Localizable.strings */; }; CE72435F2B30235300F9E0D7 /* SearchLectureSceneViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE72435E2B30235300F9E0D7 /* SearchLectureSceneViewModel.swift */; }; CE7243612B30240D00F9E0D7 /* InteractiveDismissKeyboardModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7243602B30240D00F9E0D7 /* InteractiveDismissKeyboardModifier.swift */; }; + CE8590182C5E1B5C00ECFE9E /* KakaoSDKShare in Frameworks */ = {isa = PBXBuildFile; productRef = CE8590172C5E1B5C00ECFE9E /* KakaoSDKShare */; }; + CE85901A2C5E219C00ECFE9E /* KakaoSDKCommon in Frameworks */ = {isa = PBXBuildFile; productRef = CE8590192C5E219C00ECFE9E /* KakaoSDKCommon */; }; + CE85901C2C5E21D600ECFE9E /* KakaoSDKTemplate in Frameworks */ = {isa = PBXBuildFile; productRef = CE85901B2C5E21D600ECFE9E /* KakaoSDKTemplate */; }; + CE85901E2C5E549600ECFE9E /* FriendState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE85901D2C5E549600ECFE9E /* FriendState.swift */; }; CE98204B2A09FBDD001037F5 /* DebugState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE98204A2A09FBDD001037F5 /* DebugState.swift */; }; CE9820502A0A0BB7001037F5 /* NetworkLogListScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE98204F2A0A0BB7001037F5 /* NetworkLogListScene.swift */; }; CE9820522A0A0BE9001037F5 /* NetworkLogEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9820512A0A0BE9001037F5 /* NetworkLogEntryView.swift */; }; @@ -579,6 +583,7 @@ CE6CA91129E24676004E92B1 /* TimetableAccessoryCircularView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimetableAccessoryCircularView.swift; sourceTree = ""; }; CE72435E2B30235300F9E0D7 /* SearchLectureSceneViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchLectureSceneViewModel.swift; sourceTree = ""; }; CE7243602B30240D00F9E0D7 /* InteractiveDismissKeyboardModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveDismissKeyboardModifier.swift; sourceTree = ""; }; + CE85901D2C5E549600ECFE9E /* FriendState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendState.swift; sourceTree = ""; }; CE98204A2A09FBDD001037F5 /* DebugState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugState.swift; sourceTree = ""; }; CE98204F2A0A0BB7001037F5 /* NetworkLogListScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkLogListScene.swift; sourceTree = ""; }; CE9820512A0A0BE9001037F5 /* NetworkLogEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkLogEntryView.swift; sourceTree = ""; }; @@ -636,12 +641,15 @@ 731C244E2C4442590015877B /* KakaoSDK in Frameworks */, B8B7501D2B6274A5004F6272 /* KakaoMapsSDK_SPM in Frameworks */, BE4CD86328F5A56200BA9BBC /* FirebaseAnalyticsSwift in Frameworks */, + CE85901C2C5E21D600ECFE9E /* KakaoSDKTemplate in Frameworks */, BE779B1828E3DD5B009960EB /* FacebookLogin in Frameworks */, CEF992F72BF3B8FC00F0FFA4 /* ReactNativeKit in Frameworks */, 736AF8512C2F279900ED9C1A /* GoogleSignInSwift in Frameworks */, BE4CD86528F5A56200BA9BBC /* FirebaseCrashlytics in Frameworks */, BEDE34D42879A59B00525014 /* Alamofire in Frameworks */, BE4CD86128F5A56200BA9BBC /* FirebaseAnalytics in Frameworks */, + CE85901A2C5E219C00ECFE9E /* KakaoSDKCommon in Frameworks */, + CE8590182C5E1B5C00ECFE9E /* KakaoSDKShare in Frameworks */, 736AF84F2C2F279900ED9C1A /* GoogleSignIn in Frameworks */, 731C24502C4442590015877B /* KakaoSDKAuth in Frameworks */, ); @@ -673,6 +681,7 @@ B87B315A28D5A70F005C170B /* TimetableState.swift */, CE63A3BB2A21B06900A633FC /* RoutingState.swift */, B87B315828D5A70F005C170B /* UserState.swift */, + CE85901D2C5E549600ECFE9E /* FriendState.swift */, BE2CB3622959C0CC00FCF0F0 /* ReviewState.swift */, CEDDCA812A6AF66D00474D4E /* VacancyState.swift */, B87DF6F82918B7AD008BB95B /* PopupState.swift */, @@ -1240,6 +1249,8 @@ 731C244D2C4442590015877B /* KakaoSDK */, 731C244F2C4442590015877B /* KakaoSDKAuth */, 731C24512C4442590015877B /* KakaoSDKCommon */, + CE8590172C5E1B5C00ECFE9E /* KakaoSDKShare */, + CE85901B2C5E21D600ECFE9E /* KakaoSDKTemplate */, ); productName = SNUTT; productReference = BE682BB22879E24D009EBCB7 /* SNUTT.app */; @@ -1474,6 +1485,7 @@ B8823BC32BC2ED41003A3B69 /* BuildingRouter.swift in Sources */, B8AF8D3E28C72A880056DE62 /* ValidationUtils.swift in Sources */, CEDDCA7E2A6AEF2200474D4E /* VacancyLectureList.swift in Sources */, + CE85901E2C5E549600ECFE9E /* FriendState.swift in Sources */, B82EA54C2B62C8490029FDF3 /* LectureMapView.swift in Sources */, BE8BB3AD285D763B00070A66 /* SearchLectureScene.swift in Sources */, BE682C0528881852009EBCB7 /* SearchService.swift in Sources */, @@ -2394,7 +2406,15 @@ repositoryURL = "https://github.com/wafflestudio/ios-rn-prebuilt"; requirement = { kind = exactVersion; - version = 0.17.0; + version = 0.22.0; + }; + }; + CE8590162C5E1B5C00ECFE9E /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kakao/kakao-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.22.5; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -2460,6 +2480,21 @@ package = BEDE34D22879A59B00525014 /* XCRemoteSwiftPackageReference "Alamofire" */; productName = Alamofire; }; + CE8590172C5E1B5C00ECFE9E /* KakaoSDKShare */ = { + isa = XCSwiftPackageProductDependency; + package = CE8590162C5E1B5C00ECFE9E /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */; + productName = KakaoSDKShare; + }; + CE8590192C5E219C00ECFE9E /* KakaoSDKCommon */ = { + isa = XCSwiftPackageProductDependency; + package = CE8590162C5E1B5C00ECFE9E /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */; + productName = KakaoSDKCommon; + }; + CE85901B2C5E21D600ECFE9E /* KakaoSDKTemplate */ = { + isa = XCSwiftPackageProductDependency; + package = CE8590162C5E1B5C00ECFE9E /* XCRemoteSwiftPackageReference "kakao-ios-sdk" */; + productName = KakaoSDKTemplate; + }; CEF992F62BF3B8FC00F0FFA4 /* ReactNativeKit */ = { isa = XCSwiftPackageProductDependency; package = CE1F49AF2A8DB5A400B81E4E /* XCRemoteSwiftPackageReference "ios-rn-prebuilt" */; diff --git a/SNUTT-2022/SNUTT.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SNUTT-2022/SNUTT.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3e3f36663..5a367ba29 100644 --- a/SNUTT-2022/SNUTT.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SNUTT-2022/SNUTT.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -114,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wafflestudio/ios-rn-prebuilt", "state" : { - "revision" : "0fab694088eaca04b868a00df42ba9d153e409fd", - "version" : "0.17.0" + "revision" : "3ba8aad9239857b8e2901e3da3e006db3eede0a3", + "version" : "0.22.0" } }, { @@ -123,8 +123,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/kakao/kakao-ios-sdk", "state" : { - "revision" : "08089eeffc9b442da1c7343a70bf66c6de1a72c9", - "version" : "2.22.4" + "revision" : "66b3bddc2657e8ccb7a16fa0264aac99c57be09b", + "version" : "2.22.5" } }, { diff --git a/SNUTT-2022/SNUTT.xcodeproj/xcshareddata/xcschemes/SNUTT.xcscheme b/SNUTT-2022/SNUTT.xcodeproj/xcshareddata/xcschemes/SNUTT.xcscheme index ff63caf71..1ff532f8a 100644 --- a/SNUTT-2022/SNUTT.xcodeproj/xcshareddata/xcschemes/SNUTT.xcscheme +++ b/SNUTT-2022/SNUTT.xcodeproj/xcshareddata/xcschemes/SNUTT.xcscheme @@ -72,7 +72,7 @@ - FacebookAdvertiserIDCollectionEnabled - - KAKAO_PHASE - - KAKAO_APP_KEY - $(KAKAO_APP_KEY) - LSApplicationQueriesSchemes - - nmap - kakaomap - - NMFClientId - $(NAVER_MAP_CLIENT_ID) - CADisableMinimumFrameDurationOnPhone - - UIViewControllerBasedStatusBarAppearance - - ITSAppUsesNonExemptEncryption - ACCESS_TOKEN_TEST $(ACCESS_TOKEN_TEST) API_KEY @@ -46,7 +27,17 @@ kakao$(KAKAO_APP_KEY) + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + kakao$(KAKAO_APP_KEY) + + + FacebookAdvertiserIDCollectionEnabled + FacebookAppID $(FACEBOOK_APP_ID) FacebookClientToken diff --git a/SNUTT-2022/SNUTT/Repositories/NetworkUtils.swift b/SNUTT-2022/SNUTT/Repositories/NetworkUtils.swift index d3c86a3c1..899419acb 100644 --- a/SNUTT-2022/SNUTT/Repositories/NetworkUtils.swift +++ b/SNUTT-2022/SNUTT/Repositories/NetworkUtils.swift @@ -99,21 +99,13 @@ final class Logger: EventMonitor { } // Event called when any type of Request is resumed. - func requestDidResume(_ request: Request) { - debugPrint("Resuming: \(request)") - } + func requestDidResume(_: Request) {} // Event called whenever a DataRequest has parsed a response. func request(_: DataRequest, didParseResponse response: DataResponse) { Task { await logStore?.log(response: response) } - - if response.error == nil { - debugPrint("Finished: \(response)") - } else { - debugPrint("Error: \(response)") - } } } diff --git a/SNUTT-2022/SNUTT/Services/FriendsService.swift b/SNUTT-2022/SNUTT/Services/FriendsService.swift index c294f9f83..6561a3541 100644 --- a/SNUTT-2022/SNUTT/Services/FriendsService.swift +++ b/SNUTT-2022/SNUTT/Services/FriendsService.swift @@ -6,14 +6,13 @@ // import Alamofire +import Combine import Foundation import ReactNativeKit -#if DEBUG - enum RNEvent: String, SupportedEvent { - case addFriendKakao = "add-friend-kakao" - } -#endif +enum RNEvent: String, SupportedEvent { + case addFriendKakao = "add-friend-kakao" +} @MainActor protocol FriendsServiceProtocol: Sendable { @@ -24,9 +23,6 @@ struct FriendsService: FriendsServiceProtocol, ConfigsProvidable { var appState: AppState var webRepositories: AppEnvironment.WebRepositories var localRepositories: AppEnvironment.LocalRepositories - #if DEBUG - static let eventEmitter: EventEmitter = .init() - #endif private let rnBundlePath = "ReactNativeBundles" @@ -42,6 +38,8 @@ struct FriendsService: FriendsServiceProtocol, ConfigsProvidable { localRepositories.userDefaultsRepository } + // MARK: Kakao Friend Request + // MARK: RN Bundle Management func fetchReactNativeBundleUrl() async throws -> URL { diff --git a/SNUTT-2022/SNUTT/ViewModels/FriendsViewModel.swift b/SNUTT-2022/SNUTT/ViewModels/FriendsViewModel.swift index f45acbe3f..ad634c3be 100644 --- a/SNUTT-2022/SNUTT/ViewModels/FriendsViewModel.swift +++ b/SNUTT-2022/SNUTT/ViewModels/FriendsViewModel.swift @@ -5,17 +5,53 @@ // Created by 박신홍 on 2023/07/15. // +import Combine import Foundation +import KakaoSDKCommon +import KakaoSDKShare +import KakaoSDKTemplate +import ReactNativeKit +import UIKit class FriendsViewModel: BaseViewModel, ObservableObject { + private let eventEmitter: EventEmitter = .init() + private var cancellables = Set() + + @Published private(set) var friendRequestError: FriendRequestError? + + var isErrorAlertPresented: Bool { + get { friendRequestError != nil } + set { + if !newValue { + friendRequestError = nil + } + } + } + override init(container: DIContainer) { super.init(container: container) + appState.friend.$pendingFriendRequestToken + .compactMap { $0 } + .sink { token in + Task { @MainActor [weak self] in + guard let self else { return } + appState.friend.pendingFriendRequestToken = nil + await eventEmitter.emitEvent(.addFriendKakao, payload: ["requestToken": token]) + } + } + .store(in: &cancellables) } var accessToken: String? { appState.user.accessToken } + func startListeningEvents() { + Task { + await listenJSEventStream() + } + } + func fetchReactNativeBundleUrl() async -> URL? { do { return try await services.friendsService.fetchReactNativeBundleUrl() @@ -25,3 +61,72 @@ class FriendsViewModel: BaseViewModel, ObservableObject { return nil } } + +// MARK: Kakao Friend Request + +extension FriendsViewModel { + enum FriendRequestError: LocalizedError { + case shareUnavailable + case preparationFailed + case unknownError + + var errorDescription: String? { + switch self { + case .shareUnavailable: + "카카오톡 초대 기능을 사용할 수 없습니다" + case .preparationFailed, .unknownError: + "알 수 없는 오류가 발생했습니다" + } + } + + var failureReason: String? { + nil + } + + var helpAnchor: String? { nil } + + var recoverySuggestion: String? { + switch self { + case .shareUnavailable: + "카카오톡이 설치되어 있는지 확인해보세요." + case .preparationFailed, .unknownError: + "개발자 괴롭히기를 통해 문의해주세요." + } + } + } + + func listenJSEventStream() async { + for await event in await eventEmitter.jsEventStream { + switch event.name { + case RNEvent.addFriendKakao.rawValue: + guard let requestToken = event.payload?["requestToken"] as? String else { return } + do { + try await sendKakaoMessage(with: requestToken) + } catch let error as FriendRequestError { + self.friendRequestError = error + } catch { + assertionFailure(error.localizedDescription) + } + default: + continue + } + } + } + + func sendKakaoMessage(with requestToken: String) async throws { + try await Task.sleep(nanoseconds: 500_000_000) + guard ShareApi.isKakaoTalkSharingAvailable() else { throw FriendRequestError.shareUnavailable } + let params = ["type": RNEvent.addFriendKakao.rawValue, "requestToken": requestToken] + let link = Link(androidExecutionParams: params, iosExecutionParams: params) + let button = Button(title: "수락하기", link: link) + let feedTemplate = FeedTemplate(content: .init(title: "SNUTT : 서울대학교 시간표 앱", imageUrl: URL(string: "https://is1-ssl.mzstatic.com/image/thumb/PurpleSource122/v4/f0/c6/58/f0c6581d-dd41-3bad-9d9a-516561d35af1/0d1dfc21-5d2e-4dcf-8cff-c6eb25fe7284_2_2.png/460x0w.webp"), description: "스누티티 친구 초대가 도착했어요", link: link), buttons: [button]) + let feedTemplateJsonData = try SdkJSONEncoder.custom.encode(feedTemplate) + guard let templateJsonObject = SdkUtils.toJsonObject(feedTemplateJsonData) else { throw FriendRequestError.preparationFailed } + ShareApi.shared.shareDefault(templateObject: templateJsonObject) { sharingResult, _ in + if let sharingResult { + UIApplication.shared.open(sharingResult.url, + options: [:], completionHandler: nil) + } + } + } +} diff --git a/SNUTT-2022/SNUTT/Views/AppDelegate.swift b/SNUTT-2022/SNUTT/Views/AppDelegate.swift index 1a681da67..68f3418e4 100644 --- a/SNUTT-2022/SNUTT/Views/AppDelegate.swift +++ b/SNUTT-2022/SNUTT/Views/AppDelegate.swift @@ -8,6 +8,7 @@ import FacebookCore import FirebaseCore import FirebaseMessaging +import KakaoSDKCommon import SwiftUI import UIKit @@ -44,6 +45,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { Messaging.messaging().delegate = self } + KakaoSDK.initSDK(appKey: Bundle.main.infoDictionary?["KAKAO_APP_KEY"] as! String) + // configure facebook sdk return ApplicationDelegate.shared.application(application, didFinishLaunchingWithOptions: launchOptions) } diff --git a/SNUTT-2022/SNUTT/Views/Scenes/FriendsScene.swift b/SNUTT-2022/SNUTT/Views/Scenes/FriendsScene.swift index 8ab70e8a0..61b5256a8 100644 --- a/SNUTT-2022/SNUTT/Views/Scenes/FriendsScene.swift +++ b/SNUTT-2022/SNUTT/Views/Scenes/FriendsScene.swift @@ -9,10 +9,11 @@ import ReactNativeKit import SwiftUI struct FriendsScene: View { - var viewModel: FriendsViewModel + @StateObject var viewModel: FriendsViewModel @State private var bundleUrl: URL? @Environment(\.colorScheme) var colorScheme + @Environment(\.scenePhase) private var phase var body: some View { let _ = debugChanges() @@ -26,15 +27,23 @@ struct FriendsScene: View { ProgressView() } } + .alert(isPresented: $viewModel.isErrorAlertPresented, error: viewModel.friendRequestError, actions: { _ in + Button("확인", role: .none, action: {}) + }, message: { error in Text(error.recoverySuggestion ?? "") }) .task { // bundleUrl = URL(string: "http://localhost:8081/index.bundle?platform=ios")! bundleUrl = await viewModel.fetchReactNativeBundleUrl() } - .task { - #if DEBUG - // 네이티브 <-> RN 이벤트 전달 테스트용 - await FriendsService.eventEmitter.emitEvent(.addFriendKakao, payload: ["test": "test"]) - #endif + .onAppear { + viewModel.startListeningEvents() + } + .onChange(of: phase) { newPhase in + switch newPhase { + case .active: + viewModel.startListeningEvents() + default: + return + } } } }