// // LNAccountManager.swift // Lanu // // Created by OneeChan on 2025/11/6. // import Foundation import GoogleSignIn import AuthenticationServices protocol LNAccountManagerNotify { func onUserLogin() func onUserLogout() func onLoginCaptchaCoolDownChanged(time: Int) } extension LNAccountManagerNotify { func onUserLogin() {} func onUserLogout() {} func onLoginCaptchaCoolDownChanged(time: Int) { } } extension String { var isMyUid: Bool { myUid == self } } var myUid: String { LNAccountManager.shared.uid } var hasLogin: Bool { !myUid.isEmpty } class LNAccountManager: NSObject { static let shared = LNAccountManager() private let onlineHeartbeatInterval: TimeInterval = 5 private var onlineHeartbeatTimer: Timer? private var isReportingOnlineHeartbeat = false private(set) var token = LNUserDefaults[.token, ""] { didSet { LNUserDefaults[.token] = token } } private(set) var uid: String = LNUserDefaults[.uid, ""] { didSet { LNUserDefaults[.uid] = uid } } var wasLogin: Bool { !token.isEmpty && !uid.isEmpty } private let captchaCoolDown = 60 private var captchaRemain = 0 private var captchaTimer: Timer? var canSendCaptcha: Bool { captchaRemain == 0 } func doGoogleLogin(_ vc: UIViewController) { showLoading() GIDSignIn.sharedInstance.signIn(withPresenting: vc) { [weak self] result, err in guard let self else { return } guard err == nil, let result, let token = result.user.idToken?.tokenString else { dismissLoading() return } self.loginByGoogle(data: token) { _ in dismissLoading() } } } func doAppleLogin() { let provider = ASAuthorizationAppleIDProvider() let request = provider.createRequest() request.requestedScopes = [.fullName, .email] let controller = ASAuthorizationController(authorizationRequests: [request]) controller.delegate = self // controller.presentationContextProvider = self showLoading() controller.performRequests() } private override init() { super.init() let clientID = if LNAppConfig.shared.curEnv == .test { "981655295954-noc65ii1gfgpq3mrc0r75t7gq66v57bj.apps.googleusercontent.com" } else { "955524882346-a7fs1l3798khu5hn058m0veqqcvli7h4.apps.googleusercontent.com" } GIDSignIn.sharedInstance.configuration = GIDConfiguration(clientID: clientID) } } extension LNAccountManager: ASAuthorizationControllerDelegate { func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential, let token = credential.identityToken, let tokenStr = String(data: token, encoding: .utf8) else { dismissLoading() return } loginByApple(data: tokenStr) { _ in dismissLoading() } } func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: any Error) { dismissLoading() } } extension LNAccountManager { func loginByToken(handler: ((Bool) -> Void)? = nil) { LNHttpManager.shared.refreshToken { [weak self] res, err in guard let self else { return } guard err == nil, let res else { if case .serverError = err { self.clean() } showToast(err?.errorDesc) handler?(false) return } self.token = res.token handler?(true) self.notifyUserLogin() } } private func loginByGoogle(data: String, handler: ((Bool) -> Void)? = nil) { LNHttpManager.shared.loginByGoogle(token: data) { [weak self] response, err in guard let self else { return } guard err == nil, let response else { showToast(err?.errorDesc) handler?(false) self.clean() return } self.token = response.token self.uid = response.userProfile.userNo handler?(true) self.notifyUserLogin() LNStatisticManager.shared.reportRegister(method: .google) } } private func loginByApple(data: String, handler: ((Bool) -> Void)? = nil) { LNHttpManager.shared.loginByApple(token: data) { [weak self] response, err in guard let self else { return } guard err == nil, let response else { showToast(err?.errorDesc) handler?(false) self.clean() return } self.token = response.token self.uid = response.userProfile.userNo handler?(true) self.notifyUserLogin() LNStatisticManager.shared.reportRegister(method: .apple) } } func loginByPhone(code: String, num: String, captcha: String, handler: ((Bool) -> Void)? = nil) { LNHttpManager.shared.loginByPhone(code: code, num: num, captcha: captcha) { [weak self] response, err in guard let self else { return } guard err == nil, let response else { showToast(err?.errorDesc) handler?(false) self.clean() return } self.token = response.token self.uid = response.userProfile.userNo handler?(true) self.notifyUserLogin() LNStatisticManager.shared.reportRegister(method: .phone) } } func logout() { LNHttpManager.shared.logout { [weak self] err in guard let self else { return } guard err == nil else { return } self.clean() } } #if DEBUG func loginByEmail(email: String, completion: @escaping (Bool) -> Void) { LNHttpManager.shared.loginByEmail(email: email) { [weak self] response, err in guard let self else { return } guard err == nil, let response else { showToast(err?.errorDesc) completion(false) self.clean() return } self.token = response.token self.uid = response.userProfile.userNo completion(true) self.notifyUserLogin() } } #endif } extension LNAccountManager { func getLoginCaptcha(code: String, phone: String, queue: DispatchQueue = .main, handler: @escaping (Bool) -> Void) { LNHttpManager.shared.getLoginCaptcha(code: code, phone: phone) { [weak self] err in queue.asyncIfNotGlobal { handler(err == nil) } guard let self else { return } if let err { showToast(err.errorDesc) } else { captchaRemain = captchaCoolDown notifyCaptchaTime(time: captchaRemain) startCaptchaTimer() } } } private func startCaptchaTimer() { runOnMain { [weak self] in guard let self else { return } stopCaptchaTimer() let timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in guard let self else { return } captchaRemain -= 1 notifyCaptchaTime(time: captchaRemain) if captchaRemain == 0 { stopCaptchaTimer() } } RunLoop.main.add(timer, forMode: .common) captchaTimer = timer } } private func stopCaptchaTimer() { captchaTimer?.invalidate() captchaTimer = nil } } extension LNAccountManager { func clean() { let wasLogin = !token.isEmpty token = "" uid = "" if wasLogin { notifyUserLogout() } } } extension LNAccountManager { private func startOnlineHeartbeatTimerIfNeed() { runOnMain { [weak self] in guard let self else { return } guard onlineHeartbeatTimer == nil else { return } let timer = Timer.scheduledTimer(withTimeInterval: onlineHeartbeatInterval, repeats: true) { [weak self] _ in guard let self else { return } guard wasLogin && LNAppConfig.shared.isForeground else { return } isReportingOnlineHeartbeat = true LNHttpManager.shared.reportOnlineHeartbeat { [weak self] err in guard let self else { return } isReportingOnlineHeartbeat = false if let err { Log.d("report online heartbeat failed: \(err.errorDesc)") } } } RunLoop.main.add(timer, forMode: .common) onlineHeartbeatTimer = timer Log.d("start online heartbeat") } } private func stopOnlineHeartbeatTimer() { runOnMain { [weak self] in guard let self else { return } onlineHeartbeatTimer?.invalidate() onlineHeartbeatTimer = nil Log.d("stop online heartbeat") } } } extension LNAccountManager { private func notifyUserLogin() { startOnlineHeartbeatTimerIfNeed() LNEventDeliver.notifyEvent { ($0 as? LNAccountManagerNotify)?.onUserLogin() } } private func notifyUserLogout() { stopOnlineHeartbeatTimer() LNEventDeliver.notifyEvent { ($0 as? LNAccountManagerNotify)?.onUserLogout() } } private func notifyCaptchaTime(time: Int) { LNEventDeliver.notifyEvent { ($0 as? LNAccountManagerNotify)?.onLoginCaptchaCoolDownChanged(time: time) } } }