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
10 changes: 9 additions & 1 deletion LineSDK/LineSDK/General/LineSDKError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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."
}
}

Expand All @@ -654,6 +660,7 @@ extension LineSDKError.GeneralErrorReason {
case .parameterError(_, _): return 4002
case .notOriginalTask(_): return 4003
case .processDiscarded: return 4004
case .loginManagerReset: return 4005
}
}

Expand All @@ -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) })
}
Expand Down
69 changes: 63 additions & 6 deletions LineSDK/LineSDK/Login/LoginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions LineSDK/LineSDK/Login/LoginProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion LineSDK/LineSDKTests/LineSDKErrorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand Down
147 changes: 143 additions & 4 deletions LineSDK/LineSDKTests/Login/LoginManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class LoginManagerTests: XCTestCase, ViewControllerCompatibleTest {
}

override func tearDown() async throws {
LoginManager.shared.reset()
LoginManager.shared.resetForTesting()
resetViewController()
}

Expand All @@ -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)")

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -823,4 +963,3 @@ class LoginManagerTests: XCTestCase, ViewControllerCompatibleTest {
}

}

2 changes: 1 addition & 1 deletion LineSDK/LineSDKTests/LoginButtonTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class LoginButtonTests: XCTestCase, ViewControllerCompatibleTest {
}

override func tearDown() async throws {
LoginManager.shared.reset()
LoginManager.shared.resetForTesting()
resetViewController()
loginButton = nil
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class RefreshTokenPipelineTests: XCTestCase, Sendable {
}

override func tearDown() async throws {
LoginManager.shared.reset()
LoginManager.shared.resetForTesting()
}

func testRefreshTokenPipelineSuccess() {
Expand Down
4 changes: 2 additions & 2 deletions LineSDK/LineSDKTests/OpenChat/OpenChatControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class OpenChatCreatingControllerTests: XCTestCase, ViewControllerCompatibleTest
}

override func tearDown() async throws {
LoginManager.shared.reset()
LoginManager.shared.resetForTesting()
resetViewController()
}

Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion LineSDK/LineSDKTests/Sharing/ShareControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class ShareRootViewControllerTests: XCTestCase, ViewControllerCompatibleTest {
resetViewController()
Session._shared = originalSession
shareRootViewController = nil
LoginManager.shared.reset()
LoginManager.shared.resetForTesting()
}

// MARK: - Initialization Tests
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion LineSDK/LineSDKTests/Utils/APITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class APITests: XCTestCase {
}

override func tearDown() async throws {
await LoginManager.shared.reset()
await LoginManager.shared.resetForTesting()
try await super.tearDown()
}

Expand Down
Loading