@wwdrew/expo-spotify-sdk 2.2.2 → 2.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -406,6 +406,7 @@ Need the deprecated shims temporarily? Pin **`1.x`**: `npm install @wwdrew/expo-
406
406
  | --- | --- |
407
407
  | [API reference](./docs/api-reference.md) | All namespaces, methods, hooks |
408
408
  | [Error codes](./docs/error-codes.md) | Per-namespace codes with when/what-to-do |
409
+ | [Auth error mapping](./docs/auth-error-mapping.md) | iOS/Android auth → JS mapping matrix and parity checklist |
409
410
  | [App Remote error mapping](./docs/app-remote-error-mapping.md) | iOS/Android native → JS mapping matrix |
410
411
  | [Platform differences](./docs/guides/platform-differences.md) | iOS vs Android parity |
411
412
  | [Native SDK distribution](./docs/guides/native-sdk-distribution.md) | How iOS/Android binaries are fetched, packaged, and bumped |
@@ -8,7 +8,7 @@ import expo.modules.kotlin.functions.Coroutine
8
8
  import expo.modules.kotlin.modules.Module
9
9
  import expo.modules.kotlin.modules.ModuleDefinition
10
10
 
11
- private const val SDK_VERSION = "2.2.2" // x-release-please-version
11
+ private const val SDK_VERSION = "2.2.3" // x-release-please-version
12
12
  private const val EVENT_SESSION_CHANGE = "onSessionChange"
13
13
  private const val EVENT_CONNECTION_STATE_CHANGE = "onConnectionStateChange"
14
14
  private const val EVENT_CONNECTION_ERROR = "onConnectionError"
@@ -165,6 +165,13 @@ class ExpoSpotifySDKModule : Module() {
165
165
  } catch (e: expo.modules.kotlin.exception.CodedException) {
166
166
  emitSessionError(e.code ?: "UNKNOWN", e.localizedMessage ?: e.code ?: "Unknown error")
167
167
  throw e
168
+ } catch (e: Exception) {
169
+ val wrapped = UnknownSpotifyException(
170
+ "Unexpected authentication error: ${e.message ?: e::class.java.simpleName}",
171
+ e,
172
+ )
173
+ emitSessionError(wrapped.code ?: "UNKNOWN", wrapped.localizedMessage ?: "Unknown error")
174
+ throw wrapped
168
175
  }
169
176
  }
170
177
 
@@ -187,6 +194,13 @@ class ExpoSpotifySDKModule : Module() {
187
194
  } catch (e: expo.modules.kotlin.exception.CodedException) {
188
195
  emitSessionError(e.code ?: "UNKNOWN", e.localizedMessage ?: e.code ?: "Unknown error")
189
196
  throw e
197
+ } catch (e: Exception) {
198
+ val wrapped = UnknownSpotifyException(
199
+ "Unexpected token refresh error: ${e.message ?: e::class.java.simpleName}",
200
+ e,
201
+ )
202
+ emitSessionError(wrapped.code ?: "UNKNOWN", wrapped.localizedMessage ?: "Unknown error")
203
+ throw wrapped
190
204
  }
191
205
  }
192
206
 
@@ -4,6 +4,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
4
4
  import okhttp3.Call
5
5
  import okhttp3.Callback
6
6
  import okhttp3.FormBody
7
+ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
7
8
  import okhttp3.OkHttpClient
8
9
  import okhttp3.Request
9
10
  import okhttp3.Response
@@ -43,11 +44,13 @@ class SpotifyTokenSwapClient(
43
44
  tokenSwapURL: String,
44
45
  requestedScopes: List<String>,
45
46
  ): SpotifySessionPayload {
47
+ val url = tokenSwapURL.toHttpUrlOrNull()
48
+ ?: throw InvalidConfigException("Invalid token swap URL: $tokenSwapURL")
46
49
  val body = FormBody.Builder()
47
50
  .add("code", code)
48
51
  .build()
49
52
  val request = Request.Builder()
50
- .url(tokenSwapURL)
53
+ .url(url)
51
54
  .header("User-Agent", userAgent)
52
55
  .post(body)
53
56
  .build()
@@ -60,11 +63,13 @@ class SpotifyTokenSwapClient(
60
63
  tokenRefreshURL: String,
61
64
  previousScopes: List<String>,
62
65
  ): SpotifySessionPayload {
66
+ val url = tokenRefreshURL.toHttpUrlOrNull()
67
+ ?: throw InvalidConfigException("Invalid token refresh URL: $tokenRefreshURL")
63
68
  val body = FormBody.Builder()
64
69
  .add("refresh_token", refreshToken)
65
70
  .build()
66
71
  val request = Request.Builder()
67
- .url(tokenRefreshURL)
72
+ .url(url)
68
73
  .header("User-Agent", userAgent)
69
74
  .post(body)
70
75
  .build()
@@ -1,7 +1,7 @@
1
1
  import ExpoModulesCore
2
2
  import SpotifyiOS
3
3
 
4
- private let SDK_VERSION = "2.2.2" // x-release-please-version
4
+ private let SDK_VERSION = "2.2.3" // x-release-please-version
5
5
  private let EVENT_SESSION_CHANGE = "onSessionChange"
6
6
  private let EVENT_CONNECTION_STATE_CHANGE = "onConnectionStateChange"
7
7
  private let EVENT_CONNECTION_ERROR = "onConnectionError"
@@ -1,4 +1,5 @@
1
1
  import ExpoModulesCore
2
+ import AuthenticationServices
2
3
  import Foundation
3
4
  import SpotifyiOS
4
5
  import UIKit
@@ -11,6 +12,10 @@ enum SpotifyError: Error {
11
12
  case authInProgress
12
13
  case userCancelled
13
14
  case spotifyNotInstalled
15
+ case networkError(message: String, cause: Error)
16
+ case tokenSwapFailed(status: Int?, message: String, cause: Error)
17
+ case tokenSwapParseError(message: String, cause: Error)
18
+ case authError(message: String, cause: Error)
14
19
  case underlying(message: String, cause: Error)
15
20
 
16
21
  var code: String {
@@ -19,6 +24,10 @@ enum SpotifyError: Error {
19
24
  case .authInProgress: return "AUTH_IN_PROGRESS"
20
25
  case .userCancelled: return "USER_CANCELLED"
21
26
  case .spotifyNotInstalled: return "SPOTIFY_NOT_INSTALLED"
27
+ case .networkError: return "NETWORK_ERROR"
28
+ case .tokenSwapFailed: return "TOKEN_SWAP_FAILED"
29
+ case .tokenSwapParseError: return "TOKEN_SWAP_PARSE_ERROR"
30
+ case .authError: return "AUTH_ERROR"
22
31
  case .underlying: return "UNKNOWN"
23
32
  }
24
33
  }
@@ -29,6 +38,10 @@ enum SpotifyError: Error {
29
38
  case .authInProgress: return "Another authentication request is already in progress"
30
39
  case .userCancelled: return "Authentication was cancelled by the user"
31
40
  case .spotifyNotInstalled: return "The Spotify app is not installed on this device"
41
+ case .networkError(let message, _): return message
42
+ case .tokenSwapFailed(_, let message, _): return message
43
+ case .tokenSwapParseError(let message, _): return message
44
+ case .authError(let message, _): return message
32
45
  case .underlying(let message, _): return message
33
46
  }
34
47
  }
@@ -38,6 +51,10 @@ enum SpotifyError: Error {
38
51
  /// full chain rather than collapsing into "undefined reason".
39
52
  var underlyingCause: Error? {
40
53
  switch self {
54
+ case .networkError(_, let cause): return cause
55
+ case .tokenSwapFailed(_, _, let cause): return cause
56
+ case .tokenSwapParseError(_, let cause): return cause
57
+ case .authError(_, let cause): return cause
41
58
  case .underlying(_, let cause): return cause
42
59
  default: return nil
43
60
  }
@@ -123,6 +140,10 @@ actor SpotifyAuthCoordinator {
123
140
  // mutating it here takes effect for the upcoming initiateSession call.
124
141
  sptConfiguration.tokenSwapURL = tokenSwapURL
125
142
  sptConfiguration.tokenRefreshURL = tokenRefreshURL
143
+ bridge.updateAuthContext(
144
+ tokenSwapURL: tokenSwapURL,
145
+ tokenRefreshURL: tokenRefreshURL
146
+ )
126
147
  // alwaysShowAuthorizationDialog is a property on SPTSessionManager, not an
127
148
  // SPTAuthorizationOptions flag (the options enum has no such case).
128
149
  sessionManager.alwaysShowAuthorizationDialog = showDialog
@@ -146,6 +167,7 @@ actor SpotifyAuthCoordinator {
146
167
  func deliver(_ result: Result<SPTSession, Error>) {
147
168
  let cont = pending
148
169
  pending = nil
170
+ bridge.clearAuthContext()
149
171
  cont?.resume(with: result)
150
172
  }
151
173
 
@@ -168,7 +190,21 @@ actor SpotifyAuthCoordinator {
168
190
  /// can't satisfy directly. The bridge is a tiny NSObject that hops the call
169
191
  /// onto the actor's executor.
170
192
  final class SpotifySessionDelegateBridge: NSObject, SPTSessionManagerDelegate {
193
+ private struct AuthContext {
194
+ let tokenSwapURL: URL?
195
+ let tokenRefreshURL: URL?
196
+ }
197
+
171
198
  weak var coordinator: SpotifyAuthCoordinator?
199
+ private var authContext: AuthContext?
200
+
201
+ func updateAuthContext(tokenSwapURL: URL?, tokenRefreshURL: URL?) {
202
+ authContext = AuthContext(tokenSwapURL: tokenSwapURL, tokenRefreshURL: tokenRefreshURL)
203
+ }
204
+
205
+ func clearAuthContext() {
206
+ authContext = nil
207
+ }
172
208
 
173
209
  func sessionManager(manager _: SPTSessionManager, didInitiate session: SPTSession) {
174
210
  NSLog(
@@ -199,14 +235,194 @@ final class SpotifySessionDelegateBridge: NSObject, SPTSessionManagerDelegate {
199
235
  /// Translate a few well-known SDK error shapes into our taxonomy.
200
236
  private func mapSDKError(_ error: Error) -> Error {
201
237
  let nsError = error as NSError
202
- let description = nsError.localizedDescription.lowercased()
203
- if description.contains("cancel") {
238
+ if isUserCancelled(error: nsError) {
239
+ NSLog(
240
+ "[ExpoSpotifySDK] mapSDKError classified=USER_CANCELLED domain=%@ code=%d detail=%@",
241
+ nsError.domain,
242
+ nsError.code,
243
+ describeError(nsError)
244
+ )
204
245
  return SpotifyError.userCancelled
205
246
  }
247
+
248
+ if let classified = classifyNonCancellationAuthError(nsError) {
249
+ return classified
250
+ }
251
+
206
252
  // Keep the original NSError as `cause` so the structured underlying-chain
207
253
  // is preserved (Sentry, debug breadcrumbs); the rendered string goes to
208
254
  // `message` so JS callers get a single human-readable line.
209
- return SpotifyError.underlying(message: describeError(nsError), cause: nsError)
255
+ let detail = describeError(nsError)
256
+ NSLog(
257
+ "[ExpoSpotifySDK] mapSDKError classified=UNKNOWN domain=%@ code=%d detail=%@",
258
+ nsError.domain,
259
+ nsError.code,
260
+ detail
261
+ )
262
+ return SpotifyError.underlying(message: detail, cause: nsError)
263
+ }
264
+
265
+ private func classifyNonCancellationAuthError(_ error: NSError) -> SpotifyError? {
266
+ let detail = describeError(error)
267
+ let lower = detail.lowercased()
268
+ let tokenSwapConfigured = authContext?.tokenSwapURL != nil
269
+
270
+ if isNetworkFailure(error) {
271
+ return SpotifyError.networkError(message: "Network error during Spotify authentication: \(detail)", cause: error)
272
+ }
273
+
274
+ // OAuth-style rejection from Spotify auth endpoints — evaluated before the
275
+ // tokenSwapConfigured gate so these are never misclassified as token-swap errors.
276
+ if lower.contains("access_denied") || lower.contains("invalid_scope") || lower.contains("invalid_client") ||
277
+ lower.contains("authorization") || lower.contains("oauth") || lower.contains("spotify account")
278
+ {
279
+ return SpotifyError.authError(
280
+ message: "Spotify authorization failed: \(detail)",
281
+ cause: error
282
+ )
283
+ }
284
+
285
+ if let status = extractHTTPStatusCode(from: detail), status == 401 || status == 403 {
286
+ return SpotifyError.authError(
287
+ message: "Spotify authorization failed (HTTP \(status)): \(detail)",
288
+ cause: error
289
+ )
290
+ }
291
+
292
+ if tokenSwapConfigured || lower.contains("token swap") || lower.contains("token endpoint") || lower.contains("token exchange") {
293
+ if let status = extractHTTPStatusCode(from: detail) {
294
+ return SpotifyError.tokenSwapFailed(
295
+ status: status,
296
+ message: "Token swap server returned HTTP \(status): \(detail)",
297
+ cause: error
298
+ )
299
+ }
300
+ if lower.contains("parse") || lower.contains("json") || lower.contains("decode") || lower.contains("malformed") {
301
+ return SpotifyError.tokenSwapParseError(
302
+ message: "Token swap response was invalid: \(detail)",
303
+ cause: error
304
+ )
305
+ }
306
+ return SpotifyError.tokenSwapFailed(
307
+ status: nil,
308
+ message: "Token swap failed: \(detail)",
309
+ cause: error
310
+ )
311
+ }
312
+
313
+ return nil
314
+ }
315
+
316
+ /// Detect user cancellation from the full NSError chain rather than relying
317
+ /// on a single wrapper domain/code pair.
318
+ private func isUserCancelled(error: NSError) -> Bool {
319
+ var visited = Set<ObjectIdentifier>()
320
+ var stack: [NSError] = [error]
321
+
322
+ while let current = stack.popLast() {
323
+ let id = ObjectIdentifier(current)
324
+ guard visited.insert(id).inserted else { continue }
325
+
326
+ if isKnownCancellationCode(current) || messageLooksCancelled(current) {
327
+ return true
328
+ }
329
+
330
+ if let underlying = current.userInfo[NSUnderlyingErrorKey] as? NSError {
331
+ stack.append(underlying)
332
+ }
333
+ }
334
+
335
+ return false
336
+ }
337
+
338
+ private func isKnownCancellationCode(_ error: NSError) -> Bool {
339
+ if error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled {
340
+ return true
341
+ }
342
+
343
+ if #available(iOS 12.0, *),
344
+ error.domain == ASWebAuthenticationSessionErrorDomain,
345
+ error.code == ASWebAuthenticationSessionError.canceledLogin.rawValue
346
+ {
347
+ return true
348
+ }
349
+
350
+ if error.domain == "SFAuthenticationErrorDomain" && error.code == 1 {
351
+ return true
352
+ }
353
+
354
+ return false
355
+ }
356
+
357
+ private func isNetworkFailure(_ error: NSError) -> Bool {
358
+ var visited = Set<ObjectIdentifier>()
359
+ var stack: [NSError] = [error]
360
+
361
+ while let current = stack.popLast() {
362
+ let id = ObjectIdentifier(current)
363
+ guard visited.insert(id).inserted else { continue }
364
+
365
+ if current.domain == NSURLErrorDomain,
366
+ current.code != NSURLErrorCancelled
367
+ {
368
+ return true
369
+ }
370
+
371
+ if let underlying = current.userInfo[NSUnderlyingErrorKey] as? NSError {
372
+ stack.append(underlying)
373
+ }
374
+ }
375
+
376
+ return false
377
+ }
378
+
379
+ private func messageLooksCancelled(_ error: NSError) -> Bool {
380
+ // Only apply fuzzy text matching in auth/browser domains to avoid mapping
381
+ // unrelated transport or backend failures into USER_CANCELLED.
382
+ guard isLikelyAuthCancellationDomain(error.domain) else {
383
+ return false
384
+ }
385
+
386
+ let cancelKeywords = ["cancel", "canceled", "cancelled"]
387
+ let negativeKeywords = [
388
+ "timed out",
389
+ "timeout",
390
+ "network",
391
+ "offline",
392
+ "unreachable",
393
+ "server",
394
+ "unauthorized",
395
+ "forbidden",
396
+ "invalid"
397
+ ]
398
+ let messageParts = [
399
+ error.localizedDescription,
400
+ error.localizedFailureReason ?? "",
401
+ error.userInfo[NSLocalizedDescriptionKey] as? String ?? "",
402
+ error.userInfo[NSLocalizedFailureReasonErrorKey] as? String ?? ""
403
+ ]
404
+ let combined = messageParts
405
+ .joined(separator: " ")
406
+ .lowercased()
407
+
408
+ if negativeKeywords.contains(where: { combined.contains($0) }) {
409
+ return false
410
+ }
411
+
412
+ return cancelKeywords.contains(where: { combined.contains($0) })
413
+ }
414
+
415
+ private func isLikelyAuthCancellationDomain(_ domain: String) -> Bool {
416
+ if domain == ASWebAuthenticationSessionErrorDomain || domain == "SFAuthenticationErrorDomain" {
417
+ return true
418
+ }
419
+
420
+ // SPT wrappers commonly bubble auth-web errors under their own namespace.
421
+ if domain.lowercased().contains("spotify") && domain.lowercased().contains("auth") {
422
+ return true
423
+ }
424
+
425
+ return false
210
426
  }
211
427
 
212
428
  /// Build a diagnostic string from an NSError that includes the domain,
@@ -232,4 +448,29 @@ final class SpotifySessionDelegateBridge: NSObject, SPTSessionManagerDelegate {
232
448
  }
233
449
  return parts.joined(separator: " ")
234
450
  }
451
+
452
+ private func extractHTTPStatusCode(from message: String) -> Int? {
453
+ let patterns = [
454
+ #"http\s+([1-5][0-9]{2})"#,
455
+ #"status(?:\s+code)?[:=\s]+([1-5][0-9]{2})"#
456
+ ]
457
+
458
+ for pattern in patterns {
459
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
460
+ continue
461
+ }
462
+ let range = NSRange(message.startIndex..<message.endIndex, in: message)
463
+ guard let match = regex.firstMatch(in: message, options: [], range: range),
464
+ match.numberOfRanges > 1,
465
+ let codeRange = Range(match.range(at: 1), in: message)
466
+ else {
467
+ continue
468
+ }
469
+ if let code = Int(message[codeRange]) {
470
+ return code
471
+ }
472
+ }
473
+
474
+ return nil
475
+ }
235
476
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wwdrew/expo-spotify-sdk",
3
- "version": "2.2.2",
3
+ "version": "2.2.3",
4
4
  "description": "Expo module wrapping the native Spotify iOS (v5) and Android (v4) SDKs for OAuth authentication and App Remote playback control",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",