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/AccountsQueryParameters.java b/auth-lib/src/main/java/com/spotify/sdk/android/auth/AccountsQueryParameters.java index a05b34d..34221ca 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,6 +14,7 @@ public interface AccountsQueryParameters { String CODE = "code"; String ACCESS_TOKEN = "access_token"; String EXPIRES_IN = "expires_in"; + String ASSOCIATED_CONTENT = "associated_content"; String CODE_CHALLENGE = "code_challenge"; String CODE_CHALLENGE_METHOD = "code_challenge_method"; } 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 2f2298d..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 @@ -21,16 +21,21 @@ 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.Nullable; import androidx.annotation.VisibleForTesting; +import org.json.JSONObject; + +import java.nio.charset.Charset; import java.util.HashMap; import java.util.Map; @@ -50,6 +55,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; @@ -59,6 +66,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; private final PKCEInformation mPkceInformation; /** @@ -78,6 +87,8 @@ public static class Builder { private String mCampaign; private PKCEInformation mPkceInformation; private final Map mCustomParams = new HashMap<>(); + private String mContentUri; + private String mContentUrl; public Builder(String clientId, AuthorizationResponse.Type responseType, String redirectUri) { if (clientId == null) { @@ -126,6 +137,16 @@ 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 Builder setPkceInformation(PKCEInformation pkceInformation) { mPkceInformation = pkceInformation; return this; @@ -133,7 +154,7 @@ public Builder setPkceInformation(PKCEInformation pkceInformation) { public AuthorizationRequest build() { return new AuthorizationRequest(mClientId, mResponseType, mRedirectUri, - mState, mScopes, mShowDialog, mCustomParams, mCampaign, mPkceInformation); + mState, mScopes, mShowDialog, mCustomParams, mCampaign, mContentUri, mContentUrl, mPkceInformation); } } @@ -146,6 +167,8 @@ public AuthorizationRequest(Parcel source) { mShowDialog = source.readByte() == 1; mCustomParams = new HashMap<>(); mCampaign = source.readString(); + mContentUri = source.readString(); + mContentUrl = source.readString(); mPkceInformation = source.readParcelable(PKCEInformation.class.getClassLoader()); Bundle bundle = source.readBundle(getClass().getClassLoader()); for (String key : bundle.keySet()) { @@ -177,6 +200,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; } @@ -198,8 +253,10 @@ private AuthorizationRequest(String clientId, boolean showDialog, Map customParams, String campaign, - PKCEInformation pkceInformation) { - + String contentUri, + String contentUrl, + PKCEInformation pkceInformation + ) { mClientId = clientId; mResponseType = responseType.toString(); mRedirectUri = redirectUri; @@ -208,6 +265,8 @@ private AuthorizationRequest(String clientId, mShowDialog = showDialog; mCustomParams = customParams; mCampaign = campaign; + mContentUri = contentUri; + mContentUrl = contentUrl; mPkceInformation = pkceInformation; } @@ -238,6 +297,11 @@ public Uri toUri() { } } + String associatedContent = getEncodedContent(); + + if(associatedContent != null) { + uriBuilder.appendQueryParameter(ASSOCIATED_CONTENT, associatedContent); + } if (mPkceInformation != null) { uriBuilder.appendQueryParameter(AccountsQueryParameters.CODE_CHALLENGE, mPkceInformation.getChallenge()); uriBuilder.appendQueryParameter(AccountsQueryParameters.CODE_CHALLENGE_METHOD, mPkceInformation.getCodeChallengeMethod()); @@ -269,6 +333,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); dest.writeParcelable(mPkceInformation, flags); Bundle bundle = new Bundle(); 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 88160c3..0fd4cef 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 @@ -26,4 +26,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 a1779d4..af977fd 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_CODE_CHALLENGE; import static com.spotify.sdk.android.auth.IntentExtras.KEY_CODE_CHALLENGE_METHOD; @@ -107,6 +108,11 @@ 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); + } final PKCEInformation pkceInfo = mRequest.getPkceInformation(); if (pkceInfo != null) { intent.putExtra(KEY_CODE_CHALLENGE, pkceInfo.getChallenge()); 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 9b02c1a..9084326 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 @@ -217,6 +217,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 818cd93..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 @@ -172,6 +172,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); + final SpotifyNativeAuthUtil authUtil = new SpotifyNativeAuthUtil( + activity, + authorizationRequest, + 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)); + } + @Test public void shouldIncludePkceParametersInIntent() { final String verifier = "test_verifier_1234567890"; @@ -179,14 +210,14 @@ public void shouldIncludePkceParametersInIntent() { 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, @@ -207,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,