Skip to content

Commit e935cf1

Browse files
authored
Merge pull request #243 from line/fix/remove-multiple-channel-assertion
Support LoginManager reset reuse and sample channel switching
2 parents 658eab1 + c5cdadb commit e935cf1

18 files changed

+396
-34
lines changed

LineSDK/LineSDK/General/LineSDKError.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,13 @@ public enum LineSDKError: Error {
222222
case notOriginalTask(token: UInt)
223223

224224
/// The process is discarded when a new login process is created. This only
225-
/// happens when `allowRecreatingLoginProcess` in `LoginManager.Parameters` is `true`
225+
/// happens when `allowRecreatingLoginProcess` in `LoginManager.Parameters` is `true`
226226
/// and users are trying to create another login process. Code 4004.
227227
case processDiscarded(LoginProcess)
228+
229+
/// The LoginManager was reset during an ongoing login process. This occurs when
230+
/// `LoginManager.reset()` is called while a login operation is in progress. Code 4005.
231+
case loginManagerReset
228232
}
229233

230234
/// An error occurred while constructing a request.
@@ -645,6 +649,8 @@ extension LineSDKError.GeneralErrorReason {
645649
return "Image downloading finished but it is not the original one. Token \"\(token)\"."
646650
case .processDiscarded(let process):
647651
return "Current process is discarded. \(process)"
652+
case .loginManagerReset:
653+
return "The LoginManager is reset during the login process."
648654
}
649655
}
650656

@@ -654,6 +660,7 @@ extension LineSDKError.GeneralErrorReason {
654660
case .parameterError(_, _): return 4002
655661
case .notOriginalTask(_): return 4003
656662
case .processDiscarded: return 4004
663+
case .loginManagerReset: return 4005
657664
}
658665
}
659666

@@ -669,6 +676,7 @@ extension LineSDKError.GeneralErrorReason {
669676
case .notOriginalTask: break
670677
case .processDiscarded(let process):
671678
userInfo[.process] = process
679+
case .loginManagerReset: break
672680
}
673681
return .init(uniqueKeysWithValues: userInfo.map { ($0.rawValue, $1) })
674682
}

LineSDK/LineSDK/Login/LoginManager.swift

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,17 +82,50 @@ public final class LoginManager: @unchecked Sendable /* Sendable is ensured by t
8282
defer { lock.unlock() }
8383

8484
guard !setup else {
85-
Log.assertionFailure("Trying to set configuration multiple times is not permitted.")
85+
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.")
8686
return
8787
}
88-
defer { setup = true }
8988

9089
let config = LoginConfiguration(channelID: channelID, universalLinkURL: universalLinkURL)
91-
LoginConfiguration._shared = config
92-
AccessTokenStore._shared = AccessTokenStore(configuration: config)
93-
Session._shared = Session(configuration: config)
90+
configureSDK(config)
91+
setup = true
9492
}
95-
93+
94+
/// Resets the LINE SDK to its initial state.
95+
///
96+
/// This method clears all SDK configurations, shared instances, and cancels any ongoing login process.
97+
/// After calling this method, you must call `setup(channelID:universalLinkURL:)` again before using
98+
/// any other SDK functionality.
99+
///
100+
/// - Important: Access tokens remain stored in the keychain and are scoped by channel ID. Resetting with the
101+
/// same channel reuses the existing token. Call `logout()` before `reset()` if you need to remove
102+
/// cached credentials.
103+
///
104+
/// - Warning: If there is an ongoing login process when this method is called, the login completion
105+
/// handler will be invoked with a `LineSDKError.generalError(reason: .loginManagerReset)` error.
106+
///
107+
/// - Note: This method is `@MainActor` isolated. Call it on the main thread to ensure the login
108+
/// process cancellation is delivered immediately.
109+
///
110+
/// ## Usage Example
111+
/// ```swift
112+
/// // Reset the SDK to switch to a different channel
113+
/// LoginManager.shared.reset()
114+
/// LoginManager.shared.setup(channelID: "newChannelID", universalLinkURL: nil)
115+
/// ```
116+
@MainActor
117+
public func reset() {
118+
lock.lock()
119+
120+
let cancelledProcess = cleanCurrentProcess()
121+
configureSDK(nil)
122+
setup = false
123+
124+
lock.unlock()
125+
126+
cancelledProcess?.cancelDueToReset()
127+
}
128+
96129
/// Logs in to the LINE Platform.
97130
///
98131
/// - Parameters:
@@ -308,6 +341,30 @@ public final class LoginManager: @unchecked Sendable /* Sendable is ensured by t
308341
return currentProcess.nonisolatedResumeOpenURL(url: url)
309342
}
310343

344+
/// Clears the current login process reference and returns it for further handling.
345+
private func cleanCurrentProcess() -> LoginProcess? {
346+
let process = currentProcess
347+
currentProcess = nil
348+
return process
349+
}
350+
351+
/// Configures or clears the SDK's shared instances based on the provided configuration.
352+
///
353+
/// - Parameter config: The configuration to apply. If `nil`, all shared instances will be cleared
354+
/// and any active network sessions will be cancelled.
355+
private func configureSDK(_ config: LoginConfiguration?) {
356+
if let config = config {
357+
LoginConfiguration._shared = config
358+
AccessTokenStore._shared = AccessTokenStore(configuration: config)
359+
Session._shared = Session(configuration: config)
360+
} else {
361+
LoginConfiguration._shared = nil
362+
AccessTokenStore._shared = nil
363+
Session._shared?.session.invalidateAndCancel()
364+
Session._shared = nil
365+
}
366+
}
367+
311368
// MARK: - Deprecated
312369

313370
/// Sets the preferred language used when logging in with the web authorization flow.

LineSDK/LineSDK/Login/LoginProcess.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,10 @@ public class LoginProcess {
419419
onSucceed.call((result, response))
420420
}
421421

422+
func cancelDueToReset() {
423+
invokeFailure(error: LineSDKError.generalError(reason: .loginManagerReset))
424+
}
425+
422426
private func invokeFailure(error: Error) {
423427
resetFlows()
424428
onFail.call(error)

LineSDK/LineSDKTests/LineSDKErrorTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ class LineSDKErrorTests: XCTestCase {
167167

168168
LoginManager.shared.setup(channelID: "123", universalLinkURL: nil)
169169
defer {
170-
LoginManager.shared.reset()
170+
LoginManager.shared.resetForTesting()
171171
}
172172

173173
let generalReasons: [LineSDKError.GeneralErrorReason] = [

LineSDK/LineSDKTests/Login/LoginManagerTests.swift

Lines changed: 143 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class LoginManagerTests: XCTestCase, ViewControllerCompatibleTest {
4343
}
4444

4545
override func tearDown() async throws {
46-
LoginManager.shared.reset()
46+
LoginManager.shared.resetForTesting()
4747
resetViewController()
4848
}
4949

@@ -54,7 +54,147 @@ class LoginManagerTests: XCTestCase, ViewControllerCompatibleTest {
5454

5555
XCTAssertTrue(LoginManager.shared.isSetupFinished)
5656
}
57-
57+
58+
func testResetLoginManager() {
59+
// Ensure LoginManager is properly set up first
60+
XCTAssertTrue(LoginManager.shared.isSetupFinished)
61+
XCTAssertNotNil(Session.shared)
62+
XCTAssertNotNil(AccessTokenStore.shared)
63+
XCTAssertNotNil(LoginConfiguration.shared)
64+
65+
// Test resetting without active login process
66+
LoginManager.shared.reset()
67+
68+
// After reset, all shared instances should be nil and setup should be false
69+
XCTAssertFalse(LoginManager.shared.isSetupFinished)
70+
XCTAssertNil(Session._shared)
71+
XCTAssertNil(AccessTokenStore._shared)
72+
XCTAssertNil(LoginConfiguration._shared)
73+
74+
// Test that we can reconfigure after reset
75+
let newURL = URL(string: "https://newexample.com/auth")
76+
LoginManager.shared.setup(channelID: "456", universalLinkURL: newURL)
77+
78+
// After reconfiguration, instances should exist again
79+
XCTAssertTrue(LoginManager.shared.isSetupFinished)
80+
XCTAssertNotNil(Session.shared)
81+
XCTAssertNotNil(AccessTokenStore.shared)
82+
XCTAssertNotNil(LoginConfiguration.shared)
83+
XCTAssertEqual(LoginConfiguration.shared.channelID, "456")
84+
XCTAssertEqual(LoginConfiguration.shared.universalLinkURL, newURL)
85+
}
86+
87+
func testResetLoginManagerWithActiveProcess() {
88+
let expect = expectation(description: "\(#file)_\(#line)")
89+
90+
// Mock a session that will never respond (to keep login process active)
91+
let delegateStub = SessionDelegateStub(stubs: [])
92+
Session._shared = Session(
93+
configuration: LoginConfiguration.shared,
94+
delegate: delegateStub
95+
)
96+
97+
// Start a login process but don't complete it
98+
var process: LoginProcess!
99+
process = LoginManager.shared.login(permissions: [.profile], in: setupViewController()) { loginResult in
100+
// This callback should receive a loginManagerReset error
101+
XCTAssertNotNil(loginResult.error)
102+
if let error = loginResult.error {
103+
if case .generalError(let reason) = error {
104+
if case .loginManagerReset = reason {
105+
// Expected error when reset is called during login
106+
expect.fulfill()
107+
} else {
108+
XCTFail("Expected loginManagerReset error, got: \(reason)")
109+
}
110+
} else {
111+
XCTFail("Expected generalError with loginManagerReset, got: \(error)")
112+
}
113+
}
114+
}
115+
116+
// Verify process is active
117+
XCTAssertNotNil(process)
118+
XCTAssertTrue(LoginManager.shared.isAuthorizing)
119+
120+
// Reset the login manager while process is active
121+
LoginManager.shared.reset()
122+
123+
// After reset, no process should be active and setup should be false
124+
XCTAssertFalse(LoginManager.shared.isSetupFinished)
125+
XCTAssertFalse(LoginManager.shared.isAuthorizing)
126+
127+
waitForExpectations(timeout: 1, handler: nil)
128+
129+
// Re-setup after reset to avoid tearDown issues
130+
let url = URL(string: "https://example.com/auth")
131+
LoginManager.shared.setup(channelID: "123", universalLinkURL: url)
132+
}
133+
134+
func testResetKeepsTokenScopedByChannel() {
135+
let loginExpectation = expectation(description: "\(#file)_\(#line)_login")
136+
137+
let delegateStub = SessionDelegateStub(stubs: [
138+
.init(data: PostExchangeTokenRequest.successData, responseCode: 200),
139+
.init(data: GetUserProfileRequest.successData, responseCode: 200)
140+
])
141+
Session._shared = Session(
142+
configuration: LoginConfiguration.shared,
143+
delegate: delegateStub
144+
)
145+
146+
let viewController = setupViewController()
147+
var process: LoginProcess!
148+
var capturedToken: AccessToken?
149+
process = LoginManager.shared.login(permissions: [.profile], in: viewController) { result in
150+
switch result {
151+
case .success(let loginResult):
152+
capturedToken = loginResult.accessToken
153+
XCTAssertEqual(loginResult.accessToken.value, PostExchangeTokenRequest.successToken)
154+
XCTAssertEqual(AccessTokenStore.shared.current?.value, PostExchangeTokenRequest.successToken)
155+
case .failure(let error):
156+
XCTFail("Unexpected failure: \(error)")
157+
}
158+
159+
loginExpectation.fulfill()
160+
}!
161+
162+
process.appUniversalLinkFlow = AppUniversalLinkFlow(parameter: sampleFlowParameters)
163+
164+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
165+
let urlString = "\(Constant.thirdPartyAppReturnURL)?code=123&state=\(process.processID)"
166+
let handled = process.resumeOpenURL(url: URL(string: urlString)!)
167+
XCTAssertTrue(handled)
168+
}
169+
170+
waitForExpectations(timeout: 2, handler: nil)
171+
172+
guard let token = capturedToken else {
173+
XCTFail("Token should be captured after login")
174+
return
175+
}
176+
177+
// After reset, in-memory singletons are cleared but the keychain token remains.
178+
LoginManager.shared.reset()
179+
XCTAssertFalse(LoginManager.shared.isSetupFinished)
180+
XCTAssertNil(LoginConfiguration._shared)
181+
XCTAssertNil(Session._shared)
182+
XCTAssertNil(AccessTokenStore._shared)
183+
184+
// Setup with a different channel should not see the previous token.
185+
let secondaryURL = URL(string: "https://alternate.example.com/auth")
186+
LoginManager.shared.setup(channelID: "456", universalLinkURL: secondaryURL)
187+
XCTAssertTrue(LoginManager.shared.isSetupFinished)
188+
XCTAssertNil(AccessTokenStore.shared.current)
189+
190+
// Reset again to switch back to the original channel.
191+
LoginManager.shared.reset()
192+
193+
let primaryURL = URL(string: "https://example.com/auth")
194+
LoginManager.shared.setup(channelID: "123", universalLinkURL: primaryURL)
195+
XCTAssertEqual(AccessTokenStore.shared.current?.value, token.value)
196+
}
197+
58198
func testLoginAction() {
59199
let expect = expectation(description: "\(#file)_\(#line)")
60200

@@ -349,7 +489,7 @@ class LoginManagerTests: XCTestCase, ViewControllerCompatibleTest {
349489
let mockAccessToken = try! JSONDecoder().decode(AccessToken.self, from: PostExchangeTokenRequest.successData)
350490

351491
// Create JWK from test RSA public key that matches the test JWT token
352-
let testRSAPublicKeyPEM = """
492+
/* let testRSAPublicKeyPEM */ _ = """
353493
-----BEGIN PUBLIC KEY-----
354494
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdlatRjRjogo3WojgGHFHYLugd
355495
UWAY9iR3fy4arWNA1KoS8kVw33cJibXr8bvwUAUparCwlvdbH6dvEOfou0/gCFQs
@@ -823,4 +963,3 @@ class LoginManagerTests: XCTestCase, ViewControllerCompatibleTest {
823963
}
824964

825965
}
826-

LineSDK/LineSDKTests/LoginButtonTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class LoginButtonTests: XCTestCase, ViewControllerCompatibleTest {
3535
}
3636

3737
override func tearDown() async throws {
38-
LoginManager.shared.reset()
38+
LoginManager.shared.resetForTesting()
3939
resetViewController()
4040
loginButton = nil
4141
}

LineSDK/LineSDKTests/Networking/RefreshTokenPipelineTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class RefreshTokenPipelineTests: XCTestCase, Sendable {
3333
}
3434

3535
override func tearDown() async throws {
36-
LoginManager.shared.reset()
36+
LoginManager.shared.resetForTesting()
3737
}
3838

3939
func testRefreshTokenPipelineSuccess() {

LineSDK/LineSDKTests/OpenChat/OpenChatControllerTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class OpenChatCreatingControllerTests: XCTestCase, ViewControllerCompatibleTest
3232
}
3333

3434
override func tearDown() async throws {
35-
LoginManager.shared.reset()
35+
LoginManager.shared.resetForTesting()
3636
resetViewController()
3737
}
3838

@@ -65,7 +65,7 @@ class OpenChatCreatingControllerTests: XCTestCase, ViewControllerCompatibleTest
6565

6666
func testLocalAuthorizationStatusForCreatingOpenChat() {
6767
// Test with no token
68-
LoginManager.shared.reset()
68+
LoginManager.shared.resetForTesting()
6969
LoginManager.shared.setup(channelID: "123", universalLinkURL: nil)
7070

7171
let statusNoToken = OpenChatCreatingController.localAuthorizationStatusForCreatingOpenChat()

LineSDK/LineSDKTests/Sharing/ShareControllerTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class ShareControllerTests: XCTestCase {
4545

4646
func testNoTokenStatus() {
4747
LoginManager.shared.setup(channelID: "123", universalLinkURL: nil)
48-
defer { LoginManager.shared.reset() }
48+
defer { LoginManager.shared.resetForTesting() }
4949

5050
let status = ShareViewController
5151
.localAuthorizationStatusForSendingMessage()

LineSDK/LineSDKTests/Sharing/ShareRootViewControllerTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class ShareRootViewControllerTests: XCTestCase, ViewControllerCompatibleTest {
3939
resetViewController()
4040
Session._shared = originalSession
4141
shareRootViewController = nil
42-
LoginManager.shared.reset()
42+
LoginManager.shared.resetForTesting()
4343
}
4444

4545
// MARK: - Initialization Tests

0 commit comments

Comments
 (0)