Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6749d24
feat(iOS): add token swap login
edeuss Mar 10, 2025
eb8c881
feat(iOS): add tokenRefreshUrl to connectToSpotifyTokenSwap function
edeuss Mar 31, 2025
a04ba80
feat(iOS): add AppDelegate overwrite
edeuss Apr 5, 2025
e90f50e
feat: return the token swap code if no refresh url provided
edeuss Apr 5, 2025
26056b9
chore: add iOS token swap docs
edeuss Apr 5, 2025
6398c42
fix(README): redirectUri example
edeuss Apr 6, 2025
f9e72b6
fix(iOS): add a public var
edeuss Apr 6, 2025
88e006e
feat(iOS): add return to methodIsSpotifyInstalled
edeuss Apr 6, 2025
a074f01
feat(iOS): make functions public instead of an extension
edeuss Apr 6, 2025
b0b43a2
fix(iOS): join scopes to string
edeuss Apr 18, 2025
a85e4c1
fix(iOS): redirect var name
edeuss Apr 18, 2025
c1cbe80
chore(iOS): update tokenSwap comment
edeuss Apr 18, 2025
6ee5bad
feat(iOS): change connectToSpotifyTokenSwap to getSwapToken
edeuss Apr 18, 2025
d6285d6
feat(android): add support
edeuss Apr 18, 2025
25f1b42
fix(android): move authenticate
edeuss Apr 18, 2025
a221aa1
fix(android): remove imports
edeuss Apr 18, 2025
e266f7a
chore(android): update android auth dependencie
edeuss Apr 18, 2025
de82355
fix(android): var names
edeuss Apr 18, 2025
978ec14
fix(android): missing import
edeuss Apr 18, 2025
a69dc28
feat(android): add missing method and clean code
edeuss Apr 18, 2025
7b01f8a
fix(android): isSpotifyInstalled
edeuss Apr 18, 2025
ee01e59
fix(android): argument name
edeuss Apr 18, 2025
c5158fa
chore: remove iOS only tag
edeuss Apr 18, 2025
4c6deaa
feat(iOS): add custom plist item
edeuss Apr 18, 2025
7804c97
fix(iOS): method name
edeuss Apr 18, 2025
f9cd981
chore(README): remove old text
edeuss Apr 18, 2025
7abc263
chore(README): remove old text
edeuss Apr 18, 2025
bf5f21d
fix(iOS): incorrect method name
edeuss Apr 18, 2025
0244a9d
chore(README): add more iOS plist info
edeuss Apr 18, 2025
a3dbb59
fix(iOS): check if contains url
edeuss Apr 18, 2025
62fe4f9
chore(README): add Spotify docs link
edeuss Apr 18, 2025
c537b6d
chore(README): add mobile note
edeuss Apr 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,42 @@ Have a look [in the example](example/lib/main.dart) for detailed insights on how

You can optionally specify "token swap" URLs to manage tokens with a backend service that protects your OAuth client secret. For more information refer to the [Spotify Token Swap and Refresh Guide](https://developer.spotify.com/documentation/ios/guides/token-swap-and-refresh/)

#### Web setup

```dart
SpotifySdkPlugin.tokenSwapURL = 'https://example.com/api/spotify/token';
SpotifySdkPlugin.tokenRefreshURL = 'https://example.com/api/spotify/refresh';
````

On web, this package will perform an Authorization Code (without PKCE) flow, then exchange the code and refresh the token with a backend service you run at the URLs provided.

Token Swap is for now "web only". While the iOS SDK also supports the "token swap", this flow is not yet supported.
#### Mobile setup

* Note: Mobile implementation currently is only for getting the Swap token to exchange for a real token. It will not authenticate the SpotifySdk's other functions.

For iOS setup: Add a `SpotifySDKCallbackURL` and `LSApplicationQueriesSchemes` to your Info.plist file. This allows the SDK to search for the Spotify app and launch your app after receiving authentication from the Spotify app.

```swift
<key>LSApplicationQueriesSchemes</key>
<array>
<string>spotify</string>
</array>
<key>SpotifySDKCallbackURL</key>
<string>example://auth/callback</string>
```

Dart code:

```dart
SpotifySdk.getSwapToken(
clientId: "",
redirectUri: "example://auth/spotify/callback",
scopes: "app-remote-control,user-modify-playback-state,playlist-read-private",
tokenSwapUrl: "https://example.com/api/spotify/swap",
);
```

Check the [Spotify docs](https://developer.spotify.com/documentation/ios/concepts/token-swap-and-refresh) for how to setup the tokenSwapUrl.

### Api

Expand All @@ -164,6 +192,7 @@ Token Swap is for now "web only". While the iOS SDK also supports the "token swa
| Function | Description| Android | iOS | Web |
|---|---|---|---|---|
| connectToSpotifyRemote | Connects the App to Spotify | ✔ | ✔ | ✔ |
| getSwapToken | Get's the Swap Token from the Spotify app | ✔ | ✔ | ❌ |
| getAccessToken | Gets the Access Token that you can use to work with the [Web Api](https://developer.spotify.com/documentation/web-api/) | ✔ | ✔ | ✔ |
| disconnect | Disconnects the app connection | ✔ | ✔ | ✔ |
| subscribeConnectionStatus | Subscribes to the current player state. | ✔ | ✔ | 🚧 |
Expand Down
2 changes: 1 addition & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ android {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
// -- spotify
implementation "com.spotify.android:auth:2.1.0"
implementation "com.spotify.android:auth:2.1.2"
implementation project(':spotify-app-remote')
implementation 'com.google.code.gson:gson:2.10.1'
// -- events
Expand Down
12 changes: 12 additions & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Spotify SDK Authentication Activities -->
<activity
android:exported="true"
android:name="com.spotify.sdk.android.authentication.AuthCallbackActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
</intent-filter>
</activity>

<activity
android:name="com.spotify.sdk.android.authentication.LoginActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
Expand Down
108 changes: 105 additions & 3 deletions android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifySdkPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.util.Log
import android.content.pm.PackageManager
import android.webkit.URLUtil
import com.spotify.android.appremote.api.ConnectionParams
import com.spotify.android.appremote.api.Connector.ConnectionListener
import com.spotify.android.appremote.api.SpotifyAppRemote
import com.spotify.android.appremote.api.error.*
import com.spotify.sdk.android.auth.AuthorizationClient
import com.spotify.sdk.android.auth.AuthorizationRequest
import com.spotify.sdk.android.auth.AuthorizationResponse
import com.spotify.sdk.android.auth.*
import de.minimalme.spotify_sdk.subscriptions.*
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
Expand All @@ -35,6 +35,8 @@ class SpotifySdkPlugin : MethodCallHandler, FlutterPlugin, ActivityAware, Plugin
private val channelName = "spotify_sdk"
private val loggingTag = "spotify_sdk"

private val REQUEST_SWAP_CODE = 1337

// event channels
private var playerContextChannel : EventChannel? = null
private var playerStateChannel : EventChannel? = null
Expand All @@ -50,8 +52,10 @@ class SpotifySdkPlugin : MethodCallHandler, FlutterPlugin, ActivityAware, Plugin

//connecting
private val methodConnectToSpotify = "connectToSpotify"
private val methodGetSwapToken = "getSwapToken"
private val methodGetAccessToken = "getAccessToken"
private val methodDisconnectFromSpotify = "disconnectFromSpotify"
private val methodIsSpotifyInstalled = "isSpotifyInstalled"

// connectApi
private val methodSwitchToLocalDevice = "switchToLocalDevice"
Expand Down Expand Up @@ -86,6 +90,8 @@ class SpotifySdkPlugin : MethodCallHandler, FlutterPlugin, ActivityAware, Plugin
private val paramClientId = "clientId"
private val paramRedirectUrl = "redirectUrl"
private val paramScope = "scope"
private val paramScopes = "scopes"
private val paramTokenSwapUrl = "tokenSwapUrl"
private val paramSpotifyUri = "spotifyUri"
private val paramImageUri = "imageUri"
private val paramImageDimension = "imageDimension"
Expand Down Expand Up @@ -175,8 +181,10 @@ class SpotifySdkPlugin : MethodCallHandler, FlutterPlugin, ActivityAware, Plugin
when (call.method) {
//connecting to spotify
methodConnectToSpotify -> connectToSpotify(call.argument(paramClientId), call.argument(paramRedirectUrl), result)
methodGetSwapToken -> getSwapToken(call.argument(paramClientId), call.argument(paramRedirectUrl), call.argument(paramScopes), call.argument(paramTokenSwapUrl), result)
methodGetAccessToken -> getAccessToken(call.argument(paramClientId), call.argument(paramRedirectUrl), call.argument(paramScope), result)
methodDisconnectFromSpotify -> disconnectFromSpotify(result)
methodIsSpotifyInstalled -> isSpotifyInstalled(result)
//connectApi calls
methodSwitchToLocalDevice -> spotifyConnectApi?.switchToLocalDevice()
//playerApi calls
Expand Down Expand Up @@ -204,6 +212,7 @@ class SpotifySdkPlugin : MethodCallHandler, FlutterPlugin, ActivityAware, Plugin
//imageApi calls
methodGetImage -> spotifyImagesApi?.getImage(call.argument(paramImageUri), call.argument(paramImageDimension))
// method call is not implemented yet

else -> result.notImplemented()
}
}
Expand Down Expand Up @@ -305,6 +314,64 @@ class SpotifySdkPlugin : MethodCallHandler, FlutterPlugin, ActivityAware, Plugin
}
}

/**
* Gets a swap token from Spotify using token swap authentication.
*
* @param clientId The Spotify client ID
* @param redirectUrl The redirect URL registered in Spotify dashboard
* @param scopes Comma-separated list of Spotify authorization scopes
* @param tokenSwapUrl URL for token swap endpoint
* @param result Flutter result callback
*/
private fun getSwapToken(
clientId: String?,
redirectUrl: String?,
scopes: String?,
tokenSwapUrl: String?,
result: Result
) {
try {
// Validate required parameters
when {
clientId.isNullOrBlank() -> throw IllegalArgumentException("Client ID is required")
redirectUrl.isNullOrBlank() -> throw IllegalArgumentException("Redirect URL is required")
scopes.isNullOrBlank() -> throw IllegalArgumentException("Scopes are required")
tokenSwapUrl.isNullOrBlank() -> throw IllegalArgumentException("Token swap URL is required")
!URLUtil.isValidUrl(tokenSwapUrl) -> throw IllegalArgumentException("Invalid token swap URL")
}

// Check Spotify installation
if (!_isSpotifyInstalled()) {
result.error("SPOTIFY_NOT_INSTALLED", "Spotify is not installed", null)
return
}

// Verify activity exists
val activity = applicationActivity ?: run {
result.error("NO_ACTIVITY", "Activity is not attached", null)
return
}

// Store pending operation
methodGetSwapToken.checkAndSetPendingOperation(result)

// Build authorization request
val request = AuthorizationRequest.Builder(
clientId,
AuthorizationResponse.Type.CODE,
redirectUrl
).apply {
setScopes(scopes.split(",").toTypedArray())
setShowDialog(true)
}.build()

// Start authentication
AuthorizationClient.openLoginActivity(activity, REQUEST_SWAP_CODE, request)
} catch (e: Exception) {
result.error("AUTH_ERROR", e.message, e.toString())
}
}

private fun getAccessToken(clientId: String?, redirectUrl: String?, scope: String?, result: Result) {
if (applicationActivity == null) {
throw IllegalStateException("getAccessToken needs a foreground activity")
Expand Down Expand Up @@ -363,6 +430,9 @@ class SpotifySdkPlugin : MethodCallHandler, FlutterPlugin, ActivityAware, Plugin
AuthorizationResponse.Type.TOKEN -> {
result.success(response.accessToken)
}
AuthorizationResponse.Type.CODE -> {
result.success(response.code)
}
AuthorizationResponse.Type.ERROR -> result.error(errorAuthenticationToken, "Authentication went wrong", response.error)
else -> result.notImplemented()
}
Expand All @@ -376,6 +446,38 @@ class SpotifySdkPlugin : MethodCallHandler, FlutterPlugin, ActivityAware, Plugin
}
pendingOperation = PendingOperation(this, result)
}

private fun isSpotifyInstalled(result: Result) {
try {
if (applicationActivity == null) {
result.error(
"NO_ACTIVITY",
"Activity is not attached",
null
)
return
}

val isInstalled = _isSpotifyInstalled()

result.success(isInstalled)
} catch (e: Exception) {
result.error(
"SPOTIFY_CHECK_FAILED",
"Failed to check if Spotify is installed",
e.toString()
)
}
}

private fun _isSpotifyInstalled(): Boolean {
return try {
applicationActivity!!.packageManager.getPackageInfo("com.spotify.music", 0)
true
} catch (e: PackageManager.NameNotFoundException) {
false
}
}
}

private class PendingOperation internal constructor(val method: String, val result: Result)
5 changes: 5 additions & 0 deletions ios/Classes/SpotifySdkConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ public class SpotifySdkConstants
{
//connecting
public static let methodConnectToSpotify = "connectToSpotify"
public static let methodGetSwapToken = "getSwapToken"
public static let methodGetAccessToken = "getAccessToken"
public static let methodDisconnectFromSpotify = "disconnectFromSpotify"

Expand Down Expand Up @@ -31,6 +32,7 @@ public class SpotifySdkConstants

public static let paramClientId = "clientId"
public static let paramRedirectUrl = "redirectUrl"
public static let paramTokenSwapUrl = "tokenSwapUrl"
public static let paramSpotifyUri = "spotifyUri"
public static let paramAsRadio = "asRadio"
public static let paramImageUri = "imageUri"
Expand All @@ -42,5 +44,8 @@ public class SpotifySdkConstants
public static let paramRepeatMode = "repeatMode"
public static let paramTrackIndex = "trackIndex"
public static let scope = "scope"
public static let paramScopes = "scopes"
public static let getLibraryState = "getLibraryState"

public static let methodIsSpotifyInstalled = "isSpotifyInstalled"
}
32 changes: 32 additions & 0 deletions ios/Classes/SwiftAppDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Flutter
import UIKit

public class SpotifyAppDelegate {
public static func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) {
// Initialize Spotify-specific components
if let registrar = (application.delegate as? FlutterAppDelegate)?.registrar(
forPlugin: "SwiftSpotifySdkPlugin")
{
SwiftSpotifySdkPlugin.register(with: registrar)
}
}

public static func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
// Handle Spotify URL callback
if let value = Bundle.main.infoDictionary?["SpotifySDKCallbackURL"] as? String {
if url.absoluteString.contains(value) {
SwiftSpotifySdkPlugin.shared.handleSpotifyCallback(url: url)
return true
}
}

return false
}
}
Loading