@wwdrew/expo-spotify-sdk 0.6.0 → 0.7.0

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
@@ -194,6 +194,7 @@ Starts a Spotify OAuth flow. If the Spotify app is installed it authenticates na
194
194
  | `scopes` | `SpotifyScope[]` | ✅ | OAuth scopes to request. Must contain at least one entry. |
195
195
  | `tokenSwapURL` | `string` | — | URL of your token swap server endpoint. Triggers CODE flow (recommended). Required on Android to receive a `refreshToken`. |
196
196
  | `tokenRefreshURL` | `string` | — | URL of your token refresh server endpoint. Used by iOS SDK natively and by `refreshSessionAsync` on both platforms. |
197
+ | `showDialog` | `boolean` | — | Force Spotify to show the authorization dialog even when the user already has an active session. Defaults to `false`. Useful during development; avoid in production. |
197
198
 
198
199
  **Returns (`SpotifySession`):**
199
200
 
@@ -94,6 +94,7 @@ class ExpoSpotifySDKModule : Module() {
94
94
  redirectUri = manifest.redirectUri,
95
95
  responseType = responseType,
96
96
  scopes = config.scopes.toTypedArray(),
97
+ showDialog = config.showDialog,
97
98
  )
98
99
 
99
100
  val response = coordinator.authenticate(authLauncher, input)
@@ -33,6 +33,7 @@ class SpotifyAuthorizationContract :
33
33
  input.redirectUri,
34
34
  )
35
35
  .setScopes(input.scopes)
36
+ .setShowDialog(input.showDialog)
36
37
  .build()
37
38
  return AuthorizationClient.createLoginActivityIntent(activity, request)
38
39
  }
@@ -12,6 +12,7 @@ class SpotifyAuthenticateOptions : Record {
12
12
  @Field val scopes: List<String> = emptyList()
13
13
  @Field val tokenSwapURL: String? = null
14
14
  @Field val tokenRefreshURL: String? = null
15
+ @Field val showDialog: Boolean = false
15
16
  }
16
17
 
17
18
  /**
@@ -60,6 +61,7 @@ data class SpotifyAuthInput(
60
61
  val redirectUri: String,
61
62
  val responseType: AuthorizationResponse.Type,
62
63
  val scopes: Array<String>,
64
+ val showDialog: Boolean,
63
65
  ) : Serializable {
64
66
  override fun equals(other: Any?): Boolean {
65
67
  if (this === other) return true
@@ -67,7 +69,8 @@ data class SpotifyAuthInput(
67
69
  return clientId == other.clientId &&
68
70
  redirectUri == other.redirectUri &&
69
71
  responseType == other.responseType &&
70
- scopes.contentEquals(other.scopes)
72
+ scopes.contentEquals(other.scopes) &&
73
+ showDialog == other.showDialog
71
74
  }
72
75
 
73
76
  override fun hashCode(): Int {
@@ -75,6 +78,7 @@ data class SpotifyAuthInput(
75
78
  result = 31 * result + redirectUri.hashCode()
76
79
  result = 31 * result + responseType.hashCode()
77
80
  result = 31 * result + scopes.contentHashCode()
81
+ result = 31 * result + showDialog.hashCode()
78
82
  return result
79
83
  }
80
84
  }
@@ -46,6 +46,14 @@ export interface SpotifyConfig {
46
46
  * `refreshSessionAsync` on both platforms.
47
47
  */
48
48
  tokenRefreshURL?: string;
49
+ /**
50
+ * If `true`, forces Spotify to show the authorization dialog even when
51
+ * the user already has an active session. Defaults to `false`.
52
+ *
53
+ * Maps to `SPTSessionManager.alwaysShowAuthorizationDialog` on iOS and
54
+ * `AuthorizationRequest.Builder.setShowDialog(true)` on Android.
55
+ */
56
+ showDialog?: boolean;
49
57
  }
50
58
  /**
51
59
  * Spotify OAuth scope identifiers that are valid through the iOS, Android
@@ -1 +1 @@
1
- {"version":3,"file":"ExpoSpotifySDK.types.d.ts","sourceRoot":"","sources":["../src/ExpoSpotifySDK.types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC7B,0BAA0B;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,uDAAuD;IACvD,cAAc,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,MAAM,EAAE,YAAY,EAAE,CAAC;CACxB;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,kEAAkE;IAClE,YAAY,EAAE,MAAM,CAAC;IACrB,iDAAiD;IACjD,eAAe,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,gEAAgE;IAChE,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,MAAM,YAAY,GACpB,kBAAkB,GAClB,0BAA0B,GAC1B,4BAA4B,GAC5B,6BAA6B,GAC7B,oBAAoB,GACpB,WAAW,GACX,uBAAuB,GACvB,6BAA6B,GAC7B,yBAAyB,GACzB,wBAAwB,GACxB,oBAAoB,GACpB,kBAAkB,GAClB,eAAe,GACf,2BAA2B,GAC3B,qBAAqB,GACrB,mBAAmB,GACnB,iBAAiB,GACjB,mBAAmB,CAAC;AAExB;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GACxB,gBAAgB,GAChB,kBAAkB,GAClB,gBAAgB,GAChB,eAAe,GACf,mBAAmB,GACnB,wBAAwB,GACxB,uBAAuB,GACvB,YAAY,GACZ,SAAS,CAAC;AAEd;;;;;;GAMG;AACH,MAAM,MAAM,yBAAyB,GACjC;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,cAAc,CAAA;CAAE,GAChD;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,OAAO,EAAE,cAAc,CAAA;CAAE,GAC7C;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE;QAAE,IAAI,EAAE,gBAAgB,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAE5E;;;GAGG;AACH,qBAAa,YAAa,SAAQ,KAAK;IACrC,SAAgB,IAAI,EAAE,gBAAgB,CAAC;gBAE3B,IAAI,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM;CAKpD"}
1
+ {"version":3,"file":"ExpoSpotifySDK.types.d.ts","sourceRoot":"","sources":["../src/ExpoSpotifySDK.types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC7B,0BAA0B;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,uDAAuD;IACvD,cAAc,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,MAAM,EAAE,YAAY,EAAE,CAAC;CACxB;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,kEAAkE;IAClE,YAAY,EAAE,MAAM,CAAC;IACrB,iDAAiD;IACjD,eAAe,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,gEAAgE;IAChE,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED;;;GAGG;AACH,MAAM,MAAM,YAAY,GACpB,kBAAkB,GAClB,0BAA0B,GAC1B,4BAA4B,GAC5B,6BAA6B,GAC7B,oBAAoB,GACpB,WAAW,GACX,uBAAuB,GACvB,6BAA6B,GAC7B,yBAAyB,GACzB,wBAAwB,GACxB,oBAAoB,GACpB,kBAAkB,GAClB,eAAe,GACf,2BAA2B,GAC3B,qBAAqB,GACrB,mBAAmB,GACnB,iBAAiB,GACjB,mBAAmB,CAAC;AAExB;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GACxB,gBAAgB,GAChB,kBAAkB,GAClB,gBAAgB,GAChB,eAAe,GACf,mBAAmB,GACnB,wBAAwB,GACxB,uBAAuB,GACvB,YAAY,GACZ,SAAS,CAAC;AAEd;;;;;;GAMG;AACH,MAAM,MAAM,yBAAyB,GACjC;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,cAAc,CAAA;CAAE,GAChD;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,OAAO,EAAE,cAAc,CAAA;CAAE,GAC7C;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE;QAAE,IAAI,EAAE,gBAAgB,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAE5E;;;GAGG;AACH,qBAAa,YAAa,SAAQ,KAAK;IACrC,SAAgB,IAAI,EAAE,gBAAgB,CAAC;gBAE3B,IAAI,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM;CAKpD"}
@@ -1 +1 @@
1
- {"version":3,"file":"ExpoSpotifySDK.types.js","sourceRoot":"","sources":["../src/ExpoSpotifySDK.types.ts"],"names":[],"mappings":"AAuGA;;;GAGG;AACH,MAAM,OAAO,YAAa,SAAQ,KAAK;IACrB,IAAI,CAAmB;IAEvC,YAAY,IAAsB,EAAE,OAAe;QACjD,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,cAAc,CAAC;QAC3B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF","sourcesContent":["/**\n * Result of a successful Spotify authentication.\n *\n * The shape is identical across iOS and Android. On the Android implicit\n * (TOKEN) flow `refreshToken` is `null` and `scopes` reflects what was\n * *requested*, not granted — see the README's \"Android implicit flow is not\n * recommended\" section.\n */\nexport interface SpotifySession {\n /** OAuth access token. */\n accessToken: string;\n /**\n * OAuth refresh token. `null` on Android when no `tokenSwapURL` is provided\n * (the Spotify Android SDK does not expose a refresh token for implicit\n * grants — see the README).\n */\n refreshToken: string | null;\n /** Expiration timestamp as Unix epoch milliseconds. */\n expirationDate: number;\n /** Scopes the access token was granted (or requested, on Android implicit). */\n scopes: SpotifyScope[];\n}\n\n/**\n * Configuration accepted by `refreshSessionAsync`.\n */\nexport interface SpotifyRefreshConfig {\n /** The refresh token from a previous `authenticateAsync` call. */\n refreshToken: string;\n /** URL of your token refresh server endpoint. */\n tokenRefreshURL: string;\n}\n\n/**\n * Configuration accepted by `authenticateAsync`.\n */\nexport interface SpotifyConfig {\n /** OAuth scopes to request. Must contain at least one entry. */\n scopes: SpotifyScope[];\n /**\n * If supplied, requests an authorization code rather than an implicit\n * token, then POSTs the code to this URL to exchange it for tokens.\n * **Required on Android** to receive a usable `refreshToken`.\n */\n tokenSwapURL?: string;\n /**\n * Used by the iOS SDK to refresh access tokens automatically, and by\n * `refreshSessionAsync` on both platforms.\n */\n tokenRefreshURL?: string;\n}\n\n/**\n * Spotify OAuth scope identifiers that are valid through the iOS, Android\n * and Web auth flows. See https://developer.spotify.com/documentation/web-api/concepts/scopes\n */\nexport type SpotifyScope =\n | \"ugc-image-upload\"\n | \"user-read-playback-state\"\n | \"user-modify-playback-state\"\n | \"user-read-currently-playing\"\n | \"app-remote-control\"\n | \"streaming\"\n | \"playlist-read-private\"\n | \"playlist-read-collaborative\"\n | \"playlist-modify-private\"\n | \"playlist-modify-public\"\n | \"user-follow-modify\"\n | \"user-follow-read\"\n | \"user-top-read\"\n | \"user-read-recently-played\"\n | \"user-library-modify\"\n | \"user-library-read\"\n | \"user-read-email\"\n | \"user-read-private\";\n\n/**\n * JS-side error code constants thrown via `Promise.reject(new Error(...))`\n * by the native modules.\n */\nexport type SpotifyErrorCode =\n | \"USER_CANCELLED\"\n | \"AUTH_IN_PROGRESS\"\n | \"INVALID_CONFIG\"\n | \"NETWORK_ERROR\"\n | \"TOKEN_SWAP_FAILED\"\n | \"TOKEN_SWAP_PARSE_ERROR\"\n | \"SPOTIFY_NOT_INSTALLED\"\n | \"AUTH_ERROR\"\n | \"UNKNOWN\";\n\n/**\n * Payload delivered to `addSessionChangeListener` subscribers.\n *\n * - `didInitiate` — a new session was created by `authenticateAsync`\n * - `didRenew` — an existing session was refreshed by `refreshSessionAsync`\n * - `didFail` — an auth or refresh attempt failed\n */\nexport type SpotifySessionChangeEvent =\n | { type: \"didInitiate\"; session: SpotifySession }\n | { type: \"didRenew\"; session: SpotifySession }\n | { type: \"didFail\"; error: { code: SpotifyErrorCode; message: string } };\n\n/**\n * Error subclass thrown by `authenticateAsync` and `refreshSessionAsync`\n * carrying a structured `code` field for branching.\n */\nexport class SpotifyError extends Error {\n public readonly code: SpotifyErrorCode;\n\n constructor(code: SpotifyErrorCode, message: string) {\n super(message);\n this.name = \"SpotifyError\";\n this.code = code;\n }\n}\n"]}
1
+ {"version":3,"file":"ExpoSpotifySDK.types.js","sourceRoot":"","sources":["../src/ExpoSpotifySDK.types.ts"],"names":[],"mappings":"AA+GA;;;GAGG;AACH,MAAM,OAAO,YAAa,SAAQ,KAAK;IACrB,IAAI,CAAmB;IAEvC,YAAY,IAAsB,EAAE,OAAe;QACjD,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,cAAc,CAAC;QAC3B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF","sourcesContent":["/**\n * Result of a successful Spotify authentication.\n *\n * The shape is identical across iOS and Android. On the Android implicit\n * (TOKEN) flow `refreshToken` is `null` and `scopes` reflects what was\n * *requested*, not granted — see the README's \"Android implicit flow is not\n * recommended\" section.\n */\nexport interface SpotifySession {\n /** OAuth access token. */\n accessToken: string;\n /**\n * OAuth refresh token. `null` on Android when no `tokenSwapURL` is provided\n * (the Spotify Android SDK does not expose a refresh token for implicit\n * grants — see the README).\n */\n refreshToken: string | null;\n /** Expiration timestamp as Unix epoch milliseconds. */\n expirationDate: number;\n /** Scopes the access token was granted (or requested, on Android implicit). */\n scopes: SpotifyScope[];\n}\n\n/**\n * Configuration accepted by `refreshSessionAsync`.\n */\nexport interface SpotifyRefreshConfig {\n /** The refresh token from a previous `authenticateAsync` call. */\n refreshToken: string;\n /** URL of your token refresh server endpoint. */\n tokenRefreshURL: string;\n}\n\n/**\n * Configuration accepted by `authenticateAsync`.\n */\nexport interface SpotifyConfig {\n /** OAuth scopes to request. Must contain at least one entry. */\n scopes: SpotifyScope[];\n /**\n * If supplied, requests an authorization code rather than an implicit\n * token, then POSTs the code to this URL to exchange it for tokens.\n * **Required on Android** to receive a usable `refreshToken`.\n */\n tokenSwapURL?: string;\n /**\n * Used by the iOS SDK to refresh access tokens automatically, and by\n * `refreshSessionAsync` on both platforms.\n */\n tokenRefreshURL?: string;\n /**\n * If `true`, forces Spotify to show the authorization dialog even when\n * the user already has an active session. Defaults to `false`.\n *\n * Maps to `SPTSessionManager.alwaysShowAuthorizationDialog` on iOS and\n * `AuthorizationRequest.Builder.setShowDialog(true)` on Android.\n */\n showDialog?: boolean;\n}\n\n/**\n * Spotify OAuth scope identifiers that are valid through the iOS, Android\n * and Web auth flows. See https://developer.spotify.com/documentation/web-api/concepts/scopes\n */\nexport type SpotifyScope =\n | \"ugc-image-upload\"\n | \"user-read-playback-state\"\n | \"user-modify-playback-state\"\n | \"user-read-currently-playing\"\n | \"app-remote-control\"\n | \"streaming\"\n | \"playlist-read-private\"\n | \"playlist-read-collaborative\"\n | \"playlist-modify-private\"\n | \"playlist-modify-public\"\n | \"user-follow-modify\"\n | \"user-follow-read\"\n | \"user-top-read\"\n | \"user-read-recently-played\"\n | \"user-library-modify\"\n | \"user-library-read\"\n | \"user-read-email\"\n | \"user-read-private\";\n\n/**\n * JS-side error code constants thrown via `Promise.reject(new Error(...))`\n * by the native modules.\n */\nexport type SpotifyErrorCode =\n | \"USER_CANCELLED\"\n | \"AUTH_IN_PROGRESS\"\n | \"INVALID_CONFIG\"\n | \"NETWORK_ERROR\"\n | \"TOKEN_SWAP_FAILED\"\n | \"TOKEN_SWAP_PARSE_ERROR\"\n | \"SPOTIFY_NOT_INSTALLED\"\n | \"AUTH_ERROR\"\n | \"UNKNOWN\";\n\n/**\n * Payload delivered to `addSessionChangeListener` subscribers.\n *\n * - `didInitiate` — a new session was created by `authenticateAsync`\n * - `didRenew` — an existing session was refreshed by `refreshSessionAsync`\n * - `didFail` — an auth or refresh attempt failed\n */\nexport type SpotifySessionChangeEvent =\n | { type: \"didInitiate\"; session: SpotifySession }\n | { type: \"didRenew\"; session: SpotifySession }\n | { type: \"didFail\"; error: { code: SpotifyErrorCode; message: string } };\n\n/**\n * Error subclass thrown by `authenticateAsync` and `refreshSessionAsync`\n * carrying a structured `code` field for branching.\n */\nexport class SpotifyError extends Error {\n public readonly code: SpotifyErrorCode;\n\n constructor(code: SpotifyErrorCode, message: string) {\n super(message);\n this.name = \"SpotifyError\";\n this.code = code;\n }\n}\n"]}
@@ -28,7 +28,8 @@ public class ExpoSpotifySDKModule: Module {
28
28
  let session = try await coordinator.authenticate(
29
29
  scopes: scopes,
30
30
  tokenSwapURL: config.tokenSwapURL.flatMap(URL.init),
31
- tokenRefreshURL: config.tokenRefreshURL.flatMap(URL.init)
31
+ tokenRefreshURL: config.tokenRefreshURL.flatMap(URL.init),
32
+ showDialog: config.showDialog
32
33
  )
33
34
  let map = self.sessionToMap(session)
34
35
  self.sendEvent(EVENT_SESSION_CHANGE, ["type": "didInitiate", "session": map])
@@ -99,6 +100,7 @@ struct AuthenticateOptions: Record {
99
100
  @Field var scopes: [String] = []
100
101
  @Field var tokenSwapURL: String? = nil
101
102
  @Field var tokenRefreshURL: String? = nil
103
+ @Field var showDialog: Bool = false
102
104
  }
103
105
 
104
106
  struct RefreshOptions: Record {
@@ -78,7 +78,8 @@ actor SpotifyAuthCoordinator {
78
78
  func authenticate(
79
79
  scopes: SPTScope,
80
80
  tokenSwapURL: URL?,
81
- tokenRefreshURL: URL?
81
+ tokenRefreshURL: URL?,
82
+ showDialog: Bool = false
82
83
  ) async throws -> SPTSession {
83
84
  guard pending == nil else { throw SpotifyError.authInProgress }
84
85
 
@@ -86,6 +87,17 @@ actor SpotifyAuthCoordinator {
86
87
  // mutating it here takes effect for the upcoming initiateSession call.
87
88
  sptConfiguration.tokenSwapURL = tokenSwapURL
88
89
  sptConfiguration.tokenRefreshURL = tokenRefreshURL
90
+ // alwaysShowAuthorizationDialog is a property on SPTSessionManager, not an
91
+ // SPTAuthorizationOptions flag (the options enum has no such case).
92
+ sessionManager.alwaysShowAuthorizationDialog = showDialog
93
+
94
+ NSLog(
95
+ "[ExpoSpotifySDK] initiateSession redirectURL=%@ tokenSwapURL=%@ tokenRefreshURL=%@ showDialog=%d",
96
+ sptConfiguration.redirectURL.absoluteString,
97
+ tokenSwapURL?.absoluteString ?? "nil",
98
+ tokenRefreshURL?.absoluteString ?? "nil",
99
+ showDialog ? 1 : 0
100
+ )
89
101
 
90
102
  return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<SPTSession, Error>) in
91
103
  self.pending = cont
@@ -109,15 +121,28 @@ final class SpotifySessionDelegateBridge: NSObject, SPTSessionManagerDelegate {
109
121
  weak var coordinator: SpotifyAuthCoordinator?
110
122
 
111
123
  func sessionManager(manager _: SPTSessionManager, didInitiate session: SPTSession) {
124
+ NSLog(
125
+ "[ExpoSpotifySDK] didInitiate accessToken.length=%d refreshToken.length=%d expirationDate=%@ scope=%lu",
126
+ session.accessToken.count,
127
+ session.refreshToken.count,
128
+ session.expirationDate as NSDate,
129
+ session.scope.rawValue
130
+ )
112
131
  Task { await coordinator?.deliver(.success(session)) }
113
132
  }
114
133
 
115
134
  func sessionManager(manager _: SPTSessionManager, didFailWith error: Error) {
116
135
  let mapped: Error = mapSDKError(error)
136
+ NSLog("[ExpoSpotifySDK] didFailWithError %@", String(describing: error))
117
137
  Task { await coordinator?.deliver(.failure(mapped)) }
118
138
  }
119
139
 
120
140
  func sessionManager(manager _: SPTSessionManager, didRenew session: SPTSession) {
141
+ NSLog(
142
+ "[ExpoSpotifySDK] didRenew accessToken.length=%d refreshToken.length=%d",
143
+ session.accessToken.count,
144
+ session.refreshToken.count
145
+ )
121
146
  Task { await coordinator?.deliver(.success(session)) }
122
147
  }
123
148
 
@@ -128,6 +153,34 @@ final class SpotifySessionDelegateBridge: NSObject, SPTSessionManagerDelegate {
128
153
  if description.contains("cancel") {
129
154
  return SpotifyError.userCancelled
130
155
  }
131
- return SpotifyError.underlying(error)
156
+ return SpotifyError.underlying(NSError(
157
+ domain: nsError.domain,
158
+ code: nsError.code,
159
+ userInfo: [NSLocalizedDescriptionKey: describeError(nsError)]
160
+ ))
161
+ }
162
+
163
+ /// Build a diagnostic string from an NSError that includes the domain,
164
+ /// code, localized description, and the full chain of underlying errors.
165
+ /// Used because `SPTError` (and many `URLSession` errors it wraps) often
166
+ /// have an empty `localizedDescription`, surfacing as "undefined reason"
167
+ /// in JS without this expansion.
168
+ private func describeError(_ error: NSError) -> String {
169
+ var parts: [String] = []
170
+ parts.append("\(error.domain) code \(error.code)")
171
+ let desc = error.localizedDescription
172
+ if !desc.isEmpty {
173
+ parts.append("\"\(desc)\"")
174
+ }
175
+ var ui = error.userInfo
176
+ ui.removeValue(forKey: NSUnderlyingErrorKey)
177
+ ui.removeValue(forKey: NSLocalizedDescriptionKey)
178
+ if !ui.isEmpty {
179
+ parts.append("userInfo=\(ui)")
180
+ }
181
+ if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError {
182
+ parts.append("→ underlying: \(describeError(underlying))")
183
+ }
184
+ return parts.joined(separator: " ")
132
185
  }
133
186
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wwdrew/expo-spotify-sdk",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Expo module wrapping the native Spotify iOS (v5) and Android (v4) SDKs for OAuth authentication",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",