Skip to content
Merged
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
13 changes: 13 additions & 0 deletions Siksha.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,19 @@
3333F90C2F13F2E6004B13CA /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
AccountManageViewModelTests.swift,
AccountUseCaseTests.swift,
AppStateTests.swift,
AuthRepositoryImplTests.swift,
AuthSessionLocalDataSourceTests.swift,
AuthUseCaseTests.swift,
DeviceTokenRemoteDataSourceTests.swift,
DeviceTokenUseCaseTests.swift,
ProfileEditViewModelTests.swift,
RemoteImageLoaderTests.swift,
RenewalSettingsViewModelTests.swift,
ResolveInitialAuthStateUseCaseTests.swift,
RestaurantLocalDataSourceTests.swift,
SikshaTests.swift,
);
target = 1E3A56FC25C6FCE900DBB3F4 /* SikshaTests */;
Expand Down
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
}
42 changes: 42 additions & 0 deletions Siksha/Data/External/AppleCredentialService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//
// AppleCredentialService.swift
// Siksha
//
// Created by Codex on 7/5/26.
//

import AuthenticationServices

protocol AppleCredentialService {
func credentialStatus(for userIdentifier: String) async -> AppleCredentialStatus
}

final class AppleCredentialServiceImpl: AppleCredentialService {
private let provider: ASAuthorizationAppleIDProvider

init(provider: ASAuthorizationAppleIDProvider = ASAuthorizationAppleIDProvider()) {
self.provider = provider
}

func credentialStatus(for userIdentifier: String) async -> AppleCredentialStatus {
await withCheckedContinuation { continuation in
provider.getCredentialState(forUserID: userIdentifier) { credentialState, error in
guard error == nil else {
continuation.resume(returning: .unknown)
return
}

switch credentialState {
case .authorized:
continuation.resume(returning: .authorized)
case .revoked:
continuation.resume(returning: .revoked)
case .notFound:
continuation.resume(returning: .notFound)
default:
continuation.resume(returning: .unknown)
}
}
}
}
}
49 changes: 49 additions & 0 deletions Siksha/Data/External/FirebaseMessagingService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// FirebaseMessagingService.swift
// Siksha
//
// Created by Codex on 7/13/26.
//

import FirebaseMessaging
import Foundation

private enum FirebaseMessagingServiceError: LocalizedError {
case missingToken

var errorDescription: String? {
"Firebase Messaging did not return a token."
}
}

final class FirebaseMessagingServiceImpl: PushMessagingTokenServiceProtocol {
func setAPNSToken(_ token: Data) {
Messaging.messaging().apnsToken = token
}

func fetchToken() async throws -> String {
try await withCheckedThrowingContinuation { continuation in
Messaging.messaging().token { token, error in
if let error {
continuation.resume(throwing: error)
} else if let token {
continuation.resume(returning: token)
} else {
continuation.resume(throwing: FirebaseMessagingServiceError.missingToken)
}
}
}
}

func deleteToken() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
Messaging.messaging().deleteToken { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
}
}
}
}
207 changes: 207 additions & 0 deletions Siksha/Data/External/SocialLoginService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
//
// 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 {
@MainActor
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?

@MainActor
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)
}

@MainActor
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)
}

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

let signInResult = try await GIDSignIn.sharedInstance.signIn(
withPresenting: presentingViewController
)

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

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

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

return try await coordinator.perform()
}
}

@MainActor
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()
}
}
Loading