From 3fbe5dfd90010932c70397b9477a91e01de3c88b Mon Sep 17 00:00:00 2001 From: Hamid Mehmood Date: Fri, 25 Jul 2025 15:08:58 +0100 Subject: [PATCH 1/2] Adding support for content links --- .../android/auth/AccountsQueryParameters.java | 1 + .../android/auth/AuthorizationRequest.java | 72 ++++++++++++++++++- .../sdk/android/auth/IntentExtras.java | 1 + .../auth/app/SpotifyNativeAuthUtil.java | 7 ++ .../auth/AuthorizationRequestTest.java | 20 ++++++ .../auth/app/SpotifyNativeAuthUtilTest.java | 31 ++++++++ 6 files changed, 130 insertions(+), 2 deletions(-) diff --git a/auth-lib/src/main/java/com/spotify/sdk/android/auth/AccountsQueryParameters.java b/auth-lib/src/main/java/com/spotify/sdk/android/auth/AccountsQueryParameters.java index a82b0a6..cf62c80 100644 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/AccountsQueryParameters.java +++ b/auth-lib/src/main/java/com/spotify/sdk/android/auth/AccountsQueryParameters.java @@ -14,4 +14,5 @@ public interface AccountsQueryParameters { String CODE = "code"; String ACCESS_TOKEN = "access_token"; String EXPIRES_IN = "expires_in"; + String ASSOCIATED_CONTENT = "associated_content"; } diff --git a/auth-lib/src/main/java/com/spotify/sdk/android/auth/AuthorizationRequest.java b/auth-lib/src/main/java/com/spotify/sdk/android/auth/AuthorizationRequest.java index 75f0483..6409188 100644 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/AuthorizationRequest.java +++ b/auth-lib/src/main/java/com/spotify/sdk/android/auth/AuthorizationRequest.java @@ -21,15 +21,20 @@ package com.spotify.sdk.android.auth; +import static com.spotify.sdk.android.auth.AccountsQueryParameters.ASSOCIATED_CONTENT; import android.net.Uri; import android.os.Bundle; import android.os.Parcel; import android.os.Parcelable; import android.text.TextUtils; +import android.util.Base64; import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; +import org.json.JSONObject; + +import java.nio.charset.Charset; import java.util.HashMap; import java.util.Map; @@ -49,6 +54,8 @@ public class AuthorizationRequest implements Parcelable { public static final String SPOTIFY_SDK = "spotify-sdk"; @VisibleForTesting public static final String ANDROID_SDK = "android-sdk"; + private static final String KEY_URI = "uri"; + private static final String KEY_URL = "url"; private final String mClientId; private final String mResponseType; @@ -58,6 +65,8 @@ public class AuthorizationRequest implements Parcelable { private final boolean mShowDialog; private final Map mCustomParams; private final String mCampaign; + private final String mContentUri; + private final String mContentUrl; /** * Use this builder to create an {@link AuthorizationRequest} @@ -75,6 +84,8 @@ public static class Builder { private boolean mShowDialog; private String mCampaign; private final Map mCustomParams = new HashMap<>(); + private String mContentUri; + private String mContentUrl; public Builder(String clientId, AuthorizationResponse.Type responseType, String redirectUri) { if (clientId == null) { @@ -123,9 +134,19 @@ public Builder setCampaign(String campaign) { return this; } + public Builder setContentUri(String contentUri) { + mContentUri = contentUri; + return this; + } + + public Builder setContentUrl(String contentUrl) { + mContentUrl = contentUrl; + return this; + } + public AuthorizationRequest build() { return new AuthorizationRequest(mClientId, mResponseType, mRedirectUri, - mState, mScopes, mShowDialog, mCustomParams, mCampaign); + mState, mScopes, mShowDialog, mCustomParams, mCampaign, mContentUri, mContentUrl); } } @@ -138,6 +159,8 @@ public AuthorizationRequest(Parcel source) { mShowDialog = source.readByte() == 1; mCustomParams = new HashMap<>(); mCampaign = source.readString(); + mContentUri = source.readString(); + mContentUrl = source.readString(); Bundle bundle = source.readBundle(getClass().getClassLoader()); for (String key : bundle.keySet()) { mCustomParams.put(key, bundle.getString(key)); @@ -168,6 +191,38 @@ public String getCustomParam(String key) { return mCustomParams.get(key); } + public String getEncodedContent() { + JSONObject contentJson = getContentsJson(); + + if(contentJson.length() == 0) { + return null; // No content to encode + } + + return Base64.encodeToString(contentJson.toString().getBytes(Charset.forName("UTF-8")), Base64.URL_SAFE | Base64.NO_WRAP); + } + + @NonNull + private JSONObject getContentsJson() { + JSONObject contentJson = new JSONObject(); + + if(mContentUri != null && !mContentUri.isEmpty()) { + try { + contentJson.put(KEY_URI, mContentUri); + } catch (Exception e) { + throw new IllegalArgumentException("Error creating JSON for content URI: " + e.getMessage()); + } + } + + if(mContentUrl != null && !mContentUrl.isEmpty()) { + try { + contentJson.put(KEY_URL, mContentUrl); + } catch (Exception e) { + throw new IllegalArgumentException("Error creating JSON for content URL: " + e.getMessage()); + } + } + return contentJson; + } + @NonNull public String getCampaign() { return TextUtils.isEmpty(mCampaign) ? ANDROID_SDK : mCampaign; } @@ -184,7 +239,10 @@ private AuthorizationRequest(String clientId, String[] scopes, boolean showDialog, Map customParams, - String campaign) { + String campaign, + String contentUri, + String contentUrl + ) { mClientId = clientId; mResponseType = responseType.toString(); @@ -194,6 +252,8 @@ private AuthorizationRequest(String clientId, mShowDialog = showDialog; mCustomParams = customParams; mCampaign = campaign; + mContentUri = contentUri; + mContentUrl = contentUrl; } public Uri toUri() { @@ -223,6 +283,12 @@ public Uri toUri() { } } + String associatedContent = getEncodedContent(); + + if(associatedContent != null) { + uriBuilder.appendQueryParameter(ASSOCIATED_CONTENT, associatedContent); + } + return uriBuilder.build(); } @@ -249,6 +315,8 @@ public void writeToParcel(Parcel dest, int flags) { dest.writeStringArray(mScopes); dest.writeByte((byte) (mShowDialog ? 1 : 0)); dest.writeString(mCampaign); + dest.writeString(mContentUri); + dest.writeString(mContentUrl); Bundle bundle = new Bundle(); for (Map.Entry entry : mCustomParams.entrySet()) { diff --git a/auth-lib/src/main/java/com/spotify/sdk/android/auth/IntentExtras.java b/auth-lib/src/main/java/com/spotify/sdk/android/auth/IntentExtras.java index a165ff4..9a652da 100644 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/IntentExtras.java +++ b/auth-lib/src/main/java/com/spotify/sdk/android/auth/IntentExtras.java @@ -24,4 +24,5 @@ public interface IntentExtras { * DO NOT CHANGE THIS. */ String KEY_VERSION = "VERSION"; + String KEY_ASSOCIATED_CONTENT = "associated_content"; } diff --git a/auth-lib/src/main/java/com/spotify/sdk/android/auth/app/SpotifyNativeAuthUtil.java b/auth-lib/src/main/java/com/spotify/sdk/android/auth/app/SpotifyNativeAuthUtil.java index e46f663..7f88c1c 100644 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/app/SpotifyNativeAuthUtil.java +++ b/auth-lib/src/main/java/com/spotify/sdk/android/auth/app/SpotifyNativeAuthUtil.java @@ -21,6 +21,7 @@ package com.spotify.sdk.android.auth.app; +import static com.spotify.sdk.android.auth.IntentExtras.KEY_ASSOCIATED_CONTENT; import static com.spotify.sdk.android.auth.IntentExtras.KEY_CLIENT_ID; import static com.spotify.sdk.android.auth.IntentExtras.KEY_REDIRECT_URI; import static com.spotify.sdk.android.auth.IntentExtras.KEY_REQUESTED_SCOPES; @@ -104,6 +105,12 @@ public boolean startAuthActivity() { intent.putExtra(KEY_UTM_CAMPAIGN, mRequest.getCampaign()); intent.putExtra(KEY_UTM_MEDIUM, mRequest.getMedium()); + String associatedContent = mRequest.getEncodedContent(); + + if(associatedContent != null) { + intent.putExtra(KEY_ASSOCIATED_CONTENT, associatedContent); + } + try { mContextActivity.startActivityForResult(intent, LoginActivity.REQUEST_CODE); } catch (ActivityNotFoundException e) { diff --git a/auth-lib/src/test/java/com/spotify/sdk/android/auth/AuthorizationRequestTest.java b/auth-lib/src/test/java/com/spotify/sdk/android/auth/AuthorizationRequestTest.java index d54d915..177fbd2 100644 --- a/auth-lib/src/test/java/com/spotify/sdk/android/auth/AuthorizationRequestTest.java +++ b/auth-lib/src/test/java/com/spotify/sdk/android/auth/AuthorizationRequestTest.java @@ -215,6 +215,26 @@ public void shouldSetCustomParams() { assertEquals(uri, authorizationRequest.toUri()); } + @Test + public void shouldSetContent() { + String contentUri = "spotify:track:1234567890"; + String contentUrl = "https://open.spotify.com/track/1234567890"; + String encodedContent = "eyJ1cmkiOiJzcG90aWZ5OnRyYWNrOjEyMzQ1Njc4OTAiLCJ1cmwiOiJodHRwczpcL1wvb3Blbi5zcG90aWZ5LmNvbVwvdHJhY2tcLzEyMzQ1Njc4OTAifQ=="; + + AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + .setContentUri(contentUri) + .setContentUrl(contentUrl) + .build(); + + assertEquals(authorizationRequest.getEncodedContent(), encodedContent); + + Uri.Builder uriBuilder = getBaseAuthUri(mClientId, mResponseType.toString(), mRedirectUri, mDefaultCampaign); + uriBuilder.appendQueryParameter(AccountsQueryParameters.ASSOCIATED_CONTENT, encodedContent); + Uri uri = uriBuilder.build(); + + assertEquals(uri, authorizationRequest.toUri()); + } + @Test(expected = IllegalArgumentException.class) public void shouldThrowWithNullCustomParamKey() { new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) diff --git a/auth-lib/src/test/java/com/spotify/sdk/android/auth/app/SpotifyNativeAuthUtilTest.java b/auth-lib/src/test/java/com/spotify/sdk/android/auth/app/SpotifyNativeAuthUtilTest.java index e89b9aa..d607ac8 100644 --- a/auth-lib/src/test/java/com/spotify/sdk/android/auth/app/SpotifyNativeAuthUtilTest.java +++ b/auth-lib/src/test/java/com/spotify/sdk/android/auth/app/SpotifyNativeAuthUtilTest.java @@ -170,6 +170,37 @@ public void hasUtmExtrasSetToLoginIntent() { assertEquals(campaign, intent.getStringExtra(IntentExtras.KEY_UTM_CAMPAIGN)); } + @Test + public void hasContentExtrasSetToLoginIntent() { + String campaign = "campaign"; + String contentUri = "spotify:track:1234567890"; + String contentUrl = "https://open.spotify.com/track/1234567890"; + String encodedContent = "eyJ1cmkiOiJzcG90aWZ5OnRyYWNrOjEyMzQ1Njc4OTAiLCJ1cmwiOiJodHRwczpcL1wvb3Blbi5zcG90aWZ5LmNvbVwvdHJhY2tcLzEyMzQ1Njc4OTAifQ=="; + Activity activity = mock(Activity.class); + Mockito.doNothing().when(activity).startActivityForResult(any(Intent.class), anyInt()); + AuthorizationRequest authorizationRequest = + new AuthorizationRequest + .Builder("test", AuthorizationResponse.Type.TOKEN, "to://me") + .setScopes(new String[]{"testa", "toppen"}) + .setCampaign(campaign) + .setContentUri(contentUri) + .setContentUrl(contentUrl) + .build(); + configureMocksWithSigningInfo(activity); + SpotifyNativeAuthUtil authUtil = new SpotifyNativeAuthUtil( + activity, + authorizationRequest, + new FakeSha1HashUtil(Collections.singletonMap(DEFAULT_TEST_SIGNATURE, SPOTIFY_HASH)) + ); + authUtil.startAuthActivity(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Intent.class); + verify(activity, times(1)).startActivityForResult(captor.capture(), anyInt()); + Intent intent = captor.getValue(); + + assertEquals(encodedContent, intent.getStringExtra(IntentExtras.KEY_ASSOCIATED_CONTENT)); + } + private void configureDefaultMocks(Context mockedContext) { PackageInfo packageInfo = new PackageInfo(); Signature mockedSignature = mock(Signature.class); From 8fc4866e8bb002b857eb352da0f60d63af2feb72 Mon Sep 17 00:00:00 2001 From: Hamid Mehmood Date: Wed, 10 Dec 2025 11:57:46 +0000 Subject: [PATCH 2/2] Merging latest changes from master --- auth-lib/build.gradle | 2 +- .../android/auth/AuthorizationRequest.java | 2 ++ .../auth/app/SpotifyNativeAuthUtilTest.java | 21 +++++++++---------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/auth-lib/build.gradle b/auth-lib/build.gradle index f02452b..29b77df 100644 --- a/auth-lib/build.gradle +++ b/auth-lib/build.gradle @@ -82,7 +82,7 @@ android { } } - def manifestPlaceholdersForTests = [redirectSchemeName: "spotify-sdk", redirectHostName: "auth"] + def manifestPlaceholdersForTests = [redirectSchemeName: "spotify-sdk", redirectHostName: "auth", redirectPathPattern: ".*"] namespace 'com.spotify.sdk.android.auth' unitTestVariants.configureEach { it.mergedFlavor.manifestPlaceholders += manifestPlaceholdersForTests diff --git a/auth-lib/src/main/java/com/spotify/sdk/android/auth/AuthorizationRequest.java b/auth-lib/src/main/java/com/spotify/sdk/android/auth/AuthorizationRequest.java index b1571ac..643d68f 100644 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/AuthorizationRequest.java +++ b/auth-lib/src/main/java/com/spotify/sdk/android/auth/AuthorizationRequest.java @@ -144,6 +144,8 @@ public Builder setContentUri(String contentUri) { public Builder setContentUrl(String contentUrl) { mContentUrl = contentUrl; + return this; + } public Builder setPkceInformation(PKCEInformation pkceInformation) { mPkceInformation = pkceInformation; diff --git a/auth-lib/src/test/java/com/spotify/sdk/android/auth/app/SpotifyNativeAuthUtilTest.java b/auth-lib/src/test/java/com/spotify/sdk/android/auth/app/SpotifyNativeAuthUtilTest.java index 065fa58..b88cb2e 100644 --- a/auth-lib/src/test/java/com/spotify/sdk/android/auth/app/SpotifyNativeAuthUtilTest.java +++ b/auth-lib/src/test/java/com/spotify/sdk/android/auth/app/SpotifyNativeAuthUtilTest.java @@ -195,30 +195,29 @@ public void hasContentExtrasSetToLoginIntent() { new FakeSha1HashUtil(Collections.singletonMap(DEFAULT_TEST_SIGNATURE, SPOTIFY_HASH)) ); authUtil.startAuthActivity(); - + final ArgumentCaptor captor = ArgumentCaptor.forClass(Intent.class); verify(activity, times(1)).startActivityForResult(captor.capture(), anyInt()); final Intent intent = captor.getValue(); - - assertEquals(encodedContent, intent.getStringExtra(IntentExtras.KEY_ASSOCIATED_CONTENT)) - assertEquals(challenge, intent.getStringExtra(IntentExtras.KEY_CODE_CHALLENGE)); - assertEquals("S256", intent.getStringExtra(IntentExtras.KEY_CODE_CHALLENGE_METHOD)); - } - + + assertEquals(encodedContent, intent.getStringExtra(IntentExtras.KEY_ASSOCIATED_CONTENT)); + } + + @Test public void shouldIncludePkceParametersInIntent() { final String verifier = "test_verifier_1234567890"; final String challenge = "test_challenge_abcdef"; final PKCEInformation pkceInfo = PKCEInformation.sha256(verifier, challenge); final Activity activity = mock(Activity.class); Mockito.doNothing().when(activity).startActivityForResult(any(Intent.class), anyInt()); - + final AuthorizationRequest authorizationRequest = new AuthorizationRequest .Builder("test", AuthorizationResponse.Type.TOKEN, "to://me") .setScopes(new String[]{"testa", "toppen"}) .setPkceInformation(pkceInfo) .build(); - + configureMocksWithSigningInfo(activity); final SpotifyNativeAuthUtil authUtil = new SpotifyNativeAuthUtil( activity, @@ -239,14 +238,14 @@ public void shouldIncludePkceParametersInIntent() { public void shouldNotIncludePkceParametersWhenNull() { final Activity activity = mock(Activity.class); Mockito.doNothing().when(activity).startActivityForResult(any(Intent.class), anyInt()); - + final AuthorizationRequest authorizationRequest = new AuthorizationRequest .Builder("test", AuthorizationResponse.Type.TOKEN, "to://me") .setScopes(new String[]{"testa", "toppen"}) .setPkceInformation(null) .build(); - + configureMocksWithSigningInfo(activity); final SpotifyNativeAuthUtil authUtil = new SpotifyNativeAuthUtil( activity,