diff --git a/.gitignore b/.gitignore index 5cf8f6f..c9cccbe 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,5 @@ .idea/ .DS_Store build/ -doc/ -doc-store/ +docs/ +docs-store/ diff --git a/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AuthorizationClient.kt b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AuthorizationClient.kt index c9d144d..b72bba6 100644 --- a/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AuthorizationClient.kt +++ b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AuthorizationClient.kt @@ -270,7 +270,7 @@ class AuthorizationClient( override fun onCancel() { Log.i(TAG, "Spotify auth response: User cancelled") val response = AuthorizationResponse.Builder() - .setType(AuthorizationResponse.Type.EMPTY) + .setType(AuthorizationResponse.Type.CANCELLED) .build() sendComplete(authHandler, response) } @@ -301,7 +301,7 @@ class AuthorizationClient( if (currentHandler?.isAuthInProgress() == true) { Log.i(TAG, "Spotify auth response: User cancelled") val response = AuthorizationResponse.Builder() - .setType(AuthorizationResponse.Type.EMPTY) + .setType(AuthorizationResponse.Type.CANCELLED) .build() complete(response) } diff --git a/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AuthorizationResponse.kt b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AuthorizationResponse.kt index 4634534..7d0d61a 100644 --- a/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AuthorizationResponse.kt +++ b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AuthorizationResponse.kt @@ -69,10 +69,15 @@ data class AuthorizationResponse internal constructor( ERROR("error"), /** - * Response doesn't contain data because auth flow was cancelled or LoginActivity killed. + * Response doesn't contain data due to technical error (malformed response, missing data, etc.) */ EMPTY("empty"), + /** + * User explicitly cancelled the authorization flow (closed browser, pressed back, etc.) + */ + CANCELLED("cancelled"), + /** * The response is unknown. */ diff --git a/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/LoginActivity.kt b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/LoginActivity.kt index 7c0577d..7f3ff5a 100644 --- a/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/LoginActivity.kt +++ b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/LoginActivity.kt @@ -182,7 +182,10 @@ class LoginActivity : Activity(), AuthorizationClient.AuthorizationClientListene } } + } else if (resultCode == RESULT_CANCELED) { + response.setType(Type.CANCELLED) } else { + // Unknown/invalid result code - treat as technical error response.setType(Type.EMPTY) } @@ -201,7 +204,15 @@ class LoginActivity : Activity(), AuthorizationClient.AuthorizationClientListene bundle.putParcelable(RESPONSE_KEY, response) resultIntent.putExtra(EXTRA_AUTH_RESPONSE, bundle) - setResult(RESULT_OK, resultIntent) + + // Return RESULT_CANCELED for user cancellations + val resultCode = if (response.type == Type.CANCELLED) { + RESULT_CANCELED + } else { + RESULT_OK + } + + setResult(resultCode, resultIntent) finish() } diff --git a/auth-lib/src/testAuth/java/com/spotify/sdk/android/auth/LoginActivityAuthTest.java b/auth-lib/src/testAuth/java/com/spotify/sdk/android/auth/LoginActivityAuthTest.java index 52a0640..3f77e4c 100644 --- a/auth-lib/src/testAuth/java/com/spotify/sdk/android/auth/LoginActivityAuthTest.java +++ b/auth-lib/src/testAuth/java/com/spotify/sdk/android/auth/LoginActivityAuthTest.java @@ -45,9 +45,17 @@ @RunWith(RobolectricTestRunner.class) public class LoginActivityAuthTest { - @Test - public void shouldFinishLoginActivityWhenCompleted() { + private static class LoginActivitySetup { + final LoginActivity loginActivity; + final ShadowActivity shadowLoginActivity; + + LoginActivitySetup(LoginActivity loginActivity, ShadowActivity shadowLoginActivity) { + this.loginActivity = loginActivity; + this.shadowLoginActivity = shadowLoginActivity; + } + } + private LoginActivitySetup createLoginActivity() { Activity context = Robolectric .buildActivity(Activity.class) .create() @@ -57,11 +65,6 @@ public void shouldFinishLoginActivityWhenCompleted() { AuthorizationRequest request = new AuthorizationRequest.Builder("test", AuthorizationResponse.Type.TOKEN, "test://test") .setPkceInformation(pkceInfo) .build(); - AuthorizationResponse response = new AuthorizationResponse.Builder() - .setType(AuthorizationResponse.Type.TOKEN) - .setAccessToken("test_token") - .setExpiresIn(3600) - .build(); Bundle bundle = new Bundle(); bundle.putParcelable(LoginActivity.REQUEST_KEY, request); @@ -69,22 +72,58 @@ public void shouldFinishLoginActivityWhenCompleted() { Intent intent = new Intent(context, LoginActivity.class); intent.putExtra(LoginActivity.EXTRA_AUTH_REQUEST, bundle); - ActivityController loginActivityActivityController = buildActivity(LoginActivity.class, intent); + ActivityController loginActivityController = buildActivity(LoginActivity.class, intent); + LoginActivity loginActivity = loginActivityController.get(); + ShadowActivity shadowLoginActivity = shadowOf(loginActivity); + shadowLoginActivity.setCallingActivity(context.getComponentName()); + loginActivityController.create(); - final LoginActivity loginActivity = loginActivityActivityController.get(); + return new LoginActivitySetup(loginActivity, shadowLoginActivity); + } - final ShadowActivity shadowLoginActivity = shadowOf(loginActivity); - shadowLoginActivity.setCallingActivity(context.getComponentName()); + private void assertCompletion(LoginActivitySetup setup, AuthorizationResponse response, int expectedResultCode) { + setup.loginActivity.onClientComplete(response); + + assertTrue(setup.loginActivity.isFinishing()); + assertEquals(expectedResultCode, setup.shadowLoginActivity.getResultCode()); + assertEquals(response, setup.shadowLoginActivity.getResultIntent().getBundleExtra(LoginActivity.EXTRA_AUTH_RESPONSE).get(LoginActivity.RESPONSE_KEY)); + } + + @Test + public void shouldFinishLoginActivityWhenCompleted() { + LoginActivitySetup setup = createLoginActivity(); + + AuthorizationResponse response = new AuthorizationResponse.Builder() + .setType(AuthorizationResponse.Type.TOKEN) + .setAccessToken("test_token") + .setExpiresIn(3600) + .build(); - loginActivityActivityController.create(); + assertFalse(setup.loginActivity.isFinishing()); - assertFalse(loginActivity.isFinishing()); + assertCompletion(setup, response, Activity.RESULT_OK); + } + + @Test + public void shouldReturnResultCanceledWhenUserCancels() { + LoginActivitySetup setup = createLoginActivity(); - loginActivity.onClientComplete(response); + AuthorizationResponse response = new AuthorizationResponse.Builder() + .setType(AuthorizationResponse.Type.CANCELLED) + .build(); + + assertCompletion(setup, response, Activity.RESULT_CANCELED); + } + + @Test + public void shouldReturnResultOkForTechnicalErrors() { + LoginActivitySetup setup = createLoginActivity(); + + AuthorizationResponse response = new AuthorizationResponse.Builder() + .setType(AuthorizationResponse.Type.EMPTY) + .build(); - assertTrue(loginActivity.isFinishing()); - assertEquals(Activity.RESULT_OK, shadowLoginActivity.getResultCode()); - assertEquals(response, shadowLoginActivity.getResultIntent().getBundleExtra(LoginActivity.EXTRA_AUTH_RESPONSE).get(LoginActivity.RESPONSE_KEY)); + assertCompletion(setup, response, Activity.RESULT_OK); } } diff --git a/auth-sample/build.gradle.kts b/auth-sample/build.gradle.kts index c7d9b6c..86d4867 100644 --- a/auth-sample/build.gradle.kts +++ b/auth-sample/build.gradle.kts @@ -21,6 +21,8 @@ plugins { id("com.android.application") + id("kotlin-android") + id("kotlin-parcelize") } android { diff --git a/auth-sample/src/main/java/com/spotify/sdk/android/auth/sample/MainActivity.java b/auth-sample/src/main/java/com/spotify/sdk/android/auth/sample/MainActivity.java deleted file mode 100644 index 55e0cde..0000000 --- a/auth-sample/src/main/java/com/spotify/sdk/android/auth/sample/MainActivity.java +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright (c) 2015-2016 Spotify AB - * - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package com.spotify.sdk.android.auth.sample; - -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.view.View; -import android.widget.TextView; - -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.content.ContextCompat; - -import com.google.android.material.snackbar.Snackbar; -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.BuildConfig; -import com.spotify.sdk.android.authentication.sample.R; - -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.IOException; -import java.util.Locale; - -import okhttp3.Call; -import okhttp3.Callback; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; - -public class MainActivity extends AppCompatActivity { - - public static final String CLIENT_ID = "089d841ccc194c10a77afad9e1c11d54"; - public static final String REDIRECT_URI = "spotify-sdk://auth"; - public static final int AUTH_TOKEN_REQUEST_CODE = 0x10; - public static final int AUTH_CODE_REQUEST_CODE = 0x11; - - private final OkHttpClient mOkHttpClient = new OkHttpClient(); - private String mAccessToken; - private String mAccessCode; - private Call mCall; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - getSupportActionBar().setTitle(String.format( - Locale.US, "Spotify Auth Sample %s", BuildConfig.LIB_VERSION_NAME)); - } - - @Override - protected void onDestroy() { - cancelCall(); - super.onDestroy(); - } - - public void onGetUserProfileClicked(View view) { - if (mAccessToken == null) { - final Snackbar snackbar = Snackbar.make(findViewById(R.id.activity_main), R.string.warning_need_token, Snackbar.LENGTH_SHORT); - snackbar.getView().setBackgroundColor(ContextCompat.getColor(this, R.color.colorAccent)); - snackbar.show(); - return; - } - - final Request request = new Request.Builder() - .url("https://api.spotify.com/v1/me") - .addHeader("Authorization", "Bearer " + mAccessToken) - .build(); - - cancelCall(); - mCall = mOkHttpClient.newCall(request); - - mCall.enqueue(new Callback() { - @Override - public void onFailure(Call call, IOException e) { - setResponse("Failed to fetch data: " + e); - } - - @Override - public void onResponse(Call call, Response response) throws IOException { - try { - final JSONObject jsonObject = new JSONObject(response.body().string()); - setResponse(jsonObject.toString(3)); - } catch (JSONException e) { - setResponse("Failed to parse data: " + e); - } - } - }); - } - - public void onRequestCodeClicked(View view) { - final AuthorizationRequest request = getAuthenticationRequest(AuthorizationResponse.Type.CODE); - AuthorizationClient.openLoginActivity(this, AUTH_CODE_REQUEST_CODE, request); - } - - public void onRequestTokenClicked(View view) { - final AuthorizationRequest request = getAuthenticationRequest(AuthorizationResponse.Type.TOKEN); - AuthorizationClient.openLoginActivity(this, AUTH_TOKEN_REQUEST_CODE, request); - } - - private AuthorizationRequest getAuthenticationRequest(AuthorizationResponse.Type type) { - return new AuthorizationRequest.Builder(CLIENT_ID, type, getRedirectUri().toString()) - .setShowDialog(false) - .setScopes(new String[]{"user-read-email"}) - .setCampaign("your-campaign-token") - .build(); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - final AuthorizationResponse response = AuthorizationClient.getResponse(resultCode, data); - - if (AUTH_TOKEN_REQUEST_CODE == requestCode) { - mAccessToken = response.getAccessToken(); - updateTokenView(); - } else if (AUTH_CODE_REQUEST_CODE == requestCode) { - mAccessCode = response.getCode(); - updateCodeView(); - } - } - - private void setResponse(final String text) { - runOnUiThread(new Runnable() { - @Override - public void run() { - final TextView responseView = findViewById(R.id.response_text_view); - responseView.setText(text); - } - }); - } - - private void updateTokenView() { - final TextView tokenView = findViewById(R.id.token_text_view); - tokenView.setText(getString(R.string.token, mAccessToken)); - } - - private void updateCodeView() { - final TextView codeView = findViewById(R.id.code_text_view); - codeView.setText(getString(R.string.code, mAccessCode)); - } - - private void cancelCall() { - if (mCall != null) { - mCall.cancel(); - } - } - - private Uri getRedirectUri() { - return Uri.parse(REDIRECT_URI); - } -} diff --git a/auth-sample/src/main/kotlin/com/spotify/sdk/android/auth/sample/MainActivity.kt b/auth-sample/src/main/kotlin/com/spotify/sdk/android/auth/sample/MainActivity.kt new file mode 100644 index 0000000..7b91c53 --- /dev/null +++ b/auth-sample/src/main/kotlin/com/spotify/sdk/android/auth/sample/MainActivity.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2015-2016 Spotify AB + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package com.spotify.sdk.android.auth.sample + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import com.google.android.material.snackbar.Snackbar +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.BuildConfig +import com.spotify.sdk.android.authentication.sample.R +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.util.Locale + +class MainActivity : AppCompatActivity() { + + companion object { + const val CLIENT_ID = "089d841ccc194c10a77afad9e1c11d54" + const val REDIRECT_URI = "spotify-sdk://auth" + const val AUTH_TOKEN_REQUEST_CODE = 0x10 + const val AUTH_CODE_REQUEST_CODE = 0x11 + } + + private val okHttpClient = OkHttpClient() + private var accessToken: String? = null + private var accessCode: String? = null + private var call: Call? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + supportActionBar?.title = String.format( + Locale.US, "Spotify Auth Sample %s", BuildConfig.LIB_VERSION_NAME + ) + } + + override fun onDestroy() { + cancelCall() + super.onDestroy() + } + + fun onGetUserProfileClicked(view: View) { + if (accessToken == null) { + val snackbar = Snackbar.make( + findViewById(R.id.activity_main), + R.string.warning_need_token, + Snackbar.LENGTH_SHORT + ) + snackbar.view.setBackgroundColor(ContextCompat.getColor(this, R.color.colorAccent)) + snackbar.show() + return + } + + val request = Request.Builder() + .url("https://api.spotify.com/v1/me") + .addHeader("Authorization", "Bearer $accessToken") + .build() + + cancelCall() + call = okHttpClient.newCall(request) + + call?.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + setResponse("Failed to fetch data: $e") + } + + override fun onResponse(call: Call, response: Response) { + try { + val jsonObject = JSONObject(response.body?.string() ?: "") + setResponse(jsonObject.toString(3)) + } catch (e: JSONException) { + setResponse("Failed to parse data: $e") + } + } + }) + } + + fun onRequestCodeClicked(view: View) { + val request = getAuthenticationRequest(AuthorizationResponse.Type.CODE) + AuthorizationClient.openLoginActivity(this, AUTH_CODE_REQUEST_CODE, request) + } + + fun onRequestTokenClicked(view: View) { + val request = getAuthenticationRequest(AuthorizationResponse.Type.TOKEN) + AuthorizationClient.openLoginActivity(this, AUTH_TOKEN_REQUEST_CODE, request) + } + + private fun getAuthenticationRequest(type: AuthorizationResponse.Type): AuthorizationRequest { + return AuthorizationRequest.Builder(CLIENT_ID, type, getRedirectUri().toString()) + .setShowDialog(false) + .setScopes(arrayOf("user-read-email")) + .setCampaign("your-campaign-token") + .build() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + val response = AuthorizationClient.getResponse(resultCode, data) + + when (requestCode) { + AUTH_TOKEN_REQUEST_CODE -> { + accessToken = response.accessToken + updateTokenView() + } + AUTH_CODE_REQUEST_CODE -> { + accessCode = response.code + updateCodeView() + } + } + } + + private fun setResponse(text: String) { + runOnUiThread { + val responseView = findViewById(R.id.response_text_view) + responseView.text = text + } + } + + private fun updateTokenView() { + val tokenView = findViewById(R.id.token_text_view) + tokenView.text = getString(R.string.token, accessToken) + } + + private fun updateCodeView() { + val codeView = findViewById(R.id.code_text_view) + codeView.text = getString(R.string.code, accessCode) + } + + private fun cancelCall() { + call?.cancel() + } + + private fun getRedirectUri(): Uri { + return Uri.parse(REDIRECT_URI) + } +}