Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Siksha/Data/DTO/AppVersionDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// AppVersionDTO.swift
// Siksha
//
// Created by Codex on 7/5/26.
//

import Foundation

struct AppVersionLookupResponseDTO: Decodable {
let results: [AppVersionResultDTO]
}

struct AppVersionResultDTO: Decodable {
let version: String
}
16 changes: 16 additions & 0 deletions Siksha/Data/DTO/AuthDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// AuthDTO.swift
// Siksha
//
// Created by Codex on 7/5/26.
//

import Foundation

struct AuthTokenResponseDTO: Decodable {
let accessToken: String

enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
}
}
18 changes: 18 additions & 0 deletions Siksha/Data/DTO/UserDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// UserDTO.swift
// Siksha
//
// Created by Codex on 7/5/26.
//

import Foundation

struct UserDTO: Decodable {
let id: Int
let type: String
let identity: String
let nickname: String?
let profileUrl: String?
let createdAt: Date
let updatedAt: Date
}
213 changes: 213 additions & 0 deletions Siksha/Data/External/SocialLoginService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
//
// SocialLoginService.swift
// Siksha
//
// Created by Codex on 7/5/26.
//

import AuthenticationServices
import Foundation
import GoogleSignIn
import KakaoSDKAuth
import KakaoSDKUser
import UIKit

protocol SocialLoginService: AnyObject {
func login(
provider: LoginProvider,
presentingViewController: UIViewController?
) async throws -> LoginCredential
@MainActor
func handleOpenURL(_ url: URL) -> Bool
}

enum SocialLoginServiceError: Error {
case missingPresentingViewController
case missingCredential
}

final class SocialLoginServiceImpl: SocialLoginService {
private var appleAuthorizationCoordinator: AppleAuthorizationCoordinator?

func login(
provider: LoginProvider,
presentingViewController: UIViewController?
) async throws -> LoginCredential {
switch provider {
case .kakao:
return try await loginWithKakao()
case .google:
return try await loginWithGoogle(presentingViewController: presentingViewController)
case .apple:
return try await loginWithApple(presentingViewController: presentingViewController)
}
}

@MainActor
func handleOpenURL(_ url: URL) -> Bool {
if AuthApi.isKakaoTalkLoginUrl(url) {
return AuthController.handleOpenUrl(url: url)
}

return GIDSignIn.sharedInstance.handle(url)
}

private func loginWithKakao() async throws -> LoginCredential {
let oauthToken = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<OAuthToken, Error>) in
let completion: (OAuthToken?, Error?) -> Void = { oauthToken, error in
if let error {
continuation.resume(throwing: error)
return
}

guard let oauthToken else {
continuation.resume(throwing: SocialLoginServiceError.missingCredential)
return
}

continuation.resume(returning: oauthToken)
}

if UserApi.isKakaoTalkLoginAvailable() {
UserApi.shared.loginWithKakaoTalk(completion: completion)
} else {
UserApi.shared.loginWithKakaoAccount(completion: completion)
}
}

return LoginCredential(provider: .kakao, token: oauthToken.accessToken)
}

private func loginWithGoogle(
presentingViewController: UIViewController?
) async throws -> LoginCredential {
guard let presentingViewController else {
throw SocialLoginServiceError.missingPresentingViewController
}

let signInResult = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<GIDSignInResult, Error>) in
GIDSignIn.sharedInstance.signIn(withPresenting: presentingViewController) { signInResult, error in
if let error {
continuation.resume(throwing: error)
return
}

guard let signInResult else {
continuation.resume(throwing: SocialLoginServiceError.missingCredential)
return
}

continuation.resume(returning: signInResult)
}
}

guard let token = signInResult.user.idToken?.tokenString else {
throw SocialLoginServiceError.missingCredential
}

return LoginCredential(provider: .google, token: token)
}

private func loginWithApple(
presentingViewController: UIViewController?
) async throws -> LoginCredential {
let coordinator = AppleAuthorizationCoordinator(
presentingViewController: presentingViewController
)
appleAuthorizationCoordinator = coordinator
defer {
appleAuthorizationCoordinator = nil
}

return try await coordinator.perform()
}
}

private final class AppleAuthorizationCoordinator: NSObject {
private weak var presentingViewController: UIViewController?
private var continuation: CheckedContinuation<LoginCredential, Error>?

init(presentingViewController: UIViewController?) {
self.presentingViewController = presentingViewController
}

func perform() async throws -> LoginCredential {
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation

let provider = ASAuthorizationAppleIDProvider()
let request = provider.createRequest()
request.requestedScopes = [.fullName, .email]

let authorizationController = ASAuthorizationController(
authorizationRequests: [request]
)
authorizationController.delegate = self
authorizationController.presentationContextProvider = self
authorizationController.performRequests()
}
}

private func complete(with result: Result<LoginCredential, Error>) {
guard let continuation else {
return
}

self.continuation = nil

switch result {
case .success(let credential):
continuation.resume(returning: credential)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}

extension AppleAuthorizationCoordinator: ASAuthorizationControllerDelegate {
func authorizationController(
controller: ASAuthorizationController,
didCompleteWithAuthorization authorization: ASAuthorization
) {
guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential,
let tokenData = appleIDCredential.identityToken,
let token = String(data: tokenData, encoding: .utf8) else {
complete(with: .failure(SocialLoginServiceError.missingCredential))
return
}

complete(
with: .success(
LoginCredential(
provider: .apple,
token: token,
appleUserIdentifier: appleIDCredential.user
)
)
)
}

func authorizationController(
controller: ASAuthorizationController,
didCompleteWithError error: Error
) {
complete(with: .failure(error))
}
}

extension AppleAuthorizationCoordinator: ASAuthorizationControllerPresentationContextProviding {
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
if let window = presentingViewController?.view.window {
return window
}

if let keyWindow = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.flatMap(\.windows)
.first(where: \.isKeyWindow) {
return keyWindow
}

return ASPresentationAnchor()
}
}
79 changes: 79 additions & 0 deletions Siksha/Data/Local/AuthSessionLocalDataSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//
// AuthSessionLocalDataSource.swift
// Siksha
//
// Created by Codex on 7/5/26.
//

import Foundation

protocol AuthSessionLocalDataSource {
func loadSession() -> AuthSession?
func saveSession(_ session: AuthSession)
func clearSession()
}

final class AuthSessionLocalDataSourceImpl: AuthSessionLocalDataSource {
private enum Key {
static let accessToken = "accessToken"
static let tokenExpDate = "tokenExpDate"
static let appleUserIdentifier = "appleUserIdentifier"
static let signedInWithApple = "signedInWithApple"
}

private let userDefaults: UserDefaults

init(userDefaults: UserDefaults = .standard) {
self.userDefaults = userDefaults
}

func loadSession() -> AuthSession? {
guard let accessToken = userDefaults.string(forKey: Key.accessToken) else {
return nil
}

let expiresAt = expirationDate()
let appleUserIdentifier = userDefaults.bool(forKey: Key.signedInWithApple)
? userDefaults.string(forKey: Key.appleUserIdentifier)
: nil

return AuthSession(
accessToken: accessToken,
expiresAt: expiresAt,
appleUserIdentifier: appleUserIdentifier
)
}

func saveSession(_ session: AuthSession) {
userDefaults.set(session.accessToken, forKey: Key.accessToken)

if let expiresAt = session.expiresAt {
userDefaults.set(expiresAt.timeIntervalSince1970, forKey: Key.tokenExpDate)
} else {
userDefaults.removeObject(forKey: Key.tokenExpDate)
}

if let appleUserIdentifier = session.appleUserIdentifier {
userDefaults.set(appleUserIdentifier, forKey: Key.appleUserIdentifier)
userDefaults.set(true, forKey: Key.signedInWithApple)
} else {
userDefaults.removeObject(forKey: Key.appleUserIdentifier)
userDefaults.set(false, forKey: Key.signedInWithApple)
}
}

func clearSession() {
userDefaults.removeObject(forKey: Key.accessToken)
userDefaults.removeObject(forKey: Key.tokenExpDate)
userDefaults.removeObject(forKey: Key.appleUserIdentifier)
userDefaults.removeObject(forKey: Key.signedInWithApple)
}

private func expirationDate() -> Date? {
guard let timestamp = userDefaults.object(forKey: Key.tokenExpDate) as? Double else {
return nil
}

return Date(timeIntervalSince1970: timestamp)
}
}
42 changes: 42 additions & 0 deletions Siksha/Data/Network/AppVersionRemoteDataSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//
// AppVersionRemoteDataSource.swift
// Siksha
//
// Created by Codex on 7/5/26.
//

import Foundation

protocol AppVersionRemoteDataSource {
func fetchLatestAppStoreVersion() async throws -> AppVersionLookupResponseDTO
}

final class AppVersionRemoteDataSourceImpl: AppVersionRemoteDataSource {
private let lookupURL: URL
private let session: URLSession

init(
lookupURL: URL = URL(string: "https://itunes.apple.com/lookup?id=1032700617")!,
session: URLSession = .shared
) {
self.lookupURL = lookupURL
self.session = session
}

func fetchLatestAppStoreVersion() async throws -> AppVersionLookupResponseDTO {
let (data, response) = try await session.data(from: lookupURL)

guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.decodingError
}

guard 200..<300 ~= httpResponse.statusCode else {
throw NetworkError.apiError(
message: "HTTP \(httpResponse.statusCode)",
code: "\(httpResponse.statusCode)"
)
}

return try JSONDecoder().decode(AppVersionLookupResponseDTO.self, from: data)
}
}
Loading