diff --git a/LineSDK/LineSDK/General/LineSDKError.swift b/LineSDK/LineSDK/General/LineSDKError.swift index 083d63ba..9dfb4c5e 100644 --- a/LineSDK/LineSDK/General/LineSDKError.swift +++ b/LineSDK/LineSDK/General/LineSDKError.swift @@ -222,9 +222,13 @@ public enum LineSDKError: Error { case notOriginalTask(token: UInt) /// The process is discarded when a new login process is created. This only - /// happens when `allowRecreatingLoginProcess` in `LoginManager.Parameters` is `true` + /// happens when `allowRecreatingLoginProcess` in `LoginManager.Parameters` is `true` /// and users are trying to create another login process. Code 4004. case processDiscarded(LoginProcess) + + /// The LoginManager was reset during an ongoing login process. This occurs when + /// `LoginManager.reset()` is called while a login operation is in progress. Code 4005. + case loginManagerReset } /// An error occurred while constructing a request. @@ -645,6 +649,8 @@ extension LineSDKError.GeneralErrorReason { return "Image downloading finished but it is not the original one. Token \"\(token)\"." case .processDiscarded(let process): return "Current process is discarded. \(process)" + case .loginManagerReset: + return "The LoginManager is reset during the login process." } } @@ -654,6 +660,7 @@ extension LineSDKError.GeneralErrorReason { case .parameterError(_, _): return 4002 case .notOriginalTask(_): return 4003 case .processDiscarded: return 4004 + case .loginManagerReset: return 4005 } } @@ -669,6 +676,7 @@ extension LineSDKError.GeneralErrorReason { case .notOriginalTask: break case .processDiscarded(let process): userInfo[.process] = process + case .loginManagerReset: break } return .init(uniqueKeysWithValues: userInfo.map { ($0.rawValue, $1) }) } diff --git a/LineSDK/LineSDK/Login/LoginManager.swift b/LineSDK/LineSDK/Login/LoginManager.swift index 2c5b64d3..3d0d9f3c 100644 --- a/LineSDK/LineSDK/Login/LoginManager.swift +++ b/LineSDK/LineSDK/Login/LoginManager.swift @@ -82,17 +82,50 @@ public final class LoginManager: @unchecked Sendable /* Sendable is ensured by t defer { lock.unlock() } guard !setup else { - Log.assertionFailure("Trying to set configuration multiple times is not permitted.") + Log.assertionFailure("Trying to set configuration multiple times is not permitted. Call `reset` first if you need to reconfigure the SDK with another channel ID and/or universal link URL.") return } - defer { setup = true } let config = LoginConfiguration(channelID: channelID, universalLinkURL: universalLinkURL) - LoginConfiguration._shared = config - AccessTokenStore._shared = AccessTokenStore(configuration: config) - Session._shared = Session(configuration: config) + configureSDK(config) + setup = true } - + + /// Resets the LINE SDK to its initial state. + /// + /// This method clears all SDK configurations, shared instances, and cancels any ongoing login process. + /// After calling this method, you must call `setup(channelID:universalLinkURL:)` again before using + /// any other SDK functionality. + /// + /// - Important: Access tokens remain stored in the keychain and are scoped by channel ID. Resetting with the + /// same channel reuses the existing token. Call `logout()` before `reset()` if you need to remove + /// cached credentials. + /// + /// - Warning: If there is an ongoing login process when this method is called, the login completion + /// handler will be invoked with a `LineSDKError.generalError(reason: .loginManagerReset)` error. + /// + /// - Note: This method is `@MainActor` isolated. Call it on the main thread to ensure the login + /// process cancellation is delivered immediately. + /// + /// ## Usage Example + /// ```swift + /// // Reset the SDK to switch to a different channel + /// LoginManager.shared.reset() + /// LoginManager.shared.setup(channelID: "newChannelID", universalLinkURL: nil) + /// ``` + @MainActor + public func reset() { + lock.lock() + + let cancelledProcess = cleanCurrentProcess() + configureSDK(nil) + setup = false + + lock.unlock() + + cancelledProcess?.cancelDueToReset() + } + /// Logs in to the LINE Platform. /// /// - Parameters: @@ -308,6 +341,30 @@ public final class LoginManager: @unchecked Sendable /* Sendable is ensured by t return currentProcess.nonisolatedResumeOpenURL(url: url) } + /// Clears the current login process reference and returns it for further handling. + private func cleanCurrentProcess() -> LoginProcess? { + let process = currentProcess + currentProcess = nil + return process + } + + /// Configures or clears the SDK's shared instances based on the provided configuration. + /// + /// - Parameter config: The configuration to apply. If `nil`, all shared instances will be cleared + /// and any active network sessions will be cancelled. + private func configureSDK(_ config: LoginConfiguration?) { + if let config = config { + LoginConfiguration._shared = config + AccessTokenStore._shared = AccessTokenStore(configuration: config) + Session._shared = Session(configuration: config) + } else { + LoginConfiguration._shared = nil + AccessTokenStore._shared = nil + Session._shared?.session.invalidateAndCancel() + Session._shared = nil + } + } + // MARK: - Deprecated /// Sets the preferred language used when logging in with the web authorization flow. diff --git a/LineSDK/LineSDK/Login/LoginProcess.swift b/LineSDK/LineSDK/Login/LoginProcess.swift index d47f5557..78d174c3 100644 --- a/LineSDK/LineSDK/Login/LoginProcess.swift +++ b/LineSDK/LineSDK/Login/LoginProcess.swift @@ -419,6 +419,10 @@ public class LoginProcess { onSucceed.call((result, response)) } + func cancelDueToReset() { + invokeFailure(error: LineSDKError.generalError(reason: .loginManagerReset)) + } + private func invokeFailure(error: Error) { resetFlows() onFail.call(error) diff --git a/LineSDK/LineSDKTests/LineSDKErrorTests.swift b/LineSDK/LineSDKTests/LineSDKErrorTests.swift index 5cd31110..1273a260 100644 --- a/LineSDK/LineSDKTests/LineSDKErrorTests.swift +++ b/LineSDK/LineSDKTests/LineSDKErrorTests.swift @@ -167,7 +167,7 @@ class LineSDKErrorTests: XCTestCase { LoginManager.shared.setup(channelID: "123", universalLinkURL: nil) defer { - LoginManager.shared.reset() + LoginManager.shared.resetForTesting() } let generalReasons: [LineSDKError.GeneralErrorReason] = [ diff --git a/LineSDK/LineSDKTests/Login/LoginManagerTests.swift b/LineSDK/LineSDKTests/Login/LoginManagerTests.swift index b8208d6f..6d21b3c3 100644 --- a/LineSDK/LineSDKTests/Login/LoginManagerTests.swift +++ b/LineSDK/LineSDKTests/Login/LoginManagerTests.swift @@ -43,7 +43,7 @@ class LoginManagerTests: XCTestCase, ViewControllerCompatibleTest { } override func tearDown() async throws { - LoginManager.shared.reset() + LoginManager.shared.resetForTesting() resetViewController() } @@ -54,7 +54,147 @@ class LoginManagerTests: XCTestCase, ViewControllerCompatibleTest { XCTAssertTrue(LoginManager.shared.isSetupFinished) } - + + func testResetLoginManager() { + // Ensure LoginManager is properly set up first + XCTAssertTrue(LoginManager.shared.isSetupFinished) + XCTAssertNotNil(Session.shared) + XCTAssertNotNil(AccessTokenStore.shared) + XCTAssertNotNil(LoginConfiguration.shared) + + // Test resetting without active login process + LoginManager.shared.reset() + + // After reset, all shared instances should be nil and setup should be false + XCTAssertFalse(LoginManager.shared.isSetupFinished) + XCTAssertNil(Session._shared) + XCTAssertNil(AccessTokenStore._shared) + XCTAssertNil(LoginConfiguration._shared) + + // Test that we can reconfigure after reset + let newURL = URL(string: "https://newexample.com/auth") + LoginManager.shared.setup(channelID: "456", universalLinkURL: newURL) + + // After reconfiguration, instances should exist again + XCTAssertTrue(LoginManager.shared.isSetupFinished) + XCTAssertNotNil(Session.shared) + XCTAssertNotNil(AccessTokenStore.shared) + XCTAssertNotNil(LoginConfiguration.shared) + XCTAssertEqual(LoginConfiguration.shared.channelID, "456") + XCTAssertEqual(LoginConfiguration.shared.universalLinkURL, newURL) + } + + func testResetLoginManagerWithActiveProcess() { + let expect = expectation(description: "\(#file)_\(#line)") + + // Mock a session that will never respond (to keep login process active) + let delegateStub = SessionDelegateStub(stubs: []) + Session._shared = Session( + configuration: LoginConfiguration.shared, + delegate: delegateStub + ) + + // Start a login process but don't complete it + var process: LoginProcess! + process = LoginManager.shared.login(permissions: [.profile], in: setupViewController()) { loginResult in + // This callback should receive a loginManagerReset error + XCTAssertNotNil(loginResult.error) + if let error = loginResult.error { + if case .generalError(let reason) = error { + if case .loginManagerReset = reason { + // Expected error when reset is called during login + expect.fulfill() + } else { + XCTFail("Expected loginManagerReset error, got: \(reason)") + } + } else { + XCTFail("Expected generalError with loginManagerReset, got: \(error)") + } + } + } + + // Verify process is active + XCTAssertNotNil(process) + XCTAssertTrue(LoginManager.shared.isAuthorizing) + + // Reset the login manager while process is active + LoginManager.shared.reset() + + // After reset, no process should be active and setup should be false + XCTAssertFalse(LoginManager.shared.isSetupFinished) + XCTAssertFalse(LoginManager.shared.isAuthorizing) + + waitForExpectations(timeout: 1, handler: nil) + + // Re-setup after reset to avoid tearDown issues + let url = URL(string: "https://example.com/auth") + LoginManager.shared.setup(channelID: "123", universalLinkURL: url) + } + + func testResetKeepsTokenScopedByChannel() { + let loginExpectation = expectation(description: "\(#file)_\(#line)_login") + + let delegateStub = SessionDelegateStub(stubs: [ + .init(data: PostExchangeTokenRequest.successData, responseCode: 200), + .init(data: GetUserProfileRequest.successData, responseCode: 200) + ]) + Session._shared = Session( + configuration: LoginConfiguration.shared, + delegate: delegateStub + ) + + let viewController = setupViewController() + var process: LoginProcess! + var capturedToken: AccessToken? + process = LoginManager.shared.login(permissions: [.profile], in: viewController) { result in + switch result { + case .success(let loginResult): + capturedToken = loginResult.accessToken + XCTAssertEqual(loginResult.accessToken.value, PostExchangeTokenRequest.successToken) + XCTAssertEqual(AccessTokenStore.shared.current?.value, PostExchangeTokenRequest.successToken) + case .failure(let error): + XCTFail("Unexpected failure: \(error)") + } + + loginExpectation.fulfill() + }! + + process.appUniversalLinkFlow = AppUniversalLinkFlow(parameter: sampleFlowParameters) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + let urlString = "\(Constant.thirdPartyAppReturnURL)?code=123&state=\(process.processID)" + let handled = process.resumeOpenURL(url: URL(string: urlString)!) + XCTAssertTrue(handled) + } + + waitForExpectations(timeout: 2, handler: nil) + + guard let token = capturedToken else { + XCTFail("Token should be captured after login") + return + } + + // After reset, in-memory singletons are cleared but the keychain token remains. + LoginManager.shared.reset() + XCTAssertFalse(LoginManager.shared.isSetupFinished) + XCTAssertNil(LoginConfiguration._shared) + XCTAssertNil(Session._shared) + XCTAssertNil(AccessTokenStore._shared) + + // Setup with a different channel should not see the previous token. + let secondaryURL = URL(string: "https://alternate.example.com/auth") + LoginManager.shared.setup(channelID: "456", universalLinkURL: secondaryURL) + XCTAssertTrue(LoginManager.shared.isSetupFinished) + XCTAssertNil(AccessTokenStore.shared.current) + + // Reset again to switch back to the original channel. + LoginManager.shared.reset() + + let primaryURL = URL(string: "https://example.com/auth") + LoginManager.shared.setup(channelID: "123", universalLinkURL: primaryURL) + XCTAssertEqual(AccessTokenStore.shared.current?.value, token.value) + } + func testLoginAction() { let expect = expectation(description: "\(#file)_\(#line)") @@ -349,7 +489,7 @@ class LoginManagerTests: XCTestCase, ViewControllerCompatibleTest { let mockAccessToken = try! JSONDecoder().decode(AccessToken.self, from: PostExchangeTokenRequest.successData) // Create JWK from test RSA public key that matches the test JWT token - let testRSAPublicKeyPEM = """ + /* let testRSAPublicKeyPEM */ _ = """ -----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdlatRjRjogo3WojgGHFHYLugd UWAY9iR3fy4arWNA1KoS8kVw33cJibXr8bvwUAUparCwlvdbH6dvEOfou0/gCFQs @@ -823,4 +963,3 @@ class LoginManagerTests: XCTestCase, ViewControllerCompatibleTest { } } - diff --git a/LineSDK/LineSDKTests/LoginButtonTests.swift b/LineSDK/LineSDKTests/LoginButtonTests.swift index 6c3ee60f..117f07e3 100644 --- a/LineSDK/LineSDKTests/LoginButtonTests.swift +++ b/LineSDK/LineSDKTests/LoginButtonTests.swift @@ -35,7 +35,7 @@ class LoginButtonTests: XCTestCase, ViewControllerCompatibleTest { } override func tearDown() async throws { - LoginManager.shared.reset() + LoginManager.shared.resetForTesting() resetViewController() loginButton = nil } diff --git a/LineSDK/LineSDKTests/Networking/RefreshTokenPipelineTests.swift b/LineSDK/LineSDKTests/Networking/RefreshTokenPipelineTests.swift index 114c9260..c64edcac 100644 --- a/LineSDK/LineSDKTests/Networking/RefreshTokenPipelineTests.swift +++ b/LineSDK/LineSDKTests/Networking/RefreshTokenPipelineTests.swift @@ -33,7 +33,7 @@ class RefreshTokenPipelineTests: XCTestCase, Sendable { } override func tearDown() async throws { - LoginManager.shared.reset() + LoginManager.shared.resetForTesting() } func testRefreshTokenPipelineSuccess() { diff --git a/LineSDK/LineSDKTests/OpenChat/OpenChatControllerTests.swift b/LineSDK/LineSDKTests/OpenChat/OpenChatControllerTests.swift index 1810b802..01087b1c 100644 --- a/LineSDK/LineSDKTests/OpenChat/OpenChatControllerTests.swift +++ b/LineSDK/LineSDKTests/OpenChat/OpenChatControllerTests.swift @@ -32,7 +32,7 @@ class OpenChatCreatingControllerTests: XCTestCase, ViewControllerCompatibleTest } override func tearDown() async throws { - LoginManager.shared.reset() + LoginManager.shared.resetForTesting() resetViewController() } @@ -65,7 +65,7 @@ class OpenChatCreatingControllerTests: XCTestCase, ViewControllerCompatibleTest func testLocalAuthorizationStatusForCreatingOpenChat() { // Test with no token - LoginManager.shared.reset() + LoginManager.shared.resetForTesting() LoginManager.shared.setup(channelID: "123", universalLinkURL: nil) let statusNoToken = OpenChatCreatingController.localAuthorizationStatusForCreatingOpenChat() diff --git a/LineSDK/LineSDKTests/Sharing/ShareControllerTests.swift b/LineSDK/LineSDKTests/Sharing/ShareControllerTests.swift index 8c232fbd..50a00add 100644 --- a/LineSDK/LineSDKTests/Sharing/ShareControllerTests.swift +++ b/LineSDK/LineSDKTests/Sharing/ShareControllerTests.swift @@ -45,7 +45,7 @@ class ShareControllerTests: XCTestCase { func testNoTokenStatus() { LoginManager.shared.setup(channelID: "123", universalLinkURL: nil) - defer { LoginManager.shared.reset() } + defer { LoginManager.shared.resetForTesting() } let status = ShareViewController .localAuthorizationStatusForSendingMessage() diff --git a/LineSDK/LineSDKTests/Sharing/ShareRootViewControllerTests.swift b/LineSDK/LineSDKTests/Sharing/ShareRootViewControllerTests.swift index 12c7948e..48681a97 100644 --- a/LineSDK/LineSDKTests/Sharing/ShareRootViewControllerTests.swift +++ b/LineSDK/LineSDKTests/Sharing/ShareRootViewControllerTests.swift @@ -39,7 +39,7 @@ class ShareRootViewControllerTests: XCTestCase, ViewControllerCompatibleTest { resetViewController() Session._shared = originalSession shareRootViewController = nil - LoginManager.shared.reset() + LoginManager.shared.resetForTesting() } // MARK: - Initialization Tests diff --git a/LineSDK/LineSDKTests/Sharing/ShareViewControllerTests.swift b/LineSDK/LineSDKTests/Sharing/ShareViewControllerTests.swift index c9cd42a2..788e0e41 100644 --- a/LineSDK/LineSDKTests/Sharing/ShareViewControllerTests.swift +++ b/LineSDK/LineSDKTests/Sharing/ShareViewControllerTests.swift @@ -52,7 +52,7 @@ class ShareViewControllerTests: XCTestCase, ViewControllerCompatibleTest { // Restore original token state AccessTokenStore.shared.current = originalToken - LoginManager.shared.reset() + LoginManager.shared.resetForTesting() } // MARK: - Core Tests diff --git a/LineSDK/LineSDKTests/Utils/APITests.swift b/LineSDK/LineSDKTests/Utils/APITests.swift index 05a59b28..1a610736 100644 --- a/LineSDK/LineSDKTests/Utils/APITests.swift +++ b/LineSDK/LineSDKTests/Utils/APITests.swift @@ -36,7 +36,7 @@ class APITests: XCTestCase { } override func tearDown() async throws { - await LoginManager.shared.reset() + await LoginManager.shared.resetForTesting() try await super.tearDown() } diff --git a/LineSDK/LineSDKTests/Utils/LoginManagerExtension.swift b/LineSDK/LineSDKTests/Utils/LoginManagerExtension.swift index f42c326c..d8fd4c44 100644 --- a/LineSDK/LineSDKTests/Utils/LoginManagerExtension.swift +++ b/LineSDK/LineSDKTests/Utils/LoginManagerExtension.swift @@ -24,15 +24,8 @@ import Foundation extension LoginManager { @MainActor - func reset() { - setup = false - - Session._shared = nil - + func resetForTesting() { try! AccessTokenStore.shared.removeCurrentAccessToken() - AccessTokenStore._shared = nil - - LoginConfiguration._shared = nil - currentProcess?.stop() + reset() } } diff --git a/LineSDKSample/LineSDKSample.xcodeproj/project.pbxproj b/LineSDKSample/LineSDKSample.xcodeproj/project.pbxproj index 4af53f4f..8ed15931 100644 --- a/LineSDKSample/LineSDKSample.xcodeproj/project.pbxproj +++ b/LineSDKSample/LineSDKSample.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 65E6BDB92154D80A0024D7C1 /* LoginPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E6BDB82154D80A0024D7C1 /* LoginPage.swift */; }; 9735344E241BB60900D47AAA /* Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9735344D241BB60900D47AAA /* Page.swift */; }; 97353450241BB67500D47AAA /* AuthenticationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9735344F241BB67500D47AAA /* AuthenticationTests.swift */; }; + BD1081521DA6442B9F29E151 /* SampleChannelSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0EE337A49B4B8083550663 /* SampleChannelSettings.swift */; }; D17A9F3724BD312A00D0FD0D /* LoginSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D17A9F3624BD312A00D0FD0D /* LoginSettingsViewController.swift */; }; D17A9F3924BD34EE00D0FD0D /* LoginSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D17A9F3824BD34EE00D0FD0D /* LoginSettings.swift */; }; D1F6B58D243C5ACF00024910 /* OpenChatRoomTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F6B58C243C5ACF00024910 /* OpenChatRoomTableViewController.swift */; }; @@ -135,6 +136,7 @@ 65E6BDB82154D80A0024D7C1 /* LoginPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginPage.swift; sourceTree = ""; }; 9735344D241BB60900D47AAA /* Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Page.swift; sourceTree = ""; }; 9735344F241BB67500D47AAA /* AuthenticationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationTests.swift; sourceTree = ""; }; + BF0EE337A49B4B8083550663 /* SampleChannelSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleChannelSettings.swift; sourceTree = ""; }; D1467333238F6D5B004BF2B6 /* LineSDKSample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LineSDKSample.entitlements; sourceTree = ""; }; D17A9F3624BD312A00D0FD0D /* LoginSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginSettingsViewController.swift; sourceTree = ""; }; D17A9F3824BD34EE00D0FD0D /* LoginSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginSettings.swift; sourceTree = ""; }; @@ -279,6 +281,7 @@ 4BCD279D2113FE9D00B90D8F /* IndicatorDisplay.swift */, 4BCD279F2114007500B90D8F /* UIAlertControllerHelpers.swift */, 4BB84462211BF48F005B60D7 /* UIViewControllerExtensions.swift */, + BF0EE337A49B4B8083550663 /* SampleChannelSettings.swift */, ); path = Utils; sourceTree = ""; @@ -503,6 +506,7 @@ 4B38A3EF22FA70DB00A21F05 /* ShareMessageTemplateAddingViewController.swift in Sources */, 4B121F132249DE4000473132 /* SampleUIHomeViewController.swift in Sources */, 4BFE511B22F7BAC600500B72 /* MessageStore.swift in Sources */, + BD1081521DA6442B9F29E151 /* SampleChannelSettings.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/LineSDKSample/LineSDKSample/AppDelegate.swift b/LineSDKSample/LineSDKSample/AppDelegate.swift index fb871f7c..a1979295 100644 --- a/LineSDKSample/LineSDKSample/AppDelegate.swift +++ b/LineSDKSample/LineSDKSample/AppDelegate.swift @@ -37,9 +37,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { #endif // Modify Config.xcconfig to setup your LINE channel ID. - if let channelID = Bundle.main.infoDictionary?["LINE Channel ID"] as? String, - let _ = Int(channelID) - { + if let channelID = SampleChannelSettings.resolveInitialChannelID() { LoginManager.shared.setup(channelID: channelID, universalLinkURL: nil) } else { fatalError("Please set correct channel ID in Config.xcconfig file.") @@ -67,4 +65,3 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - diff --git a/LineSDKSample/LineSDKSample/Login/LoginSettingsViewController.swift b/LineSDKSample/LineSDKSample/Login/LoginSettingsViewController.swift index c089987f..f0ad4a06 100644 --- a/LineSDKSample/LineSDKSample/Login/LoginSettingsViewController.swift +++ b/LineSDKSample/LineSDKSample/Login/LoginSettingsViewController.swift @@ -28,12 +28,15 @@ protocol LoginSettingsViewControllerDelegate: AnyObject { class LoginSettingsViewController: UITableViewController { enum Section: Int, CaseIterable { + case channel case permissions case openID case parameters var sectionTitle: String { switch self { + case .channel: + return "Channel" case .permissions: return "Permissions" case .openID: @@ -108,6 +111,9 @@ class LoginSettingsViewController: UITableViewController { var loginSettings: LoginSettings! weak var delegate: LoginSettingsViewControllerDelegate? + private weak var onlyResetAction: UIAlertAction? + private weak var resetAndSetupAction: UIAlertAction? + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) delegate?.loginSettingsViewControllerWillDisappear(self) @@ -123,6 +129,7 @@ class LoginSettingsViewController: UITableViewController { override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch Section(rawValue: section) { + case .channel: return 1 case .permissions: return permissions.count case .openID: return openIDs.count case .parameters: return parameters.count @@ -137,6 +144,10 @@ class LoginSettingsViewController: UITableViewController { preconditionFailure() } switch section { + case .channel: + cell.textLabel?.text = "Channel ID" + cell.detailTextLabel?.text = SampleChannelSettings.storedChannelID ?? "Not Set" + cell.accessoryType = .disclosureIndicator case .permissions: let p = permissions[indexPath.row] cell.textLabel?.text = p.title @@ -166,6 +177,8 @@ class LoginSettingsViewController: UITableViewController { return } switch section { + case .channel: + presentChannelEditing() case .permissions: let p = permissions[indexPath.row] loginSettings.togglePermission(p.permission) @@ -177,6 +190,92 @@ class LoginSettingsViewController: UITableViewController { p.action(&loginSettings.parameters) } tableView.deselectRow(at: indexPath, animated: true) - tableView.reloadRows(at: [indexPath], with: .none) + if section == .parameters || section == .permissions || section == .openID { + tableView.reloadRows(at: [indexPath], with: .none) + } + } + + private func presentChannelEditing() { + let alert = UIAlertController( + title: "Channel ID", + message: "Enter the LINE channel ID to use with the SDK.", + preferredStyle: .alert + ) + alert.addTextField { [weak self] textField in + textField.keyboardType = .numberPad + textField.text = SampleChannelSettings.storedChannelID + textField.clearButtonMode = .whileEditing + if let self = self { + textField.addTarget(self, action: #selector(LoginSettingsViewController.channelTextFieldDidChange(_:)), for: .editingChanged) + } + } + + let onlyReset = UIAlertAction(title: "Only Reset", style: .destructive) { [weak self, weak alert] _ in + guard let channelID = self?.normalizedChannelID(from: alert) else { + return + } + self?.applyChannelChange(channelID: channelID, shouldSetup: false) + } + onlyReset.isEnabled = false + alert.addAction(onlyReset) + + let resetAndSetup = UIAlertAction(title: "Reset & Setup", style: .default) { [weak self, weak alert] _ in + guard let channelID = self?.normalizedChannelID(from: alert) else { + return + } + self?.applyChannelChange(channelID: channelID, shouldSetup: true) + } + resetAndSetup.isEnabled = false + alert.addAction(resetAndSetup) + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + + onlyResetAction = onlyReset + resetAndSetupAction = resetAndSetup + + present(alert, animated: true) { [weak self, weak alert] in + guard let textField = alert?.textFields?.first else { return } + self?.updateChannelActions(for: textField.text) + } + } + + @objc private func channelTextFieldDidChange(_ sender: UITextField) { + updateChannelActions(for: sender.text) + } + + private func updateChannelActions(for text: String?) { + let isValid = SampleChannelSettings.isValid(channelID: text ?? "") + onlyResetAction?.isEnabled = isValid + resetAndSetupAction?.isEnabled = isValid + } + + private func normalizedChannelID(from alert: UIAlertController?) -> String? { + guard let raw = alert?.textFields?.first?.text else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard SampleChannelSettings.isValid(channelID: trimmed) else { return nil } + return trimmed + } + + private func applyChannelChange(channelID: String, shouldSetup: Bool) { + SampleChannelSettings.updateChannelID(channelID) + LoginManager.shared.reset() + + if shouldSetup { + LoginManager.shared.setup(channelID: channelID, universalLinkURL: nil) + } + + let indexPath = IndexPath(row: 0, section: Section.channel.rawValue) + tableView.reloadRows(at: [indexPath], with: .automatic) + + let message: String + if shouldSetup { + message = "The SDK was reset and configured with the new channel ID." + } else { + message = "The SDK was reset. Call setup again before performing login operations." + } + + let confirmation = UIAlertController(title: "Channel Updated", message: message, preferredStyle: .alert) + confirmation.addAction(UIAlertAction(title: "OK", style: .default)) + present(confirmation, animated: true) } } diff --git a/LineSDKSample/LineSDKSample/Login/LoginViewController.swift b/LineSDKSample/LineSDKSample/Login/LoginViewController.swift index 3dd56d51..3bf88197 100644 --- a/LineSDKSample/LineSDKSample/Login/LoginViewController.swift +++ b/LineSDKSample/LineSDKSample/Login/LoginViewController.swift @@ -82,6 +82,10 @@ class LoginViewController: UIViewController, IndicatorDisplay { func updateLoginButtonData() { loginButton.permissions = loginSettings.permissions loginButton.parameters = loginSettings.parameters + + let canPerformLogin = LoginManager.shared.isSetupFinished + loginButton.isEnabled = canPerformLogin + webLoginButton.isEnabled = canPerformLogin } @objc diff --git a/LineSDKSample/LineSDKSample/Utils/SampleChannelSettings.swift b/LineSDKSample/LineSDKSample/Utils/SampleChannelSettings.swift new file mode 100644 index 00000000..9bcdd8f9 --- /dev/null +++ b/LineSDKSample/LineSDKSample/Utils/SampleChannelSettings.swift @@ -0,0 +1,57 @@ +// +// SampleChannelSettings.swift +// +// Copyright (c) 2016-present, LY Corporation. All rights reserved. +// +// You are hereby granted a non-exclusive, worldwide, royalty-free license to use, +// copy and distribute this software in source code or binary form for use +// in connection with the web services and APIs provided by LY Corporation. +// +// As with any software that integrates with the LY Corporation platform, your use of this software +// is subject to the LINE Developers Agreement [http://terms2.line.me/LINE_Developers_Agreement]. +// This copyright notice shall be included in all copies or substantial portions of the software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import Foundation + +enum SampleChannelSettings { + private static let channelStorageKey = "com.linecorp.linesdk.sample.channelID" + private static var defaults: UserDefaults { .standard } + + static func resolveInitialChannelID(bundle: Bundle = .main) -> String? { + if let stored = storedChannelID, isValid(channelID: stored) { + return stored + } + + guard let bundledID = bundle.infoDictionary?["LINE Channel ID"] as? String, + isValid(channelID: bundledID) + else { + return nil + } + + updateChannelID(bundledID) + return bundledID + } + + static var storedChannelID: String? { + defaults.string(forKey: channelStorageKey) + } + + static func updateChannelID(_ channelID: String) { + defaults.set(channelID.trimmingCharacters(in: .whitespacesAndNewlines), forKey: channelStorageKey) + } + + static func isValid(channelID: String) -> Bool { + let trimmed = channelID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + return Int(trimmed) != nil + } +} +