@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 +36 -0
- package/android/src/main/java/expo/modules/spotifysdk/ExpoSpotifySDKModule.kt +1 -1
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyErrors.kt +7 -0
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyTokenSwapClient.kt +30 -2
- package/build/auth/error.d.ts +1 -1
- package/build/auth/error.d.ts.map +1 -1
- package/build/auth/error.js.map +1 -1
- package/build/auth/index.js +1 -1
- package/build/auth/index.js.map +1 -1
- package/build/internal/native-errors.d.ts +0 -2
- package/build/internal/native-errors.d.ts.map +1 -1
- package/build/internal/native-errors.js +5 -8
- package/build/internal/native-errors.js.map +1 -1
- package/ios/ExpoSpotifySDKModule.swift +26 -15
- package/ios/NSError+SafeLocalizedDescription.swift +40 -0
- package/ios/SpotifyAppRemoteConnection.swift +2 -2
- package/ios/SpotifyAppRemoteCoordinator.swift +1 -1
- package/ios/SpotifyAppRemoteErrorMapping.swift +28 -24
- package/ios/SpotifyAuthCoordinator.swift +5 -324
- package/ios/SpotifyAuthErrorMapping.swift +257 -0
- package/ios/SpotifyError.swift +82 -0
- package/ios/SpotifyTokenRefreshClient.swift +19 -6
- package/package.json +1 -1
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.
|
|
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(
|
|
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>,
|
package/build/auth/error.d.ts
CHANGED
|
@@ -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"}
|
package/build/auth/error.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"error.js","sourceRoot":"","sources":["../../src/auth/error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;
|
|
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"]}
|
package/build/auth/index.js
CHANGED
|
@@ -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",
|
package/build/auth/index.js.map
CHANGED
|
@@ -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;
|
|
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,
|
|
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;
|
|
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.
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
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.
|
|
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 — %@",
|
|
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 — %@",
|
|
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.
|
|
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(
|
|
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
|
|
41
|
-
if
|
|
42
|
-
return .invalidURI("\(callsite): \(
|
|
41
|
+
let lower = desc.lowercased()
|
|
42
|
+
if lower.contains("uri") {
|
|
43
|
+
return .invalidURI("\(callsite): \(desc)")
|
|
43
44
|
}
|
|
44
|
-
return .invalidParameter(
|
|
45
|
+
return .invalidParameter(desc)
|
|
45
46
|
}
|
|
46
47
|
if isRequestFailed(error) {
|
|
47
|
-
let
|
|
48
|
-
if
|
|
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(
|
|
52
|
-
return .operationNotAllowed("\(callsite): \(
|
|
52
|
+
if containsRestriction(desc) {
|
|
53
|
+
return .operationNotAllowed("\(callsite): \(desc)")
|
|
53
54
|
}
|
|
54
|
-
return .unknown(
|
|
55
|
+
return .unknown(desc)
|
|
55
56
|
}
|
|
56
|
-
return .unknown(
|
|
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(
|
|
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): \(
|
|
69
|
+
return .invalidURI("\(callsite): \(desc)")
|
|
68
70
|
}
|
|
69
71
|
if isRequestFailed(error) {
|
|
70
|
-
if containsRestriction(
|
|
71
|
-
return .operationNotAllowed("\(callsite): \(
|
|
72
|
+
if containsRestriction(desc) {
|
|
73
|
+
return .operationNotAllowed("\(callsite): \(desc)")
|
|
72
74
|
}
|
|
73
|
-
return .unknown(
|
|
75
|
+
return .unknown(desc)
|
|
74
76
|
}
|
|
75
|
-
return .unknown(
|
|
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(
|
|
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
|
|
87
|
-
if
|
|
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(
|
|
93
|
+
return .unknown(desc)
|
|
91
94
|
}
|
|
92
|
-
return .unknown(
|
|
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(
|
|
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(
|
|
112
|
+
return .unknown(desc)
|
|
109
113
|
}
|
|
110
114
|
}
|