@wwdrew/expo-spotify-sdk 0.4.3 → 0.6.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.
Files changed (81) hide show
  1. package/LICENSE +21 -3
  2. package/README.md +345 -96
  3. package/android/build.gradle +28 -22
  4. package/android/consumer-rules.pro +8 -0
  5. package/android/src/main/AndroidManifest.xml +3 -0
  6. package/android/src/main/java/expo/modules/spotifysdk/ExpoSpotifySDKModule.kt +130 -145
  7. package/android/src/main/java/expo/modules/spotifysdk/SpotifyAuthCoordinator.kt +36 -0
  8. package/android/src/main/java/expo/modules/spotifysdk/SpotifyAuthorizationContract.kt +45 -0
  9. package/android/src/main/java/expo/modules/spotifysdk/SpotifyConfig.kt +80 -0
  10. package/android/src/main/java/expo/modules/spotifysdk/SpotifyErrors.kt +34 -0
  11. package/android/src/main/java/expo/modules/spotifysdk/SpotifyTokenSwapClient.kt +154 -0
  12. package/build/ExpoSpotifySDK.types.d.ts +76 -1
  13. package/build/ExpoSpotifySDK.types.d.ts.map +1 -1
  14. package/build/ExpoSpotifySDK.types.js +12 -1
  15. package/build/ExpoSpotifySDK.types.js.map +1 -1
  16. package/build/ExpoSpotifySDKModule.web.d.ts +7 -3
  17. package/build/ExpoSpotifySDKModule.web.d.ts.map +1 -1
  18. package/build/ExpoSpotifySDKModule.web.js +17 -7
  19. package/build/ExpoSpotifySDKModule.web.js.map +1 -1
  20. package/build/index.d.ts +39 -2
  21. package/build/index.d.ts.map +1 -1
  22. package/build/index.js +117 -4
  23. package/build/index.js.map +1 -1
  24. package/expo-module.config.json +1 -1
  25. package/ios/ExpoSpotifyAppDelegate.swift +14 -0
  26. package/ios/ExpoSpotifyConfiguration.swift +33 -15
  27. package/ios/ExpoSpotifySDK.podspec +2 -4
  28. package/ios/ExpoSpotifySDKModule.swift +96 -26
  29. package/ios/SPTScopeSerializer.swift +40 -39
  30. package/ios/SpotifyAuthCoordinator.swift +133 -0
  31. package/ios/SpotifySDK/SpotifyiOS.xcframework/Info.plist +5 -9
  32. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTAppRemote.h +43 -41
  33. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTAppRemoteCommon.h +9 -8
  34. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTAppRemoteConnectivityAPI.h +4 -3
  35. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTAppRemoteContentAPI.h +8 -6
  36. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTAppRemoteContentItem.h +3 -3
  37. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTAppRemoteImageAPI.h +3 -3
  38. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTAppRemotePlayerAPI.h +12 -7
  39. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTAppRemoteUserAPI.h +4 -5
  40. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTConfiguration.h +5 -5
  41. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTError.h +7 -9
  42. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTMacros.h +59 -23
  43. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTScope.h +67 -63
  44. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTSession.h +2 -1
  45. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTSessionManager.h +59 -19
  46. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SpotifyAppRemote.h +11 -15
  47. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SpotifyiOS.h +1 -1
  48. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Info.plist +0 -0
  49. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Modules/module.modulemap +0 -1
  50. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/SpotifyiOS +0 -0
  51. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTAppRemote.h +43 -41
  52. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTAppRemoteCommon.h +9 -8
  53. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTAppRemoteConnectivityAPI.h +4 -3
  54. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTAppRemoteContentAPI.h +8 -6
  55. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTAppRemoteContentItem.h +3 -3
  56. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTAppRemoteImageAPI.h +3 -3
  57. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTAppRemotePlayerAPI.h +12 -7
  58. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTAppRemoteUserAPI.h +4 -5
  59. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTConfiguration.h +5 -5
  60. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTError.h +7 -9
  61. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTMacros.h +59 -23
  62. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTScope.h +67 -63
  63. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTSession.h +2 -1
  64. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTSessionManager.h +59 -19
  65. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SpotifyAppRemote.h +11 -15
  66. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SpotifyiOS.h +1 -1
  67. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Info.plist +0 -0
  68. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Modules/module.modulemap +0 -1
  69. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/SpotifyiOS +0 -0
  70. package/ios/SpotifyTokenRefreshClient.swift +118 -0
  71. package/package.json +12 -11
  72. package/plugin/build/android/withSpotifyAndroidAppBuildGradle.js +15 -10
  73. package/plugin/build/ios/withSpotifyConfigValues.js +12 -7
  74. package/plugin/build/ios/withSpotifyURLScheme.d.ts +1 -1
  75. package/plugin/build/ios/withSpotifyURLScheme.js +9 -8
  76. package/plugin/build/types.d.ts +35 -0
  77. package/plugin/build/types.js +0 -19
  78. package/ios/ExpoSpotifyAuthDelegate.swift +0 -13
  79. package/ios/ExpoSpotifySessionManager+SessionManagerDelegate.swift +0 -16
  80. package/ios/ExpoSpotifySessionManager.swift +0 -90
  81. package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/_CodeSignature/CodeResources +0 -454
@@ -1,179 +1,164 @@
1
1
  package expo.modules.spotifysdk
2
2
 
3
3
  import android.content.pm.PackageManager
4
- import expo.modules.kotlin.Promise
5
- import expo.modules.kotlin.modules.Module
6
- import expo.modules.kotlin.modules.ModuleDefinition
7
- import com.spotify.sdk.android.auth.AuthorizationClient
8
- import com.spotify.sdk.android.auth.AuthorizationRequest
9
4
  import com.spotify.sdk.android.auth.AuthorizationResponse
10
- import com.spotify.sdk.android.auth.app.SpotifyNativeAuthUtil
5
+ import expo.modules.kotlin.activityresult.AppContextActivityResultLauncher
11
6
  import expo.modules.kotlin.exception.Exceptions
12
- import expo.modules.kotlin.records.Field
13
- import expo.modules.kotlin.records.Record
7
+ import expo.modules.kotlin.functions.Coroutine
8
+ import expo.modules.kotlin.modules.Module
9
+ import expo.modules.kotlin.modules.ModuleDefinition
14
10
 
15
- import okhttp3.OkHttpClient
16
- import okhttp3.FormBody
17
- import okhttp3.Request
18
- import okhttp3.Callback
19
- import okhttp3.Call
20
- import okhttp3.Response
11
+ private const val SDK_VERSION = "0.5.0"
12
+ private const val EVENT_SESSION_CHANGE = "onSessionChange"
21
13
 
22
- import java.io.IOException
23
- import org.json.JSONObject
14
+ class ExpoSpotifySDKModule : Module() {
24
15
 
25
- class SpotifyConfigOptions : Record {
26
- @Field
27
- val scopes: List<String> = emptyList()
16
+ private lateinit var authLauncher: AppContextActivityResultLauncher<SpotifyAuthInput, AuthorizationResponse>
17
+ private val coordinator = SpotifyAuthCoordinator()
28
18
 
29
- @Field
30
- val tokenSwapURL: String? = null
19
+ private val context
20
+ get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
31
21
 
32
- @Field
33
- val tokenRefreshURL: String? = null
34
- }
22
+ private fun readManifestConfig(): SpotifyManifestConfig {
23
+ val packageInfo = try {
24
+ context.packageManager.getPackageInfo(
25
+ context.packageName,
26
+ PackageManager.GET_META_DATA,
27
+ )
28
+ } catch (e: PackageManager.NameNotFoundException) {
29
+ throw InvalidConfigException("Failed to read application meta-data", e)
30
+ }
31
+ val metaData = packageInfo.applicationInfo?.metaData
32
+ ?: throw InvalidConfigException("Application meta-data is missing")
33
+ val clientId = metaData.getString("spotifyClientId")
34
+ ?: throw InvalidConfigException(
35
+ "Missing meta-data 'spotifyClientId' in AndroidManifest.xml. " +
36
+ "Did you add @wwdrew/expo-spotify-sdk to your Expo plugins?",
37
+ )
38
+ val redirectUri = metaData.getString("spotifyRedirectUri")
39
+ ?: throw InvalidConfigException(
40
+ "Missing meta-data 'spotifyRedirectUri' in AndroidManifest.xml.",
41
+ )
42
+ return SpotifyManifestConfig(clientId = clientId, redirectUri = redirectUri)
43
+ }
35
44
 
36
- class ExpoSpotifySDKModule : Module() {
45
+ private fun isSpotifyInstalled(): Boolean {
46
+ return try {
47
+ context.packageManager.getPackageInfo("com.spotify.music", 0)
48
+ true
49
+ } catch (_: PackageManager.NameNotFoundException) {
50
+ false
51
+ }
52
+ }
37
53
 
38
- private val requestCode = 2095
39
- private var requestConfig: SpotifyConfigOptions? = null
40
- private var authPromise: Promise? = null
41
- private val context
42
- get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
43
- private val currentActivity
44
- get() = appContext.currentActivity ?: throw Exceptions.MissingActivity()
54
+ private fun emitSession(type: String, payload: SpotifySessionPayload) {
55
+ sendEvent(
56
+ EVENT_SESSION_CHANGE,
57
+ mapOf("type" to type, "session" to payload.toMap()),
58
+ )
59
+ }
45
60
 
46
- override fun definition() = ModuleDefinition {
61
+ private fun emitError(type: String, code: String, message: String) {
62
+ sendEvent(
63
+ EVENT_SESSION_CHANGE,
64
+ mapOf("type" to type, "error" to mapOf("code" to code, "message" to message)),
65
+ )
66
+ }
47
67
 
68
+ override fun definition() = ModuleDefinition {
48
69
  Name("ExpoSpotifySDK")
49
70
 
71
+ Events(EVENT_SESSION_CHANGE)
72
+
50
73
  Function("isAvailable") {
51
- return@Function SpotifyNativeAuthUtil.isSpotifyInstalled(context)
74
+ isSpotifyInstalled()
52
75
  }
53
76
 
54
- AsyncFunction("authenticateAsync") { config: SpotifyConfigOptions, promise: Promise ->
55
- try {
56
- val packageInfo =
57
- context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_META_DATA)
58
- val applicationInfo = packageInfo.applicationInfo
59
- val metaData = applicationInfo?.metaData
60
- val clientId = metaData?.getString("spotifyClientId")
61
- val redirectUri = metaData?.getString("spotifyRedirectUri")
62
-
63
- requestConfig = config
64
-
65
- if (clientId == null || redirectUri == null) {
66
- promise.reject(
67
- "ERR_EXPO_SPOTIFY_SDK",
68
- "Missing Spotify configuration in AndroidManifest.xml. Ensure SPOTIFY_CLIENT_ID and SPOTIFY_REDIRECT_URI are set.",
69
- null
70
- )
71
- return@AsyncFunction
72
- }
77
+ RegisterActivityContracts {
78
+ authLauncher = registerForActivityResult(SpotifyAuthorizationContract())
79
+ }
73
80
 
74
- val responseType = if (config.tokenSwapURL != null || config.tokenRefreshURL != null) {
75
- AuthorizationResponse.Type.CODE
76
- } else {
77
- AuthorizationResponse.Type.TOKEN
81
+ AsyncFunction("authenticateAsync") Coroutine { config: SpotifyAuthenticateOptions ->
82
+ try {
83
+ if (config.scopes.isEmpty()) {
84
+ throw InvalidConfigException("`scopes` must contain at least one entry")
78
85
  }
86
+ val manifest = readManifestConfig()
79
87
 
80
- val request = AuthorizationRequest.Builder(
81
- clientId,
82
- responseType,
83
- redirectUri
84
- )
85
- .setScopes(config.scopes.toTypedArray())
86
- .build()
87
-
88
- authPromise = promise
89
- AuthorizationClient.openLoginActivity(currentActivity, requestCode, request)
88
+ val responseType =
89
+ if (config.tokenSwapURL != null) AuthorizationResponse.Type.CODE
90
+ else AuthorizationResponse.Type.TOKEN
90
91
 
91
- } catch (e: PackageManager.NameNotFoundException) {
92
- promise.reject(
93
- "ERR_EXPO_SPOTIFY_SDK",
94
- "Missing Spotify configuration in AndroidManifest.xml",
95
- e
92
+ val input = SpotifyAuthInput(
93
+ clientId = manifest.clientId,
94
+ redirectUri = manifest.redirectUri,
95
+ responseType = responseType,
96
+ scopes = config.scopes.toTypedArray(),
96
97
  )
97
- }
98
- }
99
-
100
- OnActivityResult { _, payload ->
101
- if (payload.requestCode == requestCode) {
102
- val authResponse = AuthorizationClient.getResponse(payload.resultCode, payload.data)
103
98
 
104
- when (authResponse.type) {
105
- AuthorizationResponse.Type.TOKEN -> {
106
- val expirationDate = System.currentTimeMillis() + authResponse.expiresIn * 1000
99
+ val response = coordinator.authenticate(authLauncher, input)
107
100
 
108
- authPromise?.resolve(
109
- mapOf(
110
- "accessToken" to authResponse.accessToken,
111
- "refreshToken" to null, // Spotify SDK does not return refresh token
112
- "expirationDate" to expirationDate,
113
- "scope" to requestConfig?.scopes
101
+ val payload = when (response.type) {
102
+ AuthorizationResponse.Type.TOKEN -> SpotifySessionPayload(
103
+ accessToken = response.accessToken
104
+ ?: throw TokenSwapParseException("Spotify returned TOKEN without an access token"),
105
+ refreshToken = null,
106
+ expirationDate = System.currentTimeMillis() + response.expiresIn * 1000L,
107
+ scopes = config.scopes,
108
+ )
109
+ AuthorizationResponse.Type.CODE -> {
110
+ val swapURL = config.tokenSwapURL
111
+ ?: throw InvalidConfigException(
112
+ "Received CODE response but no tokenSwapURL was configured",
114
113
  )
114
+ val client = SpotifyTokenSwapClient(SDK_VERSION, manifest.clientId)
115
+ client.swap(
116
+ code = response.code
117
+ ?: throw TokenSwapParseException("Spotify returned CODE without a code"),
118
+ redirectUri = manifest.redirectUri,
119
+ tokenSwapURL = swapURL,
120
+ requestedScopes = config.scopes,
115
121
  )
116
122
  }
123
+ AuthorizationResponse.Type.CANCELLED -> throw UserCancelledException()
124
+ AuthorizationResponse.Type.EMPTY -> throw UnknownSpotifyException(
125
+ "Spotify returned an empty response (auth activity may have been killed)",
126
+ )
127
+ AuthorizationResponse.Type.ERROR -> throw SpotifyAuthErrorException(
128
+ response.error ?: "Spotify returned an unspecified error",
129
+ )
130
+ AuthorizationResponse.Type.UNKNOWN, null -> throw UnknownSpotifyException(
131
+ "Unknown Spotify authorization response type",
132
+ )
133
+ }
134
+ emitSession("didInitiate", payload)
135
+ payload.toMap()
136
+ } catch (e: expo.modules.kotlin.exception.CodedException) {
137
+ emitError("didFail", e.code ?: "UNKNOWN", e.localizedMessage ?: e.code ?: "Unknown error")
138
+ throw e
139
+ }
140
+ }
117
141
 
118
- AuthorizationResponse.Type.CODE -> {
119
- val client = OkHttpClient()
120
- val requestBody = FormBody.Builder()
121
- .add("code", authResponse.code)
122
- .build()
123
-
124
- val request = Request.Builder()
125
- .url(requestConfig?.tokenSwapURL!!)
126
- .post(requestBody)
127
- .header("Content-Type", "application/x-www-form-urlencoded")
128
- .build()
129
-
130
- client.newCall(request).enqueue(object : Callback {
131
- override fun onFailure(call: Call, e: IOException) {
132
- authPromise?.reject("ERR_EXPO_SPOTIFY_SDK", e.message, e)
133
- authPromise = null
134
- }
135
-
136
- override fun onResponse(call: Call, response: Response) {
137
- if (!response.isSuccessful) {
138
- authPromise?.reject("ERR_EXPO_SPOTIFY_SDK", "Failed to swap code for token", null)
139
- authPromise = null
140
- return
141
- }
142
-
143
- response.body?.string()?.let { body ->
144
- val json = JSONObject(body)
145
- val accessToken = json.getString("access_token")
146
- val refreshToken = json.getString("refresh_token")
147
- val expiresIn = json.getInt("expires_in")
148
- val scope = json.getString("scope")
149
- val expirationDate = System.currentTimeMillis() + expiresIn * 1000
150
-
151
- authPromise?.resolve(
152
- mapOf(
153
- "accessToken" to accessToken,
154
- "refreshToken" to refreshToken,
155
- "expirationDate" to expirationDate,
156
- "scope" to scope.split(' ')
157
- )
158
- )
159
- } ?: run {
160
- authPromise?.reject("ERR_EXPO_SPOTIFY_SDK", "Empty response body", null)
161
- }
162
- authPromise = null
163
- }
164
- })
165
- }
166
-
167
- AuthorizationResponse.Type.ERROR -> {
168
- authPromise?.reject("ERR_EXPO_SPOTIFY_SDK", authResponse.error, null)
169
- authPromise = null
170
- }
171
-
172
- else -> {
173
- authPromise?.reject("ERR_EXPO_SPOTIFY_SDK", "Unknown response type", null)
174
- authPromise = null
175
- }
142
+ AsyncFunction("refreshSessionAsync") Coroutine { options: SpotifyRefreshOptions ->
143
+ try {
144
+ if (options.refreshToken.isBlank()) {
145
+ throw InvalidConfigException("`refreshToken` is required")
176
146
  }
147
+ if (options.tokenRefreshURL.isBlank()) {
148
+ throw InvalidConfigException("`tokenRefreshURL` is required")
149
+ }
150
+ val manifest = readManifestConfig()
151
+ val client = SpotifyTokenSwapClient(SDK_VERSION, manifest.clientId)
152
+ val payload = client.refresh(
153
+ refreshToken = options.refreshToken,
154
+ tokenRefreshURL = options.tokenRefreshURL,
155
+ previousScopes = emptyList(),
156
+ )
157
+ emitSession("didRenew", payload)
158
+ payload.toMap()
159
+ } catch (e: expo.modules.kotlin.exception.CodedException) {
160
+ emitError("didFail", e.code ?: "UNKNOWN", e.localizedMessage ?: e.code ?: "Unknown error")
161
+ throw e
177
162
  }
178
163
  }
179
164
  }
@@ -0,0 +1,36 @@
1
+ package expo.modules.spotifysdk
2
+
3
+ import com.spotify.sdk.android.auth.AuthorizationResponse
4
+ import expo.modules.kotlin.activityresult.AppContextActivityResultLauncher
5
+ import kotlinx.coroutines.sync.Mutex
6
+ import kotlinx.coroutines.sync.withLock
7
+
8
+ /**
9
+ * Guards against concurrent Spotify auth calls.
10
+ *
11
+ * `AppContextActivityResultLauncher.launch(input)` is already a `suspend`
12
+ * function that bridges the activity-result callback into a coroutine, so we
13
+ * no longer need `suspendCancellableCoroutine` here. The only responsibility
14
+ * of this class is to enforce the single-in-flight constraint via `Mutex`.
15
+ */
16
+ class SpotifyAuthCoordinator {
17
+ private val mutex = Mutex()
18
+
19
+ /**
20
+ * Launches the Spotify auth flow and suspends until the activity returns a
21
+ * result. Re-entry while a call is in flight rejects with [AuthInProgressException].
22
+ */
23
+ suspend fun authenticate(
24
+ launcher: AppContextActivityResultLauncher<SpotifyAuthInput, AuthorizationResponse>,
25
+ input: SpotifyAuthInput,
26
+ ): AuthorizationResponse {
27
+ if (!mutex.tryLock()) {
28
+ throw AuthInProgressException()
29
+ }
30
+ return try {
31
+ launcher.launch(input)
32
+ } finally {
33
+ mutex.unlock()
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,45 @@
1
+ package expo.modules.spotifysdk
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import com.spotify.sdk.android.auth.AuthorizationClient
7
+ import com.spotify.sdk.android.auth.AuthorizationRequest
8
+ import com.spotify.sdk.android.auth.AuthorizationResponse
9
+ import expo.modules.kotlin.activityresult.AppContextActivityResultContract
10
+
11
+ /**
12
+ * Expo-compatible wrapper around Spotify's auth flow.
13
+ *
14
+ * `AppContextActivityResultContract<I, O>` is the Expo Modules Core version of
15
+ * AndroidX's `ActivityResultContract`. The key difference is that:
16
+ * - `I` must implement `Serializable` (for state preservation across process death)
17
+ * - `parseResult` receives the original `input` as its first argument
18
+ *
19
+ * We use `SpotifyAuthInput` as our serializable input type to work around
20
+ * `AuthorizationRequest` only implementing `Parcelable`.
21
+ */
22
+ class SpotifyAuthorizationContract :
23
+ AppContextActivityResultContract<SpotifyAuthInput, AuthorizationResponse> {
24
+
25
+ override fun createIntent(context: Context, input: SpotifyAuthInput): Intent {
26
+ val activity = context as? Activity
27
+ ?: throw IllegalStateException(
28
+ "SpotifyAuthorizationContract requires an Activity context, got ${context.javaClass.name}"
29
+ )
30
+ val request = AuthorizationRequest.Builder(
31
+ input.clientId,
32
+ input.responseType,
33
+ input.redirectUri,
34
+ )
35
+ .setScopes(input.scopes)
36
+ .build()
37
+ return AuthorizationClient.createLoginActivityIntent(activity, request)
38
+ }
39
+
40
+ override fun parseResult(
41
+ input: SpotifyAuthInput,
42
+ resultCode: Int,
43
+ intent: Intent?,
44
+ ): AuthorizationResponse = AuthorizationClient.getResponse(resultCode, intent)
45
+ }
@@ -0,0 +1,80 @@
1
+ package expo.modules.spotifysdk
2
+
3
+ import com.spotify.sdk.android.auth.AuthorizationResponse
4
+ import expo.modules.kotlin.records.Field
5
+ import expo.modules.kotlin.records.Record
6
+ import java.io.Serializable
7
+
8
+ /**
9
+ * JS-facing input for `authenticateAsync`.
10
+ */
11
+ class SpotifyAuthenticateOptions : Record {
12
+ @Field val scopes: List<String> = emptyList()
13
+ @Field val tokenSwapURL: String? = null
14
+ @Field val tokenRefreshURL: String? = null
15
+ }
16
+
17
+ /**
18
+ * JS-facing input for `refreshSessionAsync`. Phase 1 ships the Kotlin support;
19
+ * the public JS function is added in Phase 3.
20
+ */
21
+ class SpotifyRefreshOptions : Record {
22
+ @Field val refreshToken: String = ""
23
+ @Field val tokenRefreshURL: String = ""
24
+ }
25
+
26
+ /**
27
+ * Result shape returned to JS. Mirrors `SpotifySession` in the TypeScript layer.
28
+ */
29
+ data class SpotifySessionPayload(
30
+ val accessToken: String,
31
+ val refreshToken: String?,
32
+ val expirationDate: Long,
33
+ val scopes: List<String>,
34
+ ) {
35
+ fun toMap(): Map<String, Any?> = mapOf(
36
+ "accessToken" to accessToken,
37
+ "refreshToken" to refreshToken,
38
+ "expirationDate" to expirationDate,
39
+ "scopes" to scopes,
40
+ )
41
+ }
42
+
43
+ /**
44
+ * Native config read from `AndroidManifest.xml` meta-data placeholders set
45
+ * by the Expo config plugin.
46
+ */
47
+ data class SpotifyManifestConfig(
48
+ val clientId: String,
49
+ val redirectUri: String,
50
+ )
51
+
52
+ /**
53
+ * Serializable input for the `AppContextActivityResultContract`. We need our
54
+ * own class because `AuthorizationRequest` is only `Parcelable`, not
55
+ * `Serializable`, and Expo's `AppContextActivityResultContract<I, O>` requires
56
+ * `I : Serializable`.
57
+ */
58
+ data class SpotifyAuthInput(
59
+ val clientId: String,
60
+ val redirectUri: String,
61
+ val responseType: AuthorizationResponse.Type,
62
+ val scopes: Array<String>,
63
+ ) : Serializable {
64
+ override fun equals(other: Any?): Boolean {
65
+ if (this === other) return true
66
+ if (other !is SpotifyAuthInput) return false
67
+ return clientId == other.clientId &&
68
+ redirectUri == other.redirectUri &&
69
+ responseType == other.responseType &&
70
+ scopes.contentEquals(other.scopes)
71
+ }
72
+
73
+ override fun hashCode(): Int {
74
+ var result = clientId.hashCode()
75
+ result = 31 * result + redirectUri.hashCode()
76
+ result = 31 * result + responseType.hashCode()
77
+ result = 31 * result + scopes.contentHashCode()
78
+ return result
79
+ }
80
+ }
@@ -0,0 +1,34 @@
1
+ package expo.modules.spotifysdk
2
+
3
+ import expo.modules.kotlin.exception.CodedException
4
+
5
+ class UserCancelledException(message: String = "Authentication was cancelled by the user") :
6
+ CodedException("USER_CANCELLED", message, null)
7
+
8
+ class AuthInProgressException(message: String = "Another authentication request is already in progress") :
9
+ CodedException("AUTH_IN_PROGRESS", message, null)
10
+
11
+ class InvalidConfigException(message: String, cause: Throwable? = null) :
12
+ CodedException("INVALID_CONFIG", message, cause)
13
+
14
+ class NetworkException(message: String, cause: Throwable? = null) :
15
+ CodedException("NETWORK_ERROR", message, cause)
16
+
17
+ class TokenSwapFailedException(status: Int, body: String?) :
18
+ CodedException(
19
+ "TOKEN_SWAP_FAILED",
20
+ "Token swap server returned HTTP $status${if (!body.isNullOrBlank()) ": ${body.take(512)}" else ""}",
21
+ null,
22
+ )
23
+
24
+ class TokenSwapParseException(message: String, cause: Throwable? = null) :
25
+ CodedException("TOKEN_SWAP_PARSE_ERROR", message, cause)
26
+
27
+ class SpotifyNotInstalledException(message: String = "The Spotify app is not installed on this device") :
28
+ CodedException("SPOTIFY_NOT_INSTALLED", message, null)
29
+
30
+ class SpotifyAuthErrorException(error: String) :
31
+ CodedException("AUTH_ERROR", error, null)
32
+
33
+ class UnknownSpotifyException(message: String, cause: Throwable? = null) :
34
+ CodedException("UNKNOWN", message, cause)
@@ -0,0 +1,154 @@
1
+ package expo.modules.spotifysdk
2
+
3
+ import kotlinx.coroutines.suspendCancellableCoroutine
4
+ import okhttp3.Call
5
+ import okhttp3.Callback
6
+ import okhttp3.FormBody
7
+ import okhttp3.OkHttpClient
8
+ import okhttp3.Request
9
+ import okhttp3.Response
10
+ import org.json.JSONException
11
+ import org.json.JSONObject
12
+ import java.io.IOException
13
+ import java.util.concurrent.TimeUnit
14
+ import kotlin.coroutines.resume
15
+ import kotlin.coroutines.resumeWithException
16
+
17
+ /**
18
+ * Performs the OAuth Authorization Code -> token swap and the refresh-token
19
+ * exchange against a user-provided server.
20
+ *
21
+ * Server contract:
22
+ * POST {tokenSwapURL}
23
+ * body: code=<auth-code>&redirect_uri=<redirect-uri>&client_id=<client-id>
24
+ * 200 -> { access_token, expires_in, refresh_token?, scope? }
25
+ *
26
+ * POST {tokenRefreshURL}
27
+ * body: refresh_token=<token>&client_id=<client-id>
28
+ * 200 -> { access_token, expires_in, refresh_token?, scope? }
29
+ */
30
+ class SpotifyTokenSwapClient(
31
+ private val sdkVersion: String,
32
+ private val clientId: String,
33
+ ) {
34
+ private val client: OkHttpClient = OkHttpClient.Builder()
35
+ .connectTimeout(30, TimeUnit.SECONDS)
36
+ .readTimeout(30, TimeUnit.SECONDS)
37
+ .writeTimeout(30, TimeUnit.SECONDS)
38
+ .build()
39
+
40
+ private val userAgent: String = "expo-spotify-sdk/$sdkVersion"
41
+
42
+ suspend fun swap(
43
+ code: String,
44
+ redirectUri: String,
45
+ tokenSwapURL: String,
46
+ requestedScopes: List<String>,
47
+ ): SpotifySessionPayload {
48
+ val body = FormBody.Builder()
49
+ .add("code", code)
50
+ .add("redirect_uri", redirectUri)
51
+ .add("client_id", clientId)
52
+ .build()
53
+ val request = Request.Builder()
54
+ .url(tokenSwapURL)
55
+ .header("User-Agent", userAgent)
56
+ .post(body)
57
+ .build()
58
+ val json = executeJson(request)
59
+ return parseSessionPayload(json, fallbackScopes = requestedScopes, fallbackRefreshToken = null)
60
+ }
61
+
62
+ suspend fun refresh(
63
+ refreshToken: String,
64
+ tokenRefreshURL: String,
65
+ previousScopes: List<String>,
66
+ ): SpotifySessionPayload {
67
+ val body = FormBody.Builder()
68
+ .add("refresh_token", refreshToken)
69
+ .add("client_id", clientId)
70
+ .build()
71
+ val request = Request.Builder()
72
+ .url(tokenRefreshURL)
73
+ .header("User-Agent", userAgent)
74
+ .post(body)
75
+ .build()
76
+ val json = executeJson(request)
77
+ return parseSessionPayload(
78
+ json,
79
+ fallbackScopes = previousScopes,
80
+ fallbackRefreshToken = refreshToken,
81
+ )
82
+ }
83
+
84
+ private suspend fun executeJson(request: Request): JSONObject {
85
+ val response = try {
86
+ client.executeAsync(request)
87
+ } catch (e: IOException) {
88
+ throw NetworkException("Network failure contacting ${request.url}", e)
89
+ }
90
+ response.use { res ->
91
+ val raw = res.body?.string()
92
+ if (!res.isSuccessful) {
93
+ throw TokenSwapFailedException(res.code, raw)
94
+ }
95
+ if (raw.isNullOrBlank()) {
96
+ throw TokenSwapParseException("Empty response body from ${request.url}")
97
+ }
98
+ return try {
99
+ JSONObject(raw)
100
+ } catch (e: JSONException) {
101
+ throw TokenSwapParseException("Response was not valid JSON: ${raw.take(256)}", e)
102
+ }
103
+ }
104
+ }
105
+
106
+ private fun parseSessionPayload(
107
+ json: JSONObject,
108
+ fallbackScopes: List<String>,
109
+ fallbackRefreshToken: String?,
110
+ ): SpotifySessionPayload {
111
+ val accessToken = json.optString("access_token").takeIf { it.isNotEmpty() }
112
+ ?: throw TokenSwapParseException("Response missing required field: access_token")
113
+ val expiresIn = json.optInt("expires_in", -1)
114
+ if (expiresIn < 0) {
115
+ throw TokenSwapParseException("Response missing required field: expires_in")
116
+ }
117
+ val refreshToken = json.optString("refresh_token").takeIf { it.isNotEmpty() }
118
+ ?: fallbackRefreshToken
119
+ val scopes = json.optString("scope").takeIf { it.isNotEmpty() }
120
+ ?.split(' ')
121
+ ?.filter { it.isNotEmpty() }
122
+ ?: fallbackScopes
123
+ val expirationDate = System.currentTimeMillis() + expiresIn * 1000L
124
+ return SpotifySessionPayload(
125
+ accessToken = accessToken,
126
+ refreshToken = refreshToken,
127
+ expirationDate = expirationDate,
128
+ scopes = scopes,
129
+ )
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Suspend wrapper around `OkHttpClient.newCall(...).enqueue(...)`. Cancellation
135
+ * of the surrounding coroutine cancels the in-flight call.
136
+ */
137
+ internal suspend fun OkHttpClient.executeAsync(request: Request): Response =
138
+ suspendCancellableCoroutine { cont ->
139
+ val call = newCall(request)
140
+ cont.invokeOnCancellation {
141
+ try {
142
+ call.cancel()
143
+ } catch (_: Throwable) { /* best-effort */ }
144
+ }
145
+ call.enqueue(object : Callback {
146
+ override fun onFailure(call: Call, e: IOException) {
147
+ if (cont.isActive) cont.resumeWithException(e)
148
+ }
149
+
150
+ override fun onResponse(call: Call, response: Response) {
151
+ if (cont.isActive) cont.resume(response)
152
+ }
153
+ })
154
+ }