@wwdrew/expo-spotify-sdk 2.2.3 → 2.3.1

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
@@ -308,6 +308,35 @@ function NowPlaying() {
308
308
 
309
309
  Omit `tokenSwapURL` / `tokenRefreshURL` to use the implicit TOKEN flow (iOS only for refresh; not recommended on Android — see below). For local development without a swap server, auth can still succeed on iOS; production apps should use the code + swap flow.
310
310
 
311
+ ### Refreshing the session and handling token expiry
312
+
313
+ Use `Auth.refresh()` to exchange a stored `refreshToken` for a fresh access token via your `tokenRefreshURL`.
314
+
315
+ > **Spotify policy (from July 20, 2026):** refresh tokens expire **six months** after they are issued. Once expired, Spotify returns an `invalid_grant` error and the token can no longer be used — the user must sign in again. (Only user tokens are affected; Client Credentials flows are not.)
316
+
317
+ Handle a failed refresh by **discarding the stored token and re-running `Auth.authenticate()`** — do **not** retry the refresh. When your swap server forwards Spotify's response verbatim (see [Token swap server](./docs/guides/token-swap-server.md)), an expired or revoked refresh token surfaces as `AuthError` with the dedicated code `REFRESH_TOKEN_EXPIRED`:
318
+
319
+ ```ts
320
+ async function restoreSession(refreshToken: string) {
321
+ try {
322
+ return await Auth.refresh({
323
+ refreshToken,
324
+ tokenRefreshURL: "https://your-server.example.com/refresh",
325
+ });
326
+ } catch (e) {
327
+ if (e instanceof AuthError && e.code === "REFRESH_TOKEN_EXPIRED") {
328
+ // Token expired or revoked: discard it and send the user through
329
+ // sign-in again. Never retry the refresh.
330
+ await clearStoredSession();
331
+ return login(); // your Auth.authenticate() wrapper
332
+ }
333
+ throw e; // NETWORK_ERROR / TOKEN_SWAP_FAILED etc. — transient; handle/retry as appropriate
334
+ }
335
+ }
336
+ ```
337
+
338
+ Test this reauthorization path before July 20, 2026 to avoid disruption for existing users.
339
+
311
340
  ### Check account tier (Web API)
312
341
 
313
342
  App Remote does not expose Premium status. Call Spotify's Web API with the access token from `Auth.authenticate()`:
@@ -428,6 +457,13 @@ Ensure your app's URL scheme is registered in Xcode under **Info → URL Types**
428
457
 
429
458
  On iOS this can also be a stuck state when Spotify never redirects back. Call `Auth.cancelPending()` before retrying.
430
459
 
460
+ **iOS: errors report `UNKNOWN` with the correct message (Expo SDK ≤ 56)**
461
+ On iOS before Expo SDK 57, `expo-modules-core` dropped the structured error `code` when an async function rejected — the message survived but the code did not — so a real code like `USER_CANCELLED` reached JS as `UNKNOWN: Authentication was cancelled by the user`. This is a runtime/bridge limitation, **not** a misclassification: the native module emits the correct code, and the `Auth.addListener("sessionChange", …)` `didFail` event reports it correctly regardless of runtime.
462
+
463
+ Fixed in **Expo SDK 57** ([expo/expo#47259](https://github.com/expo/expo/pull/47259)). On SDK 57+ `Auth.authenticate()` rejects with the accurate `AuthError.code` and no action is needed.
464
+
465
+ _Optional — for accurate `code`s on Expo SDK 56 and earlier:_ apply the change from that PR to your installed `expo-modules-core` with [`patch-package`](https://github.com/ds300/patch-package). It routes thrown `Exception`s through the code-preserving conversion in `JavaScriptPromise.reject` instead of stringifying them. If you'd rather not patch, read the code from the `sessionChange` `didFail` event, which already carries the correct `code`.
466
+
431
467
  **App Remote: `CONNECTION_FAILED` / `Connection refused` (iOS code 61)**
432
468
  The Spotify app is installed but its App Remote transport is not accepting connections. Common causes:
433
469
 
@@ -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.3" // x-release-please-version
11
+ private const val SDK_VERSION = "2.3.1" // 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"
@@ -24,6 +24,13 @@ class TokenSwapFailedException(status: Int, body: String?) :
24
24
  class TokenSwapParseException(message: String, cause: Throwable? = null) :
25
25
  CodedException("TOKEN_SWAP_PARSE_ERROR", message, cause)
26
26
 
27
+ class RefreshTokenExpiredException :
28
+ CodedException(
29
+ "REFRESH_TOKEN_EXPIRED",
30
+ "The refresh token is no longer valid (expired or revoked). The user must sign in again.",
31
+ null,
32
+ )
33
+
27
34
  class SpotifyNotInstalledException(message: String = "The Spotify app is not installed on this device") :
28
35
  CodedException("SPOTIFY_NOT_INSTALLED", message, null)
29
36
 
@@ -73,7 +73,7 @@ class SpotifyTokenSwapClient(
73
73
  .header("User-Agent", userAgent)
74
74
  .post(body)
75
75
  .build()
76
- val json = executeJson(request)
76
+ val json = executeJson(request, invalidGrantIsExpiredToken = true)
77
77
  return parseSessionPayload(
78
78
  json,
79
79
  fallbackScopes = previousScopes,
@@ -81,7 +81,10 @@ class SpotifyTokenSwapClient(
81
81
  )
82
82
  }
83
83
 
84
- private suspend fun executeJson(request: Request): JSONObject {
84
+ private suspend fun executeJson(
85
+ request: Request,
86
+ invalidGrantIsExpiredToken: Boolean = false,
87
+ ): JSONObject {
85
88
  val response = try {
86
89
  client.executeAsync(request)
87
90
  } catch (e: IOException) {
@@ -90,6 +93,15 @@ class SpotifyTokenSwapClient(
90
93
  response.use { res ->
91
94
  val raw = res.body?.string()
92
95
  if (!res.isSuccessful) {
96
+ // On refresh, Spotify returns `invalid_grant` (HTTP 400) when the
97
+ // refresh token has expired or been revoked — surface a dedicated code
98
+ // so callers can route to sign-in. On the swap path the same body means
99
+ // a bad authorization code, so this remap is scoped to refresh only.
100
+ // Match the structured OAuth `error` field (RFC 6749) on a 400 rather
101
+ // than a raw-body substring, so unrelated payloads can't misfire.
102
+ if (invalidGrantIsExpiredToken && res.code == 400 && isInvalidGrant(raw)) {
103
+ throw RefreshTokenExpiredException()
104
+ }
93
105
  throw TokenSwapFailedException(res.code, raw)
94
106
  }
95
107
  if (raw.isNullOrBlank()) {
@@ -103,6 +115,22 @@ class SpotifyTokenSwapClient(
103
115
  }
104
116
  }
105
117
 
118
+ /**
119
+ * True when an OAuth error response body declares `"error": "invalid_grant"`
120
+ * (RFC 6749 §5.2). Parses the JSON and checks the structured field rather than
121
+ * scanning the raw text, so the token value or other fields can't trigger a
122
+ * false positive. A non-JSON or differently-shaped body is treated as not an
123
+ * `invalid_grant`.
124
+ */
125
+ private fun isInvalidGrant(raw: String?): Boolean {
126
+ if (raw.isNullOrBlank()) return false
127
+ return try {
128
+ JSONObject(raw).optString("error").equals("invalid_grant", ignoreCase = true)
129
+ } catch (_: JSONException) {
130
+ false
131
+ }
132
+ }
133
+
106
134
  private fun parseSessionPayload(
107
135
  json: JSONObject,
108
136
  fallbackScopes: List<String>,
@@ -1,5 +1,5 @@
1
1
  import { SpotifyError } from "../error";
2
- export type AuthErrorCode = "USER_CANCELLED" | "AUTH_IN_PROGRESS" | "INVALID_CONFIG" | "NETWORK_ERROR" | "TOKEN_SWAP_FAILED" | "TOKEN_SWAP_PARSE_ERROR" | "SPOTIFY_NOT_INSTALLED" | "AUTH_ERROR" | "UNKNOWN";
2
+ export type AuthErrorCode = "USER_CANCELLED" | "AUTH_IN_PROGRESS" | "INVALID_CONFIG" | "NETWORK_ERROR" | "TOKEN_SWAP_FAILED" | "TOKEN_SWAP_PARSE_ERROR" | "REFRESH_TOKEN_EXPIRED" | "SPOTIFY_NOT_INSTALLED" | "AUTH_ERROR" | "UNKNOWN";
3
3
  export declare class AuthError extends SpotifyError {
4
4
  readonly namespace: "Auth";
5
5
  readonly code: AuthErrorCode;
@@ -1 +1 @@
1
- {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../../src/auth/error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAExC,MAAM,MAAM,aAAa,GACrB,gBAAgB,GAChB,kBAAkB,GAClB,gBAAgB,GAChB,eAAe,GACf,mBAAmB,GACnB,wBAAwB,GACxB,uBAAuB,GACvB,YAAY,GACZ,SAAS,CAAC;AAEd,qBAAa,SAAU,SAAQ,YAAY;IACzC,QAAQ,CAAC,SAAS,EAAG,MAAM,CAAU;IACrC,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;gBAEjB,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM;CAIjD"}
1
+ {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../../src/auth/error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAExC,MAAM,MAAM,aAAa,GACrB,gBAAgB,GAChB,kBAAkB,GAClB,gBAAgB,GAChB,eAAe,GACf,mBAAmB,GACnB,wBAAwB,GACxB,uBAAuB,GACvB,uBAAuB,GACvB,YAAY,GACZ,SAAS,CAAC;AAEd,qBAAa,SAAU,SAAQ,YAAY;IACzC,QAAQ,CAAC,SAAS,EAAG,MAAM,CAAU;IACrC,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;gBAEjB,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM;CAIjD"}
@@ -1 +1 @@
1
- {"version":3,"file":"error.js","sourceRoot":"","sources":["../../src/auth/error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAaxC,MAAM,OAAO,SAAU,SAAQ,YAAY;IAChC,SAAS,GAAG,MAAe,CAAC;IAC5B,IAAI,CAAgB;IAE7B,YAAY,IAAmB,EAAE,OAAe;QAC9C,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF","sourcesContent":["import { SpotifyError } from \"../error\";\n\nexport type AuthErrorCode =\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\nexport class AuthError extends SpotifyError {\n readonly namespace = \"Auth\" as const;\n readonly code: AuthErrorCode;\n\n constructor(code: AuthErrorCode, message: string) {\n super(message);\n this.code = code;\n }\n}\n"]}
1
+ {"version":3,"file":"error.js","sourceRoot":"","sources":["../../src/auth/error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAcxC,MAAM,OAAO,SAAU,SAAQ,YAAY;IAChC,SAAS,GAAG,MAAe,CAAC;IAC5B,IAAI,CAAgB;IAE7B,YAAY,IAAmB,EAAE,OAAe;QAC9C,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF","sourcesContent":["import { SpotifyError } from \"../error\";\n\nexport type AuthErrorCode =\n | \"USER_CANCELLED\"\n | \"AUTH_IN_PROGRESS\"\n | \"INVALID_CONFIG\"\n | \"NETWORK_ERROR\"\n | \"TOKEN_SWAP_FAILED\"\n | \"TOKEN_SWAP_PARSE_ERROR\"\n | \"REFRESH_TOKEN_EXPIRED\"\n | \"SPOTIFY_NOT_INSTALLED\"\n | \"AUTH_ERROR\"\n | \"UNKNOWN\";\n\nexport class AuthError extends SpotifyError {\n readonly namespace = \"Auth\" as const;\n readonly code: AuthErrorCode;\n\n constructor(code: AuthErrorCode, message: string) {\n super(message);\n this.code = code;\n }\n}\n"]}
@@ -14,7 +14,6 @@ let warnedAboutAndroidTokenFlow = false;
14
14
  const rethrowAsAuthError = createNativeErrorRethrow({
15
15
  ErrorClass: AuthError,
16
16
  unknownCode: "UNKNOWN",
17
- legacyCodePrefix: true,
18
17
  validCodes: new Set([
19
18
  "USER_CANCELLED",
20
19
  "AUTH_IN_PROGRESS",
@@ -22,6 +21,7 @@ const rethrowAsAuthError = createNativeErrorRethrow({
22
21
  "NETWORK_ERROR",
23
22
  "TOKEN_SWAP_FAILED",
24
23
  "TOKEN_SWAP_PARSE_ERROR",
24
+ "REFRESH_TOKEN_EXPIRED",
25
25
  "SPOTIFY_NOT_INSTALLED",
26
26
  "AUTH_ERROR",
27
27
  "UNKNOWN",
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type { EventSubscription } from \"expo-modules-core\";\nimport { Platform } from \"expo-modules-core\";\n\nimport ExpoSpotifySDKModule from \"../ExpoSpotifySDKModule\";\nimport { AuthError, type AuthErrorCode } from \"./error\";\nimport { createNativeErrorRethrow } from \"../internal/native-errors\";\n\nexport type { AuthErrorCode } from \"./error\";\nexport { AuthError } from \"./error\";\n\n// ---------------------------------------------------------------------------\n// Auth-specific types\n// ---------------------------------------------------------------------------\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).\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\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\nexport interface AuthenticateConfig {\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 * `Auth.refresh()` on both platforms.\n */\n tokenRefreshURL?: string;\n /**\n * If `true`, forces Spotify to show the authorization dialog even when the\n * user already has an active session. Defaults to `false`.\n */\n showDialog?: boolean;\n}\n\nexport interface RefreshConfig {\n /** The refresh token from a previous `Auth.authenticate()` call. */\n refreshToken: string;\n /** URL of your token refresh server endpoint. */\n tokenRefreshURL: string;\n /**\n * Scopes that were granted by the previous session. Used as a fallback when\n * the refresh response omits the `scope` field.\n */\n scopes?: SpotifyScope[];\n}\n\n/**\n * Payload delivered to `Auth.addListener(\"sessionChange\", ...)` subscribers.\n */\nexport type SessionChangeEvent =\n | { type: \"didInitiate\"; session: SpotifySession }\n | { type: \"didRenew\"; session: SpotifySession }\n | { type: \"didFail\"; error: { code: AuthErrorCode; message: string } };\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nconst ANDROID_TOKEN_FLOW_WARNING =\n \"[expo-spotify-sdk] You are using Auth.authenticate on Android without a \" +\n \"tokenSwapURL. The Spotify Android SDK does NOT return a refresh token or \" +\n \"the actual granted scopes through this path; see the README's \" +\n \"'Android implicit (TOKEN) flow is not recommended' section.\";\n\nlet warnedAboutAndroidTokenFlow = false;\n\nconst rethrowAsAuthError = createNativeErrorRethrow({\n ErrorClass: AuthError,\n unknownCode: \"UNKNOWN\",\n legacyCodePrefix: true,\n validCodes: new Set<AuthErrorCode>([\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\nfunction normaliseSession(raw: unknown): SpotifySession {\n if (!raw || typeof raw !== \"object\") {\n throw new AuthError(\n \"UNKNOWN\",\n \"Native module returned a non-object session\",\n );\n }\n const r = raw as Record<string, unknown>;\n const accessToken = r.accessToken;\n if (typeof accessToken !== \"string\" || accessToken.length === 0) {\n throw new AuthError(\"UNKNOWN\", \"Session is missing accessToken\");\n }\n const expirationDate = r.expirationDate;\n if (typeof expirationDate !== \"number\") {\n throw new AuthError(\"UNKNOWN\", \"Session is missing expirationDate\");\n }\n const refreshTokenRaw = r.refreshToken;\n const refreshToken =\n typeof refreshTokenRaw === \"string\" && refreshTokenRaw.length > 0\n ? refreshTokenRaw\n : null;\n const scopesRaw = r.scopes;\n const scopes = Array.isArray(scopesRaw)\n ? (scopesRaw.filter((s) => typeof s === \"string\") as SpotifyScope[])\n : [];\n return { accessToken, refreshToken, expirationDate, scopes };\n}\n\n// ---------------------------------------------------------------------------\n// Auth namespace\n// ---------------------------------------------------------------------------\n\n/**\n * Spotify Auth namespace. Handles OAuth authentication and session lifecycle.\n *\n * @example\n * ```ts\n * import { Auth } from \"@wwdrew/expo-spotify-sdk\";\n *\n * const session = await Auth.authenticate({ scopes: [\"streaming\"] });\n * ```\n */\nexport const Auth = {\n /**\n * Returns `true` if the Spotify app is installed on the device.\n */\n isAvailable(): boolean {\n return ExpoSpotifySDKModule.isAvailable();\n },\n\n /**\n * Starts a Spotify OAuth flow. Resolves with a {@link SpotifySession};\n * rejects with an {@link AuthError} carrying a `code`.\n */\n authenticate(config: AuthenticateConfig): Promise<SpotifySession> {\n if (!config.scopes || config.scopes.length === 0) {\n return Promise.reject(\n new AuthError(\n \"INVALID_CONFIG\",\n \"scopes must contain at least one entry\",\n ),\n );\n }\n if (\n Platform.OS === \"android\" &&\n !config.tokenSwapURL &&\n !warnedAboutAndroidTokenFlow\n ) {\n warnedAboutAndroidTokenFlow = true;\n console.warn(ANDROID_TOKEN_FLOW_WARNING);\n }\n return ExpoSpotifySDKModule.authenticateAsync(config)\n .then(normaliseSession)\n .catch(rethrowAsAuthError);\n },\n\n /**\n * Exchanges a refresh token for a new access token via your token refresh\n * server. Resolves with a fresh {@link SpotifySession}; rejects with an\n * {@link AuthError}.\n */\n refresh(config: RefreshConfig): Promise<SpotifySession> {\n if (!config.refreshToken) {\n return Promise.reject(\n new AuthError(\"INVALID_CONFIG\", \"refreshToken is required\"),\n );\n }\n if (!config.tokenRefreshURL) {\n return Promise.reject(\n new AuthError(\"INVALID_CONFIG\", \"tokenRefreshURL is required\"),\n );\n }\n return ExpoSpotifySDKModule.refreshSessionAsync(config)\n .then(normaliseSession)\n .catch(rethrowAsAuthError);\n },\n\n /**\n * Forcibly cancel any in-flight `Auth.authenticate()` call. No-op on\n * Android (the Android coordinator self-cleans via structured concurrency).\n *\n * Use before `Auth.authenticate()` to defensively clear any leaked iOS\n * coordinator state (the `SPTSessionManager` delegate callbacks are not\n * guaranteed to fire).\n */\n cancelPending(): Promise<void> {\n if (Platform.OS !== \"ios\") {\n return Promise.resolve();\n }\n return ExpoSpotifySDKModule.cancelPendingAuthAsync();\n },\n\n /**\n * Subscribes to session lifecycle events.\n *\n * Events fire for every `Auth.authenticate()` and `Auth.refresh()` call,\n * regardless of whether the call was awaited. Useful for persisting tokens\n * in a central store without coupling the store to the call sites.\n *\n * Returns a `Subscription` — call `.remove()` to unsubscribe.\n *\n * @example\n * ```ts\n * const sub = Auth.addListener(\"sessionChange\", (event) => {\n * if (event.type === \"didInitiate\" || event.type === \"didRenew\") {\n * store.setSession(event.session);\n * }\n * });\n * // later:\n * sub.remove();\n * ```\n */\n addListener(\n event: \"sessionChange\",\n listener: (event: SessionChangeEvent) => void,\n ): EventSubscription {\n return ExpoSpotifySDKModule.addListener(\n \"onSessionChange\",\n listener,\n ) as EventSubscription;\n },\n} as const;\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type { EventSubscription } from \"expo-modules-core\";\nimport { Platform } from \"expo-modules-core\";\n\nimport ExpoSpotifySDKModule from \"../ExpoSpotifySDKModule\";\nimport { AuthError, type AuthErrorCode } from \"./error\";\nimport { createNativeErrorRethrow } from \"../internal/native-errors\";\n\nexport type { AuthErrorCode } from \"./error\";\nexport { AuthError } from \"./error\";\n\n// ---------------------------------------------------------------------------\n// Auth-specific types\n// ---------------------------------------------------------------------------\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).\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\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\nexport interface AuthenticateConfig {\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 * `Auth.refresh()` on both platforms.\n */\n tokenRefreshURL?: string;\n /**\n * If `true`, forces Spotify to show the authorization dialog even when the\n * user already has an active session. Defaults to `false`.\n */\n showDialog?: boolean;\n}\n\nexport interface RefreshConfig {\n /** The refresh token from a previous `Auth.authenticate()` call. */\n refreshToken: string;\n /** URL of your token refresh server endpoint. */\n tokenRefreshURL: string;\n /**\n * Scopes that were granted by the previous session. Used as a fallback when\n * the refresh response omits the `scope` field.\n */\n scopes?: SpotifyScope[];\n}\n\n/**\n * Payload delivered to `Auth.addListener(\"sessionChange\", ...)` subscribers.\n */\nexport type SessionChangeEvent =\n | { type: \"didInitiate\"; session: SpotifySession }\n | { type: \"didRenew\"; session: SpotifySession }\n | { type: \"didFail\"; error: { code: AuthErrorCode; message: string } };\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nconst ANDROID_TOKEN_FLOW_WARNING =\n \"[expo-spotify-sdk] You are using Auth.authenticate on Android without a \" +\n \"tokenSwapURL. The Spotify Android SDK does NOT return a refresh token or \" +\n \"the actual granted scopes through this path; see the README's \" +\n \"'Android implicit (TOKEN) flow is not recommended' section.\";\n\nlet warnedAboutAndroidTokenFlow = false;\n\nconst rethrowAsAuthError = createNativeErrorRethrow({\n ErrorClass: AuthError,\n unknownCode: \"UNKNOWN\",\n validCodes: new Set<AuthErrorCode>([\n \"USER_CANCELLED\",\n \"AUTH_IN_PROGRESS\",\n \"INVALID_CONFIG\",\n \"NETWORK_ERROR\",\n \"TOKEN_SWAP_FAILED\",\n \"TOKEN_SWAP_PARSE_ERROR\",\n \"REFRESH_TOKEN_EXPIRED\",\n \"SPOTIFY_NOT_INSTALLED\",\n \"AUTH_ERROR\",\n \"UNKNOWN\",\n ]),\n});\n\nfunction normaliseSession(raw: unknown): SpotifySession {\n if (!raw || typeof raw !== \"object\") {\n throw new AuthError(\n \"UNKNOWN\",\n \"Native module returned a non-object session\",\n );\n }\n const r = raw as Record<string, unknown>;\n const accessToken = r.accessToken;\n if (typeof accessToken !== \"string\" || accessToken.length === 0) {\n throw new AuthError(\"UNKNOWN\", \"Session is missing accessToken\");\n }\n const expirationDate = r.expirationDate;\n if (typeof expirationDate !== \"number\") {\n throw new AuthError(\"UNKNOWN\", \"Session is missing expirationDate\");\n }\n const refreshTokenRaw = r.refreshToken;\n const refreshToken =\n typeof refreshTokenRaw === \"string\" && refreshTokenRaw.length > 0\n ? refreshTokenRaw\n : null;\n const scopesRaw = r.scopes;\n const scopes = Array.isArray(scopesRaw)\n ? (scopesRaw.filter((s) => typeof s === \"string\") as SpotifyScope[])\n : [];\n return { accessToken, refreshToken, expirationDate, scopes };\n}\n\n// ---------------------------------------------------------------------------\n// Auth namespace\n// ---------------------------------------------------------------------------\n\n/**\n * Spotify Auth namespace. Handles OAuth authentication and session lifecycle.\n *\n * @example\n * ```ts\n * import { Auth } from \"@wwdrew/expo-spotify-sdk\";\n *\n * const session = await Auth.authenticate({ scopes: [\"streaming\"] });\n * ```\n */\nexport const Auth = {\n /**\n * Returns `true` if the Spotify app is installed on the device.\n */\n isAvailable(): boolean {\n return ExpoSpotifySDKModule.isAvailable();\n },\n\n /**\n * Starts a Spotify OAuth flow. Resolves with a {@link SpotifySession};\n * rejects with an {@link AuthError} carrying a `code`.\n */\n authenticate(config: AuthenticateConfig): Promise<SpotifySession> {\n if (!config.scopes || config.scopes.length === 0) {\n return Promise.reject(\n new AuthError(\n \"INVALID_CONFIG\",\n \"scopes must contain at least one entry\",\n ),\n );\n }\n if (\n Platform.OS === \"android\" &&\n !config.tokenSwapURL &&\n !warnedAboutAndroidTokenFlow\n ) {\n warnedAboutAndroidTokenFlow = true;\n console.warn(ANDROID_TOKEN_FLOW_WARNING);\n }\n return ExpoSpotifySDKModule.authenticateAsync(config)\n .then(normaliseSession)\n .catch(rethrowAsAuthError);\n },\n\n /**\n * Exchanges a refresh token for a new access token via your token refresh\n * server. Resolves with a fresh {@link SpotifySession}; rejects with an\n * {@link AuthError}.\n */\n refresh(config: RefreshConfig): Promise<SpotifySession> {\n if (!config.refreshToken) {\n return Promise.reject(\n new AuthError(\"INVALID_CONFIG\", \"refreshToken is required\"),\n );\n }\n if (!config.tokenRefreshURL) {\n return Promise.reject(\n new AuthError(\"INVALID_CONFIG\", \"tokenRefreshURL is required\"),\n );\n }\n return ExpoSpotifySDKModule.refreshSessionAsync(config)\n .then(normaliseSession)\n .catch(rethrowAsAuthError);\n },\n\n /**\n * Forcibly cancel any in-flight `Auth.authenticate()` call. No-op on\n * Android (the Android coordinator self-cleans via structured concurrency).\n *\n * Use before `Auth.authenticate()` to defensively clear any leaked iOS\n * coordinator state (the `SPTSessionManager` delegate callbacks are not\n * guaranteed to fire).\n */\n cancelPending(): Promise<void> {\n if (Platform.OS !== \"ios\") {\n return Promise.resolve();\n }\n return ExpoSpotifySDKModule.cancelPendingAuthAsync();\n },\n\n /**\n * Subscribes to session lifecycle events.\n *\n * Events fire for every `Auth.authenticate()` and `Auth.refresh()` call,\n * regardless of whether the call was awaited. Useful for persisting tokens\n * in a central store without coupling the store to the call sites.\n *\n * Returns a `Subscription` — call `.remove()` to unsubscribe.\n *\n * @example\n * ```ts\n * const sub = Auth.addListener(\"sessionChange\", (event) => {\n * if (event.type === \"didInitiate\" || event.type === \"didRenew\") {\n * store.setSession(event.session);\n * }\n * });\n * // later:\n * sub.remove();\n * ```\n */\n addListener(\n event: \"sessionChange\",\n listener: (event: SessionChangeEvent) => void,\n ): EventSubscription {\n return ExpoSpotifySDKModule.addListener(\n \"onSessionChange\",\n listener,\n ) as EventSubscription;\n },\n} as const;\n"]}
@@ -4,8 +4,6 @@ export interface NativeErrorRethrowOptions<C extends string, E extends SpotifyEr
4
4
  }> {
5
5
  ErrorClass: new (code: C, message: string) => E;
6
6
  validCodes: ReadonlySet<C>;
7
- /** Parse legacy `"CODE: message"` prefixes embedded in native error reasons. */
8
- legacyCodePrefix?: boolean;
9
7
  unknownCode: C;
10
8
  }
11
9
  export declare function createNativeErrorRethrow<C extends string, E extends SpotifyError & {
@@ -1 +1 @@
1
- {"version":3,"file":"native-errors.d.ts","sourceRoot":"","sources":["../../src/internal/native-errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAU7C,MAAM,WAAW,yBAAyB,CACxC,CAAC,SAAS,MAAM,EAChB,CAAC,SAAS,YAAY,GAAG;IAAE,IAAI,EAAE,CAAC,CAAA;CAAE;IAEpC,UAAU,EAAE,KAAK,IAAI,EAAE,CAAC,EAAE,OAAO,EAAE,MAAM,KAAK,CAAC,CAAC;IAChD,UAAU,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC;IAC3B,gFAAgF;IAChF,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,WAAW,EAAE,CAAC,CAAC;CAChB;AAED,wBAAgB,wBAAwB,CACtC,CAAC,SAAS,MAAM,EAChB,CAAC,SAAS,YAAY,GAAG;IAAE,IAAI,EAAE,CAAC,CAAA;CAAE,EACpC,OAAO,EAAE,yBAAyB,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,KAAK,CAqBnE"}
1
+ {"version":3,"file":"native-errors.d.ts","sourceRoot":"","sources":["../../src/internal/native-errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAS7C,MAAM,WAAW,yBAAyB,CACxC,CAAC,SAAS,MAAM,EAChB,CAAC,SAAS,YAAY,GAAG;IAAE,IAAI,EAAE,CAAC,CAAA;CAAE;IAEpC,UAAU,EAAE,KAAK,IAAI,EAAE,CAAC,EAAE,OAAO,EAAE,MAAM,KAAK,CAAC,CAAC;IAChD,UAAU,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC;IAC3B,WAAW,EAAE,CAAC,CAAC;CAChB;AAED,wBAAgB,wBAAwB,CACtC,CAAC,SAAS,MAAM,EAChB,CAAC,SAAS,YAAY,GAAG;IAAE,IAAI,EAAE,CAAC,CAAA;CAAE,EACpC,OAAO,EAAE,yBAAyB,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,KAAK,CAmBnE"}
@@ -1,26 +1,23 @@
1
1
  const CAUSE_SEPARATOR = "→ Caused by: ";
2
- const LEGACY_CODE_PREFIX_RE = /^([A-Z_][A-Z0-9_]*):\s*(.*)$/s;
3
2
  function unwrapReason(message) {
4
3
  const idx = message.lastIndexOf(CAUSE_SEPARATOR);
5
4
  return idx === -1 ? message : message.slice(idx + CAUSE_SEPARATOR.length);
6
5
  }
7
6
  export function createNativeErrorRethrow(options) {
8
- const { ErrorClass, validCodes, legacyCodePrefix, unknownCode } = options;
7
+ const { ErrorClass, validCodes, unknownCode } = options;
9
8
  return function rethrowNativeError(err) {
10
9
  if (err instanceof ErrorClass)
11
10
  throw err;
12
11
  if (err instanceof Error) {
13
12
  const reason = unwrapReason(err.message);
13
+ // `err.code` is the structured code from the native `Exception`. On iOS
14
+ // before Expo SDK 57 the async-rejection bridge dropped it (the message
15
+ // survived), surfacing as `unknownCode` with the correct message; fixed
16
+ // in Expo SDK 57 (expo/expo#47259), which preserves the code.
14
17
  const maybeCode = err.code;
15
18
  if (maybeCode && validCodes.has(maybeCode)) {
16
19
  throw new ErrorClass(maybeCode, reason);
17
20
  }
18
- if (legacyCodePrefix) {
19
- const match = reason.match(LEGACY_CODE_PREFIX_RE);
20
- if (match && validCodes.has(match[1])) {
21
- throw new ErrorClass(match[1], match[2]);
22
- }
23
- }
24
21
  throw new ErrorClass(unknownCode, reason);
25
22
  }
26
23
  throw new ErrorClass(unknownCode, String(err));
@@ -1 +1 @@
1
- {"version":3,"file":"native-errors.js","sourceRoot":"","sources":["../../src/internal/native-errors.ts"],"names":[],"mappings":"AAEA,MAAM,eAAe,GAAG,eAAe,CAAC;AACxC,MAAM,qBAAqB,GAAG,+BAA+B,CAAC;AAE9D,SAAS,YAAY,CAAC,OAAe;IACnC,MAAM,GAAG,GAAG,OAAO,CAAC,WAAW,CAAC,eAAe,CAAC,CAAC;IACjD,OAAO,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,eAAe,CAAC,MAAM,CAAC,CAAC;AAC5E,CAAC;AAaD,MAAM,UAAU,wBAAwB,CAGtC,OAAwC;IACxC,MAAM,EAAE,UAAU,EAAE,UAAU,EAAE,gBAAgB,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC;IAE1E,OAAO,SAAS,kBAAkB,CAAC,GAAY;QAC7C,IAAI,GAAG,YAAY,UAAU;YAAE,MAAM,GAAG,CAAC;QACzC,IAAI,GAAG,YAAY,KAAK,EAAE,CAAC;YACzB,MAAM,MAAM,GAAG,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;YACzC,MAAM,SAAS,GAAI,GAAiC,CAAC,IAAI,CAAC;YAC1D,IAAI,SAAS,IAAI,UAAU,CAAC,GAAG,CAAC,SAAc,CAAC,EAAE,CAAC;gBAChD,MAAM,IAAI,UAAU,CAAC,SAAc,EAAE,MAAM,CAAC,CAAC;YAC/C,CAAC;YACD,IAAI,gBAAgB,EAAE,CAAC;gBACrB,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC;gBAClD,IAAI,KAAK,IAAI,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAM,CAAC,EAAE,CAAC;oBAC3C,MAAM,IAAI,UAAU,CAAC,KAAK,CAAC,CAAC,CAAM,EAAE,KAAK,CAAC,CAAC,CAAE,CAAC,CAAC;gBACjD,CAAC;YACH,CAAC;YACD,MAAM,IAAI,UAAU,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;QAC5C,CAAC;QACD,MAAM,IAAI,UAAU,CAAC,WAAW,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;IACjD,CAAC,CAAC;AACJ,CAAC","sourcesContent":["import type { SpotifyError } from \"../error\";\n\nconst CAUSE_SEPARATOR = \"→ Caused by: \";\nconst LEGACY_CODE_PREFIX_RE = /^([A-Z_][A-Z0-9_]*):\\s*(.*)$/s;\n\nfunction unwrapReason(message: string): string {\n const idx = message.lastIndexOf(CAUSE_SEPARATOR);\n return idx === -1 ? message : message.slice(idx + CAUSE_SEPARATOR.length);\n}\n\nexport interface NativeErrorRethrowOptions<\n C extends string,\n E extends SpotifyError & { code: C },\n> {\n ErrorClass: new (code: C, message: string) => E;\n validCodes: ReadonlySet<C>;\n /** Parse legacy `\"CODE: message\"` prefixes embedded in native error reasons. */\n legacyCodePrefix?: boolean;\n unknownCode: C;\n}\n\nexport function createNativeErrorRethrow<\n C extends string,\n E extends SpotifyError & { code: C },\n>(options: NativeErrorRethrowOptions<C, E>): (err: unknown) => never {\n const { ErrorClass, validCodes, legacyCodePrefix, unknownCode } = options;\n\n return function rethrowNativeError(err: unknown): never {\n if (err instanceof ErrorClass) throw err;\n if (err instanceof Error) {\n const reason = unwrapReason(err.message);\n const maybeCode = (err as Error & { code?: string }).code;\n if (maybeCode && validCodes.has(maybeCode as C)) {\n throw new ErrorClass(maybeCode as C, reason);\n }\n if (legacyCodePrefix) {\n const match = reason.match(LEGACY_CODE_PREFIX_RE);\n if (match && validCodes.has(match[1] as C)) {\n throw new ErrorClass(match[1] as C, match[2]!);\n }\n }\n throw new ErrorClass(unknownCode, reason);\n }\n throw new ErrorClass(unknownCode, String(err));\n };\n}\n"]}
1
+ {"version":3,"file":"native-errors.js","sourceRoot":"","sources":["../../src/internal/native-errors.ts"],"names":[],"mappings":"AAEA,MAAM,eAAe,GAAG,eAAe,CAAC;AAExC,SAAS,YAAY,CAAC,OAAe;IACnC,MAAM,GAAG,GAAG,OAAO,CAAC,WAAW,CAAC,eAAe,CAAC,CAAC;IACjD,OAAO,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,eAAe,CAAC,MAAM,CAAC,CAAC;AAC5E,CAAC;AAWD,MAAM,UAAU,wBAAwB,CAGtC,OAAwC;IACxC,MAAM,EAAE,UAAU,EAAE,UAAU,EAAE,WAAW,EAAE,GAAG,OAAO,CAAC;IAExD,OAAO,SAAS,kBAAkB,CAAC,GAAY;QAC7C,IAAI,GAAG,YAAY,UAAU;YAAE,MAAM,GAAG,CAAC;QACzC,IAAI,GAAG,YAAY,KAAK,EAAE,CAAC;YACzB,MAAM,MAAM,GAAG,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;YACzC,wEAAwE;YACxE,wEAAwE;YACxE,wEAAwE;YACxE,8DAA8D;YAC9D,MAAM,SAAS,GAAI,GAAiC,CAAC,IAAI,CAAC;YAC1D,IAAI,SAAS,IAAI,UAAU,CAAC,GAAG,CAAC,SAAc,CAAC,EAAE,CAAC;gBAChD,MAAM,IAAI,UAAU,CAAC,SAAc,EAAE,MAAM,CAAC,CAAC;YAC/C,CAAC;YACD,MAAM,IAAI,UAAU,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;QAC5C,CAAC;QACD,MAAM,IAAI,UAAU,CAAC,WAAW,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;IACjD,CAAC,CAAC;AACJ,CAAC","sourcesContent":["import type { SpotifyError } from \"../error\";\n\nconst CAUSE_SEPARATOR = \"→ Caused by: \";\n\nfunction unwrapReason(message: string): string {\n const idx = message.lastIndexOf(CAUSE_SEPARATOR);\n return idx === -1 ? message : message.slice(idx + CAUSE_SEPARATOR.length);\n}\n\nexport interface NativeErrorRethrowOptions<\n C extends string,\n E extends SpotifyError & { code: C },\n> {\n ErrorClass: new (code: C, message: string) => E;\n validCodes: ReadonlySet<C>;\n unknownCode: C;\n}\n\nexport function createNativeErrorRethrow<\n C extends string,\n E extends SpotifyError & { code: C },\n>(options: NativeErrorRethrowOptions<C, E>): (err: unknown) => never {\n const { ErrorClass, validCodes, unknownCode } = options;\n\n return function rethrowNativeError(err: unknown): never {\n if (err instanceof ErrorClass) throw err;\n if (err instanceof Error) {\n const reason = unwrapReason(err.message);\n // `err.code` is the structured code from the native `Exception`. On iOS\n // before Expo SDK 57 the async-rejection bridge dropped it (the message\n // survived), surfacing as `unknownCode` with the correct message; fixed\n // in Expo SDK 57 (expo/expo#47259), which preserves the code.\n const maybeCode = (err as Error & { code?: string }).code;\n if (maybeCode && validCodes.has(maybeCode as C)) {\n throw new ErrorClass(maybeCode as C, reason);\n }\n throw new ErrorClass(unknownCode, reason);\n }\n throw new ErrorClass(unknownCode, String(err));\n };\n}\n"]}
@@ -1,7 +1,7 @@
1
1
  import ExpoModulesCore
2
2
  import SpotifyiOS
3
3
 
4
- private let SDK_VERSION = "2.2.3" // x-release-please-version
4
+ private let SDK_VERSION = "2.3.1" // 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"
@@ -72,12 +72,18 @@ public class ExpoSpotifySDKModule: Module {
72
72
  let map = self.sessionToMap(session)
73
73
  self.sendEvent(EVENT_SESSION_CHANGE, ["type": "didInitiate", "session": map])
74
74
  return map
75
- } catch let error as SpotifyError {
76
- self.sendEvent(EVENT_SESSION_CHANGE, [
77
- "type": "didFail",
78
- "error": ["code": error.code, "message": error.message],
79
- ])
80
- throw SpotifyAuthException(error)
75
+ } catch {
76
+ // Already-typed `SpotifyError`s pass through; anything else is a raw
77
+ // failure the SDK surfaced without routing through the delegate (e.g. a
78
+ // user-cancelled web auth). `classify` is the same entry point the
79
+ // delegate uses, so both paths map identical errors to identical codes.
80
+ let mapped = error as? SpotifyError
81
+ ?? SpotifyAuthErrorMapping.classify(
82
+ error,
83
+ context: .init(tokenSwapConfigured: config.tokenSwapURL.flatMap(URL.init) != nil)
84
+ )
85
+ self.emitDidFail(mapped)
86
+ throw SpotifyAuthException(mapped)
81
87
  }
82
88
  }
83
89
 
@@ -109,16 +115,10 @@ public class ExpoSpotifySDKModule: Module {
109
115
  self.sendEvent(EVENT_SESSION_CHANGE, ["type": "didRenew", "session": map])
110
116
  return map
111
117
  } catch let error as SpotifyError {
112
- self.sendEvent(EVENT_SESSION_CHANGE, [
113
- "type": "didFail",
114
- "error": ["code": error.code, "message": error.message],
115
- ])
118
+ self.emitDidFail(error)
116
119
  throw SpotifyAuthException(error)
117
120
  } catch let error as SpotifyRefreshError {
118
- self.sendEvent(EVENT_SESSION_CHANGE, [
119
- "type": "didFail",
120
- "error": ["code": error.code, "message": error.message],
121
- ])
121
+ self.emitDidFail(code: error.code, message: error.message)
122
122
  throw SpotifyRefreshException(error)
123
123
  }
124
124
  }
@@ -325,6 +325,17 @@ public class ExpoSpotifySDKModule: Module {
325
325
 
326
326
  // MARK: — Helpers
327
327
 
328
+ private func emitDidFail(_ error: SpotifyError) {
329
+ emitDidFail(code: error.code, message: error.message)
330
+ }
331
+
332
+ private func emitDidFail(code: String, message: String) {
333
+ sendEvent(EVENT_SESSION_CHANGE, [
334
+ "type": "didFail",
335
+ "error": ["code": code, "message": message],
336
+ ])
337
+ }
338
+
328
339
  private func sessionToMap(_ session: SPTSession) -> [String: Any?] {
329
340
  return [
330
341
  "accessToken": session.accessToken,
@@ -0,0 +1,40 @@
1
+ import Foundation
2
+
3
+ extension NSError {
4
+ /// `NSError.localizedDescription` force-bridges `userInfo[NSLocalizedDescriptionKey]`
5
+ /// to `String`. When that value is a non-`String` object (Spotify's SDK can deliver an
6
+ /// empty `__NSDictionary0`), the bridge sends `-length` to a dictionary and aborts.
7
+ var safeLocalizedDescription: String {
8
+ guard let value = userInfo[NSLocalizedDescriptionKey] else {
9
+ return localizedDescription
10
+ }
11
+ return (value as? String) ?? ""
12
+ }
13
+
14
+ /// Same force-bridge hazard as `localizedDescription`, but for
15
+ /// `NSLocalizedFailureReasonErrorKey`.
16
+ var safeLocalizedFailureReason: String? {
17
+ guard let value = userInfo[NSLocalizedFailureReasonErrorKey] else {
18
+ return localizedFailureReason
19
+ }
20
+ return value as? String
21
+ }
22
+ }
23
+
24
+ extension Error {
25
+ /// Summary for logging that avoids `String(describing:)` / `localizedDescription`
26
+ /// force-bridges on poisoned `userInfo` values.
27
+ var safeLogSummary: String {
28
+ let nsError = self as NSError
29
+ let description = nsError.safeLocalizedDescription
30
+ if description.isEmpty {
31
+ return "\(nsError.domain) code \(nsError.code)"
32
+ }
33
+ return "\(nsError.domain) code \(nsError.code): \(description)"
34
+ }
35
+ }
36
+
37
+ func safeLogSummary(for error: (any Error)?) -> String {
38
+ guard let error else { return "nil" }
39
+ return error.safeLogSummary
40
+ }
@@ -210,12 +210,12 @@ final class SpotifyAppRemoteDelegateBridge: NSObject, SPTAppRemoteDelegate {
210
210
  }
211
211
 
212
212
  func appRemote(_ appRemote: SPTAppRemote, didFailConnectionAttemptWithError error: (any Error)?) {
213
- NSLog("[ExpoSpotifySDK] AppRemote: connection failed — %@", String(describing: error))
213
+ NSLog("[ExpoSpotifySDK] AppRemote: connection failed — %@", safeLogSummary(for: error))
214
214
  Task { await coordinator?.didFailToConnect(error: error) }
215
215
  }
216
216
 
217
217
  func appRemote(_ appRemote: SPTAppRemote, didDisconnectWithError error: (any Error)?) {
218
- NSLog("[ExpoSpotifySDK] AppRemote: disconnected — %@", String(describing: error))
218
+ NSLog("[ExpoSpotifySDK] AppRemote: disconnected — %@", safeLogSummary(for: error))
219
219
  Task { await coordinator?.didDisconnect(error: error) }
220
220
  }
221
221
  }
@@ -421,7 +421,7 @@ actor SpotifyAppRemoteCoordinator {
421
421
 
422
422
  func describeNSError(_ error: NSError) -> String {
423
423
  var parts: [String] = ["\(error.domain) code \(error.code)"]
424
- let desc = error.localizedDescription
424
+ let desc = error.safeLocalizedDescription
425
425
  if !desc.isEmpty { parts.append("\"\(desc)\"") }
426
426
  if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError {
427
427
  parts.append("→ \(describeNSError(underlying))")
@@ -30,71 +30,75 @@ enum SpotifyAppRemoteErrorMapping {
30
30
  }
31
31
 
32
32
  static func mapPlayerError(_ error: NSError, callsite: String) -> NativePlayerError {
33
+ let desc = error.safeLocalizedDescription
33
34
  guard isSPTError(error) else {
34
- return .unknown(error.localizedDescription)
35
+ return .unknown(desc)
35
36
  }
36
37
  if isConnectionTerminated(error) {
37
38
  return .connectionLost(connectionLostMessage(callsite: callsite))
38
39
  }
39
40
  if isInvalidArguments(error) {
40
- let desc = error.localizedDescription.lowercased()
41
- if desc.contains("uri") {
42
- return .invalidURI("\(callsite): \(error.localizedDescription)")
41
+ let lower = desc.lowercased()
42
+ if lower.contains("uri") {
43
+ return .invalidURI("\(callsite): \(desc)")
43
44
  }
44
- return .invalidParameter(error.localizedDescription)
45
+ return .invalidParameter(desc)
45
46
  }
46
47
  if isRequestFailed(error) {
47
- let desc = error.localizedDescription.lowercased()
48
- if desc.contains("premium") {
48
+ let lower = desc.lowercased()
49
+ if lower.contains("premium") {
49
50
  return .premiumRequired("\(callsite): Spotify Premium is required for on-demand playback")
50
51
  }
51
- if containsRestriction(error.localizedDescription) {
52
- return .operationNotAllowed("\(callsite): \(error.localizedDescription)")
52
+ if containsRestriction(desc) {
53
+ return .operationNotAllowed("\(callsite): \(desc)")
53
54
  }
54
- return .unknown(error.localizedDescription)
55
+ return .unknown(desc)
55
56
  }
56
- return .unknown(error.localizedDescription)
57
+ return .unknown(desc)
57
58
  }
58
59
 
59
60
  static func mapUserError(_ error: NSError, callsite: String) -> NativeUserError {
61
+ let desc = error.safeLocalizedDescription
60
62
  guard isSPTError(error) else {
61
- return .unknown(error.localizedDescription)
63
+ return .unknown(desc)
62
64
  }
63
65
  if isConnectionTerminated(error) {
64
66
  return .connectionLost(connectionLostMessage(callsite: callsite))
65
67
  }
66
68
  if isInvalidArguments(error) {
67
- return .invalidURI("\(callsite): \(error.localizedDescription)")
69
+ return .invalidURI("\(callsite): \(desc)")
68
70
  }
69
71
  if isRequestFailed(error) {
70
- if containsRestriction(error.localizedDescription) {
71
- return .operationNotAllowed("\(callsite): \(error.localizedDescription)")
72
+ if containsRestriction(desc) {
73
+ return .operationNotAllowed("\(callsite): \(desc)")
72
74
  }
73
- return .unknown(error.localizedDescription)
75
+ return .unknown(desc)
74
76
  }
75
- return .unknown(error.localizedDescription)
77
+ return .unknown(desc)
76
78
  }
77
79
 
78
80
  static func mapContentError(_ error: NSError, callsite: String) -> NativeContentError {
81
+ let desc = error.safeLocalizedDescription
79
82
  guard isSPTError(error) else {
80
- return .unknown(error.localizedDescription)
83
+ return .unknown(desc)
81
84
  }
82
85
  if isConnectionTerminated(error) {
83
86
  return .connectionLost(connectionLostMessage(callsite: callsite))
84
87
  }
85
88
  if isRequestFailed(error) {
86
- let desc = error.localizedDescription.lowercased()
87
- if desc.contains("not supported") || desc.contains("unsupported") {
89
+ let lower = desc.lowercased()
90
+ if lower.contains("not supported") || lower.contains("unsupported") {
88
91
  return .contentAPIUnavailable("\(callsite): content API is unavailable on this Spotify app version")
89
92
  }
90
- return .unknown(error.localizedDescription)
93
+ return .unknown(desc)
91
94
  }
92
- return .unknown(error.localizedDescription)
95
+ return .unknown(desc)
93
96
  }
94
97
 
95
98
  static func mapImagesError(_ error: NSError, callsite: String) -> NativeImagesError {
99
+ let desc = error.safeLocalizedDescription
96
100
  guard isSPTError(error) else {
97
- return .unknown(error.localizedDescription)
101
+ return .unknown(desc)
98
102
  }
99
103
  if isConnectionTerminated(error) {
100
104
  return .notConnected(connectionLostMessage(callsite: callsite))
@@ -105,6 +109,6 @@ enum SpotifyAppRemoteErrorMapping {
105
109
  if isRequestFailed(error) {
106
110
  return .imageLoadFailed("\(callsite): Spotify rejected image request")
107
111
  }
108
- return .unknown(error.localizedDescription)
112
+ return .unknown(desc)
109
113
  }
110
114
  }