@wwdrew/expo-spotify-sdk 0.5.0 → 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.
- package/LICENSE +21 -3
- package/README.md +345 -96
- package/android/build.gradle +28 -22
- package/android/consumer-rules.pro +8 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/expo/modules/spotifysdk/ExpoSpotifySDKModule.kt +130 -145
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyAuthCoordinator.kt +36 -0
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyAuthorizationContract.kt +45 -0
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyConfig.kt +80 -0
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyErrors.kt +34 -0
- package/android/src/main/java/expo/modules/spotifysdk/SpotifyTokenSwapClient.kt +154 -0
- package/build/ExpoSpotifySDK.types.d.ts +76 -1
- package/build/ExpoSpotifySDK.types.d.ts.map +1 -1
- package/build/ExpoSpotifySDK.types.js +12 -1
- package/build/ExpoSpotifySDK.types.js.map +1 -1
- package/build/ExpoSpotifySDKModule.web.d.ts +7 -3
- package/build/ExpoSpotifySDKModule.web.d.ts.map +1 -1
- package/build/ExpoSpotifySDKModule.web.js +17 -7
- package/build/ExpoSpotifySDKModule.web.js.map +1 -1
- package/build/index.d.ts +39 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +117 -4
- package/build/index.js.map +1 -1
- package/expo-module.config.json +1 -1
- package/ios/ExpoSpotifyAppDelegate.swift +14 -0
- package/ios/ExpoSpotifyConfiguration.swift +33 -15
- package/ios/ExpoSpotifySDK.podspec +2 -4
- package/ios/ExpoSpotifySDKModule.swift +96 -26
- package/ios/SPTScopeSerializer.swift +40 -39
- package/ios/SpotifyAuthCoordinator.swift +133 -0
- package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTConfiguration.h +1 -1
- package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTError.h +6 -7
- package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTScope.h +43 -39
- package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SPTSessionManager.h +8 -4
- package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Headers/SpotifyiOS.h +1 -1
- package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/Info.plist +0 -0
- package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64/SpotifyiOS.framework/SpotifyiOS +0 -0
- package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTConfiguration.h +1 -1
- package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTError.h +6 -7
- package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTScope.h +43 -39
- package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SPTSessionManager.h +8 -4
- package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Headers/SpotifyiOS.h +1 -1
- package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/Info.plist +0 -0
- package/ios/SpotifySDK/SpotifyiOS.xcframework/ios-arm64_x86_64-simulator/SpotifyiOS.framework/SpotifyiOS +0 -0
- package/ios/SpotifyTokenRefreshClient.swift +118 -0
- package/package.json +12 -11
- package/plugin/build/android/withSpotifyAndroidAppBuildGradle.js +15 -10
- package/plugin/build/ios/withSpotifyConfigValues.js +12 -7
- package/plugin/build/ios/withSpotifyURLScheme.d.ts +1 -1
- package/plugin/build/ios/withSpotifyURLScheme.js +9 -8
- package/plugin/build/types.d.ts +35 -0
- package/plugin/build/types.js +0 -19
- package/ios/ExpoSpotifyAuthDelegate.swift +0 -13
- package/ios/ExpoSpotifySessionManager+SessionManagerDelegate.swift +0 -16
- package/ios/ExpoSpotifySessionManager.swift +0 -90
- package/ios/SpotifySDK/SpotifyiOS.xcframework/_CodeSignature/CodeDirectory +0 -0
- package/ios/SpotifySDK/SpotifyiOS.xcframework/_CodeSignature/CodeRequirements +0 -0
- package/ios/SpotifySDK/SpotifyiOS.xcframework/_CodeSignature/CodeRequirements-1 +0 -0
- package/ios/SpotifySDK/SpotifyiOS.xcframework/_CodeSignature/CodeResources +0 -1118
- package/ios/SpotifySDK/SpotifyiOS.xcframework/_CodeSignature/CodeSignature +0 -0
|
@@ -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
|
|
5
|
+
import expo.modules.kotlin.activityresult.AppContextActivityResultLauncher
|
|
11
6
|
import expo.modules.kotlin.exception.Exceptions
|
|
12
|
-
import expo.modules.kotlin.
|
|
13
|
-
import expo.modules.kotlin.
|
|
7
|
+
import expo.modules.kotlin.functions.Coroutine
|
|
8
|
+
import expo.modules.kotlin.modules.Module
|
|
9
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
14
10
|
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
23
|
-
import org.json.JSONObject
|
|
14
|
+
class ExpoSpotifySDKModule : Module() {
|
|
24
15
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
val scopes: List<String> = emptyList()
|
|
16
|
+
private lateinit var authLauncher: AppContextActivityResultLauncher<SpotifyAuthInput, AuthorizationResponse>
|
|
17
|
+
private val coordinator = SpotifyAuthCoordinator()
|
|
28
18
|
|
|
29
|
-
|
|
30
|
-
|
|
19
|
+
private val context
|
|
20
|
+
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
|
|
31
21
|
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
isSpotifyInstalled()
|
|
52
75
|
}
|
|
53
76
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
105
|
-
AuthorizationResponse.Type.TOKEN -> {
|
|
106
|
-
val expirationDate = System.currentTimeMillis() + authResponse.expiresIn * 1000
|
|
99
|
+
val response = coordinator.authenticate(authLauncher, input)
|
|
107
100
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
}
|