@superfan-app/spotify-auth 0.1.73 → 0.1.75

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.
@@ -1,5 +1,6 @@
1
1
  package expo.modules.spotifyauth
2
2
 
3
+ import android.app.Activity
3
4
  import android.content.Intent
4
5
  import android.content.pm.PackageManager
5
6
  import android.net.Uri
@@ -23,9 +24,9 @@ import java.util.concurrent.Executors
23
24
  /**
24
25
  * Core Spotify authentication logic for Android, mirroring the iOS SpotifyAuthAuth class.
25
26
  *
26
- * Supports two auth flows:
27
- * 1. App-switch via Spotify's AuthorizationClient (when Spotify app is installed)
28
- * 2. Web auth via AuthorizationClient's WebView fallback (built into the Spotify auth-lib)
27
+ * Uses browser-based auth via Spotify's AuthorizationClient.openLoginInBrowser().
28
+ * On Android, app-switch is disabled regardless of whether the Spotify app is installed.
29
+ * Auth results are delivered via onNewIntent (handled by handleNewIntent()).
29
30
  *
30
31
  * Token exchange and refresh are handled via the backend token swap/refresh URLs,
31
32
  * matching the iOS implementation.
@@ -192,9 +193,9 @@ class SpotifyAuthAuth private constructor(private val appContext: AppContext) {
192
193
  // region Authentication Flow
193
194
 
194
195
  /**
195
- * Initiate the Spotify authorization flow.
196
- * Uses Spotify's AuthorizationClient which handles both app-switch (when Spotify is installed)
197
- * and WebView fallback automatically.
196
+ * Initiate the Spotify authorization flow via the system browser.
197
+ * On Android, app-switch is always bypassed. The auth result is delivered
198
+ * via onNewIntent, handled by handleNewIntent().
198
199
  */
199
200
  fun initAuth(config: AuthorizeConfig) {
200
201
  secureLog("initAuth called with showDialog=${config.showDialog}")
@@ -223,13 +224,7 @@ class SpotifyAuthAuth private constructor(private val appContext: AppContext) {
223
224
  val redirectUri = redirectURL
224
225
  val scopeArray = scopes.toTypedArray()
225
226
 
226
- val spotifyInstalled = isSpotifyInstalled()
227
227
  secureLog("Configuration - ClientID: ${clientId.take(8)}..., RedirectURI: $redirectUri, Scopes: ${scopeArray.size}")
228
- Log.d(TAG, "Spotify app installed: $spotifyInstalled (will use ${if (spotifyInstalled) "app-switch" else "WebView"} auth)")
229
-
230
- if (!spotifyInstalled) {
231
- Log.w(TAG, "Spotify app not detected. Will use WebView fallback. If WebView fails, check package visibility in AndroidManifest (<queries> tag)")
232
- }
233
228
 
234
229
  if (scopeArray.isEmpty()) {
235
230
  Log.e(TAG, "No valid scopes found in configuration")
@@ -261,18 +256,17 @@ class SpotifyAuthAuth private constructor(private val appContext: AppContext) {
261
256
 
262
257
  val request = builder.build()
263
258
 
264
- secureLog("Opening Spotify authorization activity with REQUEST_CODE=$REQUEST_CODE")
259
+ secureLog("Opening Spotify authorization in browser")
265
260
 
266
- // === ENHANCED DEBUG LOGGING ===
261
+ // === SPOTIFY AUTH DEBUG ===
267
262
  Log.d(TAG, "=== SPOTIFY AUTH DEBUG ===")
268
- Log.d(TAG, "Auth flow type: ${if (spotifyInstalled) "APP_SWITCH" else "WEBVIEW"}")
263
+ Log.d(TAG, "Auth flow type: BROWSER (app-switch disabled on Android)")
269
264
  Log.d(TAG, "Client ID: ${clientId.take(10)}...")
270
265
  Log.d(TAG, "Redirect URI: $redirectUri")
271
266
  Log.d(TAG, "Response Type: CODE")
272
267
  Log.d(TAG, "Scopes: ${scopeArray.joinToString(",")}")
273
268
  Log.d(TAG, "Package name: ${appContext.reactContext?.packageName}")
274
269
  Log.d(TAG, "Activity: ${activity.javaClass.name}")
275
- Log.d(TAG, "Activity launchMode: ${activity.packageManager.getActivityInfo(activity.componentName, 0).launchMode}")
276
270
  Log.d(TAG, "========================")
277
271
 
278
272
  // Set a timeout to detect if the auth flow doesn't complete
@@ -288,13 +282,12 @@ class SpotifyAuthAuth private constructor(private val appContext: AppContext) {
288
282
  mainHandler.postDelayed(authTimeoutHandler!!, AUTH_TIMEOUT_MS)
289
283
 
290
284
  try {
291
- // AuthorizationClient.openLoginActivity handles both flows:
292
- // - If Spotify is installed: app-switch auth
293
- // - If Spotify is not installed: opens a WebView with Spotify login
294
- AuthorizationClient.openLoginActivity(activity, REQUEST_CODE, request)
295
- secureLog("AuthorizationClient.openLoginActivity called successfully")
285
+ // Use browser-based auth on Android (bypasses Spotify app-switch).
286
+ // The auth result is delivered via onNewIntent, handled by handleNewIntent().
287
+ AuthorizationClient.openLoginInBrowser(activity, request)
288
+ secureLog("AuthorizationClient.openLoginInBrowser called successfully")
296
289
  } catch (e: Exception) {
297
- Log.e(TAG, "Failed to open authorization activity: ${e.message}", e)
290
+ Log.e(TAG, "Failed to open browser authorization: ${e.message}", e)
298
291
  throw SpotifyAuthException.AuthenticationFailed("Failed to open Spotify authorization: ${e.message}")
299
292
  }
300
293
 
@@ -392,7 +385,33 @@ class SpotifyAuthAuth private constructor(private val appContext: AppContext) {
392
385
  }
393
386
  }
394
387
  AuthorizationResponse.Type.EMPTY -> {
395
- Log.w(TAG, "Authorization returned EMPTY - user likely cancelled")
388
+ val spotifyInstalled = isSpotifyInstalled()
389
+ if (spotifyInstalled) {
390
+ Log.e(TAG, "")
391
+ Log.e(TAG, "╔══════════════════════════════════════════════════════════════╗")
392
+ Log.e(TAG, "║ SPOTIFY APP-SWITCH AUTH: EMPTY RESPONSE ║")
393
+ Log.e(TAG, "╠══════════════════════════════════════════════════════════════╣")
394
+ Log.e(TAG, "║ Spotify is installed but auth returned empty immediately. ║")
395
+ Log.e(TAG, "║ The auth dialog flashed and dismissed without going to ║")
396
+ Log.e(TAG, "║ Spotify. Common client-side causes: ║")
397
+ Log.e(TAG, "║ ║")
398
+ Log.e(TAG, "║ 1. MainActivity launchMode is singleTask (most likely). ║")
399
+ Log.e(TAG, "║ Change to singleTop in your AndroidManifest.xml. ║")
400
+ Log.e(TAG, "║ ║")
401
+ Log.e(TAG, "║ 2. Redirect URI '${redirectURL.take(30)}...' not registered ║")
402
+ Log.e(TAG, "║ in Spotify Developer Dashboard. ║")
403
+ Log.e(TAG, "║ ║")
404
+ Log.e(TAG, "║ 3. manifestPlaceholders redirectHostName in build.gradle ║")
405
+ Log.e(TAG, "║ does not match the host portion of your redirect URI. ║")
406
+ Log.e(TAG, "║ (Run the Expo config plugin to regenerate.) ║")
407
+ Log.e(TAG, "║ ║")
408
+ Log.e(TAG, "║ 4. Installed Spotify version is too old to support ║")
409
+ Log.e(TAG, "║ app-switch auth. ║")
410
+ Log.e(TAG, "╚══════════════════════════════════════════════════════════════╝")
411
+ Log.e(TAG, "")
412
+ } else {
413
+ Log.w(TAG, "Authorization returned EMPTY - user likely cancelled")
414
+ }
396
415
  module?.onAuthorizationError(SpotifyAuthException.UserCancelled())
397
416
  }
398
417
  else -> {
@@ -410,6 +429,78 @@ class SpotifyAuthAuth private constructor(private val appContext: AppContext) {
410
429
  }
411
430
  }
412
431
 
432
+ /**
433
+ * Handle the Spotify auth callback delivered via onNewIntent (browser-based flow).
434
+ * Called by the module's OnNewIntent handler after openLoginInBrowser() completes.
435
+ */
436
+ fun handleNewIntent(intent: Intent) {
437
+ Log.d(TAG, "handleNewIntent called - action=${intent.action}, data=${intent.data}")
438
+
439
+ if (!isAuthenticating) {
440
+ Log.d(TAG, "Ignoring new intent - not currently authenticating")
441
+ return
442
+ }
443
+
444
+ // Cancel the timeout
445
+ authTimeoutHandler?.let {
446
+ mainHandler.removeCallbacks(it)
447
+ secureLog("Auth timeout cancelled")
448
+ }
449
+
450
+ isAuthenticating = false
451
+ secureLog("Setting isAuthenticating to false")
452
+
453
+ if (module == null) {
454
+ Log.e(TAG, "CRITICAL: Module is null in handleNewIntent - cannot send events to JS")
455
+ return
456
+ }
457
+
458
+ try {
459
+ val response = AuthorizationClient.getResponse(Activity.RESULT_OK, intent)
460
+ Log.d(TAG, "Spotify response type: ${response.type}")
461
+
462
+ when (response.type) {
463
+ AuthorizationResponse.Type.CODE -> {
464
+ val code = response.code
465
+ secureLog("Authorization code received, length=${code?.length ?: 0}")
466
+ if (code != null) {
467
+ exchangeCodeForToken(code)
468
+ } else {
469
+ Log.e(TAG, "Authorization code was null despite CODE response type")
470
+ module?.onAuthorizationError(
471
+ SpotifyAuthException.AuthenticationFailed("No authorization code received")
472
+ )
473
+ }
474
+ }
475
+ AuthorizationResponse.Type.ERROR -> {
476
+ val errorMsg = response.error ?: "Unknown error"
477
+ Log.e(TAG, "Spotify authorization error: $errorMsg")
478
+ if (errorMsg.contains("access_denied", ignoreCase = true) ||
479
+ errorMsg.contains("cancelled", ignoreCase = true)) {
480
+ module?.onAuthorizationError(SpotifyAuthException.UserCancelled())
481
+ } else {
482
+ module?.onAuthorizationError(SpotifyAuthException.AuthorizationError(errorMsg))
483
+ }
484
+ }
485
+ AuthorizationResponse.Type.EMPTY -> {
486
+ Log.w(TAG, "Browser auth returned EMPTY - user likely cancelled")
487
+ module?.onAuthorizationError(SpotifyAuthException.UserCancelled())
488
+ }
489
+ else -> {
490
+ Log.e(TAG, "Unexpected Spotify response type: ${response.type}")
491
+ module?.onAuthorizationError(
492
+ SpotifyAuthException.AuthenticationFailed("Unexpected response type: ${response.type}")
493
+ )
494
+ }
495
+ }
496
+ } catch (e: Exception) {
497
+ Log.e(TAG, "Exception in handleNewIntent: ${e.message}", e)
498
+ module?.onAuthorizationError(
499
+ SpotifyAuthException.AuthenticationFailed("Error processing auth result: ${e.message}")
500
+ )
501
+ }
502
+ }
503
+
413
504
  // endregion
414
505
 
415
506
  // region Token Exchange
@@ -658,16 +749,14 @@ class SpotifyAuthAuth private constructor(private val appContext: AppContext) {
658
749
  // region Web Auth Cancellation
659
750
 
660
751
  /**
661
- * Cancel an in-progress web auth session.
662
- * On Android, this clears cookies and notifies JS of cancellation.
752
+ * Cancel an in-progress auth session.
753
+ * For browser-based auth the system browser cannot be closed programmatically,
754
+ * so this clears internal state and notifies JS of cancellation.
663
755
  */
664
756
  fun cancelWebAuth() {
665
- val activity = appContext.currentActivity
666
- if (activity != null) {
667
- AuthorizationClient.stopLoginActivity(activity, REQUEST_CODE)
668
- }
669
- module?.onAuthorizationError(SpotifyAuthException.UserCancelled())
757
+ authTimeoutHandler?.let { mainHandler.removeCallbacks(it) }
670
758
  isAuthenticating = false
759
+ module?.onAuthorizationError(SpotifyAuthException.UserCancelled())
671
760
  }
672
761
 
673
762
  // endregion
@@ -62,6 +62,11 @@ class SpotifyAuthModule : Module() {
62
62
  spotifyAuth.cancelWebAuth()
63
63
  }
64
64
 
65
+ OnNewIntent { intent ->
66
+ secureLog("OnNewIntent received in module - action=${intent.action}, data=${intent.data}")
67
+ spotifyAuth.handleNewIntent(intent)
68
+ }
69
+
65
70
  OnActivityResult { _, payload ->
66
71
  secureLog("OnActivityResult received in module - requestCode=${payload.requestCode}, resultCode=${payload.resultCode}")
67
72
  android.util.Log.d("SpotifyAuth", "=== MODULE ACTIVITY RESULT ===")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@superfan-app/spotify-auth",
3
- "version": "0.1.73",
3
+ "version": "0.1.75",
4
4
  "description": "Spotify OAuth module for Expo",
5
5
  "main": "src/index.tsx",
6
6
  "types": "build/index.d.ts",
@@ -3,6 +3,16 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const config_plugins_1 = require("@expo/config-plugins");
5
5
  const pkg = require('../../package.json');
6
+ /**
7
+ * Splits a callback value (e.g. "auth" or "auth/spotify") into its host and optional path components.
8
+ * Android intent filter `android:host` must not include a path segment.
9
+ */
10
+ function parseCallback(callback) {
11
+ const slashIdx = callback.indexOf('/');
12
+ if (slashIdx === -1)
13
+ return { host: callback, path: null };
14
+ return { host: callback.slice(0, slashIdx), path: callback.slice(slashIdx) };
15
+ }
6
16
  function validateSpotifyConfig(config) {
7
17
  if (!config.clientID)
8
18
  throw new Error("Spotify clientID is required");
@@ -77,9 +87,12 @@ const withSpotifyManifestPlaceholders = (config, props) => {
77
87
  let buildGradle = config.modResults.contents;
78
88
  // Escape values for Gradle string literals (escape backslashes and quotes)
79
89
  const scheme = String(props.scheme).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
80
- const host = String(props.callback).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
81
- // ".*" accepts any path - retains previous behavior per Spotify changelog (auth lib 3.0.0)
82
- const pathPattern = '.*';
90
+ // Split callback into host and optional path — android:host must not contain a slash
91
+ const { host: callbackHost, path: callbackPath } = parseCallback(props.callback);
92
+ const host = String(callbackHost).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
93
+ // If the callback has a path component (e.g. "auth/spotify"), prefix the pathPattern
94
+ // so SpotifyAuthorizationActivity's intent filter correctly matches the return URI.
95
+ const pathPattern = callbackPath ? `${callbackPath}.*` : '.*';
83
96
  const placeholdersBlock = ` manifestPlaceholders = [
84
97
  redirectSchemeName: "${scheme}",
85
98
  redirectHostName: "${host}",
@@ -109,6 +122,8 @@ const withSpotifyAndroidManifest = (config, props) => {
109
122
  const mainApplication = config_plugins_1.AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);
110
123
  // Construct the redirect URL from scheme and callback
111
124
  const redirectUrl = `${props.scheme}://${props.callback}`;
125
+ // Split callback into host and optional path for correct intent filter construction
126
+ const { host: callbackHost, path: callbackPath } = parseCallback(props.callback);
112
127
  // Add Spotify configuration as meta-data elements
113
128
  const metaDataEntries = [
114
129
  { name: 'SpotifyClientID', value: props.clientID },
@@ -137,22 +152,24 @@ const withSpotifyAndroidManifest = (config, props) => {
137
152
  mainActivity['intent-filter'] = [];
138
153
  }
139
154
  // Check if we already have a Spotify redirect intent filter
140
- const hasSpotifyIntentFilter = mainActivity['intent-filter'].some((filter) => filter.data?.some((d) => d.$?.['android:scheme'] === props.scheme && d.$?.['android:host'] === props.callback));
155
+ const hasSpotifyIntentFilter = mainActivity['intent-filter'].some((filter) => filter.data?.some((d) => d.$?.['android:scheme'] === props.scheme && d.$?.['android:host'] === callbackHost));
141
156
  if (!hasSpotifyIntentFilter) {
157
+ // Build the data element: android:host must be the hostname only (no path).
158
+ // If the callback has a path component (e.g. "auth/spotify"), add android:pathPrefix.
159
+ const dataAttrs = {
160
+ 'android:scheme': props.scheme,
161
+ 'android:host': callbackHost,
162
+ };
163
+ if (callbackPath) {
164
+ dataAttrs['android:pathPrefix'] = callbackPath;
165
+ }
142
166
  mainActivity['intent-filter'].push({
143
167
  action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
144
168
  category: [
145
169
  { $: { 'android:name': 'android.intent.category.DEFAULT' } },
146
170
  { $: { 'android:name': 'android.intent.category.BROWSABLE' } },
147
171
  ],
148
- data: [
149
- {
150
- $: {
151
- 'android:scheme': props.scheme,
152
- 'android:host': props.callback,
153
- },
154
- },
155
- ],
172
+ data: [{ $: dataAttrs }],
156
173
  });
157
174
  }
158
175
  return config;
@@ -5,6 +5,16 @@ import { SpotifyConfig } from './types.js'
5
5
 
6
6
  const pkg = require('../../package.json');
7
7
 
8
+ /**
9
+ * Splits a callback value (e.g. "auth" or "auth/spotify") into its host and optional path components.
10
+ * Android intent filter `android:host` must not include a path segment.
11
+ */
12
+ function parseCallback(callback: string): { host: string; path: string | null } {
13
+ const slashIdx = callback.indexOf('/')
14
+ if (slashIdx === -1) return { host: callback, path: null }
15
+ return { host: callback.slice(0, slashIdx), path: callback.slice(slashIdx) }
16
+ }
17
+
8
18
  function validateSpotifyConfig(config: SpotifyConfig) {
9
19
  if (!config.clientID) throw new Error("Spotify clientID is required")
10
20
  if (!config.scheme) throw new Error("URL scheme is required")
@@ -86,9 +96,12 @@ const withSpotifyManifestPlaceholders: ConfigPlugin<SpotifyConfig> = (config, pr
86
96
 
87
97
  // Escape values for Gradle string literals (escape backslashes and quotes)
88
98
  const scheme = String(props.scheme).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
89
- const host = String(props.callback).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
90
- // ".*" accepts any path - retains previous behavior per Spotify changelog (auth lib 3.0.0)
91
- const pathPattern = '.*';
99
+ // Split callback into host and optional path — android:host must not contain a slash
100
+ const { host: callbackHost, path: callbackPath } = parseCallback(props.callback)
101
+ const host = String(callbackHost).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
102
+ // If the callback has a path component (e.g. "auth/spotify"), prefix the pathPattern
103
+ // so SpotifyAuthorizationActivity's intent filter correctly matches the return URI.
104
+ const pathPattern = callbackPath ? `${callbackPath}.*` : '.*';
92
105
 
93
106
  const placeholdersBlock = ` manifestPlaceholders = [
94
107
  redirectSchemeName: "${scheme}",
@@ -131,6 +144,8 @@ const withSpotifyAndroidManifest: ConfigPlugin<SpotifyConfig> = (config, props)
131
144
 
132
145
  // Construct the redirect URL from scheme and callback
133
146
  const redirectUrl = `${props.scheme}://${props.callback}`;
147
+ // Split callback into host and optional path for correct intent filter construction
148
+ const { host: callbackHost, path: callbackPath } = parseCallback(props.callback)
134
149
 
135
150
  // Add Spotify configuration as meta-data elements
136
151
  const metaDataEntries = [
@@ -170,25 +185,28 @@ const withSpotifyAndroidManifest: ConfigPlugin<SpotifyConfig> = (config, props)
170
185
  const hasSpotifyIntentFilter = mainActivity['intent-filter'].some(
171
186
  (filter: any) =>
172
187
  filter.data?.some(
173
- (d: any) => d.$?.['android:scheme'] === props.scheme && d.$?.['android:host'] === props.callback
188
+ (d: any) => d.$?.['android:scheme'] === props.scheme && d.$?.['android:host'] === callbackHost
174
189
  )
175
190
  );
176
191
 
177
192
  if (!hasSpotifyIntentFilter) {
193
+ // Build the data element: android:host must be the hostname only (no path).
194
+ // If the callback has a path component (e.g. "auth/spotify"), add android:pathPrefix.
195
+ const dataAttrs: Record<string, string> = {
196
+ 'android:scheme': props.scheme,
197
+ 'android:host': callbackHost,
198
+ }
199
+ if (callbackPath) {
200
+ dataAttrs['android:pathPrefix'] = callbackPath
201
+ }
202
+
178
203
  mainActivity['intent-filter'].push({
179
204
  action: [{ $: { 'android:name': 'android.intent.action.VIEW' } }],
180
205
  category: [
181
206
  { $: { 'android:name': 'android.intent.category.DEFAULT' } },
182
207
  { $: { 'android:name': 'android.intent.category.BROWSABLE' } },
183
208
  ],
184
- data: [
185
- {
186
- $: {
187
- 'android:scheme': props.scheme,
188
- 'android:host': props.callback,
189
- },
190
- },
191
- ],
209
+ data: [{ $: dataAttrs }],
192
210
  });
193
211
  }
194
212