@wwdrew/expo-spotify-sdk 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,7 @@ An Expo module that wraps the native [Spotify iOS SDK](https://github.com/spotif
15
15
  | `isAvailable()` | ✅ | ✅ | ✅ (always `false`) |
16
16
  | `authenticateAsync` — CODE flow (recommended) | ✅ | ✅ | — |
17
17
  | `authenticateAsync` — TOKEN flow (implicit) | ✅ | ⚠️ see below | — |
18
+ | `cancelPendingAuthAsync()` | ✅ | no-op | no-op |
18
19
  | `refreshSessionAsync` | ✅ | ✅ | — |
19
20
  | Auth via installed Spotify app | ✅ | ✅ | — |
20
21
  | Auth via Spotify web fallback | ✅ | ✅ | — |
@@ -80,7 +81,7 @@ android {
80
81
  spotifyRedirectUri: "myapp://spotify-auth",
81
82
  redirectSchemeName: "myapp",
82
83
  redirectHostName: "spotify-auth",
83
- redirectPathPattern: "/.*"
84
+ redirectPathPattern: ".*"
84
85
  ]
85
86
  }
86
87
  }
@@ -107,16 +108,23 @@ export default {
107
108
  clientID: "your-spotify-client-id",
108
109
  scheme: "myapp",
109
110
  host: "spotify-auth",
110
- // Optional: path pattern accepted by the Spotify Android SDK redirect.
111
- // Defaults to "/.*" (matches any path). Change this only if you have a
112
- // specific redirect path registered in your Spotify app settings.
113
- redirectPathPattern: "/.*",
114
111
  },
115
112
  ],
116
113
  ],
117
114
  };
118
115
  ```
119
116
 
117
+ `redirectPathPattern` is optional and defaults to `".*"`, which matches every redirect URI shape Spotify will hand back. Only set it if you have a specific path registered in your Spotify app settings:
118
+
119
+ ```ts
120
+ {
121
+ clientID: "your-spotify-client-id",
122
+ scheme: "myapp",
123
+ host: "spotify-auth",
124
+ redirectPathPattern: "/auth/.*",
125
+ }
126
+ ```
127
+
120
128
  ### Plugin options
121
129
 
122
130
  | Option | Type | Required | Description |
@@ -124,7 +132,7 @@ export default {
124
132
  | `clientID` | `string` | ✅ | Your Spotify application's Client ID |
125
133
  | `scheme` | `string` | ✅ | URL scheme registered for your app (e.g. `"myapp"`) |
126
134
  | `host` | `string` | ✅ | Host component of the redirect URI (e.g. `"spotify-auth"`) |
127
- | `redirectPathPattern` | `string` | — | Android redirect path regex. Defaults to `"/.*"` |
135
+ | `redirectPathPattern` | `string` | — | Android redirect path regex. Defaults to `".*"` |
128
136
 
129
137
  The redirect URI registered in your [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) must match `{scheme}://{host}` exactly (e.g. `myapp://spotify-auth`).
130
138
 
@@ -207,19 +215,56 @@ Starts a Spotify OAuth flow. If the Spotify app is installed it authenticates na
207
215
 
208
216
  ---
209
217
 
218
+ ### `cancelPendingAuthAsync(): Promise<void>`
219
+
220
+ Forcibly cancels any in-flight `authenticateAsync` call. Resolves once the native coordinator's pending state is cleared. No-op when nothing is in flight.
221
+
222
+ **Why this exists:** the iOS `SPTSessionManager` delegate callbacks (`didInitiate` / `didFailWith`) are not guaranteed to fire — for example when Spotify never redirects back to the host app, or the host process is backgrounded mid-flow. When that happens the coordinator's pending continuation leaks and every subsequent `authenticateAsync` rejects immediately with `AUTH_IN_PROGRESS` until the process restarts.
223
+
224
+ **When to call it:** defensively, before each `authenticateAsync`, so retries always start from a clean slate. The cost is one cheap async hop when nothing is leaked.
225
+
226
+ ```ts
227
+ import {
228
+ authenticateAsync,
229
+ cancelPendingAuthAsync,
230
+ } from "@wwdrew/expo-spotify-sdk";
231
+
232
+ async function login() {
233
+ await cancelPendingAuthAsync();
234
+ return authenticateAsync({ scopes: ["user-read-email", "streaming"] });
235
+ }
236
+ ```
237
+
238
+ If a pending call is cancelled this way, its original `authenticateAsync` promise rejects with `SpotifyError` `code: "USER_CANCELLED"`.
239
+
240
+ **Platform notes:** no-op on Android (the Kotlin coordinator self-cleans via structured concurrency) and on web. Safe to call unconditionally.
241
+
242
+ ---
243
+
210
244
  ### `refreshSessionAsync(options): Promise<SpotifySession>`
211
245
 
212
246
  Exchanges a refresh token for a new access token via your token refresh server.
213
247
 
214
248
  **Parameters:**
215
249
 
216
- | Field | Type | Description |
217
- | ----------------- | -------- | ----------------------------------------------------------- |
218
- | `refreshToken` | `string` | The refresh token from a previous `authenticateAsync` call. |
219
- | `tokenRefreshURL` | `string` | URL of your token refresh server endpoint. |
250
+ | Field | Type | Required | Description |
251
+ | ----------------- | ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
252
+ | `refreshToken` | `string` | | The refresh token from a previous `authenticateAsync` call. |
253
+ | `tokenRefreshURL` | `string` | | URL of your token refresh server endpoint. |
254
+ | `scopes` | `SpotifyScope[]` | — | Previously-granted scopes. Used as a fallback when the refresh response omits the `scope` field. **Pass through the previous session's `scopes`** to avoid silently losing scope info. |
220
255
 
221
256
  **Returns** a fresh `SpotifySession` with an updated `accessToken` and `expirationDate`. If the server rotates the refresh token the new one is returned in `refreshToken`; otherwise the original token is returned so you can continue refreshing.
222
257
 
258
+ **Why pass `scopes`:** Spotify's refresh endpoint only returns `scope` when the granted scope set has changed since the last issuance — most refresh responses omit it. Without `scopes` plumbed through, the returned session's `scopes` will be `[]` on every refresh that doesn't include the field, even though the access token itself still carries the same scopes.
259
+
260
+ ```ts
261
+ const refreshed = await refreshSessionAsync({
262
+ refreshToken: previous.refreshToken,
263
+ tokenRefreshURL: "https://your-server.example.com/refresh",
264
+ scopes: previous.scopes,
265
+ });
266
+ ```
267
+
223
268
  ---
224
269
 
225
270
  ### `addSessionChangeListener(listener): Subscription`
@@ -270,7 +315,7 @@ try {
270
315
  if (e instanceof SpotifyError) {
271
316
  switch (e.code) {
272
317
  case "USER_CANCELLED": // user closed the auth screen — benign
273
- case "AUTH_IN_PROGRESS": // concurrent call — benign
318
+ case "AUTH_IN_PROGRESS": // concurrent call, or iOS stuck state see cancelPendingAuthAsync
274
319
  return;
275
320
  case "INVALID_CONFIG": // missing clientID / scopes / tokenSwapURL
276
321
  case "NETWORK_ERROR": // connectivity failure during token swap
@@ -389,7 +434,9 @@ Android 11+ requires a `<queries>` element to inspect other apps' package names.
389
434
  Ensure your app's URL scheme is registered in Xcode under **Info → URL Types** and that it matches the `scheme` in the plugin config. The `expo prebuild` step does this automatically; if you have a bare workflow, check `CFBundleURLSchemes` in `Info.plist`.
390
435
 
391
436
  **`AUTH_IN_PROGRESS`**
392
- `authenticateAsync` was called while a previous call was still pending. Wait for the first call to resolve or reject before calling again.
437
+ `authenticateAsync` was called while a previous call was still pending. Usually this means a concurrent call wait for the first one to resolve.
438
+
439
+ On iOS this can also be a stuck state: the SPTSessionManager delegate callbacks aren't guaranteed to fire (e.g. Spotify never redirected back to the app), so the previous call's continuation leaks and every retry rejects immediately. Call [`cancelPendingAuthAsync()`](#cancelpendingauthasync-promisevoid) before retrying — or, defensively, before every `authenticateAsync`.
393
440
 
394
441
  ## Acknowledgements
395
442
 
@@ -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 = "0.5.0"
11
+ private const val SDK_VERSION = "0.8.0" // x-release-please-version
12
12
  private const val EVENT_SESSION_CHANGE = "onSessionChange"
13
13
 
14
14
  class ExpoSpotifySDKModule : Module() {
@@ -122,8 +122,8 @@ class ExpoSpotifySDKModule : Module() {
122
122
  )
123
123
  }
124
124
  AuthorizationResponse.Type.CANCELLED -> throw UserCancelledException()
125
- AuthorizationResponse.Type.EMPTY -> throw UnknownSpotifyException(
126
- "Spotify returned an empty response (auth activity may have been killed)",
125
+ AuthorizationResponse.Type.EMPTY -> throw UserCancelledException(
126
+ "Spotify returned an empty response (auth activity dismissed before completion)",
127
127
  )
128
128
  AuthorizationResponse.Type.ERROR -> throw SpotifyAuthErrorException(
129
129
  response.error ?: "Spotify returned an unspecified error",
@@ -153,7 +153,7 @@ class ExpoSpotifySDKModule : Module() {
153
153
  val payload = client.refresh(
154
154
  refreshToken = options.refreshToken,
155
155
  tokenRefreshURL = options.tokenRefreshURL,
156
- previousScopes = emptyList(),
156
+ previousScopes = options.scopes,
157
157
  )
158
158
  emitSession("didRenew", payload)
159
159
  payload.toMap()
@@ -22,6 +22,7 @@ class SpotifyAuthenticateOptions : Record {
22
22
  class SpotifyRefreshOptions : Record {
23
23
  @Field val refreshToken: String = ""
24
24
  @Field val tokenRefreshURL: String = ""
25
+ @Field val scopes: List<String> = emptyList()
25
26
  }
26
27
 
27
28
  /**
@@ -28,6 +28,18 @@ export interface SpotifyRefreshConfig {
28
28
  refreshToken: string;
29
29
  /** URL of your token refresh server endpoint. */
30
30
  tokenRefreshURL: string;
31
+ /**
32
+ * Scopes that were granted by the previous session. Used as a fallback
33
+ * when the refresh response omits the `scope` field (which most
34
+ * `accounts.spotify.com` refresh responses do — Spotify only returns
35
+ * `scope` when the granted scope set has changed).
36
+ *
37
+ * If omitted, the returned `SpotifySession.scopes` will be `[]` whenever
38
+ * the refresh response also omits `scope` — silently losing scope
39
+ * information you held a moment ago. Pass through the previous session's
40
+ * `scopes` to avoid that.
41
+ */
42
+ scopes?: SpotifyScope[];
31
43
  }
32
44
  /**
33
45
  * Configuration accepted by `authenticateAsync`.
@@ -1 +1 @@
1
- {"version":3,"file":"ExpoSpotifySDK.types.d.ts","sourceRoot":"","sources":["../src/ExpoSpotifySDK.types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC7B,0BAA0B;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,uDAAuD;IACvD,cAAc,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,MAAM,EAAE,YAAY,EAAE,CAAC;CACxB;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,kEAAkE;IAClE,YAAY,EAAE,MAAM,CAAC;IACrB,iDAAiD;IACjD,eAAe,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,gEAAgE;IAChE,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED;;;GAGG;AACH,MAAM,MAAM,YAAY,GACpB,kBAAkB,GAClB,0BAA0B,GAC1B,4BAA4B,GAC5B,6BAA6B,GAC7B,oBAAoB,GACpB,WAAW,GACX,uBAAuB,GACvB,6BAA6B,GAC7B,yBAAyB,GACzB,wBAAwB,GACxB,oBAAoB,GACpB,kBAAkB,GAClB,eAAe,GACf,2BAA2B,GAC3B,qBAAqB,GACrB,mBAAmB,GACnB,iBAAiB,GACjB,mBAAmB,CAAC;AAExB;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GACxB,gBAAgB,GAChB,kBAAkB,GAClB,gBAAgB,GAChB,eAAe,GACf,mBAAmB,GACnB,wBAAwB,GACxB,uBAAuB,GACvB,YAAY,GACZ,SAAS,CAAC;AAEd;;;;;;GAMG;AACH,MAAM,MAAM,yBAAyB,GACjC;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,cAAc,CAAA;CAAE,GAChD;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,OAAO,EAAE,cAAc,CAAA;CAAE,GAC7C;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE;QAAE,IAAI,EAAE,gBAAgB,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAE5E;;;GAGG;AACH,qBAAa,YAAa,SAAQ,KAAK;IACrC,SAAgB,IAAI,EAAE,gBAAgB,CAAC;gBAE3B,IAAI,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM;CAKpD"}
1
+ {"version":3,"file":"ExpoSpotifySDK.types.d.ts","sourceRoot":"","sources":["../src/ExpoSpotifySDK.types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC7B,0BAA0B;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,uDAAuD;IACvD,cAAc,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,MAAM,EAAE,YAAY,EAAE,CAAC;CACxB;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,kEAAkE;IAClE,YAAY,EAAE,MAAM,CAAC;IACrB,iDAAiD;IACjD,eAAe,EAAE,MAAM,CAAC;IACxB;;;;;;;;;;OAUG;IACH,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,gEAAgE;IAChE,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED;;;GAGG;AACH,MAAM,MAAM,YAAY,GACpB,kBAAkB,GAClB,0BAA0B,GAC1B,4BAA4B,GAC5B,6BAA6B,GAC7B,oBAAoB,GACpB,WAAW,GACX,uBAAuB,GACvB,6BAA6B,GAC7B,yBAAyB,GACzB,wBAAwB,GACxB,oBAAoB,GACpB,kBAAkB,GAClB,eAAe,GACf,2BAA2B,GAC3B,qBAAqB,GACrB,mBAAmB,GACnB,iBAAiB,GACjB,mBAAmB,CAAC;AAExB;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GACxB,gBAAgB,GAChB,kBAAkB,GAClB,gBAAgB,GAChB,eAAe,GACf,mBAAmB,GACnB,wBAAwB,GACxB,uBAAuB,GACvB,YAAY,GACZ,SAAS,CAAC;AAEd;;;;;;GAMG;AACH,MAAM,MAAM,yBAAyB,GACjC;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,cAAc,CAAA;CAAE,GAChD;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,OAAO,EAAE,cAAc,CAAA;CAAE,GAC7C;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE;QAAE,IAAI,EAAE,gBAAgB,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAE5E;;;GAGG;AACH,qBAAa,YAAa,SAAQ,KAAK;IACrC,SAAgB,IAAI,EAAE,gBAAgB,CAAC;gBAE3B,IAAI,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM;CAKpD"}
@@ -1 +1 @@
1
- {"version":3,"file":"ExpoSpotifySDK.types.js","sourceRoot":"","sources":["../src/ExpoSpotifySDK.types.ts"],"names":[],"mappings":"AA+GA;;;GAGG;AACH,MAAM,OAAO,YAAa,SAAQ,KAAK;IACrB,IAAI,CAAmB;IAEvC,YAAY,IAAsB,EAAE,OAAe;QACjD,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,cAAc,CAAC;QAC3B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF","sourcesContent":["/**\n * Result of a successful Spotify authentication.\n *\n * The shape is identical across iOS and Android. On the Android implicit\n * (TOKEN) flow `refreshToken` is `null` and `scopes` reflects what was\n * *requested*, not granted — see the README's \"Android implicit flow is not\n * recommended\" section.\n */\nexport interface SpotifySession {\n /** OAuth access token. */\n accessToken: string;\n /**\n * OAuth refresh token. `null` on Android when no `tokenSwapURL` is provided\n * (the Spotify Android SDK does not expose a refresh token for implicit\n * grants — see the README).\n */\n refreshToken: string | null;\n /** Expiration timestamp as Unix epoch milliseconds. */\n expirationDate: number;\n /** Scopes the access token was granted (or requested, on Android implicit). */\n scopes: SpotifyScope[];\n}\n\n/**\n * Configuration accepted by `refreshSessionAsync`.\n */\nexport interface SpotifyRefreshConfig {\n /** The refresh token from a previous `authenticateAsync` call. */\n refreshToken: string;\n /** URL of your token refresh server endpoint. */\n tokenRefreshURL: string;\n}\n\n/**\n * Configuration accepted by `authenticateAsync`.\n */\nexport interface SpotifyConfig {\n /** OAuth scopes to request. Must contain at least one entry. */\n scopes: SpotifyScope[];\n /**\n * If supplied, requests an authorization code rather than an implicit\n * token, then POSTs the code to this URL to exchange it for tokens.\n * **Required on Android** to receive a usable `refreshToken`.\n */\n tokenSwapURL?: string;\n /**\n * Used by the iOS SDK to refresh access tokens automatically, and by\n * `refreshSessionAsync` on both platforms.\n */\n tokenRefreshURL?: string;\n /**\n * If `true`, forces Spotify to show the authorization dialog even when\n * the user already has an active session. Defaults to `false`.\n *\n * Maps to `SPTSessionManager.alwaysShowAuthorizationDialog` on iOS and\n * `AuthorizationRequest.Builder.setShowDialog(true)` on Android.\n */\n showDialog?: boolean;\n}\n\n/**\n * Spotify OAuth scope identifiers that are valid through the iOS, Android\n * and Web auth flows. See https://developer.spotify.com/documentation/web-api/concepts/scopes\n */\nexport type SpotifyScope =\n | \"ugc-image-upload\"\n | \"user-read-playback-state\"\n | \"user-modify-playback-state\"\n | \"user-read-currently-playing\"\n | \"app-remote-control\"\n | \"streaming\"\n | \"playlist-read-private\"\n | \"playlist-read-collaborative\"\n | \"playlist-modify-private\"\n | \"playlist-modify-public\"\n | \"user-follow-modify\"\n | \"user-follow-read\"\n | \"user-top-read\"\n | \"user-read-recently-played\"\n | \"user-library-modify\"\n | \"user-library-read\"\n | \"user-read-email\"\n | \"user-read-private\";\n\n/**\n * JS-side error code constants thrown via `Promise.reject(new Error(...))`\n * by the native modules.\n */\nexport type SpotifyErrorCode =\n | \"USER_CANCELLED\"\n | \"AUTH_IN_PROGRESS\"\n | \"INVALID_CONFIG\"\n | \"NETWORK_ERROR\"\n | \"TOKEN_SWAP_FAILED\"\n | \"TOKEN_SWAP_PARSE_ERROR\"\n | \"SPOTIFY_NOT_INSTALLED\"\n | \"AUTH_ERROR\"\n | \"UNKNOWN\";\n\n/**\n * Payload delivered to `addSessionChangeListener` subscribers.\n *\n * - `didInitiate` — a new session was created by `authenticateAsync`\n * - `didRenew` — an existing session was refreshed by `refreshSessionAsync`\n * - `didFail` — an auth or refresh attempt failed\n */\nexport type SpotifySessionChangeEvent =\n | { type: \"didInitiate\"; session: SpotifySession }\n | { type: \"didRenew\"; session: SpotifySession }\n | { type: \"didFail\"; error: { code: SpotifyErrorCode; message: string } };\n\n/**\n * Error subclass thrown by `authenticateAsync` and `refreshSessionAsync`\n * carrying a structured `code` field for branching.\n */\nexport class SpotifyError extends Error {\n public readonly code: SpotifyErrorCode;\n\n constructor(code: SpotifyErrorCode, message: string) {\n super(message);\n this.name = \"SpotifyError\";\n this.code = code;\n }\n}\n"]}
1
+ {"version":3,"file":"ExpoSpotifySDK.types.js","sourceRoot":"","sources":["../src/ExpoSpotifySDK.types.ts"],"names":[],"mappings":"AA2HA;;;GAGG;AACH,MAAM,OAAO,YAAa,SAAQ,KAAK;IACrB,IAAI,CAAmB;IAEvC,YAAY,IAAsB,EAAE,OAAe;QACjD,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,cAAc,CAAC;QAC3B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF","sourcesContent":["/**\n * Result of a successful Spotify authentication.\n *\n * The shape is identical across iOS and Android. On the Android implicit\n * (TOKEN) flow `refreshToken` is `null` and `scopes` reflects what was\n * *requested*, not granted — see the README's \"Android implicit flow is not\n * recommended\" section.\n */\nexport interface SpotifySession {\n /** OAuth access token. */\n accessToken: string;\n /**\n * OAuth refresh token. `null` on Android when no `tokenSwapURL` is provided\n * (the Spotify Android SDK does not expose a refresh token for implicit\n * grants — see the README).\n */\n refreshToken: string | null;\n /** Expiration timestamp as Unix epoch milliseconds. */\n expirationDate: number;\n /** Scopes the access token was granted (or requested, on Android implicit). */\n scopes: SpotifyScope[];\n}\n\n/**\n * Configuration accepted by `refreshSessionAsync`.\n */\nexport interface SpotifyRefreshConfig {\n /** The refresh token from a previous `authenticateAsync` call. */\n refreshToken: string;\n /** URL of your token refresh server endpoint. */\n tokenRefreshURL: string;\n /**\n * Scopes that were granted by the previous session. Used as a fallback\n * when the refresh response omits the `scope` field (which most\n * `accounts.spotify.com` refresh responses do — Spotify only returns\n * `scope` when the granted scope set has changed).\n *\n * If omitted, the returned `SpotifySession.scopes` will be `[]` whenever\n * the refresh response also omits `scope` — silently losing scope\n * information you held a moment ago. Pass through the previous session's\n * `scopes` to avoid that.\n */\n scopes?: SpotifyScope[];\n}\n\n/**\n * Configuration accepted by `authenticateAsync`.\n */\nexport interface SpotifyConfig {\n /** OAuth scopes to request. Must contain at least one entry. */\n scopes: SpotifyScope[];\n /**\n * If supplied, requests an authorization code rather than an implicit\n * token, then POSTs the code to this URL to exchange it for tokens.\n * **Required on Android** to receive a usable `refreshToken`.\n */\n tokenSwapURL?: string;\n /**\n * Used by the iOS SDK to refresh access tokens automatically, and by\n * `refreshSessionAsync` on both platforms.\n */\n tokenRefreshURL?: string;\n /**\n * If `true`, forces Spotify to show the authorization dialog even when\n * the user already has an active session. Defaults to `false`.\n *\n * Maps to `SPTSessionManager.alwaysShowAuthorizationDialog` on iOS and\n * `AuthorizationRequest.Builder.setShowDialog(true)` on Android.\n */\n showDialog?: boolean;\n}\n\n/**\n * Spotify OAuth scope identifiers that are valid through the iOS, Android\n * and Web auth flows. See https://developer.spotify.com/documentation/web-api/concepts/scopes\n */\nexport type SpotifyScope =\n | \"ugc-image-upload\"\n | \"user-read-playback-state\"\n | \"user-modify-playback-state\"\n | \"user-read-currently-playing\"\n | \"app-remote-control\"\n | \"streaming\"\n | \"playlist-read-private\"\n | \"playlist-read-collaborative\"\n | \"playlist-modify-private\"\n | \"playlist-modify-public\"\n | \"user-follow-modify\"\n | \"user-follow-read\"\n | \"user-top-read\"\n | \"user-read-recently-played\"\n | \"user-library-modify\"\n | \"user-library-read\"\n | \"user-read-email\"\n | \"user-read-private\";\n\n/**\n * JS-side error code constants thrown via `Promise.reject(new Error(...))`\n * by the native modules.\n */\nexport type SpotifyErrorCode =\n | \"USER_CANCELLED\"\n | \"AUTH_IN_PROGRESS\"\n | \"INVALID_CONFIG\"\n | \"NETWORK_ERROR\"\n | \"TOKEN_SWAP_FAILED\"\n | \"TOKEN_SWAP_PARSE_ERROR\"\n | \"SPOTIFY_NOT_INSTALLED\"\n | \"AUTH_ERROR\"\n | \"UNKNOWN\";\n\n/**\n * Payload delivered to `addSessionChangeListener` subscribers.\n *\n * - `didInitiate` — a new session was created by `authenticateAsync`\n * - `didRenew` — an existing session was refreshed by `refreshSessionAsync`\n * - `didFail` — an auth or refresh attempt failed\n */\nexport type SpotifySessionChangeEvent =\n | { type: \"didInitiate\"; session: SpotifySession }\n | { type: \"didRenew\"; session: SpotifySession }\n | { type: \"didFail\"; error: { code: SpotifyErrorCode; message: string } };\n\n/**\n * Error subclass thrown by `authenticateAsync` and `refreshSessionAsync`\n * carrying a structured `code` field for branching.\n */\nexport class SpotifyError extends Error {\n public readonly code: SpotifyErrorCode;\n\n constructor(code: SpotifyErrorCode, message: string) {\n super(message);\n this.name = \"SpotifyError\";\n this.code = code;\n }\n}\n"]}
package/build/index.d.ts CHANGED
@@ -10,6 +10,20 @@ declare function isAvailable(): boolean;
10
10
  * rejects with a {@link SpotifyError} carrying a `code`.
11
11
  */
12
12
  declare function authenticateAsync(config: SpotifyConfig): Promise<SpotifySession>;
13
+ /**
14
+ * Forcibly cancel any in-flight `authenticateAsync` call. Resolves once the
15
+ * native coordinator's pending continuation has been cleared. No-op when
16
+ * nothing is in flight, and a no-op on Android (the Android coordinator
17
+ * self-cleans via structured concurrency).
18
+ *
19
+ * Recovery hatch for the iOS coordinator's stuck-state class of bugs: the
20
+ * SPTSessionManager delegate callbacks are not guaranteed to fire — e.g. when
21
+ * Spotify never redirects back to the host app — leaving the coordinator's
22
+ * `pending` continuation set forever and every subsequent `authenticateAsync`
23
+ * rejecting with `AUTH_IN_PROGRESS` until the process restarts. Call this
24
+ * before `authenticateAsync` to defensively clear any leaked state.
25
+ */
26
+ declare function cancelPendingAuthAsync(): Promise<void>;
13
27
  /**
14
28
  * Exchanges a refresh token for a new access token via your token refresh
15
29
  * server. Resolves with a fresh {@link SpotifySession}; rejects with a
@@ -40,6 +54,6 @@ declare function addSessionChangeListener(listener: (event: SpotifySessionChange
40
54
  declare const Authenticate: {
41
55
  authenticateAsync: typeof authenticateAsync;
42
56
  };
43
- export { isAvailable, authenticateAsync, refreshSessionAsync, addSessionChangeListener, Authenticate, SpotifyError, };
57
+ export { isAvailable, authenticateAsync, cancelPendingAuthAsync, refreshSessionAsync, addSessionChangeListener, Authenticate, SpotifyError, };
44
58
  export type { SpotifyConfig, SpotifyRefreshConfig, SpotifySession, SpotifySessionChangeEvent, SpotifyErrorCode, SpotifyScope, } from "./ExpoSpotifySDK.types";
45
59
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAY,MAAM,mBAAmB,CAAC;AAEhE,OAAO,EACL,aAAa,EACb,YAAY,EAEZ,oBAAoB,EACpB,cAAc,EACd,yBAAyB,EAC1B,MAAM,wBAAwB,CAAC;AAWhC;;;GAGG;AACH,iBAAS,WAAW,IAAI,OAAO,CAE9B;AAED;;;GAGG;AACH,iBAAS,iBAAiB,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,cAAc,CAAC,CAqBzE;AAgCD;;;;GAIG;AACH,iBAAS,mBAAmB,CAC1B,MAAM,EAAE,oBAAoB,GAC3B,OAAO,CAAC,cAAc,CAAC,CAczB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,iBAAS,wBAAwB,CAC/B,QAAQ,EAAE,CAAC,KAAK,EAAE,yBAAyB,KAAK,IAAI,GACnD,iBAAiB,CAKnB;AAgCD,QAAA,MAAM,YAAY;;CAEjB,CAAC;AAEF,OAAO,EACL,WAAW,EACX,iBAAiB,EACjB,mBAAmB,EACnB,wBAAwB,EACxB,YAAY,EACZ,YAAY,GACb,CAAC;AACF,YAAY,EACV,aAAa,EACb,oBAAoB,EACpB,cAAc,EACd,yBAAyB,EACzB,gBAAgB,EAChB,YAAY,GACb,MAAM,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAY,MAAM,mBAAmB,CAAC;AAEhE,OAAO,EACL,aAAa,EACb,YAAY,EAEZ,oBAAoB,EACpB,cAAc,EACd,yBAAyB,EAC1B,MAAM,wBAAwB,CAAC;AAWhC;;;GAGG;AACH,iBAAS,WAAW,IAAI,OAAO,CAE9B;AAED;;;GAGG;AACH,iBAAS,iBAAiB,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,cAAc,CAAC,CAqBzE;AAED;;;;;;;;;;;;GAYG;AACH,iBAAS,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC,CAK/C;AAgCD;;;;GAIG;AACH,iBAAS,mBAAmB,CAC1B,MAAM,EAAE,oBAAoB,GAC3B,OAAO,CAAC,cAAc,CAAC,CAczB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,iBAAS,wBAAwB,CAC/B,QAAQ,EAAE,CAAC,KAAK,EAAE,yBAAyB,KAAK,IAAI,GACnD,iBAAiB,CAKnB;AAgCD,QAAA,MAAM,YAAY;;CAEjB,CAAC;AAEF,OAAO,EACL,WAAW,EACX,iBAAiB,EACjB,sBAAsB,EACtB,mBAAmB,EACnB,wBAAwB,EACxB,YAAY,EACZ,YAAY,GACb,CAAC;AACF,YAAY,EACV,aAAa,EACb,oBAAoB,EACpB,cAAc,EACd,yBAAyB,EACzB,gBAAgB,EAChB,YAAY,GACb,MAAM,wBAAwB,CAAC"}
package/build/index.js CHANGED
@@ -31,6 +31,25 @@ function authenticateAsync(config) {
31
31
  .then(normaliseSession)
32
32
  .catch(rethrowAsSpotifyError);
33
33
  }
34
+ /**
35
+ * Forcibly cancel any in-flight `authenticateAsync` call. Resolves once the
36
+ * native coordinator's pending continuation has been cleared. No-op when
37
+ * nothing is in flight, and a no-op on Android (the Android coordinator
38
+ * self-cleans via structured concurrency).
39
+ *
40
+ * Recovery hatch for the iOS coordinator's stuck-state class of bugs: the
41
+ * SPTSessionManager delegate callbacks are not guaranteed to fire — e.g. when
42
+ * Spotify never redirects back to the host app — leaving the coordinator's
43
+ * `pending` continuation set forever and every subsequent `authenticateAsync`
44
+ * rejecting with `AUTH_IN_PROGRESS` until the process restarts. Call this
45
+ * before `authenticateAsync` to defensively clear any leaked state.
46
+ */
47
+ function cancelPendingAuthAsync() {
48
+ if (Platform.OS !== "ios") {
49
+ return Promise.resolve();
50
+ }
51
+ return ExpoSpotifySDKModule.cancelPendingAuthAsync();
52
+ }
34
53
  function normaliseSession(raw) {
35
54
  if (!raw || typeof raw !== "object") {
36
55
  throw new SpotifyError("UNKNOWN", "Native module returned a non-object session");
@@ -124,5 +143,5 @@ function rethrowAsSpotifyError(err) {
124
143
  const Authenticate = {
125
144
  authenticateAsync,
126
145
  };
127
- export { isAvailable, authenticateAsync, refreshSessionAsync, addSessionChangeListener, Authenticate, SpotifyError, };
146
+ export { isAvailable, authenticateAsync, cancelPendingAuthAsync, refreshSessionAsync, addSessionChangeListener, Authenticate, SpotifyError, };
128
147
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import { EventSubscription, Platform } from \"expo-modules-core\";\n\nimport {\n SpotifyConfig,\n SpotifyError,\n SpotifyErrorCode,\n SpotifyRefreshConfig,\n SpotifySession,\n SpotifySessionChangeEvent,\n} from \"./ExpoSpotifySDK.types\";\nimport ExpoSpotifySDKModule from \"./ExpoSpotifySDKModule\";\n\nconst ANDROID_TOKEN_FLOW_WARNING =\n \"[expo-spotify-sdk] You are using authenticateAsync 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\n/**\n * Returns `true` if the Spotify app is installed on the device.\n * Always returns `false` on web.\n */\nfunction isAvailable(): boolean {\n return ExpoSpotifySDKModule.isAvailable();\n}\n\n/**\n * Starts a Spotify OAuth flow. Resolves with a {@link SpotifySession};\n * rejects with a {@link SpotifyError} carrying a `code`.\n */\nfunction authenticateAsync(config: SpotifyConfig): Promise<SpotifySession> {\n if (!config.scopes || config.scopes.length === 0) {\n return Promise.reject(\n new SpotifyError(\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\n console.warn(ANDROID_TOKEN_FLOW_WARNING);\n }\n return ExpoSpotifySDKModule.authenticateAsync(config)\n .then(normaliseSession)\n .catch(rethrowAsSpotifyError);\n}\n\nfunction normaliseSession(raw: unknown): SpotifySession {\n if (!raw || typeof raw !== \"object\") {\n throw new SpotifyError(\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 SpotifyError(\"UNKNOWN\", \"Session is missing accessToken\");\n }\n const expirationDate = r.expirationDate;\n if (typeof expirationDate !== \"number\") {\n throw new SpotifyError(\"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(\n (s) => typeof s === \"string\",\n ) as SpotifySession[\"scopes\"])\n : [];\n return { accessToken, refreshToken, expirationDate, scopes };\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 a\n * {@link SpotifyError}.\n */\nfunction refreshSessionAsync(\n config: SpotifyRefreshConfig,\n): Promise<SpotifySession> {\n if (!config.refreshToken) {\n return Promise.reject(\n new SpotifyError(\"INVALID_CONFIG\", \"refreshToken is required\"),\n );\n }\n if (!config.tokenRefreshURL) {\n return Promise.reject(\n new SpotifyError(\"INVALID_CONFIG\", \"tokenRefreshURL is required\"),\n );\n }\n return ExpoSpotifySDKModule.refreshSessionAsync(config)\n .then(normaliseSession)\n .catch(rethrowAsSpotifyError);\n}\n\n/**\n * Subscribes to session lifecycle events emitted by the native module.\n *\n * Events are fired for every `authenticateAsync` and `refreshSessionAsync`\n * call, regardless of whether the call was awaited. Useful for persisting\n * tokens in a central store without coupling the store to the call sites.\n *\n * Returns a {@link Subscription} — call `.remove()` to unsubscribe.\n *\n * @example\n * ```ts\n * const sub = addSessionChangeListener((event) => {\n * if (event.type === \"didInitiate\" || event.type === \"didRenew\") {\n * store.setSession(event.session);\n * }\n * });\n * // later:\n * sub.remove();\n * ```\n */\nfunction addSessionChangeListener(\n listener: (event: SpotifySessionChangeEvent) => void,\n): EventSubscription {\n return ExpoSpotifySDKModule.addListener(\n \"onSessionChange\",\n listener,\n ) as EventSubscription;\n}\n\nconst ERROR_PREFIX_RE = /^([A-Z_][A-Z0-9_]*):\\s*(.*)$/s;\n\nconst VALID_CODES: ReadonlySet<SpotifyErrorCode> = new Set<SpotifyErrorCode>([\n \"USER_CANCELLED\",\n \"AUTH_IN_PROGRESS\",\n \"INVALID_CONFIG\",\n \"NETWORK_ERROR\",\n \"TOKEN_SWAP_FAILED\",\n \"TOKEN_SWAP_PARSE_ERROR\",\n \"SPOTIFY_NOT_INSTALLED\",\n \"AUTH_ERROR\",\n \"UNKNOWN\",\n]);\n\nfunction rethrowAsSpotifyError(err: unknown): never {\n if (err instanceof SpotifyError) throw err;\n if (err instanceof Error) {\n const m = err.message.match(ERROR_PREFIX_RE);\n if (m && VALID_CODES.has(m[1] as SpotifyErrorCode)) {\n throw new SpotifyError(m[1] as SpotifyErrorCode, m[2]);\n }\n const maybeCode = (err as Error & { code?: string }).code;\n if (maybeCode && VALID_CODES.has(maybeCode as SpotifyErrorCode)) {\n throw new SpotifyError(maybeCode as SpotifyErrorCode, err.message);\n }\n throw new SpotifyError(\"UNKNOWN\", err.message);\n }\n throw new SpotifyError(\"UNKNOWN\", String(err));\n}\n\nconst Authenticate = {\n authenticateAsync,\n};\n\nexport {\n isAvailable,\n authenticateAsync,\n refreshSessionAsync,\n addSessionChangeListener,\n Authenticate,\n SpotifyError,\n};\nexport type {\n SpotifyConfig,\n SpotifyRefreshConfig,\n SpotifySession,\n SpotifySessionChangeEvent,\n SpotifyErrorCode,\n SpotifyScope,\n} from \"./ExpoSpotifySDK.types\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import { EventSubscription, Platform } from \"expo-modules-core\";\n\nimport {\n SpotifyConfig,\n SpotifyError,\n SpotifyErrorCode,\n SpotifyRefreshConfig,\n SpotifySession,\n SpotifySessionChangeEvent,\n} from \"./ExpoSpotifySDK.types\";\nimport ExpoSpotifySDKModule from \"./ExpoSpotifySDKModule\";\n\nconst ANDROID_TOKEN_FLOW_WARNING =\n \"[expo-spotify-sdk] You are using authenticateAsync 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\n/**\n * Returns `true` if the Spotify app is installed on the device.\n * Always returns `false` on web.\n */\nfunction isAvailable(): boolean {\n return ExpoSpotifySDKModule.isAvailable();\n}\n\n/**\n * Starts a Spotify OAuth flow. Resolves with a {@link SpotifySession};\n * rejects with a {@link SpotifyError} carrying a `code`.\n */\nfunction authenticateAsync(config: SpotifyConfig): Promise<SpotifySession> {\n if (!config.scopes || config.scopes.length === 0) {\n return Promise.reject(\n new SpotifyError(\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\n console.warn(ANDROID_TOKEN_FLOW_WARNING);\n }\n return ExpoSpotifySDKModule.authenticateAsync(config)\n .then(normaliseSession)\n .catch(rethrowAsSpotifyError);\n}\n\n/**\n * Forcibly cancel any in-flight `authenticateAsync` call. Resolves once the\n * native coordinator's pending continuation has been cleared. No-op when\n * nothing is in flight, and a no-op on Android (the Android coordinator\n * self-cleans via structured concurrency).\n *\n * Recovery hatch for the iOS coordinator's stuck-state class of bugs: the\n * SPTSessionManager delegate callbacks are not guaranteed to fire — e.g. when\n * Spotify never redirects back to the host app — leaving the coordinator's\n * `pending` continuation set forever and every subsequent `authenticateAsync`\n * rejecting with `AUTH_IN_PROGRESS` until the process restarts. Call this\n * before `authenticateAsync` to defensively clear any leaked state.\n */\nfunction cancelPendingAuthAsync(): Promise<void> {\n if (Platform.OS !== \"ios\") {\n return Promise.resolve();\n }\n return ExpoSpotifySDKModule.cancelPendingAuthAsync();\n}\n\nfunction normaliseSession(raw: unknown): SpotifySession {\n if (!raw || typeof raw !== \"object\") {\n throw new SpotifyError(\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 SpotifyError(\"UNKNOWN\", \"Session is missing accessToken\");\n }\n const expirationDate = r.expirationDate;\n if (typeof expirationDate !== \"number\") {\n throw new SpotifyError(\"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(\n (s) => typeof s === \"string\",\n ) as SpotifySession[\"scopes\"])\n : [];\n return { accessToken, refreshToken, expirationDate, scopes };\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 a\n * {@link SpotifyError}.\n */\nfunction refreshSessionAsync(\n config: SpotifyRefreshConfig,\n): Promise<SpotifySession> {\n if (!config.refreshToken) {\n return Promise.reject(\n new SpotifyError(\"INVALID_CONFIG\", \"refreshToken is required\"),\n );\n }\n if (!config.tokenRefreshURL) {\n return Promise.reject(\n new SpotifyError(\"INVALID_CONFIG\", \"tokenRefreshURL is required\"),\n );\n }\n return ExpoSpotifySDKModule.refreshSessionAsync(config)\n .then(normaliseSession)\n .catch(rethrowAsSpotifyError);\n}\n\n/**\n * Subscribes to session lifecycle events emitted by the native module.\n *\n * Events are fired for every `authenticateAsync` and `refreshSessionAsync`\n * call, regardless of whether the call was awaited. Useful for persisting\n * tokens in a central store without coupling the store to the call sites.\n *\n * Returns a {@link Subscription} — call `.remove()` to unsubscribe.\n *\n * @example\n * ```ts\n * const sub = addSessionChangeListener((event) => {\n * if (event.type === \"didInitiate\" || event.type === \"didRenew\") {\n * store.setSession(event.session);\n * }\n * });\n * // later:\n * sub.remove();\n * ```\n */\nfunction addSessionChangeListener(\n listener: (event: SpotifySessionChangeEvent) => void,\n): EventSubscription {\n return ExpoSpotifySDKModule.addListener(\n \"onSessionChange\",\n listener,\n ) as EventSubscription;\n}\n\nconst ERROR_PREFIX_RE = /^([A-Z_][A-Z0-9_]*):\\s*(.*)$/s;\n\nconst VALID_CODES: ReadonlySet<SpotifyErrorCode> = new Set<SpotifyErrorCode>([\n \"USER_CANCELLED\",\n \"AUTH_IN_PROGRESS\",\n \"INVALID_CONFIG\",\n \"NETWORK_ERROR\",\n \"TOKEN_SWAP_FAILED\",\n \"TOKEN_SWAP_PARSE_ERROR\",\n \"SPOTIFY_NOT_INSTALLED\",\n \"AUTH_ERROR\",\n \"UNKNOWN\",\n]);\n\nfunction rethrowAsSpotifyError(err: unknown): never {\n if (err instanceof SpotifyError) throw err;\n if (err instanceof Error) {\n const m = err.message.match(ERROR_PREFIX_RE);\n if (m && VALID_CODES.has(m[1] as SpotifyErrorCode)) {\n throw new SpotifyError(m[1] as SpotifyErrorCode, m[2]);\n }\n const maybeCode = (err as Error & { code?: string }).code;\n if (maybeCode && VALID_CODES.has(maybeCode as SpotifyErrorCode)) {\n throw new SpotifyError(maybeCode as SpotifyErrorCode, err.message);\n }\n throw new SpotifyError(\"UNKNOWN\", err.message);\n }\n throw new SpotifyError(\"UNKNOWN\", String(err));\n}\n\nconst Authenticate = {\n authenticateAsync,\n};\n\nexport {\n isAvailable,\n authenticateAsync,\n cancelPendingAuthAsync,\n refreshSessionAsync,\n addSessionChangeListener,\n Authenticate,\n SpotifyError,\n};\nexport type {\n SpotifyConfig,\n SpotifyRefreshConfig,\n SpotifySession,\n SpotifySessionChangeEvent,\n SpotifyErrorCode,\n SpotifyScope,\n} from \"./ExpoSpotifySDK.types\";\n"]}
@@ -1,7 +1,7 @@
1
1
  import ExpoModulesCore
2
2
  import SpotifyiOS
3
3
 
4
- private let SDK_VERSION = "0.5.0"
4
+ private let SDK_VERSION = "0.8.0" // x-release-please-version
5
5
  private let EVENT_SESSION_CHANGE = "onSessionChange"
6
6
 
7
7
  public class ExpoSpotifySDKModule: Module {
@@ -13,6 +13,10 @@ public class ExpoSpotifySDKModule: Module {
13
13
  SpotifyAuthCoordinator.shared?.isSpotifyAppInstalled() ?? false
14
14
  }
15
15
 
16
+ AsyncFunction("cancelPendingAuthAsync") { () async -> Void in
17
+ await SpotifyAuthCoordinator.shared?.cancelPending()
18
+ }
19
+
16
20
  AsyncFunction("authenticateAsync") { (config: AuthenticateOptions) async throws -> [String: Any?] in
17
21
  do {
18
22
  guard let coordinator = SpotifyAuthCoordinator.shared else {
@@ -60,7 +64,7 @@ public class ExpoSpotifySDKModule: Module {
60
64
  let result = try await client.refresh(
61
65
  refreshToken: options.refreshToken,
62
66
  tokenRefreshURL: options.tokenRefreshURL,
63
- previousScopes: []
67
+ previousScopes: options.scopes
64
68
  )
65
69
  let map: [String: Any?] = [
66
70
  "accessToken": result.accessToken,
@@ -106,4 +110,5 @@ struct AuthenticateOptions: Record {
106
110
  struct RefreshOptions: Record {
107
111
  @Field var refreshToken: String = ""
108
112
  @Field var tokenRefreshURL: String = ""
113
+ @Field var scopes: [String] = []
109
114
  }
@@ -38,6 +38,11 @@ enum SPTScopeSerializer {
38
38
  for string in scopes {
39
39
  if let value = lookup[string] {
40
40
  result.insert(value)
41
+ } else {
42
+ // Unknown scope strings are silently dropped by the Spotify iOS SDK,
43
+ // which can quietly change the meaning of a caller's request. Surface
44
+ // it so it's debuggable from the device console.
45
+ NSLog("[ExpoSpotifySDK] Unknown scope dropped: %@", string)
41
46
  }
42
47
  }
43
48
  return result
@@ -112,6 +112,20 @@ actor SpotifyAuthCoordinator {
112
112
  pending = nil
113
113
  cont?.resume(with: result)
114
114
  }
115
+
116
+ /// Forcibly cancel any in-flight authenticate() call. The pending
117
+ /// continuation (if any) is resumed with `userCancelled` and `pending` is
118
+ /// cleared. Safe to call when nothing is in flight (no-op).
119
+ ///
120
+ /// Needed because the SPTSessionManager delegate callbacks are not
121
+ /// guaranteed to fire — e.g. when Spotify never redirects back to the host
122
+ /// app — leaving `pending` set forever and every subsequent authenticate()
123
+ /// rejecting with `authInProgress`.
124
+ func cancelPending() {
125
+ guard let cont = pending else { return }
126
+ pending = nil
127
+ cont.resume(throwing: SpotifyError.userCancelled)
128
+ }
115
129
  }
116
130
 
117
131
  /// `SPTSessionManagerDelegate` requires NSObject conformance, which an actor
@@ -113,6 +113,9 @@ struct SpotifyTokenRefreshClient {
113
113
  private func formURLEncoded(_ pairs: [String: String]) -> String {
114
114
  var components = URLComponents()
115
115
  components.queryItems = pairs.map { URLQueryItem(name: $0.key, value: $0.value) }
116
- return components.percentEncodedQuery ?? ""
116
+ // `URLComponents.percentEncodedQuery` does NOT escape `+`, but in
117
+ // `application/x-www-form-urlencoded` bodies `+` means space. Replace it
118
+ // explicitly so refresh tokens containing `+` survive the round-trip.
119
+ return (components.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B")
117
120
  }
118
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wwdrew/expo-spotify-sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Expo module wrapping the native Spotify iOS (v5) and Android (v4) SDKs for OAuth authentication",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.withSpotifyAndroidAppBuildGradle = void 0;
4
4
  const config_plugins_1 = require("@expo/config-plugins");
5
5
  const SENTINEL_KEY = "spotifyClientId";
6
- const DEFAULT_REDIRECT_PATH_PATTERN = "/.*";
6
+ const DEFAULT_REDIRECT_PATH_PATTERN = ".*";
7
7
  const withSpotifyAndroidAppBuildGradle = (config, spotifyConfig) => {
8
8
  return (0, config_plugins_1.withAppBuildGradle)(config, (config) => {
9
9
  if (config.modResults.contents.includes(SENTINEL_KEY)) {
@@ -15,7 +15,7 @@ export type SpotifyScopes = "ugc-image-upload" | "user-read-playback-state" | "u
15
15
  * clientID: "<spotify-client-id>",
16
16
  * scheme: "myapp",
17
17
  * host: "spotify-auth",
18
- * redirectPathPattern: "/.*"
18
+ * redirectPathPattern: ".*"
19
19
  * }]
20
20
  * ]
21
21
  * }
@@ -32,8 +32,11 @@ export interface SpotifyConfig {
32
32
  scheme: string;
33
33
  /**
34
34
  * Path pattern Spotify will accept on the redirect URI. Required by the
35
- * Spotify Android Auth SDK from version 3.0.0 onwards. Defaults to `"/.*"`
36
- * which matches any path (preserving pre-3.0.0 SDK behaviour).
35
+ * Spotify Android Auth SDK from version 3.0.0 onwards. Defaults to `".*"`
36
+ * which matches any path including the empty string, so it works for both
37
+ * `scheme://host` and `scheme://host/path` redirect URIs. Use a more
38
+ * specific pattern (e.g. `"/auth/.*"`) only if you have a specific path
39
+ * registered in your Spotify app settings.
37
40
  *
38
41
  * See: https://developer.android.com/guide/topics/manifest/data-element#path
39
42
  */