diff --git a/Data/OAuthClient/Sources/AppAuthOAuthClient.swift b/Data/OAuthClient/Sources/AppAuthOAuthClient.swift new file mode 100644 index 00000000..122a58d9 --- /dev/null +++ b/Data/OAuthClient/Sources/AppAuthOAuthClient.swift @@ -0,0 +1,110 @@ +// +// AppAuthOAuthClient.swift +// QuranEngine +// +// Created by Mohannad Hassan on 23/12/2024. +// + +import AppAuth +import Foundation +import UIKit +import VLogging + +public final class AppAuthOAuthClient: OAuthClient { + // MARK: Lifecycle + + public init() {} + + // MARK: Public + + public func set(appConfiguration: OAuthAppConfiguration) { + self.appConfiguration = appConfiguration + } + + public func login(on viewController: UIViewController) async throws { + guard let configuration = appConfiguration else { + logger.error("login invoked without OAuth client configurations being set") + throw OAuthClientError.oauthClientHasNotBeenSet + } + + // Quran.com relies on dicovering the service configuration from the issuer, + // and not using a static configuration. + let serviceConfiguration = try await discoverConfiguration(forIssuer: configuration.authorizationIssuerURL) + try await login( + withConfiguration: serviceConfiguration, + appConfiguration: configuration, + on: viewController + ) + } + + // MARK: Private + + // Needed mainly for retention. + private var authFlow: (any OIDExternalUserAgentSession)? + private var appConfiguration: OAuthAppConfiguration? + + // MARK: - Authenication Flow + + private func discoverConfiguration(forIssuer issuer: URL) async throws -> OIDServiceConfiguration { + logger.info("Discovering configuration for OAuth") + return try await withCheckedThrowingContinuation { continuation in + OIDAuthorizationService + .discoverConfiguration(forIssuer: issuer) { configuration, error in + guard error == nil else { + logger.error("Error fetching OAuth configuration: \(error!)") + continuation.resume(throwing: OAuthClientError.errorFetchingConfiguration(error)) + return + } + guard let configuration else { + // This should not happen + logger.error("Error fetching OAuth configuration: no configuration was loaded. An unexpected situtation.") + continuation.resume(throwing: OAuthClientError.errorFetchingConfiguration(nil)) + return + } + logger.info("OAuth configuration fetched successfully") + continuation.resume(returning: configuration) + } + } + } + + private func login( + withConfiguration configuration: OIDServiceConfiguration, + appConfiguration: OAuthAppConfiguration, + on viewController: UIViewController + ) async throws { + let scopes = [OIDScopeOpenID, OIDScopeProfile] + appConfiguration.scopes + let request = OIDAuthorizationRequest( + configuration: configuration, + clientId: appConfiguration.clientID, + clientSecret: nil, + scopes: scopes, + redirectURL: appConfiguration.redirectURL, + responseType: OIDResponseTypeCode, + additionalParameters: [:] + ) + + logger.info("Starting OAuth flow") + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + DispatchQueue.main.async { + self.authFlow = OIDAuthState.authState( + byPresenting: request, + presenting: viewController + ) { [weak self] state, error in + self?.authFlow = nil + guard error == nil else { + logger.error("Error authenticating: \(error!)") + continuation.resume(throwing: OAuthClientError.errorAuthenticating(error)) + return + } + guard let _ = state else { + logger.error("Error authenticating: no state returned. An unexpected situtation.") + continuation.resume(throwing: OAuthClientError.errorAuthenticating(nil)) + return + } + logger.info("OAuth flow completed successfully") + continuation.resume() + } + } + } + } +} diff --git a/Data/OAuthClient/Sources/OAuthClient.swift b/Data/OAuthClient/Sources/OAuthClient.swift new file mode 100644 index 00000000..e37bd0a2 --- /dev/null +++ b/Data/OAuthClient/Sources/OAuthClient.swift @@ -0,0 +1,44 @@ +// +// OAuthClient.swift +// QuranEngine +// +// Created by Mohannad Hassan on 19/12/2024. +// + +import Foundation +import UIKit + +public enum OAuthClientError: Error { + case oauthClientHasNotBeenSet + case errorFetchingConfiguration(Error?) + case errorAuthenticating(Error?) +} + +public struct OAuthAppConfiguration { + public let clientID: String + public let redirectURL: URL + /// Indicates the Quran.com specific scopes to be requested by the app. + /// The client requests the `offline` and `openid` scopes by default. + public let scopes: [String] + public let authorizationIssuerURL: URL + + public init(clientID: String, redirectURL: URL, scopes: [String], authorizationIssuerURL: URL) { + self.clientID = clientID + self.redirectURL = redirectURL + self.scopes = scopes + self.authorizationIssuerURL = authorizationIssuerURL + } +} + +/// Handles the OAuth flow to Quran.com +/// +/// Note that the connection relies on dicvoering the configuration from the issuer service. +public protocol OAuthClient { + func set(appConfiguration: OAuthAppConfiguration) + + /// Performs the login flow to Quran.com + /// + /// - Parameter viewController: The view controller to be used as base for presenting the login flow. + /// - Returns: Nothing is returned for now. The client may return the profile infromation in the future. + func login(on viewController: UIViewController) async throws +} diff --git a/Domain/QuranProfileService/Sources/QuranProfileService.swift b/Domain/QuranProfileService/Sources/QuranProfileService.swift new file mode 100644 index 00000000..be21737b --- /dev/null +++ b/Domain/QuranProfileService/Sources/QuranProfileService.swift @@ -0,0 +1,25 @@ +// +// QuranProfileService.swift +// QuranEngine +// +// Created by Mohannad Hassan on 23/12/2024. +// + +import OAuthClient +import UIKit + +public class QuranProfileService { + private let oauthClient: OAuthClient + + public init(oauthClient: OAuthClient) { + self.oauthClient = oauthClient + } + + /// Performs the login flow to Quran.com + /// + /// - Parameter viewController: The view controller to be used as base for presenting the login flow. + /// - Returns: Nothing is returned for now. The client may return the profile infromation in the future. + public func login(on viewController: UIViewController) async throws { + try await oauthClient.login(on: viewController) + } +} diff --git a/Example/QuranEngineApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/QuranEngineApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0307a7d7..b5d7d114 100644 --- a/Example/QuranEngineApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/QuranEngineApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "appauth-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/openid/AppAuth-iOS.git", + "state" : { + "revision" : "2781038865a80e2c425a1da12cc1327bcd56501f", + "version" : "1.7.6" + } + }, { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", diff --git a/Example/QuranEngineApp/Classes/Container.swift b/Example/QuranEngineApp/Classes/Container.swift index 8b873f47..b27a94f9 100644 --- a/Example/QuranEngineApp/Classes/Container.swift +++ b/Example/QuranEngineApp/Classes/Container.swift @@ -13,6 +13,7 @@ import CoreDataPersistence import Foundation import LastPagePersistence import NotePersistence +import OAuthClient import PageBookmarkPersistence import ReadingService import UIKit @@ -35,6 +36,13 @@ class Container: AppDependencies { private(set) lazy var lastPagePersistence: LastPagePersistence = CoreDataLastPagePersistence(stack: coreDataStack) private(set) lazy var pageBookmarkPersistence: PageBookmarkPersistence = CoreDataPageBookmarkPersistence(stack: coreDataStack) private(set) lazy var notePersistence: NotePersistence = CoreDataNotePersistence(stack: coreDataStack) + private(set) lazy var oauthClient: any OAuthClient = { + let client = AppAuthOAuthClient() + if let config = Constant.QuranOAuthAppConfigurations { + client.set(appConfiguration: config) + } + return client + }() private(set) lazy var downloadManager: DownloadManager = { let configuration = URLSessionConfiguration.background(withIdentifier: "DownloadsBackgroundIdentifier") @@ -78,4 +86,7 @@ private enum Constant { static let databasesURL = FileManager.documentsURL .appendingPathComponent("databases", isDirectory: true) + + /// If set, the Quran.com login will be enabled. + static let QuranOAuthAppConfigurations: OAuthAppConfiguration? = nil } diff --git a/Features/AppDependencies/AppDependencies.swift b/Features/AppDependencies/AppDependencies.swift index dbed97bc..1f61a541 100644 --- a/Features/AppDependencies/AppDependencies.swift +++ b/Features/AppDependencies/AppDependencies.swift @@ -11,6 +11,7 @@ import BatchDownloader import Foundation import LastPagePersistence import NotePersistence +import OAuthClient import PageBookmarkPersistence import QuranResources import QuranTextKit @@ -35,6 +36,8 @@ public protocol AppDependencies { var lastPagePersistence: LastPagePersistence { get } var notePersistence: NotePersistence { get } var pageBookmarkPersistence: PageBookmarkPersistence { get } + + var oauthClient: OAuthClient { get } } extension AppDependencies { diff --git a/Features/SettingsFeature/SettingsBuilder.swift b/Features/SettingsFeature/SettingsBuilder.swift index d78a4444..42b01f58 100644 --- a/Features/SettingsFeature/SettingsBuilder.swift +++ b/Features/SettingsFeature/SettingsBuilder.swift @@ -9,6 +9,7 @@ import AppDependencies import AudioDownloadsFeature import Localization +import QuranProfileService import ReadingSelectorFeature import SettingsService import SwiftUI @@ -29,6 +30,7 @@ public struct SettingsBuilder { let viewModel = SettingsRootViewModel( analytics: container.analytics, reviewService: ReviewService(analytics: container.analytics), + quranProfileService: QuranProfileService(oauthClient: container.oauthClient), audioDownloadsBuilder: AudioDownloadsBuilder(container: container), translationsListBuilder: TranslationsListBuilder(container: container), readingSelectorBuilder: ReadingSelectorBuilder(container: container), diff --git a/Features/SettingsFeature/SettingsRootView.swift b/Features/SettingsFeature/SettingsRootView.swift index ab96076e..f36fc2dc 100644 --- a/Features/SettingsFeature/SettingsRootView.swift +++ b/Features/SettingsFeature/SettingsRootView.swift @@ -25,13 +25,15 @@ struct SettingsRootView: View { shareApp: { viewModel.shareApp() }, writeReview: { viewModel.writeReview() }, contactUs: { viewModel.contactUs() }, - navigateToDiagnotics: { viewModel.navigateToDiagnotics() } + navigateToDiagnotics: { viewModel.navigateToDiagnotics() }, + loginAction: { await viewModel.loginToQuranCom() } ) } } private struct SettingsRootViewUI: View { @Binding var theme: Theme + let audioEnd: String let navigateToAudioEndSelector: AsyncAction let navigateToAudioManager: AsyncAction @@ -41,6 +43,7 @@ private struct SettingsRootViewUI: View { let writeReview: AsyncAction let contactUs: AsyncAction let navigateToDiagnotics: AsyncAction + let loginAction: AsyncAction var body: some View { NoorList { @@ -108,6 +111,14 @@ private struct SettingsRootViewUI: View { ) } + // TODO: Pending translations, and hiding if OAuth is not configured. + NoorBasicSection { + NoorListItem( + title: .text(l("Login with Quran.com")), + action: loginAction + ) + } + NoorBasicSection { NoorListItem( image: .init(.debug), @@ -135,7 +146,8 @@ struct SettingsRootView_Previews: PreviewProvider { shareApp: {}, writeReview: {}, contactUs: {}, - navigateToDiagnotics: {} + navigateToDiagnotics: {}, + loginAction: {} ) } } diff --git a/Features/SettingsFeature/SettingsRootViewModel.swift b/Features/SettingsFeature/SettingsRootViewModel.swift index b5fdc195..9b9d8975 100644 --- a/Features/SettingsFeature/SettingsRootViewModel.swift +++ b/Features/SettingsFeature/SettingsRootViewModel.swift @@ -12,6 +12,7 @@ import Localization import NoorUI import QuranAudio import QuranAudioKit +import QuranProfileService import ReadingSelectorFeature import SettingsService import TranslationsFeature @@ -26,6 +27,7 @@ final class SettingsRootViewModel: ObservableObject { init( analytics: AnalyticsLibrary, reviewService: ReviewService, + quranProfileService: QuranProfileService, audioDownloadsBuilder: AudioDownloadsBuilder, translationsListBuilder: TranslationsListBuilder, readingSelectorBuilder: ReadingSelectorBuilder, @@ -36,6 +38,7 @@ final class SettingsRootViewModel: ObservableObject { audioEnd = audioPreferences.audioEnd self.analytics = analytics self.reviewService = reviewService + self.quranProfileService = quranProfileService self.audioDownloadsBuilder = audioDownloadsBuilder self.translationsListBuilder = translationsListBuilder self.readingSelectorBuilder = readingSelectorBuilder @@ -50,6 +53,7 @@ final class SettingsRootViewModel: ObservableObject { let analytics: AnalyticsLibrary let reviewService: ReviewService + let quranProfileService: QuranProfileService let audioDownloadsBuilder: AudioDownloadsBuilder let translationsListBuilder: TranslationsListBuilder let readingSelectorBuilder: ReadingSelectorBuilder @@ -128,6 +132,20 @@ final class SettingsRootViewModel: ObservableObject { navigationController?.pushViewController(viewController, animated: true) } + func loginToQuranCom() async { + logger.info("Settings: Login to Quran.com") + guard let viewController = navigationController else { + return + } + do { + try await quranProfileService.login(on: viewController) + // TODO: Replace with the needed UI changes. + print("Login seems successful") + } catch { + logger.error("Failed to login to Quran.com: \(error)") + } + } + // MARK: Private private func showSingleChoiceSelector( diff --git a/Package.swift b/Package.swift index cda443d3..5b677c0e 100644 --- a/Package.swift +++ b/Package.swift @@ -48,6 +48,9 @@ let package = Package( // Async .package(url: "https://github.com/apple/swift-async-algorithms", from: "0.1.0"), + // OAuth + .package(url: "https://github.com/openid/AppAuth-iOS", .upToNextMajor(from: "1.3.0")), + // UI .package(url: "https://github.com/GenericDataSource/GenericDataSource", from: "3.1.3"), .package(url: "https://github.com/SvenTiigi/WhatsNewKit", from: "1.3.7"), @@ -295,6 +298,13 @@ private func dataTargets() -> [[Target]] { "BatchDownloader", "NetworkSupportFake", ]), + + // MARK: - Quran.com OAuth + + target(type, name: "OAuthClient", hasTests: false, dependencies: [ + "VLogging", + .product(name: "AppAuth", package: "AppAuth-iOS"), + ]), ] } @@ -458,6 +468,10 @@ private func domainTargets() -> [[Target]] { "Utilities", ]), + + target(type, name: "QuranProfileService", hasTests: false, dependencies: [ + "OAuthClient", + ]), ] } @@ -473,6 +487,7 @@ private func featuresTargets() -> [[Target]] { "LastPagePersistence", "ReadingService", "QuranResources", + "OAuthClient", ]), target(type, name: "FeaturesSupport", hasTests: false, dependencies: [ @@ -656,6 +671,7 @@ private func featuresTargets() -> [[Target]] { "ReadingSelectorFeature", "Preferences", "Zip", + "QuranProfileService", ]), target(type, name: "AppStructureFeature", hasTests: false, dependencies: [