diff --git a/.github/workflows/publish-javadoc.yml b/.github/workflows/publish-javadoc.yml index 8cbba36..46f257a 100644 --- a/.github/workflows/publish-javadoc.yml +++ b/.github/workflows/publish-javadoc.yml @@ -32,11 +32,11 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Generate Auth Javadoc - run: ./gradlew auth-lib:authReleaseJavadoc + - name: Generate Auth Documentation + run: ./gradlew auth-lib:authReleaseDokka - - name: Generate Store Javadoc - run: ./gradlew auth-lib:storeReleaseJavadoc + - name: Generate Store Documentation + run: ./gradlew auth-lib:storeReleaseDokka - name: Prepare gh-pages directory structure run: | diff --git a/auth-lib/build.gradle b/auth-lib/build.gradle deleted file mode 100644 index 03c9030..0000000 --- a/auth-lib/build.gradle +++ /dev/null @@ -1,255 +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. - */ - -apply plugin: 'com.android.library' - -project.group = 'com.spotify.android' -project.archivesBaseName = 'auth' -project.version = '3.0.0' - -android { - compileSdk 33 - buildToolsVersion = '33.0.0' - - buildFeatures { - buildConfig = true - } - - defaultConfig { - minSdkVersion 16 - targetSdkVersion 33 - buildConfigField 'String', 'LIB_VERSION_NAME', "\"$project.version\"" - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt') - } - } - - flavorDimensions.add("auth") - productFlavors { - store { - dimension "auth" - versionNameSuffix "-store" - } - auth { - getIsDefault().set(true) - dimension "auth" - } - } - - libraryVariants.configureEach { libraryVariant -> - libraryVariant.outputs.all { output -> - // Rename auth-auth-[buildtype].aar to auth-[buildtype].aar - if (libraryVariant.name.startsWith("auth")) { - outputFileName = "auth-${libraryVariant.buildType.name}.aar" - } - } - } - - lintOptions { - lintConfig file("${project.rootDir}/config/lint.xml") - quiet false - warningsAsErrors false - textReport true - textOutput 'stdout' - xmlReport false - } - - testOptions { - unitTests { - includeAndroidResources = true - } - } - - def manifestPlaceholdersForTests = [redirectSchemeName: "spotify-sdk", redirectHostName: "auth", redirectPathPattern: "/.*"] - namespace 'com.spotify.sdk.android.auth' - unitTestVariants.configureEach { - it.mergedFlavor.manifestPlaceholders += manifestPlaceholdersForTests - } - testVariants.configureEach { - it.mergedFlavor.manifestPlaceholders += manifestPlaceholdersForTests - } -} - -dependencies { - implementation 'androidx.browser:browser:1.5.0' - - testImplementation 'junit:junit:4.13.2' - testImplementation 'org.mockito:mockito-core:2.28.2' - testImplementation 'org.robolectric:robolectric:4.11.1' -} - -/* - Static analysis section - run: ./gradlew auth-lib:checkstyle auth-lib:findbugs - */ - -apply plugin: 'checkstyle' - -tasks.register('checkstyle', Checkstyle) { - configFile file("${project.rootDir}/config/checkstyle.xml") - source 'src' - include '**/*.java' - exclude '**/gen/**' - classpath = files() -} - -apply plugin: 'maven-publish' -apply plugin: 'signing' - -ext.isReleaseVersion = !version.endsWith("SNAPSHOT") - -signing { - sign publishing.publications -} - -project.ext["ossrhUsername"] = '' -project.ext["ossrhPassword"] = '' - -def getSigningVariables() { - // Try to fetch the values from local.properties, otherwise look in the environment variables - // More info here: https://central.sonatype.org/publish/requirements/gpg/ - File secretPropsFile = project.rootProject.file('local.properties') - if (secretPropsFile.exists()) { - Properties p = new Properties() - new FileInputStream(secretPropsFile).withCloseable { is -> - p.load(is) - } - p.each { name, value -> - project.ext[name] = value - } - } else { - project.ext["ossrhUsername"] = System.getenv('OSSRH_USERNAME') - project.ext["ossrhPassword"] = System.getenv('OSSRH_PASSWORD') - } -} - -/* - * Publishing is done through ossrh and can onky be done by people with access. - * reach out to the foss channel to get access. - * https://central.sonatype.org/publish/publish-portal-ossrh-staging-api/ - */ -afterEvaluate { - publishing { - repositories { - maven { - getSigningVariables() - - name = 'ossrh-staging-api' - url = "https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/" - credentials { - username = project.ext["ossrhUsername"] - password = project.ext["ossrhPassword"] - } - } - } - - android.libraryVariants.configureEach { variant -> - if (variant.buildType.name == "debug") return - - def flavored = variant.flavorName != "auth" - - def javaDocDir = "../docs/" - if (flavored) { - javaDocDir = "../docs-${variant.flavorName}/" - } - - def sourceDirs = variant.sourceSets.collect { - it.javaDirectories + it.resourcesDirectories - } - - def javadoc = task("${variant.name}Javadoc", type: Javadoc) { - description "Generates Javadoc for ${variant.name}." - source = variant.javaCompile.source - destinationDir = file(javaDocDir) - classpath += variant.javaCompileProvider.get().classpath - classpath += project.files(android.getBootClasspath().join(File.pathSeparator)) - classpath += files("build/generated/source/r/debug") - options.links("http://docs.oracle.com/javase/7/docs/api/") - options.links("http://d.android.com/reference/") - failOnError false - } - - def javadocJar = task("${variant.name}JavadocJar", type: Jar, dependsOn: javadoc) { - archiveClassifier.set('javadoc') - from javadoc.destinationDir - } - - def sourcesJar = task("${variant.name}SourcesJar", type: Jar) { - from sourceDirs - archiveClassifier.set('sources') - } - - publications { - "${variant.flavorName}Release"(MavenPublication) { - from components."${variant.flavorName}Release" - String suffix = "${flavored ? "-" + variant.flavorName : ""}" - artifact javadocJar - artifact sourcesJar - groupId = project.group - version = project.version - artifactId = project.archivesBaseName + suffix - - pom { - name = project.group + ':' + project.archivesBaseName + suffix - def descriptionSuffix = "" - if (variant.flavorName == "store") { - descriptionSuffix = " with the Play Store Fallback" - } - description = 'Spotify authorization library for Android' + descriptionSuffix - with configurePom() - } - } - } - } - } -} - -def configurePom() { - return { - packaging = 'aar' - url = 'https://github.com/spotify/android-auth' - - licenses { - license { - name = 'The Apache Software License, Version 2.0' - url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' - } - } - - scm { - connection = 'scm:git:https://github.com/spotify/android-auth.git' - developerConnection = 'scm:git:git@github.com:spotify/android-auth.git' - url = 'https://github.com/spotify/android-auth' - } - - developers { - developer { - id = 'erikg' - name = 'Erik Ghonyan' - email = 'erikg@spotify.com' - } - } - } -} diff --git a/auth-lib/build.gradle.kts b/auth-lib/build.gradle.kts new file mode 100644 index 0000000..d068803 --- /dev/null +++ b/auth-lib/build.gradle.kts @@ -0,0 +1,315 @@ +/* + * 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. + */ + +import java.util.Properties +import java.io.FileInputStream +import com.android.build.gradle.internal.api.BaseVariantOutputImpl +import org.jetbrains.dokka.gradle.DokkaTask + +plugins { + id("com.android.library") + id("kotlin-android") + id("kotlin-parcelize") + id("checkstyle") + id("maven-publish") + id("signing") + id("org.jetbrains.dokka") +} + +group = "com.spotify.android" +version = "3.0.0" + +val archivesBaseName = "auth" + +android { + compileSdk = 33 + buildToolsVersion = "33.0.0" + + buildFeatures { + buildConfig = true + } + + defaultConfig { + minSdk = 16 + targetSdk = 33 + buildConfigField("String", "LIB_VERSION_NAME", "\"${project.version}\"") + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android.txt")) + } + } + + @Suppress("UnstableApiUsage") + flavorDimensions += "auth" + productFlavors { + create("store") { + dimension = "auth" + } + create("auth") { + dimension = "auth" + isDefault = true + } + } + + libraryVariants.configureEach { + outputs.configureEach { + // Rename auth-auth-[buildtype].aar to auth-[buildtype].aar + if (name.startsWith("auth")) { + (this as com.android.build.gradle.internal.api.BaseVariantOutputImpl).outputFileName = + "auth-${buildType.name}.aar" + } + } + } + + lint { + lintConfig = file("${project.rootDir}/config/lint.xml") + quiet = false + warningsAsErrors = false + textReport = true + textOutput = file("stdout") + xmlReport = false + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + val manifestPlaceholdersForTests = mapOf( + "redirectSchemeName" to "spotify-sdk", + "redirectHostName" to "auth", + "redirectPathPattern" to "/.*" + ) + + namespace = "com.spotify.sdk.android.auth" + + unitTestVariants.configureEach { + mergedFlavor.manifestPlaceholders.putAll(manifestPlaceholdersForTests) + } + testVariants.configureEach { + mergedFlavor.manifestPlaceholders.putAll(manifestPlaceholdersForTests) + } +} + +val kotlinVersion = rootProject.extra["kotlin_version"] as String + +dependencies { + implementation("androidx.browser:browser:1.5.0") + api("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.mockito:mockito-core:2.28.2") + testImplementation("org.robolectric:robolectric:4.11.1") +} + +/* + Static analysis section + run: ./gradlew auth-lib:checkstyle auth-lib:findbugs + */ + +tasks.register("checkstyle") { + configFile = file("${project.rootDir}/config/checkstyle.xml") + source("src") + include("**/*.java") + exclude("**/gen/**") + exclude("**/*.kt") + classpath = files() +} + +val isReleaseVersion = !version.toString().endsWith("SNAPSHOT") + +signing { + sign(publishing.publications) +} + +extra["ossrhUsername"] = "" +extra["ossrhPassword"] = "" + +fun getSigningVariables() { + // Try to fetch the values from local.properties, otherwise look in the environment variables + // More info here: https://central.sonatype.org/publish/requirements/gpg/ + val secretPropsFile = project.rootProject.file("local.properties") + if (secretPropsFile.exists()) { + val p = Properties() + FileInputStream(secretPropsFile).use { p.load(it) } + p.forEach { name, value -> + extra[name.toString()] = value + } + } else { + extra["ossrhUsername"] = System.getenv("OSSRH_USERNAME") ?: "" + extra["ossrhPassword"] = System.getenv("OSSRH_PASSWORD") ?: "" + } +} + +/* + * Publishing is done through ossrh and can onky be done by people with access. + * reach out to the foss channel to get access. + * https://central.sonatype.org/publish/publish-portal-ossrh-staging-api/ + */ +afterEvaluate { + publishing { + repositories { + maven { + getSigningVariables() + + name = "ossrh-staging-api" + url = uri("https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/") + credentials { + username = extra["ossrhUsername"].toString() + password = extra["ossrhPassword"].toString() + } + } + } + + android.libraryVariants.configureEach { + if (buildType.name == "debug") return@configureEach + + val variant = this + val flavored = variant.flavorName != "auth" + + val javaDocDir = if (flavored) { + "../docs-${variant.flavorName}/" + } else { + "../docs/" + } + + val sourceDirs = variant.sourceSets.flatMap { + it.javaDirectories + it.resourcesDirectories + } + + val dokkaTaskName = "${variant.name}Dokka" + if (tasks.findByName(dokkaTaskName) == null) { + tasks.register(dokkaTaskName, DokkaTask::class.java) { + description = "Generates Dokka documentation for ${variant.name}." + + // Configure source sets + dokkaSourceSets { + configureEach { + // Include both Kotlin and Java sources + val sourceDirs = variant.sourceSets.flatMap { + it.javaDirectories + } + sourceDirs.forEach { sourceDir -> + if (sourceDir.exists()) { + this@configureEach.sourceRoots.from(sourceDir) + } + } + + // Configure classpath + classpath.from(variant.javaCompileProvider.get().classpath) + classpath.from(project.files(android.bootClasspath.joinToString(File.pathSeparator))) + + // External documentation links + externalDocumentationLink { + url.set(uri("https://docs.oracle.com/javase/8/docs/api/").toURL()) + } + externalDocumentationLink { + url.set(uri("https://developer.android.com/reference/").toURL()) + } + + // Suppress warnings for undocumented code (optional) + suppressInheritedMembers.set(false) + } + } + + // Output directory + outputDirectory.set(file(javaDocDir)) + + // Fail on warning (set to false during migration) + failOnWarning.set(false) + } + } + + val dokkaJarTaskName = "${variant.name}DokkaJar" + if (tasks.findByName(dokkaJarTaskName) == null) { + tasks.register(dokkaJarTaskName, org.gradle.jvm.tasks.Jar::class.java) { + dependsOn(dokkaTaskName) + archiveClassifier.set("javadoc") + from(file(javaDocDir)) + } + } + + val sourcesJarTaskName = "${variant.name}SourcesJar" + if (tasks.findByName(sourcesJarTaskName) == null) { + tasks.register(sourcesJarTaskName, org.gradle.jvm.tasks.Jar::class.java) { + from(sourceDirs) + archiveClassifier.set("sources") + } + } + + val dokkaJar = tasks.named(dokkaJarTaskName) + val sourcesJar = tasks.named(sourcesJarTaskName) + + publications { + create("${variant.flavorName}Release") { + from(components["${variant.flavorName}Release"]) + val suffix = if (flavored) "-${variant.flavorName}" else "" + artifact(dokkaJar) + artifact(sourcesJar) + groupId = project.group.toString() + version = project.version.toString() + artifactId = archivesBaseName + suffix + + pom { + name.set(project.group.toString() + ":" + archivesBaseName + suffix) + val descriptionSuffix = if (variant.flavorName == "store") { + " with the Play Store Fallback" + } else { + "" + } + description.set("Spotify authorization library for Android$descriptionSuffix") + configurePom() + } + } + } + } + } +} + +fun MavenPom.configurePom() { + packaging = "aar" + url.set("https://github.com/spotify/android-auth") + + licenses { + license { + name.set("The Apache Software License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + + scm { + connection.set("scm:git:https://github.com/spotify/android-auth.git") + developerConnection.set("scm:git:git@github.com:spotify/android-auth.git") + url.set("https://github.com/spotify/android-auth") + } + + developers { + developer { + id.set("erikg") + name.set("Erik Ghonyan") + email.set("erikg@spotify.com") + } + } +} diff --git a/auth-lib/src/auth/java/com/spotify/sdk/android/auth/browser/BrowserAuthHandler.java b/auth-lib/src/auth/java/com/spotify/sdk/android/auth/browser/BrowserAuthHandler.java deleted file mode 100644 index 92e16c7..0000000 --- a/auth-lib/src/auth/java/com/spotify/sdk/android/auth/browser/BrowserAuthHandler.java +++ /dev/null @@ -1,146 +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.browser; - -import static com.spotify.sdk.android.auth.browser.CustomTabsSupportChecker.getPackageSupportingCustomTabs; - -import android.Manifest; -import android.app.Activity; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.text.TextUtils; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.browser.customtabs.CustomTabsCallback; -import androidx.browser.customtabs.CustomTabsClient; -import androidx.browser.customtabs.CustomTabsIntent; -import androidx.browser.customtabs.CustomTabsServiceConnection; -import androidx.browser.customtabs.CustomTabsSession; - -import com.spotify.sdk.android.auth.AuthorizationHandler; -import com.spotify.sdk.android.auth.AuthorizationRequest; - -/** - * An AuthorizationHandler that opens the Spotify web auth page in a Custom Tab or users default web browser. - */ -public class BrowserAuthHandler implements AuthorizationHandler { - - private static final String TAG = BrowserAuthHandler.class.getSimpleName(); - - private CustomTabsSession mTabsSession; - private CustomTabsServiceConnection mTabConnection; - private boolean mIsAuthInProgress = false; - private Context mContext; - private Uri mUri; - - @Override - public boolean start(Activity contextActivity, AuthorizationRequest request) { - Log.d(TAG, "start"); - mContext = contextActivity; - mUri = request.toUri(); - String packageSupportingCustomTabs = getPackageSupportingCustomTabs(mContext, request); - boolean shouldLaunchCustomTab = !TextUtils.isEmpty(packageSupportingCustomTabs); - - if (internetPermissionNotGranted(mContext)) { - Log.e(TAG, "Missing INTERNET permission"); - } - - if (shouldLaunchCustomTab) { - Log.d(TAG, "Launching auth in a Custom Tab using package:" + packageSupportingCustomTabs); - mTabConnection = new CustomTabsServiceConnection() { - @Override - public void onCustomTabsServiceConnected(@NonNull ComponentName name, @NonNull CustomTabsClient client) { - client.warmup(0L); - mTabsSession = client.newSession(new CustomTabsCallback()); - if (mTabsSession != null) { - CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder().setSession(mTabsSession).build(); - customTabsIntent.launchUrl(mContext, request.toUri()); - mIsAuthInProgress = true; - } else { - unbindCustomTabsService(); - Log.i(TAG, "Auth using CustomTabs aborted, reason: CustomTabsSession is null."); - launchAuthInBrowserFallback(); - } - } - - @Override - public void onServiceDisconnected(ComponentName name) { - Log.i(TAG, "Auth using CustomTabs aborted, reason: CustomTabsService disconnected."); - mTabsSession = null; - mTabConnection = null; - } - }; - CustomTabsClient.bindCustomTabsService(mContext, packageSupportingCustomTabs, mTabConnection); - } else { - Log.d(TAG, "Launching auth inside a web browser"); - launchAuthInBrowserFallback(); - } - return true; - } - - @Override - public void stop() { - Log.d(TAG, "stop"); - unbindCustomTabsService(); - mContext = null; - mIsAuthInProgress = false; - } - - @Override - public void setOnCompleteListener(@Nullable OnCompleteListener listener) { - // no-op - } - - @Override - public boolean isAuthInProgress() { - return mIsAuthInProgress; - } - - private void launchAuthInBrowserFallback() { - if (internetPermissionNotGranted(mContext)) { - Log.e(TAG, "Missing INTERNET permission"); - } - mContext.startActivity(new Intent(Intent.ACTION_VIEW, mUri)); - mIsAuthInProgress = true; - } - - private boolean internetPermissionNotGranted(Context context) { - PackageManager pm = context.getPackageManager(); - String packageName = context.getPackageName(); - return pm.checkPermission(Manifest.permission.INTERNET, packageName) != PackageManager.PERMISSION_GRANTED; - } - - /** - * Unbinds from the Custom Tabs Service. - */ - public void unbindCustomTabsService() { - if (mTabConnection == null) return; - mContext.unbindService(mTabConnection); - mTabsSession = null; - mTabConnection = null; - } -} diff --git a/auth-lib/src/auth/java/com/spotify/sdk/android/auth/browser/CustomTabsSupportChecker.java b/auth-lib/src/auth/java/com/spotify/sdk/android/auth/browser/CustomTabsSupportChecker.java deleted file mode 100644 index 17c2d6d..0000000 --- a/auth-lib/src/auth/java/com/spotify/sdk/android/auth/browser/CustomTabsSupportChecker.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.spotify.sdk.android.auth.browser; - -import static androidx.browser.customtabs.CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION; - -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.net.Uri; -import android.text.TextUtils; -import android.util.Log; - -import com.spotify.sdk.android.auth.AuthorizationRequest; - -import java.util.ArrayList; -import java.util.List; - -/** - * Class that checks if auth can be done in a Custom Tab and returns a package name of the app - * that supports Custom Tabs. If auth flow cannot be done using a Custom Tab, it returns - * an empty string. - */ -public final class CustomTabsSupportChecker { - private static final String TAG = CustomTabsSupportChecker.class.getSimpleName(); - - static String getPackageSupportingCustomTabs(Context context, AuthorizationRequest request) { - String redirectUri = request.getRedirectUri(); - final String packageSupportingCustomTabs = getPackageNameSupportingCustomTabs( - context.getPackageManager(), request.toUri() - ); - // CustomTabs seems to have problem with redirecting back the app after auth when URI has http/https scheme - if (!redirectUri.startsWith("http") && !redirectUri.startsWith("https") && - hasBrowserSupportForCustomTabs(packageSupportingCustomTabs) && - hasRedirectUriActivity(context.getPackageManager(), redirectUri)) { - return packageSupportingCustomTabs; - } else { - return ""; - } - } - - private static String getPackageNameSupportingCustomTabs(PackageManager pm, Uri uri) { - Intent activityIntent = new Intent(Intent.ACTION_VIEW, uri).addCategory(Intent.CATEGORY_BROWSABLE); - // Check for default handler - ResolveInfo defaultViewHandlerInfo = pm.resolveActivity(activityIntent, 0); - String defaultViewHandlerPackageName = null; - if (defaultViewHandlerInfo != null) { - defaultViewHandlerPackageName = defaultViewHandlerInfo.activityInfo.packageName; - } - Log.d(TAG, "Found default package name for handling VIEW intents: " + defaultViewHandlerPackageName); - - // Get all apps that can handle the intent - List resolvedActivityList = pm.queryIntentActivities(activityIntent, 0); - ArrayList packagesSupportingCustomTabs = new ArrayList<>(); - for (ResolveInfo info : resolvedActivityList) { - Intent serviceIntent = new Intent(); - serviceIntent.setAction(ACTION_CUSTOM_TABS_CONNECTION); - serviceIntent.setPackage(info.activityInfo.packageName); - // Check if this package also resolves the Custom Tabs service. - if (pm.resolveService(serviceIntent, 0) != null) { - Log.d(TAG, "Adding " + info.activityInfo.packageName + " to supported packages"); - packagesSupportingCustomTabs.add(info.activityInfo.packageName); - } - } - - String packageNameToUse = null; - if (packagesSupportingCustomTabs.size() == 1) { - packageNameToUse = packagesSupportingCustomTabs.get(0); - } else if (packagesSupportingCustomTabs.size() > 1) { - if (!TextUtils.isEmpty(defaultViewHandlerPackageName) - && packagesSupportingCustomTabs.contains(defaultViewHandlerPackageName)) { - packageNameToUse = defaultViewHandlerPackageName; - } else { - packageNameToUse = packagesSupportingCustomTabs.get(0); - } - } - return packageNameToUse; - } - - private static boolean hasBrowserSupportForCustomTabs(String packageSupportingCustomTabs) { - if (TextUtils.isEmpty(packageSupportingCustomTabs)) { - Log.d(TAG, "No package supporting CustomTabs found."); - return false; - } else { - return true; - } - } - - private static boolean hasRedirectUriActivity(PackageManager pm, String redirectUri) { - if (pm == null) { - return false; - } - Intent intent = new Intent(); - intent.setAction(Intent.ACTION_VIEW); - intent.addCategory(Intent.CATEGORY_DEFAULT); - intent.addCategory(Intent.CATEGORY_BROWSABLE); - intent.setData(Uri.parse(redirectUri)); - List infoList = pm.queryIntentActivities(intent, PackageManager.GET_RESOLVED_FILTER); - - for (ResolveInfo info : infoList) { - if (RedirectUriReceiverActivity.class.getName().equals(info.activityInfo.name)) { - return true; - } - } - return false; - } -} diff --git a/auth-lib/src/auth/java/com/spotify/sdk/android/auth/browser/RedirectUriReceiverActivity.java b/auth-lib/src/auth/java/com/spotify/sdk/android/auth/browser/RedirectUriReceiverActivity.java deleted file mode 100644 index 7ab3428..0000000 --- a/auth-lib/src/auth/java/com/spotify/sdk/android/auth/browser/RedirectUriReceiverActivity.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.spotify.sdk.android.auth.browser; - -import android.app.Activity; -import android.content.Intent; -import android.os.Bundle; - -import androidx.annotation.Nullable; - -import com.spotify.sdk.android.auth.LoginActivity; - -/** - * Activity that receives the auth response sent by the browser's Custom Tab via deeplink. - * The sole purpose of this activity is to forward the response back to {@link LoginActivity}. - * This activity is used only during browser based auth flow - when the Spotify app is not - * installed on the device. - */ -public class RedirectUriReceiverActivity extends Activity { - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Intent intent = new Intent(this, LoginActivity.class); - intent.setData(getIntent().getData()); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); - startActivity(intent); - finish(); - } -} diff --git a/auth-lib/src/auth/java/com/spotify/sdk/android/auth/FallbackHandlerProvider.java b/auth-lib/src/auth/kotlin/com/spotify/sdk/android/auth/FallbackHandlerProvider.kt similarity index 77% rename from auth-lib/src/auth/java/com/spotify/sdk/android/auth/FallbackHandlerProvider.java rename to auth-lib/src/auth/kotlin/com/spotify/sdk/android/auth/FallbackHandlerProvider.kt index 929d962..865f911 100644 --- a/auth-lib/src/auth/java/com/spotify/sdk/android/auth/FallbackHandlerProvider.java +++ b/auth-lib/src/auth/kotlin/com/spotify/sdk/android/auth/FallbackHandlerProvider.kt @@ -19,19 +19,16 @@ * under the License. */ -package com.spotify.sdk.android.auth; +package com.spotify.sdk.android.auth -import androidx.annotation.NonNull; -import com.spotify.sdk.android.auth.browser.BrowserAuthHandler; +import com.spotify.sdk.android.auth.browser.BrowserAuthHandler /** * Provides an AuthorizationHandler that opens a browser when the Spotify application is not installed */ -public class FallbackHandlerProvider { +class FallbackHandlerProvider { - @NonNull - public AuthorizationHandler provideFallback() { - return new BrowserAuthHandler(); + fun provideFallback(): AuthorizationHandler { + return BrowserAuthHandler() } - } diff --git a/auth-lib/src/auth/kotlin/com/spotify/sdk/android/auth/browser/BrowserAuthHandler.kt b/auth-lib/src/auth/kotlin/com/spotify/sdk/android/auth/browser/BrowserAuthHandler.kt new file mode 100644 index 0000000..abf55e6 --- /dev/null +++ b/auth-lib/src/auth/kotlin/com/spotify/sdk/android/auth/browser/BrowserAuthHandler.kt @@ -0,0 +1,140 @@ +/* + * 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.browser + +import android.Manifest +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.text.TextUtils +import android.util.Log +import androidx.browser.customtabs.CustomTabsCallback +import androidx.browser.customtabs.CustomTabsClient +import androidx.browser.customtabs.CustomTabsIntent +import androidx.browser.customtabs.CustomTabsServiceConnection +import androidx.browser.customtabs.CustomTabsSession +import com.spotify.sdk.android.auth.AuthorizationHandler +import com.spotify.sdk.android.auth.AuthorizationRequest + +/** + * An AuthorizationHandler that opens the Spotify web auth page in a Custom Tab or users default web browser. + */ +class BrowserAuthHandler : AuthorizationHandler { + + private var tabsSession: CustomTabsSession? = null + private var tabConnection: CustomTabsServiceConnection? = null + private var isAuthInProgress = false + private var context: Context? = null + private var uri: Uri? = null + + override fun start(contextActivity: Activity, request: AuthorizationRequest): Boolean { + Log.d(TAG, "start") + context = contextActivity + uri = request.toUri() + val packageSupportingCustomTabs = CustomTabsSupportChecker.getPackageSupportingCustomTabs(contextActivity, request) + val shouldLaunchCustomTab = !TextUtils.isEmpty(packageSupportingCustomTabs) + + if (internetPermissionNotGranted(contextActivity)) { + Log.e(TAG, "Missing INTERNET permission") + } + + if (shouldLaunchCustomTab) { + Log.d(TAG, "Launching auth in a Custom Tab using package:$packageSupportingCustomTabs") + val connection = object : CustomTabsServiceConnection() { + override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) { + client.warmup(0L) + val session = client.newSession(CustomTabsCallback()) + tabsSession = session + if (session != null) { + val customTabsIntent = CustomTabsIntent.Builder().setSession(session).build() + context?.let { customTabsIntent.launchUrl(it, request.toUri()) } + isAuthInProgress = true + } else { + unbindCustomTabsService() + Log.i(TAG, "Auth using CustomTabs aborted, reason: CustomTabsSession is null.") + launchAuthInBrowserFallback() + } + } + + override fun onServiceDisconnected(name: ComponentName) { + Log.i(TAG, "Auth using CustomTabs aborted, reason: CustomTabsService disconnected.") + tabsSession = null + tabConnection = null + } + } + tabConnection = connection + CustomTabsClient.bindCustomTabsService(contextActivity, packageSupportingCustomTabs, connection) + } else { + Log.d(TAG, "Launching auth inside a web browser") + launchAuthInBrowserFallback() + } + return true + } + + override fun stop() { + Log.d(TAG, "stop") + unbindCustomTabsService() + context = null + isAuthInProgress = false + } + + override fun setOnCompleteListener(listener: AuthorizationHandler.OnCompleteListener?) { + // no-op + } + + override fun isAuthInProgress(): Boolean { + return isAuthInProgress + } + + private fun launchAuthInBrowserFallback() { + context?.let { ctx -> + if (internetPermissionNotGranted(ctx)) { + Log.e(TAG, "Missing INTERNET permission") + } + ctx.startActivity(Intent(Intent.ACTION_VIEW, uri)) + isAuthInProgress = true + } + } + + private fun internetPermissionNotGranted(context: Context): Boolean { + val pm = context.packageManager + val packageName = context.packageName + return pm.checkPermission(Manifest.permission.INTERNET, packageName) != PackageManager.PERMISSION_GRANTED + } + + /** + * Unbinds from the Custom Tabs Service. + */ + fun unbindCustomTabsService() { + val conn = tabConnection ?: return + context?.unbindService(conn) + tabsSession = null + tabConnection = null + } + + companion object { + private val TAG = BrowserAuthHandler::class.java.simpleName + } +} diff --git a/auth-lib/src/auth/kotlin/com/spotify/sdk/android/auth/browser/CustomTabsSupportChecker.kt b/auth-lib/src/auth/kotlin/com/spotify/sdk/android/auth/browser/CustomTabsSupportChecker.kt new file mode 100644 index 0000000..8ca4089 --- /dev/null +++ b/auth-lib/src/auth/kotlin/com/spotify/sdk/android/auth/browser/CustomTabsSupportChecker.kt @@ -0,0 +1,102 @@ +package com.spotify.sdk.android.auth.browser + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.net.Uri +import android.text.TextUtils +import android.util.Log +import androidx.browser.customtabs.CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION +import com.spotify.sdk.android.auth.AuthorizationRequest + +/** + * Class that checks if auth can be done in a Custom Tab and returns a package name of the app + * that supports Custom Tabs. If auth flow cannot be done using a Custom Tab, it returns + * an empty string. + */ +internal object CustomTabsSupportChecker { + private val TAG = CustomTabsSupportChecker::class.java.simpleName + + @JvmStatic + fun getPackageSupportingCustomTabs(context: Context, request: AuthorizationRequest): String { + val redirectUri = request.redirectUri + val packageSupportingCustomTabs = getPackageNameSupportingCustomTabs( + context.packageManager, request.toUri() + ) + // CustomTabs seems to have problem with redirecting back the app after auth when URI has http/https scheme + return if (!redirectUri.startsWith("http") && !redirectUri.startsWith("https") && + hasBrowserSupportForCustomTabs(packageSupportingCustomTabs) && + hasRedirectUriActivity(context.packageManager, redirectUri) + ) { + packageSupportingCustomTabs + } else { + "" + } + } + + private fun getPackageNameSupportingCustomTabs(pm: PackageManager, uri: Uri): String { + val activityIntent = Intent(Intent.ACTION_VIEW, uri).addCategory(Intent.CATEGORY_BROWSABLE) + // Check for default handler + val defaultViewHandlerInfo = pm.resolveActivity(activityIntent, 0) + val defaultViewHandlerPackageName = defaultViewHandlerInfo?.activityInfo?.packageName + Log.d(TAG, "Found default package name for handling VIEW intents: $defaultViewHandlerPackageName") + + // Get all apps that can handle the intent + val resolvedActivityList = pm.queryIntentActivities(activityIntent, 0) + val packagesSupportingCustomTabs = resolvedActivityList + .map { it.activityInfo.packageName } + .filter { packageName -> + val serviceIntent = Intent().apply { + action = ACTION_CUSTOM_TABS_CONNECTION + `package` = packageName + } + // Check if this package also resolves the Custom Tabs service. + pm.resolveService(serviceIntent, 0) != null + } + .also { packages -> + packages.forEach { packageName -> + Log.d(TAG, "Adding $packageName to supported packages") + } + } + + return when { + packagesSupportingCustomTabs.isEmpty() -> "" + packagesSupportingCustomTabs.size == 1 -> packagesSupportingCustomTabs[0] + else -> { + if (!defaultViewHandlerPackageName.isNullOrEmpty() && + packagesSupportingCustomTabs.contains(defaultViewHandlerPackageName) + ) { + defaultViewHandlerPackageName + } else { + packagesSupportingCustomTabs[0] + } + } + } + } + + private fun hasBrowserSupportForCustomTabs(packageSupportingCustomTabs: String): Boolean { + return packageSupportingCustomTabs.isNotEmpty().also { hasSupport -> + if (!hasSupport) { + Log.d(TAG, "No package supporting CustomTabs found.") + } + } + } + + private fun hasRedirectUriActivity(pm: PackageManager?, redirectUri: String): Boolean { + if (pm == null) { + return false + } + val intent = Intent().apply { + action = Intent.ACTION_VIEW + addCategory(Intent.CATEGORY_DEFAULT) + addCategory(Intent.CATEGORY_BROWSABLE) + data = Uri.parse(redirectUri) + } + val infoList = pm.queryIntentActivities(intent, PackageManager.GET_RESOLVED_FILTER) + + return infoList.any { info -> + info.activityInfo.name == RedirectUriReceiverActivity::class.java.name + } + } +} diff --git a/auth-lib/src/auth/kotlin/com/spotify/sdk/android/auth/browser/RedirectUriReceiverActivity.kt b/auth-lib/src/auth/kotlin/com/spotify/sdk/android/auth/browser/RedirectUriReceiverActivity.kt new file mode 100644 index 0000000..375ebdd --- /dev/null +++ b/auth-lib/src/auth/kotlin/com/spotify/sdk/android/auth/browser/RedirectUriReceiverActivity.kt @@ -0,0 +1,24 @@ +package com.spotify.sdk.android.auth.browser + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import com.spotify.sdk.android.auth.LoginActivity + +/** + * Activity that receives the auth response sent by the browser's Custom Tab via deeplink. + * The sole purpose of this activity is to forward the response back to [LoginActivity]. + * This activity is used only during browser based auth flow - when the Spotify app is not + * installed on the device. + */ +class RedirectUriReceiverActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val intent = Intent(this, LoginActivity::class.java) + intent.data = getIntent().data + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + startActivity(intent) + finish() + } +} 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 deleted file mode 100644 index a05b34d..0000000 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/AccountsQueryParameters.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.spotify.sdk.android.auth; - -public interface AccountsQueryParameters { - String CLIENT_ID = "client_id"; - String RESPONSE_TYPE = "response_type"; - String REDIRECT_URI = "redirect_uri"; - String STATE = "state"; - String SCOPE = "scope"; - String SHOW_DIALOG = "show_dialog"; - String UTM_SOURCE = "utm_source"; - String UTM_MEDIUM = "utm_medium"; - String UTM_CAMPAIGN = "utm_campaign"; - String ERROR = "error"; - String CODE = "code"; - String ACCESS_TOKEN = "access_token"; - String EXPIRES_IN = "expires_in"; - 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/AuthorizationClient.java b/auth-lib/src/main/java/com/spotify/sdk/android/auth/AuthorizationClient.java deleted file mode 100644 index 9dd9f9f..0000000 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/AuthorizationClient.java +++ /dev/null @@ -1,627 +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; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.net.Uri; -import android.text.TextUtils; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; - -import com.spotify.sdk.android.auth.app.SpotifyAuthHandler; -import com.spotify.sdk.android.auth.app.SpotifyNativeAuthUtil; - -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.List; - -import static com.spotify.sdk.android.auth.AuthorizationResponse.Type.TOKEN; - -/** - * AuthorizationClient provides helper methods to initialize an manage the Spotify authorization flow. - * - *

- * This client provides two versions of authorization: - *

    - *
  1. Single Sign-On using Spotify Android application with a fallback to - * Spotify Accounts Service in a browser using a - * Custom Tab

    - * - *

    SDK will try to fetch the authorization code/access token using the Spotify Android client. - * If Spotify is not installed on the device, SDK will fallback to the Custom Tabs based authorization - * and open Spotify Accounts Service in a dialog. - * After authorization flow is completed, result is returned to the activity - * that invoked the {@code AuthorizationClient}.

    - * - *

    If Spotify is installed on the device, SDK will connect to the Spotify client and - * try to fetch the authorization code/access token for current user. - * Since the user is already logged into Spotify they don't need to fill their username and password. - * If the SDK application requests scopes that have not been approved, the user will see - * a list of scopes and can choose to approve or reject them.

    - * - *

    If Spotify is not installed on the device, SDK will open a dialog and load Spotify Accounts Service - * into a Custom Tab of a supported browser. In case there's no browser installed that supports - * Custom Tabs API, the SDK will fallback to opening the Accounts page in the users default browser. - * User will have to enter their username and password to login to Spotify. - * They will also need to approve any scopes the the SDK application requests and that they - * haven't approved before.

    - * - *

    In both cases, (SSO and browser fallback) the result of the authorization flow will be returned - * in the {@code onActivityResult} method of the activity that initiated it.

    - * - *
    {@code
    - * // Code called from an activity
    - * private static final int REQUEST_CODE = 1337;
    - *
    - * final AuthorizationRequest request = new AuthorizationRequest.Builder(CLIENT_ID, AuthorizationResponse.Type.TOKEN, REDIRECT_URI)
    - *     .setScopes(new String[]{"user-read-private", "playlist-read", "playlist-read-private", "streaming"})
    - *     .build();
    - *
    - * AuthorizationClient.openLoginActivity(this, REQUEST_CODE, request);
    - * }
    - * - * It is also possible to use {@code LoginActivity} from other component such as Fragments: - *
    {@code
    - * // To start LoginActivity from a Fragment:
    - * Intent intent = AuthorizationClient.createLoginActivityIntent(getActivity(), request);
    - * startActivityForResult(intent, REQUEST_CODE);
    - *
    - * // To close LoginActivity
    - * AuthorizationClient.stopLoginActivity(getActivity(), REQUEST_CODE);
    - * }
    - *

    - * To process the result, your activity needs to override {@code onActivityResult} callback: - * - *

    {@code
    - * protected void onActivityResult(int requestCode, int resultCode, Intent intent) {
    - *     super.onActivityResult(requestCode, resultCode, intent);
    - *
    - *     // Check if result comes from the correct activity
    - *     if (requestCode == REQUEST_CODE) {
    - *         AuthorizationResponse response = AuthorizationClient.getResponse(resultCode, intent);
    - *         switch (response.getType()) {
    - *             // Response was successful and contains auth token
    - *             case TOKEN:
    - *                 // Handle successful response
    - *                 String token = response.getAccessToken();
    - *                 break;
    - *
    - *            // Auth flow returned an error
    - *            case ERROR:
    - *                 // Handle error response
    - *                 break;
    - *
    - *            // Most likely auth flow was cancelled
    - *            default:
    - *                // Handle other cases
    - *         }
    - *     }
    - * }
    - * }
    - *
  2. - *
  3. - *

    Opening Spotify Accounts Service in a web browser

    - *

    - * In this scenario the SDK creates an intent that will open the browser. Authorization - * takes part in the browser (not in the SDK application). After authorization is completed - * browser redirects back to the SDK app. - * - *

    {@code
    - * // Code called from an activity
    - * final AuthorizationRequest request = new AuthorizationRequest.Builder(CLIENT_ID, AuthorizationResponse.Type.TOKEN, REDIRECT_URI)
    - *     .setScopes(new String[]{"user-read-private", "playlist-read", "playlist-read-private", "streaming"})
    - *     .build();
    - *
    - * AuthorizationClient.openLoginInBrowser(this, request);
    - * }
    - * - * To process the result, the receiving activity needs to override one of its callbacks. With launch mode - * set to {@code singleInstance} this callback is {@code onNewIntent}: - * - *
    
    - * protected void onNewIntent(Intent intent) {
    - *     super.onNewIntent(intent);
    - *     Uri uri = intent.getData();
    - *     if (uri != null) {
    - *         AuthorizationResponse response = AuthorizationResponse.fromUri(uri);
    - *         switch (response.getType()) {
    - *             // Response was successful and contains auth token
    - *             case TOKEN:
    - *                 // Handle successful response
    - *                 String token = response.getAccessToken();
    - *                 break;
    - *
    - *            // Auth flow returned an error
    - *            case ERROR:
    - *                 // Handle error response
    - *                 break;
    - *
    - *            // Most likely auth flow was cancelled
    - *            default:
    - *                // Handle other cases
    - *         }
    - *     }
    - * }
    - * 
    - *
  4. - *
- * - * @see Web API Authorization guide - */ -public final class AuthorizationClient { - private static final String TAG = "Spotify Auth Client"; - - static final String MARKET_VIEW_PATH = "market://"; - static final String MARKET_SCHEME = "market"; - static final String MARKET_PATH = "details"; - - static final String PLAY_STORE_SCHEME = "https"; - static final String PLAY_STORE_AUTHORITY = "play.google.com"; - static final String PLAY_STORE_PATH = "store/apps/details"; - - static final String SPOTIFY_ID = "com.spotify.music"; - static final String SPOTIFY_SDK = "spotify-sdk"; - static final String ANDROID_SDK = "android-sdk"; - static final String DEFAULT_CAMPAIGN = "android-sdk"; - - /** - * Minimum Spotify app version code required for TOKEN to CODE conversion. - * Corresponds to version name 9.0.78.360. - */ - @VisibleForTesting - static final int MIN_SPOTIFY_VERSION_FOR_TOKEN_CONVERSION = 132384743; - - static final class PlayStoreParams { - public static final String ID = "id"; - public static final String REFERRER = "referrer"; - public static final String UTM_SOURCE = "utm_source"; - public static final String UTM_MEDIUM = "utm_medium"; - public static final String UTM_CAMPAIGN = "utm_campaign"; - } - - /** - * The activity that receives and processes the result of authorization flow - * and returns it to the context activity that invoked the flow. - * An instance of {@link LoginActivity} - */ - private final Activity mLoginActivity; - private boolean mAuthorizationPending; - - /** - * A handler that performs authorization. - * It is created with {@code mLoginActivity} as a context. - * This activity will receive the result through the - * {@link AuthorizationClientListener} - */ - private AuthorizationHandler mCurrentHandler; - - private List mAuthorizationHandlers = new ArrayList<>(); - - private AuthorizationClientListener mAuthorizationClientListener; - - interface AuthorizationClientListener { - - /** - * Auth flow was completed. - * The response can be successful and contain access token or authorization code. - * The response can be an error response and contain error message. - * It can also be an empty response which indicated that the - * user cancelled authorization flow. - * - * @param response Response containing a result of authorization flow. - */ - void onClientComplete(AuthorizationResponse response); - - /** - * Auth flow was cancelled before it could be completed. - * This callbacks indicates that the auth flow was interrupted - * for example because of underlying LoginActivity was paused or stopped. - * This is different from the situation when user completes the flow - * by closing LoginActivity (e.g. by pressing the back button). - */ - void onClientCancelled(); - } - - /** - * Triggers an intent to open the Spotify accounts service in a browser. Make sure that the - * redirectUri is set to an URI your app is registered for in your AndroidManifest.xml. To - * get your clientId and to set the redirectUri, please see the - * my applications - * part of our developer site. - * - * @param contextActivity The activity that should start the intent to open a browser. - * @param request Authorization request - */ - public static void openLoginInBrowser(@NonNull Activity contextActivity, @NonNull AuthorizationRequest request) { - Intent launchBrowser = new Intent(Intent.ACTION_VIEW, request.toUri()); - contextActivity.startActivity(launchBrowser); - } - - /** - * Get an intent to open the LoginActivity. - * This method can be used to open this activity from components different than - * activities; for example Fragments. - *
{@code
-     * // To start LoginActivity from a Fragment:
-     * Intent intent = AuthorizationClient.createLoginActivityIntent(getActivity(), request);
-     * startActivityForResult(intent, REQUEST_CODE);
-     *
-     * // To close LoginActivity
-     * AuthorizationClient.stopLoginActivity(getActivity(), REQUEST_CODE);
-     * }
- * - * @param contextActivity A context activity for the LoginActivity. - * @param request Authorization request - * @return The intent to open LoginActivity with. - * @throws IllegalArgumentException if any of the arguments is null - */ - @NonNull - public static Intent createLoginActivityIntent(@NonNull Activity contextActivity, @NonNull AuthorizationRequest request) { - if (contextActivity == null) { - throw new IllegalArgumentException("Context activity cannot be null"); - } - if (request == null) { - throw new IllegalArgumentException("Authorization request cannot be null"); - } - - // Append PKCE to TOKEN requests before creating intent - final AuthorizationRequest processedRequest = appendPkceIfTokenRequest(contextActivity, request); - Intent intent = LoginActivity.getAuthIntent(contextActivity, processedRequest); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - return intent; - } - - /** - * Opens LoginActivity which performs authorization. - * The result of the authorization flow will be received by the - * {@code contextActivity} in the {@code onActivityResult} callback. - * The successful result of the authorization flow will contain an access token that can be used - * to make calls to the Web API and/or to play music with Spotify. - * - * @param contextActivity A context activity for the LoginActivity. - * @param requestCode Request code for LoginActivity. - * @param request Authorization request - * @throws IllegalArgumentException if any of the arguments is null - */ - public static void openLoginActivity(@NonNull Activity contextActivity, int requestCode, @NonNull AuthorizationRequest request) { - Intent intent = createLoginActivityIntent(contextActivity, request); - contextActivity.startActivityForResult(intent, requestCode); - } - - /** - * Stops any running LoginActivity - * - * @param contextActivity The activity that was used to launch LoginActivity - * with {@link #openLoginActivity(android.app.Activity, int, AuthorizationRequest)} - * @param requestCode Request code that was used to launch LoginActivity - */ - public static void stopLoginActivity(@NonNull Activity contextActivity, int requestCode) { - contextActivity.finishActivity(requestCode); - } - - /** - * Extracts {@link AuthorizationResponse} - * from the LoginActivity result. - * - * @param resultCode Result code returned with the activity result. - * @param intent Intent received with activity result. Should contain a Uri with result data. - * @return response object. - */ - @NonNull - public static AuthorizationResponse getResponse(int resultCode, @Nullable Intent intent) { - if (resultCode == Activity.RESULT_OK && LoginActivity.getResponseFromIntent(intent) != null) { - return LoginActivity.getResponseFromIntent(intent); - } else { - return new AuthorizationResponse.Builder() - .setType(AuthorizationResponse.Type.EMPTY) - .build(); - } - } - - /** - * Opens Spotify in the Play Store or browser. - * - * @param contextActivity The activity that should start the intent to open the download page. - */ - public static void openDownloadSpotifyActivity(@NonNull Activity contextActivity) { - openDownloadSpotifyActivity(contextActivity, DEFAULT_CAMPAIGN); - } - - /** - * Opens Spotify in the Play Store or browser. - * - * @param contextActivity The activity that should start the intent to open the download page. - * @param campaign A Spotify-provided campaign ID. null if not provided. - */ - public static void openDownloadSpotifyActivity(@NonNull Activity contextActivity, @Nullable String campaign) { - - Uri.Builder uriBuilder = new Uri.Builder(); - - if (isAvailable(contextActivity, new Intent(Intent.ACTION_VIEW, Uri.parse(MARKET_VIEW_PATH)))) { - uriBuilder.scheme(MARKET_SCHEME) - .appendPath(MARKET_PATH); - } else { - uriBuilder.scheme(PLAY_STORE_SCHEME) - .authority(PLAY_STORE_AUTHORITY) - .appendEncodedPath(PLAY_STORE_PATH); - } - - uriBuilder.appendQueryParameter(PlayStoreParams.ID, SPOTIFY_ID); - - Uri.Builder referrerBuilder = new Uri.Builder(); - referrerBuilder.appendQueryParameter(PlayStoreParams.UTM_SOURCE, SPOTIFY_SDK) - .appendQueryParameter(PlayStoreParams.UTM_MEDIUM, ANDROID_SDK); - - if (TextUtils.isEmpty(campaign)) { - referrerBuilder.appendQueryParameter(PlayStoreParams.UTM_CAMPAIGN, DEFAULT_CAMPAIGN); - } else { - referrerBuilder.appendQueryParameter(PlayStoreParams.UTM_CAMPAIGN, campaign); - } - - uriBuilder.appendQueryParameter(PlayStoreParams.REFERRER, referrerBuilder.build().getEncodedQuery()); - - contextActivity.startActivity(new Intent(Intent.ACTION_VIEW, uriBuilder.build())); - } - - public static boolean isAvailable(@NonNull Context ctx, @NonNull Intent intent) { - final PackageManager mgr = ctx.getPackageManager(); - List list = - mgr.queryIntentActivities(intent, - PackageManager.MATCH_DEFAULT_ONLY); - return list.size() > 0; - } - - public AuthorizationClient(@NonNull Activity activity) { - mLoginActivity = activity; - - mAuthorizationHandlers.add(new SpotifyAuthHandler()); - mAuthorizationHandlers.add(new FallbackHandlerProvider().provideFallback()); - } - - /** - * This listener will be used when authorization flow will return a result. - * - * @param listener The listener to be notified when authorization flow completes. - */ - void setOnCompleteListener(AuthorizationClientListener listener) { - mAuthorizationClientListener = listener; - } - - /** - * Performs authorization. - * First it will try to bind spotify auth service, if this is not possible - * it will fallback to showing accounts page in a Custom Tab. - * - * @param request Authorization request - */ - void authorize(AuthorizationRequest request) { - if (mAuthorizationPending) return; - mAuthorizationPending = true; - - // Validate TOKEN requests have PKCE and convert to CODE for handlers - final AuthorizationRequest processedRequest = validateAndConvertTokenRequest(request); - - for (AuthorizationHandler authHandler : mAuthorizationHandlers) { - if (tryAuthorizationHandler(authHandler, processedRequest)) { - mCurrentHandler = authHandler; - break; - } - } - } - - /** - * Appends PKCE information to TOKEN requests before starting LoginActivity. - * PKCE is added if Spotify app is not installed (web fallback) or if the installed - * Spotify app version supports it. - * - * @param context The context used to check Spotify app version - * @param request The original authorization request - * @return The request with PKCE appended if it's a TOKEN request and appropriate - */ - private static AuthorizationRequest appendPkceIfTokenRequest(Context context, AuthorizationRequest request) { - final boolean isTokenRequest = TOKEN.toString().equals(request.getResponseType()); - final boolean isSpotifyInstalled = SpotifyNativeAuthUtil.isSpotifyInstalled(context); - final boolean isPKCESpotifyVersion = SpotifyNativeAuthUtil.isSpotifyVersionAtLeast( - context, - MIN_SPOTIFY_VERSION_FOR_TOKEN_CONVERSION); - final boolean hasSpotifyVersionWithoutPKCESupportInstalled = isSpotifyInstalled && !isPKCESpotifyVersion; - if (!isTokenRequest || hasSpotifyVersionWithoutPKCESupportInstalled) { - return request; - } - - try { - // Generate PKCE information - final PKCEInformation pkceInfo = PKCEInformationFactory.create(); - - // Create a new request with PKCE appended (keep TOKEN type for now) - return new AuthorizationRequest.Builder( - request.getClientId(), - AuthorizationResponse.Type.TOKEN, - request.getRedirectUri()) - .setState(request.getState()) - .setScopes(request.getScopes()) - .setCampaign(request.getCampaign()) - .setPkceInformation(pkceInfo) - .build(); - - } catch (final NoSuchAlgorithmException e) { - throw new RuntimeException("Failed to generate PKCE information: " + e.getMessage(), e); - } - } - - /** - * Validates TOKEN requests have PKCE and converts them to CODE requests for handlers - * if the Spotify app version supports it or if web fallback will be used. - * - * @param request The authorization request - * @return The processed request (converted to CODE if TOKEN with PKCE and appropriate) - */ - private AuthorizationRequest validateAndConvertTokenRequest(AuthorizationRequest request) { - final boolean isTokenRequest = TOKEN.toString().equals(request.getResponseType()); - final boolean hasPkce = request.getPkceInformation() != null; - - if (!isTokenRequest || !hasPkce) { - return request; - } - - final boolean isSpotifyInstalled = SpotifyNativeAuthUtil.isSpotifyInstalled(mLoginActivity); - final boolean isPKCESpotifyVersion = SpotifyNativeAuthUtil.isSpotifyVersionAtLeast( - mLoginActivity, - MIN_SPOTIFY_VERSION_FOR_TOKEN_CONVERSION); - - // Convert TOKEN to CODE if: - // 1. Spotify not installed (will use web fallback with code exchange) - // 2. Spotify installed and supports PKCE - final boolean shouldConvert = !isSpotifyInstalled || isPKCESpotifyVersion; - - if (!shouldConvert) { - return request; - } - - // Convert to CODE request for handlers - return new AuthorizationRequest.Builder( - request.getClientId(), - AuthorizationResponse.Type.CODE, - request.getRedirectUri()) - .setState(request.getState()) - .setScopes(request.getScopes()) - .setCampaign(request.getCampaign()) - .setPkceInformation(request.getPkceInformation()) - .build(); - } - - /** - * Authorization process was interrupted. - * This can happen when auth flow is not completed - * but was cancelled e.g. when underlying LoginActivity - * was paused or stopped. - */ - void cancel() { - if (!mAuthorizationPending) { - return; - } - - mAuthorizationPending = false; - closeAuthorizationHandler(mCurrentHandler); - - if (mAuthorizationClientListener != null) { - mAuthorizationClientListener.onClientCancelled(); - mAuthorizationClientListener = null; - } - } - - /** - * Authorization returned a result. - * The result doesn't have to contain a response uri - * e.g when back button was pressed. - * - * @param response The uri returned by auth flow. - */ - void complete(AuthorizationResponse response) { - sendComplete(mCurrentHandler, response); - } - - private void sendComplete(AuthorizationHandler authHandler, AuthorizationResponse response) { - mAuthorizationPending = false; - closeAuthorizationHandler(authHandler); - - if (mAuthorizationClientListener != null) { - mAuthorizationClientListener.onClientComplete(response); - mAuthorizationClientListener = null; - } else { - Log.w(TAG, "Can't deliver the Spotify Auth response. The listener is null"); - } - } - - private boolean tryAuthorizationHandler(final AuthorizationHandler authHandler, AuthorizationRequest request) { - authHandler.setOnCompleteListener(new AuthorizationHandler.OnCompleteListener() { - @Override - public void onComplete(AuthorizationResponse response) { - Log.i(TAG, String.format("Spotify auth response:%s", response.getType().name())); - sendComplete(authHandler, response); - } - - @Override - public void onCancel() { - Log.i(TAG, "Spotify auth response: User cancelled"); - AuthorizationResponse response = new AuthorizationResponse.Builder() - .setType(AuthorizationResponse.Type.EMPTY) - .build(); - - sendComplete(authHandler, response); - } - - @Override - public void onError(Throwable error) { - Log.e(TAG, "Spotify auth Error", error); - AuthorizationResponse response = new AuthorizationResponse.Builder() - .setType(AuthorizationResponse.Type.ERROR) - .setError(error.getMessage()) - .build(); - - sendComplete(authHandler, response); - } - }); - - if (!authHandler.start(mLoginActivity, request)) { - closeAuthorizationHandler(authHandler); - return false; - } - return true; - } - - private void closeAuthorizationHandler(AuthorizationHandler authHandler) { - if (authHandler != null) { - authHandler.setOnCompleteListener(null); - authHandler.stop(); - } - } - - /** - * Send empty response signaling the user canceled the auth flow if the current handler - * has an auth flow in progress. - */ - void notifyInCaseUserCanceledAuth() { - if (mCurrentHandler != null && mCurrentHandler.isAuthInProgress()) { - Log.i(TAG, "Spotify auth response: User cancelled"); - AuthorizationResponse response = new AuthorizationResponse.Builder() - .setType(AuthorizationResponse.Type.EMPTY) - .build(); - complete(response); - } - } - - void clearAuthInProgress() { - if (mCurrentHandler != null) { - Log.d(TAG, "Clearing auth in progress state"); - mCurrentHandler.stop(); - mCurrentHandler = null; - } - } -} 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 deleted file mode 100644 index 2f2298d..0000000 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/AuthorizationRequest.java +++ /dev/null @@ -1,293 +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; - -import android.net.Uri; -import android.os.Bundle; -import android.os.Parcel; -import android.os.Parcelable; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; - -import java.util.HashMap; -import java.util.Map; - -/** - * An object that helps construct the request that is sent to Spotify authorization service. - * To create one use {@link AuthorizationRequest.Builder} - * - * @see Web API Authorization guide - */ -public class AuthorizationRequest implements Parcelable { - - static final String ACCOUNTS_SCHEME = "https"; - static final String ACCOUNTS_AUTHORITY = "accounts.spotify.com"; - static final String ACCOUNTS_PATH = "authorize"; - static final String SCOPES_SEPARATOR = " "; - @VisibleForTesting - public static final String SPOTIFY_SDK = "spotify-sdk"; - @VisibleForTesting - public static final String ANDROID_SDK = "android-sdk"; - - private final String mClientId; - private final String mResponseType; - private final String mRedirectUri; - private final String mState; - private final String[] mScopes; - private final boolean mShowDialog; - private final Map mCustomParams; - private final String mCampaign; - private final PKCEInformation mPkceInformation; - - /** - * Use this builder to create an {@link AuthorizationRequest} - * - * @see AuthorizationRequest - */ - public static class Builder { - - private final String mClientId; - private final AuthorizationResponse.Type mResponseType; - private final String mRedirectUri; - - private String mState; - private String[] mScopes; - private boolean mShowDialog; - private String mCampaign; - private PKCEInformation mPkceInformation; - private final Map mCustomParams = new HashMap<>(); - - public Builder(String clientId, AuthorizationResponse.Type responseType, String redirectUri) { - if (clientId == null) { - throw new IllegalArgumentException("Client ID can't be null"); - } - if (responseType == null) { - throw new IllegalArgumentException("Response type can't be null"); - } - if (redirectUri == null || redirectUri.length() == 0) { - throw new IllegalArgumentException("Redirect URI can't be null or empty"); - } - - mClientId = clientId; - mResponseType = responseType; - mRedirectUri = redirectUri; - } - - public Builder setState(String state) { - mState = state; - return this; - } - - public Builder setScopes(String[] scopes) { - mScopes = scopes; - return this; - } - - public Builder setShowDialog(boolean showDialog) { - mShowDialog = showDialog; - return this; - } - - public Builder setCustomParam(String key, String value) { - if (key == null || key.isEmpty()) { - throw new IllegalArgumentException("Custom parameter key can't be null or empty"); - } - if (value == null || value.isEmpty()) { - throw new IllegalArgumentException("Custom parameter value can't be null or empty"); - } - mCustomParams.put(key, value); - return this; - } - - public Builder setCampaign(String campaign) { - mCampaign = campaign; - return this; - } - - public Builder setPkceInformation(PKCEInformation pkceInformation) { - mPkceInformation = pkceInformation; - return this; - } - - public AuthorizationRequest build() { - return new AuthorizationRequest(mClientId, mResponseType, mRedirectUri, - mState, mScopes, mShowDialog, mCustomParams, mCampaign, mPkceInformation); - } - } - - public AuthorizationRequest(Parcel source) { - mClientId = source.readString(); - mResponseType = source.readString(); - mRedirectUri = source.readString(); - mState = source.readString(); - mScopes = source.createStringArray(); - mShowDialog = source.readByte() == 1; - mCustomParams = new HashMap<>(); - mCampaign = source.readString(); - mPkceInformation = source.readParcelable(PKCEInformation.class.getClassLoader()); - Bundle bundle = source.readBundle(getClass().getClassLoader()); - for (String key : bundle.keySet()) { - mCustomParams.put(key, bundle.getString(key)); - } - } - - public String getClientId() { - return mClientId; - } - - public String getResponseType() { - return mResponseType; - } - - public String getRedirectUri() { - return mRedirectUri; - } - - public String getState() { - return mState; - } - - public String[] getScopes() { - return mScopes; - } - - public String getCustomParam(String key) { - return mCustomParams.get(key); - } - - @NonNull - public String getCampaign() { return TextUtils.isEmpty(mCampaign) ? ANDROID_SDK : mCampaign; } - - @NonNull - public String getSource() { return SPOTIFY_SDK; } - - @NonNull - public String getMedium() { return ANDROID_SDK; } - - public PKCEInformation getPkceInformation() { - return mPkceInformation; - } - - private AuthorizationRequest(String clientId, - AuthorizationResponse.Type responseType, - String redirectUri, - String state, - String[] scopes, - boolean showDialog, - Map customParams, - String campaign, - PKCEInformation pkceInformation) { - - mClientId = clientId; - mResponseType = responseType.toString(); - mRedirectUri = redirectUri; - mState = state; - mScopes = scopes; - mShowDialog = showDialog; - mCustomParams = customParams; - mCampaign = campaign; - mPkceInformation = pkceInformation; - } - - public Uri toUri() { - Uri.Builder uriBuilder = new Uri.Builder(); - uriBuilder.scheme(ACCOUNTS_SCHEME) - .authority(ACCOUNTS_AUTHORITY) - .appendPath(ACCOUNTS_PATH) - .appendQueryParameter(AccountsQueryParameters.CLIENT_ID, mClientId) - .appendQueryParameter(AccountsQueryParameters.RESPONSE_TYPE, mResponseType) - .appendQueryParameter(AccountsQueryParameters.REDIRECT_URI, mRedirectUri) - .appendQueryParameter(AccountsQueryParameters.SHOW_DIALOG, String.valueOf(mShowDialog)) - .appendQueryParameter(AccountsQueryParameters.UTM_SOURCE, getSource()) - .appendQueryParameter(AccountsQueryParameters.UTM_MEDIUM, getMedium()) - .appendQueryParameter(AccountsQueryParameters.UTM_CAMPAIGN, getCampaign()); - - if (mScopes != null && mScopes.length > 0) { - uriBuilder.appendQueryParameter(AccountsQueryParameters.SCOPE, scopesToString()); - } - - if (mState != null) { - uriBuilder.appendQueryParameter(AccountsQueryParameters.STATE, mState); - } - - if (mCustomParams.size() > 0) { - for (Map.Entry entry : mCustomParams.entrySet()) { - uriBuilder.appendQueryParameter(entry.getKey(), entry.getValue()); - } - } - - if (mPkceInformation != null) { - uriBuilder.appendQueryParameter(AccountsQueryParameters.CODE_CHALLENGE, mPkceInformation.getChallenge()); - uriBuilder.appendQueryParameter(AccountsQueryParameters.CODE_CHALLENGE_METHOD, mPkceInformation.getCodeChallengeMethod()); - } - - return uriBuilder.build(); - } - - private String scopesToString() { - StringBuilder concatScopes = new StringBuilder(); - for (String scope : mScopes) { - concatScopes.append(scope); - concatScopes.append(SCOPES_SEPARATOR); - } - return concatScopes.toString().trim(); - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeString(mClientId); - dest.writeString(mResponseType); - dest.writeString(mRedirectUri); - dest.writeString(mState); - dest.writeStringArray(mScopes); - dest.writeByte((byte) (mShowDialog ? 1 : 0)); - dest.writeString(mCampaign); - dest.writeParcelable(mPkceInformation, flags); - - Bundle bundle = new Bundle(); - for (Map.Entry entry : mCustomParams.entrySet()) { - bundle.putString(entry.getKey(), entry.getValue()); - } - dest.writeBundle(bundle); - } - - public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { - - @Override - public AuthorizationRequest createFromParcel(Parcel source) { - return new AuthorizationRequest(source); - } - - @Override - public AuthorizationRequest[] newArray(int size) { - return new AuthorizationRequest[size]; - } - }; -} diff --git a/auth-lib/src/main/java/com/spotify/sdk/android/auth/AuthorizationResponse.java b/auth-lib/src/main/java/com/spotify/sdk/android/auth/AuthorizationResponse.java deleted file mode 100644 index 817fc31..0000000 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/AuthorizationResponse.java +++ /dev/null @@ -1,311 +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; - -import android.net.Uri; -import android.os.Parcel; -import android.os.Parcelable; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/** - * An object that contains the parsed response from the Spotify authorization service. - * To create one use {@link AuthorizationResponse.Builder} or - * parse from {@link android.net.Uri} with {@link #fromUri(android.net.Uri)} - * - * @see Web API Authorization guide - */ -public class AuthorizationResponse implements Parcelable { - - /** - * The type of the authorization response. - */ - public enum Type { - /** - * The response is a code reply. - * - * @see Authorization code flow - */ - CODE("code"), - - /** - * The response is an implicit grant with access token. - * - * @see Implicit grant flow - */ - TOKEN("token"), - - /** - * The response is an error response. - * - * @see Web API Authorization guide - */ - ERROR("error"), - - /** - * Response doesn't contain data because auth flow was cancelled or LoginActivity killed. - */ - EMPTY("empty"), - - /** - * The response is unknown. - */ - UNKNOWN("unknown"); - - private final String mType; - - Type(String type) { - mType = type; - } - - @Override - public String toString() { - return mType; - } - } - - private final Type mType; - private final String mCode; - private final String mAccessToken; - private final String mState; - private final String mError; - private final int mExpiresIn; - @Nullable - private final String mRefreshToken; - - /** - * Use this builder to create an {@link AuthorizationResponse} - * - * @see AuthorizationResponse - */ - public static class Builder { - - private Type mType; - private String mCode; - private String mAccessToken; - private String mState; - private String mError; - private int mExpiresIn; - @Nullable - private String mRefreshToken; - - Builder setType(Type type) { - mType = type; - return this; - } - - Builder setCode(String code) { - mCode = code; - return this; - } - - Builder setAccessToken(String accessToken) { - mAccessToken = accessToken; - return this; - } - - Builder setState(String state) { - mState = state; - return this; - } - - Builder setError(String error) { - mError = error; - return this; - } - - Builder setExpiresIn(int expiresIn) { - mExpiresIn = expiresIn; - return this; - } - - Builder setRefreshToken(@Nullable String refreshToken) { - mRefreshToken = refreshToken; - return this; - } - - AuthorizationResponse build() { - return new AuthorizationResponse(mType, mCode, mAccessToken, mState, mError, mExpiresIn, mRefreshToken); - } - } - - private AuthorizationResponse(Type type, - String code, - String accessToken, - String state, - String error, - int expiresIn, - String refreshToken) { - mType = type != null ? type : Type.UNKNOWN; - mCode = code; - mAccessToken = accessToken; - mState = state; - mError = error; - mExpiresIn = expiresIn; - mRefreshToken = refreshToken; - } - - public AuthorizationResponse(@NonNull Parcel source) { - mExpiresIn = source.readInt(); - mError = source.readString(); - mState = source.readString(); - mAccessToken = source.readString(); - mCode = source.readString(); - mType = Type.values()[source.readInt()]; - mRefreshToken = source.readString(); - } - - /** - * Parses the URI returned from the Spotify accounts service. - * - * @param uri URI - * @return Authorization response. If parsing failed, this object will be populated with - * the given error codes. - */ - @NonNull - public static AuthorizationResponse fromUri(@Nullable Uri uri) { - AuthorizationResponse.Builder builder = new AuthorizationResponse.Builder(); - if (uri == null) { - builder.setType(Type.EMPTY); - return builder.build(); - } - - String possibleError = uri.getQueryParameter(AccountsQueryParameters.ERROR); - if (possibleError != null) { - String state = uri.getQueryParameter(AccountsQueryParameters.STATE); - builder.setError(possibleError); - builder.setState(state); - builder.setType(Type.ERROR); - return builder.build(); - } - - String possibleCode = uri.getQueryParameter(AccountsQueryParameters.CODE); - if (possibleCode != null) { - String state = uri.getQueryParameter(AccountsQueryParameters.STATE); - builder.setCode(possibleCode); - builder.setState(state); - builder.setType(Type.CODE); - return builder.build(); - } - - String possibleImplicit = uri.getEncodedFragment(); - if (possibleImplicit != null && possibleImplicit.length() > 0) { - String[] parts = possibleImplicit.split("&"); - String accessToken = null; - String state = null; - String expiresIn = null; - for (String part : parts) { - String[] partSplit = part.split("="); - if (partSplit.length == 2) { - if (partSplit[0].startsWith(AccountsQueryParameters.ACCESS_TOKEN)) { - accessToken = Uri.decode(partSplit[1]); - } - if (partSplit[0].startsWith(AccountsQueryParameters.STATE)) { - state = Uri.decode(partSplit[1]); - } - if (partSplit[0].startsWith(AccountsQueryParameters.EXPIRES_IN)) { - expiresIn = Uri.decode(partSplit[1]); - } - } - } - builder.setAccessToken(accessToken); - builder.setState(state); - if (expiresIn != null) { - try { - builder.setExpiresIn(Integer.parseInt(expiresIn)); - } catch (NumberFormatException e) { - // Ignore - } - } - builder.setType(Type.TOKEN); - return builder.build(); - } - - builder.setType(Type.UNKNOWN); - return builder.build(); - } - - @NonNull - public Type getType() { - return mType; - } - - @Nullable - public String getCode() { - return mCode; - } - - @Nullable - public String getAccessToken() { - return mAccessToken; - } - - @Nullable - public String getState() { - return mState; - } - - @Nullable - public String getError() { - return mError; - } - - public int getExpiresIn() { - return mExpiresIn; - } - - @Nullable - public String getRefreshToken() { - return mRefreshToken; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(@NonNull Parcel dest, int flags) { - dest.writeInt(mExpiresIn); - dest.writeString(mError); - dest.writeString(mState); - dest.writeString(mAccessToken); - dest.writeString(mCode); - dest.writeInt(mType.ordinal()); - dest.writeString(mRefreshToken); - } - - public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { - @Override - @NonNull - public AuthorizationResponse createFromParcel(@NonNull Parcel source) { - return new AuthorizationResponse(source); - } - - @Override - @NonNull - public AuthorizationResponse[] newArray(int size) { - return new AuthorizationResponse[size]; - } - }; -} 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 deleted file mode 100644 index 88160c3..0000000 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/IntentExtras.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.spotify.sdk.android.auth; - -/* - * Constants below have their counterparts in Spotify app where - * they're used to parse messages received from SDK. - * If any of these values needs to be changed, a new protocol version needs to be created on both - * sides (auth-lib and Spotify app) and bumped accordingly. - */ -public interface IntentExtras { - String KEY_CLIENT_ID = "CLIENT_ID"; - String KEY_REQUESTED_SCOPES = "SCOPES"; - String KEY_STATE = "STATE"; - String KEY_UTM_SOURCE = "UTM_SOURCE"; - String KEY_UTM_MEDIUM = "UTM_MEDIUM"; - String KEY_UTM_CAMPAIGN = "UTM_CAMPAIGN"; - String KEY_REDIRECT_URI = "REDIRECT_URI"; - String KEY_RESPONSE_TYPE = "RESPONSE_TYPE"; - String KEY_ACCESS_TOKEN = "ACCESS_TOKEN"; - String KEY_AUTHORIZATION_CODE = "AUTHORIZATION_CODE"; - String KEY_EXPIRES_IN = "EXPIRES_IN"; - String KEY_CODE_CHALLENGE = "CODE_CHALLENGE"; - String KEY_CODE_CHALLENGE_METHOD = "CODE_CHALLENGE_METHOD"; - /* - * This is used to pass information about the protocol version - * to the AuthorizationActivity. - * DO NOT CHANGE THIS. - */ - String KEY_VERSION = "VERSION"; -} diff --git a/auth-lib/src/main/java/com/spotify/sdk/android/auth/LoginActivity.java b/auth-lib/src/main/java/com/spotify/sdk/android/auth/LoginActivity.java deleted file mode 100644 index eac6d97..0000000 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/LoginActivity.java +++ /dev/null @@ -1,349 +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; - -import android.app.Activity; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.util.Log; - -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -import static com.spotify.sdk.android.auth.IntentExtras.KEY_ACCESS_TOKEN; -import static com.spotify.sdk.android.auth.AuthorizationResponse.Type.TOKEN; -import static com.spotify.sdk.android.auth.IntentExtras.KEY_AUTHORIZATION_CODE; -import static com.spotify.sdk.android.auth.IntentExtras.KEY_EXPIRES_IN; -import static com.spotify.sdk.android.auth.IntentExtras.KEY_RESPONSE_TYPE; -import static com.spotify.sdk.android.auth.IntentExtras.KEY_STATE; - -/** - * The activity that manages the login flow. - * It should not be started directly. Instead use - * {@link AuthorizationClient#openLoginActivity(android.app.Activity, int, AuthorizationRequest)} - */ -public class LoginActivity extends Activity implements AuthorizationClient.AuthorizationClientListener { - - static final String EXTRA_REPLY = "REPLY"; - static final String EXTRA_ERROR = "ERROR"; - - static final String RESPONSE_TYPE_TOKEN = "token"; - static final String RESPONSE_TYPE_CODE = "code"; - - private static final String TAG = LoginActivity.class.getName(); - private static final String NO_CALLER_ERROR = "Can't use LoginActivity with a null caller. " + - "Possible reasons: calling activity has a singleInstance mode " + - "or LoginActivity is in a singleInstance/singleTask mode"; - - private static final String NO_REQUEST_ERROR = "No authorization request"; - - static final String EXTRA_AUTH_REQUEST = "EXTRA_AUTH_REQUEST"; - static final String EXTRA_AUTH_RESPONSE = "EXTRA_AUTH_RESPONSE"; - public static final String REQUEST_KEY = "request"; - public static final String RESPONSE_KEY = "response"; - - private final AuthorizationClient mAuthorizationClient = new AuthorizationClient(this); - private final ExecutorService mExecutorService = Executors.newSingleThreadExecutor(); - private final Handler mMainHandler = new Handler(Looper.getMainLooper()); - - public static final int REQUEST_CODE = 1138; - - private static final int RESULT_ERROR = -2; - - @Override - protected void onNewIntent(Intent intent) { - final AuthorizationRequest originalRequest = getRequestFromIntent(); - super.onNewIntent(intent); - final Uri responseUri = intent.getData(); - - // Clear auth-in-progress state to prevent onResume from thinking user canceled - if (responseUri != null) { - mAuthorizationClient.clearAuthInProgress(); - } - - final AuthorizationResponse response = AuthorizationResponse.fromUri(responseUri); - - // Check if this is a CODE response from web fallback that needs token exchange - if (response.getType() == AuthorizationResponse.Type.CODE) { - // Check if original request was for TOKEN and has PKCE info - if (originalRequest != null && - originalRequest.getResponseType().equals(TOKEN.toString()) && - originalRequest.getPkceInformation() != null) { - - // Perform PKCE token exchange for web fallback - final AuthorizationResponse.Builder responseBuilder = new AuthorizationResponse.Builder() - .setType(AuthorizationResponse.Type.TOKEN) - .setState(response.getState()); - - performPkceTokenExchange(response.getCode(), originalRequest, responseBuilder); - return; // Don't complete immediately, wait for async result - } - } - - // Handle normal responses (TOKEN from web, errors, etc.) - mAuthorizationClient.complete(response); - } - - public static Intent getAuthIntent(Activity contextActivity, AuthorizationRequest request) { - if (contextActivity == null || request == null) { - throw new IllegalArgumentException("Context activity or request can't be null"); - } - - // Put request into a bundle to work around classloader problems on Samsung devices - // https://stackoverflow.com/questions/28589509/android-e-parcel-class-not-found-when-unmarshalling-only-on-samsung-tab3 - Bundle bundle = new Bundle(); - bundle.putParcelable(REQUEST_KEY, request); - - Intent intent = new Intent(contextActivity, LoginActivity.class); - intent.putExtra(EXTRA_AUTH_REQUEST, bundle); - - return intent; - } - - static AuthorizationResponse getResponseFromIntent(Intent intent) { - if (intent == null) { - return null; - } - - Bundle responseBundle = intent.getBundleExtra(EXTRA_AUTH_RESPONSE); - if (responseBundle == null) { - return null; - } - - return responseBundle.getParcelable(RESPONSE_KEY); - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.com_spotify_sdk_login_activity); - - final AuthorizationRequest request = getRequestFromIntent(); - - mAuthorizationClient.setOnCompleteListener(this); - - if (getCallingActivity() == null) { - Log.e(TAG, NO_CALLER_ERROR); - finish(); - } else if (request == null) { - Log.e(TAG, NO_REQUEST_ERROR); - setResult(Activity.RESULT_CANCELED); - finish(); - } else if (savedInstanceState == null) { - Log.d(TAG, String.format("Spotify Auth starting with the request [%s]", request.toUri().toString())); - mAuthorizationClient.authorize(request); - } - } - - private AuthorizationRequest getRequestFromIntent() { - Bundle requestBundle = getIntent().getBundleExtra(EXTRA_AUTH_REQUEST); - if (requestBundle == null) { - return null; - } - return requestBundle.getParcelable(REQUEST_KEY); - } - - @Override - protected void onResume() { - super.onResume(); - // onResume is called (except other cases) in the case - // of browser based auth flow when user pressed back/closed the Custom Tab and - // LoginActivity came to the foreground again. - mAuthorizationClient.notifyInCaseUserCanceledAuth(); - } - - @Override - protected void onDestroy() { - mAuthorizationClient.cancel(); - mAuthorizationClient.setOnCompleteListener(null); - if (mExecutorService != null) { - mExecutorService.shutdown(); - } - super.onDestroy(); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent intent) { - super.onActivityResult(requestCode, resultCode, intent); - if (requestCode == REQUEST_CODE) { - AuthorizationResponse.Builder response = new AuthorizationResponse.Builder(); - - if (resultCode == RESULT_ERROR) { - response.setType(AuthorizationResponse.Type.ERROR); - - String errorMessage; - if (intent == null) { - errorMessage = "Invalid message format"; - } else { - errorMessage = intent.getStringExtra(EXTRA_ERROR); - } - if (errorMessage == null) { - errorMessage = "Unknown error"; - } - response.setError(errorMessage); - - } else if (resultCode == RESULT_OK) { - Bundle data = intent.getParcelableExtra(EXTRA_REPLY); - - if (data == null) { - response.setType(AuthorizationResponse.Type.ERROR); - response.setError("Missing response data"); - } else { - String responseType = data.getString(KEY_RESPONSE_TYPE, "unknown"); - Log.d(TAG, "Response: " + responseType); - response.setState(data.getString(KEY_STATE, null)); - switch (responseType) { - case RESPONSE_TYPE_TOKEN: - String token = data.getString(KEY_ACCESS_TOKEN); - int expiresIn = data.getInt(KEY_EXPIRES_IN); - - response.setType(AuthorizationResponse.Type.TOKEN); - response.setAccessToken(token); - response.setExpiresIn(expiresIn); - break; - case RESPONSE_TYPE_CODE: - final String code = data.getString(KEY_AUTHORIZATION_CODE); - final AuthorizationRequest originalRequest = getRequestFromIntent(); - - // Check if original request was for TOKEN and has PKCE info - if (originalRequest != null && - originalRequest.getResponseType().equals(TOKEN.toString())) { - - if (originalRequest.getPkceInformation() != null) { - // Perform PKCE token exchange - performPkceTokenExchange(code, originalRequest, response); - return; // Don't complete immediately, wait for async result - } else { - throw new IllegalStateException( - "Exchanging the code for a token requires PKCE parameters"); - } - } else { - // Regular code response - response.setType(AuthorizationResponse.Type.CODE); - response.setCode(code); - } - break; - default: - response.setType(AuthorizationResponse.Type.UNKNOWN); - break; - } - } - - } else { - response.setType(AuthorizationResponse.Type.EMPTY); - } - - mAuthorizationClient.setOnCompleteListener(this); - mAuthorizationClient.complete(response.build()); - } - } - - @Override - public void onClientComplete(AuthorizationResponse response) { - Intent resultIntent = new Intent(); - - // Put response into a bundle to work around classloader problems on Samsung devices - // https://stackoverflow.com/questions/28589509/android-e-parcel-class-not-found-when-unmarshalling-only-on-samsung-tab3 - Log.i(TAG, String.format("Spotify auth completing. The response is in EXTRA with key '%s'", RESPONSE_KEY)); - Bundle bundle = new Bundle(); - bundle.putParcelable(RESPONSE_KEY, response); - - resultIntent.putExtra(EXTRA_AUTH_RESPONSE, bundle); - setResult(Activity.RESULT_OK, resultIntent); - finish(); - } - - @Override - public void onClientCancelled() { - // Called only when LoginActivity is destroyed and no other result is set. - Log.w(TAG, "Spotify Auth cancelled due to LoginActivity being finished"); - setResult(Activity.RESULT_CANCELED); - } - - private void performPkceTokenExchange(final String code, - final AuthorizationRequest originalRequest, - final AuthorizationResponse.Builder responseBuilder) { - Log.d(TAG, "Performing PKCE token exchange for code: " + code); - - mExecutorService.execute(new Runnable() { - @Override - public void run() { - try { - final PKCEInformation pkceInfo = originalRequest.getPkceInformation(); - final TokenExchangeRequest tokenRequest = new TokenExchangeRequest.Builder() - .setClientId(originalRequest.getClientId()) - .setCode(code) - .setRedirectUri(originalRequest.getRedirectUri()) - .setCodeVerifier(pkceInfo.getVerifier()) - .build(); - - final TokenExchangeResponse tokenResponse = tokenRequest.execute(); - - // Switch back to main thread to complete the response - mMainHandler.post(new Runnable() { - @Override - public void run() { - if (tokenResponse.isSuccess()) { - // Convert to TOKEN response - responseBuilder.setType(AuthorizationResponse.Type.TOKEN); - responseBuilder.setAccessToken(tokenResponse.getAccessToken()); - responseBuilder.setExpiresIn(tokenResponse.getExpiresIn()); - responseBuilder.setRefreshToken(tokenResponse.getRefreshToken()); - Log.d(TAG, "PKCE token exchange successful"); - } else { - // Convert to ERROR response - responseBuilder.setType(AuthorizationResponse.Type.ERROR); - final String errorMsg = tokenResponse.getError() + - (tokenResponse.getErrorDescription() != null ? - ": " + tokenResponse.getErrorDescription() : ""); - responseBuilder.setError(errorMsg); - Log.e(TAG, "PKCE token exchange failed: " + errorMsg); - } - - // Complete the authorization flow - mAuthorizationClient.setOnCompleteListener(LoginActivity.this); - mAuthorizationClient.complete(responseBuilder.build()); - } - }); - - } catch (final Exception e) { - Log.e(TAG, "PKCE token exchange error", e); - - // Switch back to main thread to complete with error - mMainHandler.post(new Runnable() { - @Override - public void run() { - responseBuilder.setType(AuthorizationResponse.Type.ERROR); - responseBuilder.setError("Token exchange failed: " + e.getMessage()); - - mAuthorizationClient.setOnCompleteListener(LoginActivity.this); - mAuthorizationClient.complete(responseBuilder.build()); - } - }); - } - } - }); - } -} diff --git a/auth-lib/src/main/java/com/spotify/sdk/android/auth/PKCEInformation.java b/auth-lib/src/main/java/com/spotify/sdk/android/auth/PKCEInformation.java deleted file mode 100644 index b5b3965..0000000 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/PKCEInformation.java +++ /dev/null @@ -1,110 +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; - -import android.os.Parcel; -import android.os.Parcelable; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.Objects; - -public class PKCEInformation implements Parcelable { - - private final String mVerifier; - private final String mChallenge; - private final String mCodeChallengeMethod; - - private PKCEInformation(@NonNull String verifier, @NonNull String challenge, @NonNull String challengeMethod) { - mVerifier = verifier; - mChallenge = challenge; - mCodeChallengeMethod = challengeMethod; - } - - @NonNull - public static PKCEInformation sha256(@NonNull String verifier, @NonNull String challenge) { - return new PKCEInformation(verifier, challenge, "S256"); - } - - @NonNull - public String getVerifier() { - return mVerifier; - } - - @NonNull - public String getChallenge() { - return mChallenge; - } - - @NonNull - public String getCodeChallengeMethod() { - return mCodeChallengeMethod; - } - - private PKCEInformation(@NonNull Parcel in) { - mVerifier = in.readString(); - mChallenge = in.readString(); - mCodeChallengeMethod = in.readString(); - } - - @Override - public void writeToParcel(@NonNull Parcel dest, int flags) { - dest.writeString(mVerifier); - dest.writeString(mChallenge); - dest.writeString(mCodeChallengeMethod); - } - - @Override - public int describeContents() { - return 0; - } - - public static final Creator CREATOR = new Creator() { - @Override - @NonNull - public PKCEInformation createFromParcel(@NonNull Parcel in) { - return new PKCEInformation(in); - } - - @Override - @NonNull - public PKCEInformation[] newArray(int size) { - return new PKCEInformation[size]; - } - }; - - @Override - public boolean equals(@Nullable Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - PKCEInformation that = (PKCEInformation) o; - return Objects.equals(mVerifier, that.mVerifier) && - Objects.equals(mChallenge, that.mChallenge) && - Objects.equals(mCodeChallengeMethod, that.mCodeChallengeMethod); - } - - @Override - public int hashCode() { - return Objects.hash(mVerifier, mChallenge, mCodeChallengeMethod); - } -} diff --git a/auth-lib/src/main/java/com/spotify/sdk/android/auth/PKCEInformationFactory.java b/auth-lib/src/main/java/com/spotify/sdk/android/auth/PKCEInformationFactory.java deleted file mode 100644 index 2ecee25..0000000 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/PKCEInformationFactory.java +++ /dev/null @@ -1,69 +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; - -import android.util.Base64; - -import androidx.annotation.NonNull; - -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; - -public class PKCEInformationFactory { - - private static final int CODE_VERIFIER_LENGTH = 128; - private static final String CODE_VERIFIER_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; - - @NonNull - public static PKCEInformation create() throws NoSuchAlgorithmException { - String codeVerifier = generateCodeVerifier(); - String codeChallenge = generateCodeChallenge(codeVerifier); - return PKCEInformation.sha256(codeVerifier, codeChallenge); - } - - @NonNull - private static String generateCodeVerifier() { - SecureRandom secureRandom = new SecureRandom(); - StringBuilder codeVerifier = new StringBuilder(); - - for (int i = 0; i < CODE_VERIFIER_LENGTH; i++) { - int randomIndex = secureRandom.nextInt(CODE_VERIFIER_CHARSET.length()); - codeVerifier.append(CODE_VERIFIER_CHARSET.charAt(randomIndex)); - } - - return codeVerifier.toString(); - } - - @NonNull - private static String generateCodeChallenge(@NonNull String codeVerifier) throws NoSuchAlgorithmException { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(codeVerifier.getBytes("US-ASCII")); - return Base64.encodeToString(hash, Base64.URL_SAFE | Base64.NO_PADDING | Base64.NO_WRAP); - } catch (final java.io.UnsupportedEncodingException e) { - // US-ASCII is guaranteed to be supported on all platforms - throw new RuntimeException("US-ASCII encoding not supported", e); - } - } -} - diff --git a/auth-lib/src/main/java/com/spotify/sdk/android/auth/TokenExchangeRequest.java b/auth-lib/src/main/java/com/spotify/sdk/android/auth/TokenExchangeRequest.java deleted file mode 100644 index 5fc22a5..0000000 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/TokenExchangeRequest.java +++ /dev/null @@ -1,226 +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; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.URLEncoder; - -/** - * A utility class for exchanging an authorization code for an access token using PKCE verifier. - * This implements the OAuth 2.0 Authorization Code Grant with PKCE as specified in RFC 7636. - */ -public class TokenExchangeRequest { - - private static final String TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token"; - private static final String CONTENT_TYPE_FORM = "application/x-www-form-urlencoded"; - private static final String GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"; - private static final int TIMEOUT_MS = 10000; // 10 seconds - - private final String mClientId; - private final String mCode; - private final String mRedirectUri; - private final String mCodeVerifier; - - /** - * Creates a new token exchange request. - * - * @param clientId The client ID of the application - * @param code The authorization code received from the authorization server - * @param redirectUri The redirect URI used in the authorization request - * @param codeVerifier The PKCE code verifier that was used to generate the code challenge - */ - public TokenExchangeRequest(@NonNull final String clientId, - @NonNull final String code, - @NonNull final String redirectUri, - @NonNull final String codeVerifier) { - if (clientId == null || clientId.isEmpty()) { - throw new IllegalArgumentException("Client ID cannot be null or empty"); - } - if (code == null || code.isEmpty()) { - throw new IllegalArgumentException("Authorization code cannot be null or empty"); - } - if (redirectUri == null || redirectUri.isEmpty()) { - throw new IllegalArgumentException("Redirect URI cannot be null or empty"); - } - if (codeVerifier == null || codeVerifier.isEmpty()) { - throw new IllegalArgumentException("Code verifier cannot be null or empty"); - } - - mClientId = clientId; - mCode = code; - mRedirectUri = redirectUri; - mCodeVerifier = codeVerifier; - } - - /** - * Executes the token exchange request synchronously. - * This method performs a blocking HTTP request and should not be called on the main thread. - * - * @return TokenExchangeResponse containing the access token or error information - */ - @NonNull - public TokenExchangeResponse execute() { - HttpURLConnection connection = null; - try { - final URL url = new URL(TOKEN_ENDPOINT); - connection = (HttpURLConnection) url.openConnection(); - - connection.setRequestMethod("POST"); - connection.setRequestProperty("Content-Type", CONTENT_TYPE_FORM); - connection.setDoOutput(true); - connection.setConnectTimeout(TIMEOUT_MS); - connection.setReadTimeout(TIMEOUT_MS); - - final String requestBody = buildRequestBody(); - - try (final OutputStream outputStream = connection.getOutputStream()) { - try { - outputStream.write(requestBody.getBytes("UTF-8")); - } catch (final java.io.UnsupportedEncodingException e) { - // UTF-8 is guaranteed to be supported on all platforms - throw new RuntimeException("UTF-8 encoding not supported", e); - } - outputStream.flush(); - } - - final int responseCode = connection.getResponseCode(); - final String responseBody = readResponse(connection, responseCode >= 400); - - return TokenExchangeResponse.fromHttpResponse(responseCode, responseBody); - - } catch (final IOException e) { - return TokenExchangeResponse.fromError("network_error", "Network error: " + e.getMessage()); - } finally { - if (connection != null) { - connection.disconnect(); - } - } - } - - @NonNull - private String buildRequestBody() { - try { - return "grant_type=" + URLEncoder.encode(GRANT_TYPE_AUTHORIZATION_CODE, "UTF-8") + - "&client_id=" + URLEncoder.encode(mClientId, "UTF-8") + - "&code=" + URLEncoder.encode(mCode, "UTF-8") + - "&redirect_uri=" + URLEncoder.encode(mRedirectUri, "UTF-8") + - "&code_verifier=" + URLEncoder.encode(mCodeVerifier, "UTF-8"); - } catch (final Exception e) { - // This should never happen with UTF-8 - throw new RuntimeException("Failed to encode request parameters", e); - } - } - - @NonNull - private String readResponse(@NonNull final HttpURLConnection connection, final boolean isError) throws IOException { - try (final BufferedReader reader = new BufferedReader(new InputStreamReader( - isError ? connection.getErrorStream() : connection.getInputStream(), - "UTF-8"))) { - - final StringBuilder response = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - response.append(line); - } - - return response.toString(); - } - } - - /** - * Builder class for creating TokenExchangeRequest instances. - */ - public static class Builder { - private String mClientId; - private String mCode; - private String mRedirectUri; - private String mCodeVerifier; - - /** - * Sets the client ID. - * - * @param clientId The client ID - * @return This builder instance for method chaining - */ - @NonNull - public Builder setClientId(@NonNull final String clientId) { - mClientId = clientId; - return this; - } - - /** - * Sets the authorization code. - * - * @param code The authorization code - * @return This builder instance for method chaining - */ - @NonNull - public Builder setCode(@NonNull final String code) { - mCode = code; - return this; - } - - /** - * Sets the redirect URI. - * - * @param redirectUri The redirect URI - * @return This builder instance for method chaining - */ - @NonNull - public Builder setRedirectUri(@NonNull final String redirectUri) { - mRedirectUri = redirectUri; - return this; - } - - /** - * Sets the PKCE code verifier. - * - * @param codeVerifier The code verifier - * @return This builder instance for method chaining - */ - @NonNull - public Builder setCodeVerifier(@NonNull final String codeVerifier) { - mCodeVerifier = codeVerifier; - return this; - } - - /** - * Builds the TokenExchangeRequest. - * - * @return A new TokenExchangeRequest instance - * @throws IllegalArgumentException if any required field is null or empty - */ - @NonNull - public TokenExchangeRequest build() { - return new TokenExchangeRequest(mClientId, mCode, mRedirectUri, mCodeVerifier); - } - } -} - diff --git a/auth-lib/src/main/java/com/spotify/sdk/android/auth/TokenExchangeResponse.java b/auth-lib/src/main/java/com/spotify/sdk/android/auth/TokenExchangeResponse.java deleted file mode 100644 index fe63c62..0000000 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/TokenExchangeResponse.java +++ /dev/null @@ -1,196 +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; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.json.JSONException; -import org.json.JSONObject; - -/** - * Response from a token exchange request. - * Contains either the access token information or error details. - */ -public class TokenExchangeResponse { - - private final boolean mIsSuccess; - private final String mAccessToken; - private final String mTokenType; - private final int mExpiresIn; - private final String mScope; - private final String mRefreshToken; - private final String mError; - private final String mErrorDescription; - - private TokenExchangeResponse(final boolean isSuccess, - @Nullable final String accessToken, - @Nullable final String tokenType, - final int expiresIn, - @Nullable final String scope, - @Nullable final String refreshToken, - @Nullable final String error, - @Nullable final String errorDescription) { - mIsSuccess = isSuccess; - mAccessToken = accessToken; - mTokenType = tokenType; - mExpiresIn = expiresIn; - mScope = scope; - mRefreshToken = refreshToken; - mError = error; - mErrorDescription = errorDescription; - } - - /** - * @return true if the token exchange was successful, false otherwise - */ - public boolean isSuccess() { - return mIsSuccess; - } - - /** - * @return the access token if successful, null otherwise - */ - @Nullable - public String getAccessToken() { - return mAccessToken; - } - - /** - * @return the token type (usually "Bearer") if successful, null otherwise - */ - @Nullable - public String getTokenType() { - return mTokenType; - } - - /** - * @return the number of seconds the access token is valid for, 0 if not provided or on error - */ - public int getExpiresIn() { - return mExpiresIn; - } - - /** - * @return the scope of the access token if provided, null otherwise - */ - @Nullable - public String getScope() { - return mScope; - } - - /** - * @return the refresh token if provided, null otherwise - */ - @Nullable - public String getRefreshToken() { - return mRefreshToken; - } - - /** - * @return the error code if the request failed, null if successful - */ - @Nullable - public String getError() { - return mError; - } - - /** - * @return the error description if the request failed, null if successful - */ - @Nullable - public String getErrorDescription() { - return mErrorDescription; - } - - /** - * Creates a TokenExchangeResponse from an HTTP response. - * - * @param responseCode The HTTP response code - * @param responseBody The response body as JSON string - * @return A TokenExchangeResponse instance - */ - @NonNull - static TokenExchangeResponse fromHttpResponse(final int responseCode, @Nullable final String responseBody) { - if (responseBody == null || responseBody.trim().isEmpty()) { - return fromError("invalid_response", "Empty response body"); - } - - try { - final JSONObject json = new JSONObject(responseBody); - - if (responseCode == 200) { - // Success response - final String accessToken = json.optString("access_token", null); - final String tokenType = json.optString("token_type", null); - final int expiresIn = json.optInt("expires_in", 0); - final String scope = json.optString("scope", null); - final String refreshToken = json.optString("refresh_token", null); - - if (accessToken == null || accessToken.isEmpty()) { - return fromError("invalid_response", "Missing access_token in response"); - } - - return fromSuccess(accessToken, tokenType, expiresIn, scope, refreshToken); - } else { - // Error response - final String error = json.optString("error", "unknown_error"); - final String errorDescription = json.optString("error_description", null); - return fromError(error, errorDescription); - } - } catch (final JSONException e) { - return fromError("invalid_response", "Invalid JSON response: " + e.getMessage()); - } - } - - /** - * Creates a successful TokenExchangeResponse. - * - * @param accessToken The access token - * @param tokenType The token type - * @param expiresIn The expiration time in seconds - * @param scope The token scope - * @param refreshToken The refresh token (optional) - * @return A successful TokenExchangeResponse - */ - @NonNull - static TokenExchangeResponse fromSuccess(@NonNull final String accessToken, - @Nullable final String tokenType, - final int expiresIn, - @Nullable final String scope, - @Nullable final String refreshToken) { - return new TokenExchangeResponse(true, accessToken, tokenType, expiresIn, scope, refreshToken, null, null); - } - - /** - * Creates an error TokenExchangeResponse. - * - * @param error The error code - * @param errorDescription The error description (optional) - * @return An error TokenExchangeResponse - */ - @NonNull - static TokenExchangeResponse fromError(@NonNull final String error, @Nullable final String errorDescription) { - return new TokenExchangeResponse(false, null, null, 0, null, null, error, errorDescription); - } -} - diff --git a/auth-lib/src/main/java/com/spotify/sdk/android/auth/app/Sha1HashUtil.java b/auth-lib/src/main/java/com/spotify/sdk/android/auth/app/Sha1HashUtil.java deleted file mode 100644 index c7d120a..0000000 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/app/Sha1HashUtil.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.spotify.sdk.android.auth.app; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.io.UnsupportedEncodingException; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; - - -interface Sha1HashUtil { - @Nullable - String sha1Hash(@NonNull String toHash); -} -final class Sha1HashUtilImpl implements Sha1HashUtil { - - @Override - @Nullable - public String sha1Hash(@NonNull String toHash) { - String hash = null; - try { - MessageDigest digest = MessageDigest.getInstance("SHA-1"); - byte[] bytes = toHash.getBytes("UTF-8"); - digest.update(bytes, 0, bytes.length); - bytes = digest.digest(); - - hash = bytesToHex(bytes); - } catch (NoSuchAlgorithmException ignored) { - } catch (UnsupportedEncodingException ignored) { - } - return hash; - } - - private final char[] HEX_ARRAY = "0123456789abcdef".toCharArray(); - - @NonNull - private String bytesToHex(@NonNull byte[] bytes) { - char[] hexChars = new char[bytes.length * 2]; - for (int j = 0; j < bytes.length; j++) { - int v = bytes[j] & 0xFF; - hexChars[j * 2] = HEX_ARRAY[v >>> 4]; - hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; - } - return new String(hexChars); - } - -} diff --git a/auth-lib/src/main/java/com/spotify/sdk/android/auth/app/SpotifyAuthHandler.java b/auth-lib/src/main/java/com/spotify/sdk/android/auth/app/SpotifyAuthHandler.java deleted file mode 100644 index ef46eee..0000000 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/app/SpotifyAuthHandler.java +++ /dev/null @@ -1,62 +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.app; - -import android.app.Activity; - -import androidx.annotation.Nullable; - -import com.spotify.sdk.android.auth.AuthorizationHandler; -import com.spotify.sdk.android.auth.AuthorizationRequest; - -public class SpotifyAuthHandler implements AuthorizationHandler { - - private SpotifyNativeAuthUtil mSpotifyNativeAuthUtil; - - @Override - public boolean start(Activity contextActivity, AuthorizationRequest request) { - mSpotifyNativeAuthUtil = new SpotifyNativeAuthUtil( - contextActivity, - request, - new Sha1HashUtilImpl() - ); - return mSpotifyNativeAuthUtil.startAuthActivity(); - } - - @Override - public void stop() { - if (mSpotifyNativeAuthUtil != null) { - mSpotifyNativeAuthUtil.stopAuthActivity(); - } - } - - @Override - public void setOnCompleteListener(@Nullable OnCompleteListener listener) { - // no-op - } - - @Override - public boolean isAuthInProgress() { - // not supported, always return false - return false; - } -} 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 deleted file mode 100644 index a1779d4..0000000 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/app/SpotifyNativeAuthUtil.java +++ /dev/null @@ -1,289 +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.app; - -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; -import static com.spotify.sdk.android.auth.IntentExtras.KEY_REDIRECT_URI; -import static com.spotify.sdk.android.auth.IntentExtras.KEY_REQUESTED_SCOPES; -import static com.spotify.sdk.android.auth.IntentExtras.KEY_RESPONSE_TYPE; -import static com.spotify.sdk.android.auth.IntentExtras.KEY_STATE; -import static com.spotify.sdk.android.auth.IntentExtras.KEY_UTM_CAMPAIGN; -import static com.spotify.sdk.android.auth.IntentExtras.KEY_UTM_MEDIUM; -import static com.spotify.sdk.android.auth.IntentExtras.KEY_UTM_SOURCE; -import static com.spotify.sdk.android.auth.IntentExtras.KEY_VERSION; - -import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.ActivityNotFoundException; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.content.pm.Signature; -import android.os.Build; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; - -import com.spotify.sdk.android.auth.AuthorizationRequest; -import com.spotify.sdk.android.auth.LoginActivity; -import com.spotify.sdk.android.auth.PKCEInformation; - -public class SpotifyNativeAuthUtil { - - /* - * The version of the auth protocol. More info about this protocol in - * {@link com.spotify.sdk.android.auth.IntentExtras}. - */ - private static final int PROTOCOL_VERSION = 1; - - private static final String SPOTIFY_AUTH_ACTIVITY_ACTION = "com.spotify.sso.action.START_AUTH_FLOW"; - private static final String SPOTIFY_PACKAGE_NAME = "com.spotify.music"; - private static final String[] SPOTIFY_PACKAGE_SUFFIXES = new String[]{ - ".debug", - ".canary", - ".partners", - "" - }; - - private static final String[] SPOTIFY_SIGNATURE_HASH = new String[]{ - "25a9b2d2745c098361edaa3b87936dc29a28e7f1", - "80abdd17dcc4cb3a33815d354355bf87c9378624", - "88df4d670ed5e01fc7b3eff13b63258628ff5a00", - "d834ae340d1e854c5f4092722f9788216d9221e5", - "1cbedd9e7345f64649bad2b493a20d9eea955352", - "4b3d76a2de89033ea830f476a1f815692938e33b", - }; - - private final Activity mContextActivity; - private final AuthorizationRequest mRequest; - @NonNull - private final Sha1HashUtil mSha1HashUtil; - - public SpotifyNativeAuthUtil(@NonNull Activity contextActivity, - @NonNull AuthorizationRequest request, - @NonNull Sha1HashUtil sha1HashUtil) { - mContextActivity = contextActivity; - mRequest = request; - mSha1HashUtil = sha1HashUtil; - } - - public boolean startAuthActivity() { - Intent intent = createAuthActivityIntent(mContextActivity, mSha1HashUtil); - if (intent == null) { - return false; - } - intent.putExtra(KEY_VERSION, PROTOCOL_VERSION); - - intent.putExtra(KEY_CLIENT_ID, mRequest.getClientId()); - intent.putExtra(KEY_REDIRECT_URI, mRequest.getRedirectUri()); - intent.putExtra(KEY_RESPONSE_TYPE, mRequest.getResponseType()); - intent.putExtra(KEY_REQUESTED_SCOPES, mRequest.getScopes()); - intent.putExtra(KEY_STATE, mRequest.getState()); - intent.putExtra(KEY_UTM_SOURCE, mRequest.getSource()); - intent.putExtra(KEY_UTM_CAMPAIGN, mRequest.getCampaign()); - intent.putExtra(KEY_UTM_MEDIUM, mRequest.getMedium()); - - final PKCEInformation pkceInfo = mRequest.getPkceInformation(); - if (pkceInfo != null) { - intent.putExtra(KEY_CODE_CHALLENGE, pkceInfo.getChallenge()); - intent.putExtra(KEY_CODE_CHALLENGE_METHOD, pkceInfo.getCodeChallengeMethod()); - } - - try { - mContextActivity.startActivityForResult(intent, LoginActivity.REQUEST_CODE); - } catch (ActivityNotFoundException e) { - return false; - } - return true; - } - - /** - * Creates an intent that will launch the auth flow on the currently installed Spotify application - * @param context The context of the caller - * @return The auth Intent or null if the Spotify application couldn't be found - */ - @Nullable - public static Intent createAuthActivityIntent(@NonNull Context context) { - return createAuthActivityIntent(context, new Sha1HashUtilImpl()); - } - - @VisibleForTesting - @Nullable - static Intent createAuthActivityIntent(@NonNull Context context, @NonNull Sha1HashUtil sha1HashUtil) { - Intent intent = null; - for (String suffix : SPOTIFY_PACKAGE_SUFFIXES) { - intent = tryResolveActivity(context, - SPOTIFY_PACKAGE_NAME + suffix, - sha1HashUtil); - if (intent != null) { - break; - } - } - return intent; - } - - /** - * Check if a version of the Spotify main application is installed - * - * @param context The context of the caller, used to check if the app is installed - * @return True if a Spotify app is installed, false otherwise - */ - public static boolean isSpotifyInstalled(@NonNull Context context) { - return isSpotifyInstalled(context, new Sha1HashUtilImpl()); - } - - @VisibleForTesting - static boolean isSpotifyInstalled(@NonNull Context context, @NonNull Sha1HashUtil sha1HashUtil) { - return createAuthActivityIntent(context, sha1HashUtil) != null; - } - - /** - * Get the version code of the installed Spotify app - * - * @param context The context of the caller, used to check package info - * @return Version code of Spotify app, or -1 if not installed or signature validation fails - */ - public static int getSpotifyAppVersionCode(@NonNull Context context) { - return getSpotifyAppVersionCode(context, new Sha1HashUtilImpl()); - } - - @VisibleForTesting - static int getSpotifyAppVersionCode(@NonNull Context context, @NonNull Sha1HashUtil sha1HashUtil) { - for (String suffix : SPOTIFY_PACKAGE_SUFFIXES) { - String packageName = SPOTIFY_PACKAGE_NAME + suffix; - try { - PackageInfo packageInfo = context.getPackageManager() - .getPackageInfo(packageName, 0); - - // Validate signature before returning version info - if (validateSignature(context, packageName, sha1HashUtil)) { - return packageInfo.versionCode; - } - } catch (PackageManager.NameNotFoundException ignored) { - // Try next package variant - } - } - return -1; // Not found or signature validation failed - } - - /** - * Check if Spotify app version meets minimum requirement - * - * @param context The context of the caller, used to check package info - * @param minVersionCode Minimum required version code - * @return true if installed {@code version >= minVersionCode}, false otherwise - */ - public static boolean isSpotifyVersionAtLeast(@NonNull Context context, int minVersionCode) { - return isSpotifyVersionAtLeast(context, minVersionCode, new Sha1HashUtilImpl()); - } - - @VisibleForTesting - static boolean isSpotifyVersionAtLeast(@NonNull Context context, int minVersionCode, @NonNull Sha1HashUtil sha1HashUtil) { - int currentVersion = getSpotifyAppVersionCode(context, sha1HashUtil); - return currentVersion >= minVersionCode; - } - - @Nullable - private static Intent tryResolveActivity(@NonNull Context context, - @NonNull String packageName, - @NonNull Sha1HashUtil sha1HashUtil) { - Intent intent = new Intent(SPOTIFY_AUTH_ACTIVITY_ACTION); - intent.setPackage(packageName); - - ComponentName componentName = intent.resolveActivity(context.getPackageManager()); - - if (componentName == null) { - return null; - } - - if (!validateSignature(context, componentName.getPackageName(), sha1HashUtil)) { - return null; - } - - return intent; - } - - @SuppressLint("PackageManagerGetSignatures") - private static boolean validateSignature(@NonNull Context context, - @NonNull String spotifyPackageName, - @NonNull Sha1HashUtil sha1HashUtil) { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - final PackageInfo packageInfo = context.getPackageManager().getPackageInfo( - spotifyPackageName, - PackageManager.GET_SIGNING_CERTIFICATES); - - if (packageInfo.signingInfo == null) { - return false; - } - if(packageInfo.signingInfo.hasMultipleSigners()){ - return validateSignatures(sha1HashUtil, packageInfo.signingInfo.getApkContentsSigners()); - } - else{ - return validateSignatures(sha1HashUtil, packageInfo.signingInfo.getSigningCertificateHistory()); - } - } else { - final PackageInfo packageInfo = context.getPackageManager().getPackageInfo( - spotifyPackageName, - PackageManager.GET_SIGNATURES); - - return validateSignatures(sha1HashUtil, packageInfo.signatures); - } - } catch (PackageManager.NameNotFoundException ignored) { - } - return false; - } - - private static boolean validateSignatures(@NonNull Sha1HashUtil sha1HashUtil, - @Nullable Signature[] apkSignatures) { - if (apkSignatures == null || apkSignatures.length == 0) { - return false; - } - - for (Signature actualApkSignature : apkSignatures) { - final String signatureString = actualApkSignature.toCharsString(); - final String sha1Signature = sha1HashUtil.sha1Hash(signatureString); - boolean matchesSignature = false; - for (String knownSpotifyHash : SPOTIFY_SIGNATURE_HASH) { - if (knownSpotifyHash.equalsIgnoreCase(sha1Signature)) { - matchesSignature = true; - break; - } - } - - // Abort upon finding a non matching signature - if (!matchesSignature) { - return false; - } - } - return true; - } - - public void stopAuthActivity() { - mContextActivity.finishActivity(LoginActivity.REQUEST_CODE); - } -} diff --git a/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AccountsQueryParameters.kt b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AccountsQueryParameters.kt new file mode 100644 index 0000000..5c1d4f6 --- /dev/null +++ b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AccountsQueryParameters.kt @@ -0,0 +1,19 @@ +package com.spotify.sdk.android.auth + +object AccountsQueryParameters { + const val CLIENT_ID = "client_id" + const val RESPONSE_TYPE = "response_type" + const val REDIRECT_URI = "redirect_uri" + const val STATE = "state" + const val SCOPE = "scope" + const val SHOW_DIALOG = "show_dialog" + const val UTM_SOURCE = "utm_source" + const val UTM_MEDIUM = "utm_medium" + const val UTM_CAMPAIGN = "utm_campaign" + const val ERROR = "error" + const val CODE = "code" + const val ACCESS_TOKEN = "access_token" + const val EXPIRES_IN = "expires_in" + const val CODE_CHALLENGE = "code_challenge" + const val CODE_CHALLENGE_METHOD = "code_challenge_method" +} 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 new file mode 100644 index 0000000..c9d144d --- /dev/null +++ b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AuthorizationClient.kt @@ -0,0 +1,588 @@ +/* + * 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 + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.net.Uri +import android.text.TextUtils +import android.util.Log +import androidx.annotation.VisibleForTesting +import com.spotify.sdk.android.auth.app.SpotifyAuthHandler +import com.spotify.sdk.android.auth.app.SpotifyNativeAuthUtil +import java.security.NoSuchAlgorithmException + +/** + * AuthorizationClient provides helper methods to initialize and manage the Spotify authorization flow. + * + * This client provides two versions of authorization: + * + * ## 1. Single Sign-On using Spotify Android application with browser fallback + * + * The SDK will try to fetch the authorization code/access token using the Spotify Android client. + * If Spotify is not installed on the device, SDK will fallback to Custom Tabs based authorization + * and open [Spotify Accounts Service](https://accounts.spotify.com) in a dialog. + * After authorization flow is completed, result is returned to the activity that invoked [AuthorizationClient]. + * + * **If Spotify is installed:** SDK will connect to the Spotify client and fetch the authorization code/access token + * for current user. Since the user is already logged into Spotify they don't need to fill their username and password. + * If the SDK application requests scopes that have not been approved, the user will see a list of scopes and can + * choose to approve or reject them. + * + * **If Spotify is not installed:** SDK will open a dialog and load Spotify Accounts Service into a + * [Custom Tab](https://developer.chrome.com/docs/android/custom-tabs/) of a supported browser. + * In case there's no browser installed that supports Custom Tabs API, the SDK will fallback to opening the + * Accounts page in the user's default browser. User will have to enter their username and password to login to Spotify. + * They will also need to approve any scopes that the SDK application requests and that they haven't approved before. + * + * In both cases (SSO and browser fallback), the result of the authorization flow will be returned in the + * `onActivityResult` method of the activity that initiated it. + * + * ### Example: Using from an Activity + * ```kotlin + * // Code called from an activity + * private const val REQUEST_CODE = 1337 + * + * val request = AuthorizationRequest.Builder(CLIENT_ID, AuthorizationResponse.Type.TOKEN, REDIRECT_URI) + * .setScopes(arrayOf("user-read-private", "playlist-read", "playlist-read-private", "streaming")) + * .build() + * + * AuthorizationClient.openLoginActivity(this, REQUEST_CODE, request) + * ``` + * + * ### Example: Using from a Fragment + * It is also possible to use [LoginActivity] from other components such as Fragments: + * ```kotlin + * // To start LoginActivity from a Fragment: + * val intent = AuthorizationClient.createLoginActivityIntent(requireActivity(), request) + * startActivityForResult(intent, REQUEST_CODE) + * + * // To close LoginActivity + * AuthorizationClient.stopLoginActivity(requireActivity(), REQUEST_CODE) + * ``` + * + * ### Example: Processing the result + * To process the result, your activity needs to override `onActivityResult` callback: + * ```kotlin + * override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { + * super.onActivityResult(requestCode, resultCode, intent) + * + * // Check if result comes from the correct activity + * if (requestCode == REQUEST_CODE) { + * val response = AuthorizationClient.getResponse(resultCode, intent) + * when (response.type) { + * // Response was successful and contains auth token + * AuthorizationResponse.Type.TOKEN -> { + * // Handle successful response + * val token = response.accessToken + * } + * // Auth flow returned an error + * AuthorizationResponse.Type.ERROR -> { + * // Handle error response + * } + * // Most likely auth flow was cancelled + * else -> { + * // Handle other cases + * } + * } + * } + * } + * ``` + * + * ## 2. Opening Spotify Accounts Service in a web browser + * + * In this scenario the SDK creates an intent that will open the browser. Authorization + * takes part in the browser (not in the SDK application). After authorization is completed + * browser redirects back to the SDK app. + * + * ### Example: Browser-based authorization + * ```kotlin + * // Code called from an activity + * val request = AuthorizationRequest.Builder(CLIENT_ID, AuthorizationResponse.Type.TOKEN, REDIRECT_URI) + * .setScopes(arrayOf("user-read-private", "playlist-read", "playlist-read-private", "streaming")) + * .build() + * + * AuthorizationClient.openLoginInBrowser(this, request) + * ``` + * + * To process the result, the receiving activity needs to override one of its callbacks. With launch mode + * set to `singleInstance` this callback is `onNewIntent`: + * + * ```kotlin + * override fun onNewIntent(intent: Intent) { + * super.onNewIntent(intent) + * val uri = intent.data + * if (uri != null) { + * val response = AuthorizationResponse.fromUri(uri) + * when (response.type) { + * // Response was successful and contains auth token + * AuthorizationResponse.Type.TOKEN -> { + * // Handle successful response + * val token = response.accessToken + * } + * // Auth flow returned an error + * AuthorizationResponse.Type.ERROR -> { + * // Handle error response + * } + * // Most likely auth flow was cancelled + * else -> { + * // Handle other cases + * } + * } + * } + * } + * ``` + * + * @see [Web API Authorization guide](https://developer.spotify.com/web-api/authorization-guide) + */ +class AuthorizationClient( + /** + * The activity that receives and processes the result of authorization flow + * and returns it to the context activity that invoked the flow. + * An instance of [LoginActivity] + */ + private val loginActivity: Activity +) { + + private var authorizationPending = false + + /** + * A handler that performs authorization. + * It is created with [loginActivity] as a context. + * This activity will receive the result through the [AuthorizationClientListener] + */ + private var currentHandler: AuthorizationHandler? = null + private val authorizationHandlers: MutableList = ArrayList() + private var authorizationClientListener: AuthorizationClientListener? = null + + init { + authorizationHandlers.add(SpotifyAuthHandler()) + authorizationHandlers.add(FallbackHandlerProvider().provideFallback()) + } + + /** + * Listener interface for receiving authorization flow completion events. + */ + interface AuthorizationClientListener { + /** + * Auth flow was completed. + * The response can be successful and contain access token or authorization code. + * The response can be an error response and contain error message. + * It can also be an empty response which indicates that the user cancelled authorization flow. + * + * @param response Response containing a result of authorization flow. + */ + fun onClientComplete(response: AuthorizationResponse) + + /** + * Auth flow was cancelled before it could be completed. + * This callback indicates that the auth flow was interrupted, + * for example because the underlying LoginActivity was paused or stopped. + * This is different from the situation when user completes the flow + * by closing LoginActivity (e.g. by pressing the back button). + */ + fun onClientCancelled() + } + + /** + * Sets the listener that will be used when authorization flow returns a result. + * + * @param listener The listener to be notified when authorization flow completes. + */ + fun setOnCompleteListener(listener: AuthorizationClientListener?) { + authorizationClientListener = listener + } + + fun authorize(request: AuthorizationRequest) { + if (authorizationPending) return + authorizationPending = true + + val processedRequest = validateAndConvertTokenRequest(request) + + for (authHandler in authorizationHandlers) { + if (tryAuthorizationHandler(authHandler, processedRequest)) { + currentHandler = authHandler + break + } + } + } + + fun cancel() { + if (!authorizationPending) { + return + } + + authorizationPending = false + closeAuthorizationHandler(currentHandler) + + authorizationClientListener?.onClientCancelled() + authorizationClientListener = null + } + + fun complete(response: AuthorizationResponse) { + sendComplete(currentHandler, response) + } + + private fun sendComplete(authHandler: AuthorizationHandler?, response: AuthorizationResponse) { + authorizationPending = false + closeAuthorizationHandler(authHandler) + + if (authorizationClientListener != null) { + authorizationClientListener?.onClientComplete(response) + authorizationClientListener = null + } else { + Log.w(TAG, "Can't deliver the Spotify Auth response. The listener is null") + } + } + + private fun tryAuthorizationHandler( + authHandler: AuthorizationHandler, + request: AuthorizationRequest + ): Boolean { + authHandler.setOnCompleteListener(object : AuthorizationHandler.OnCompleteListener { + override fun onComplete(response: AuthorizationResponse) { + Log.i(TAG, String.format("Spotify auth response:%s", response.type.name)) + sendComplete(authHandler, response) + } + + override fun onCancel() { + Log.i(TAG, "Spotify auth response: User cancelled") + val response = AuthorizationResponse.Builder() + .setType(AuthorizationResponse.Type.EMPTY) + .build() + sendComplete(authHandler, response) + } + + override fun onError(error: Throwable) { + Log.e(TAG, "Spotify auth Error", error) + val response = AuthorizationResponse.Builder() + .setType(AuthorizationResponse.Type.ERROR) + .setError(error.message) + .build() + sendComplete(authHandler, response) + } + }) + + if (!authHandler.start(loginActivity, request)) { + closeAuthorizationHandler(authHandler) + return false + } + return true + } + + private fun closeAuthorizationHandler(authHandler: AuthorizationHandler?) { + authHandler?.setOnCompleteListener(null) + authHandler?.stop() + } + + fun notifyInCaseUserCanceledAuth() { + if (currentHandler?.isAuthInProgress() == true) { + Log.i(TAG, "Spotify auth response: User cancelled") + val response = AuthorizationResponse.Builder() + .setType(AuthorizationResponse.Type.EMPTY) + .build() + complete(response) + } + } + + fun clearAuthInProgress() { + if (currentHandler != null) { + Log.d(TAG, "Clearing auth in progress state") + currentHandler?.stop() + currentHandler = null + } + } + + private fun validateAndConvertTokenRequest(request: AuthorizationRequest): AuthorizationRequest { + val isTokenRequest = AuthorizationResponse.Type.TOKEN.toString() == request.responseType + val hasPkce = request.pkceInformation != null + + if (!isTokenRequest || !hasPkce) { + return request + } + + val isSpotifyInstalled = SpotifyNativeAuthUtil.isSpotifyInstalled(loginActivity) + val isPKCESpotifyVersion = SpotifyNativeAuthUtil.isSpotifyVersionAtLeast( + loginActivity, + MIN_SPOTIFY_VERSION_FOR_TOKEN_CONVERSION + ) + + val shouldConvert = !isSpotifyInstalled || isPKCESpotifyVersion + + if (!shouldConvert) { + return request + } + + return AuthorizationRequest.Builder( + request.clientId, + AuthorizationResponse.Type.CODE, + request.redirectUri + ) + .setState(request.state) + .setScopes(request.scopes) + .setCampaign(request.getCampaign()) + .setPkceInformation(request.pkceInformation) + .build() + } + + companion object { + private const val TAG = "Spotify Auth Client" + + const val MARKET_VIEW_PATH = "market://" + const val MARKET_SCHEME = "market" + const val MARKET_PATH = "details" + + const val PLAY_STORE_SCHEME = "https" + const val PLAY_STORE_AUTHORITY = "play.google.com" + const val PLAY_STORE_PATH = "store/apps/details" + + const val SPOTIFY_ID = "com.spotify.music" + const val SPOTIFY_SDK = "spotify-sdk" + const val ANDROID_SDK = "android-sdk" + const val DEFAULT_CAMPAIGN = "android-sdk" + + /** + * Minimum Spotify app version code required for TOKEN to CODE conversion. + * Corresponds to version name 9.0.78.360. + */ + @VisibleForTesting + const val MIN_SPOTIFY_VERSION_FOR_TOKEN_CONVERSION = 132384743 + + /** + * Query parameters for Play Store intents. + */ + object PlayStoreParams { + const val ID = "id" + const val REFERRER = "referrer" + const val UTM_SOURCE = "utm_source" + const val UTM_MEDIUM = "utm_medium" + const val UTM_CAMPAIGN = "utm_campaign" + } + + /** + * Triggers an intent to open the Spotify accounts service in a browser. + * + * Make sure that the [redirectUri][AuthorizationRequest.redirectUri] is set to a URI your app is registered for + * in your AndroidManifest.xml. To get your clientId and to set the redirectUri, please see the + * [my applications](https://developer.spotify.com/my-applications) part of our developer site. + * + * @param contextActivity The activity that should start the intent to open a browser. + * @param request Authorization request containing client credentials and configuration. + */ + @JvmStatic + fun openLoginInBrowser(contextActivity: Activity, request: AuthorizationRequest) { + val launchBrowser = Intent(Intent.ACTION_VIEW, request.toUri()) + contextActivity.startActivity(launchBrowser) + } + + /** + * Creates an intent to open the [LoginActivity]. + * + * This method can be used to open this activity from components different than activities; + * for example Fragments. + * + * ### Example + * ```kotlin + * // To start LoginActivity from a Fragment: + * val intent = AuthorizationClient.createLoginActivityIntent(requireActivity(), request) + * startActivityForResult(intent, REQUEST_CODE) + * + * // To close LoginActivity + * AuthorizationClient.stopLoginActivity(requireActivity(), REQUEST_CODE) + * ``` + * + * @param contextActivity The activity context to use for the intent. + * @param request Authorization request containing client credentials and configuration. + * @return Intent that can be used to start LoginActivity. + */ + @JvmStatic + fun createLoginActivityIntent( + contextActivity: Activity, + request: AuthorizationRequest + ): Intent { + val processedRequest = appendPkceIfTokenRequest(contextActivity, request) + val intent = LoginActivity.getAuthIntent(contextActivity, processedRequest) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + return intent + } + + /** + * Opens the [LoginActivity] for result. + * + * ### Example + * ```kotlin + * private const val REQUEST_CODE = 1337 + * + * val request = AuthorizationRequest.Builder(CLIENT_ID, AuthorizationResponse.Type.TOKEN, REDIRECT_URI) + * .setScopes(arrayOf("user-read-private", "playlist-read")) + * .build() + * + * AuthorizationClient.openLoginActivity(this, REQUEST_CODE, request) + * ``` + * + * @param contextActivity The activity that should start the intent to open LoginActivity. + * @param requestCode Request code for activity result. + * @param request Authorization request containing client credentials and configuration. + */ + @JvmStatic + fun openLoginActivity( + contextActivity: Activity, + requestCode: Int, + request: AuthorizationRequest + ) { + val intent = createLoginActivityIntent(contextActivity, request) + contextActivity.startActivityForResult(intent, requestCode) + } + + /** + * Stops any running LoginActivity. + * + * @param contextActivity The activity that was used to launch LoginActivity with [openLoginActivity]. + * @param requestCode Request code that was used to launch LoginActivity. + */ + @JvmStatic + fun stopLoginActivity(contextActivity: Activity, requestCode: Int) { + contextActivity.finishActivity(requestCode) + } + + /** + * Extracts [AuthorizationResponse] from the LoginActivity result. + * + * @param resultCode Result code returned with the activity result. + * @param intent Intent received with activity result. Should contain a Uri with result data. + * @return Response object containing the authorization result. + */ + @JvmStatic + fun getResponse(resultCode: Int, intent: Intent?): AuthorizationResponse { + return if (resultCode == Activity.RESULT_OK) { + LoginActivity.getResponseFromIntent(intent) ?: AuthorizationResponse.Builder() + .setType(AuthorizationResponse.Type.EMPTY) + .build() + } else { + AuthorizationResponse.Builder() + .setType(AuthorizationResponse.Type.EMPTY) + .build() + } + } + + /** + * Opens Spotify in the Play Store or browser. + * + * @param contextActivity The activity that should start the intent to open the download page. + * @param campaign A Spotify-provided campaign ID. Uses [DEFAULT_CAMPAIGN] if not provided. + */ + @JvmStatic + @JvmOverloads + fun openDownloadSpotifyActivity( + contextActivity: Activity, + campaign: String? = DEFAULT_CAMPAIGN + ) { + val uriBuilder = Uri.Builder() + + if (isAvailable( + contextActivity, + Intent(Intent.ACTION_VIEW, Uri.parse(MARKET_VIEW_PATH)) + ) + ) { + uriBuilder.scheme(MARKET_SCHEME) + .appendPath(MARKET_PATH) + } else { + uriBuilder.scheme(PLAY_STORE_SCHEME) + .authority(PLAY_STORE_AUTHORITY) + .appendEncodedPath(PLAY_STORE_PATH) + } + + uriBuilder.appendQueryParameter(PlayStoreParams.ID, SPOTIFY_ID) + + val referrerBuilder = Uri.Builder() + referrerBuilder.appendQueryParameter(PlayStoreParams.UTM_SOURCE, SPOTIFY_SDK) + .appendQueryParameter(PlayStoreParams.UTM_MEDIUM, ANDROID_SDK) + + if (TextUtils.isEmpty(campaign)) { + referrerBuilder.appendQueryParameter(PlayStoreParams.UTM_CAMPAIGN, DEFAULT_CAMPAIGN) + } else { + referrerBuilder.appendQueryParameter(PlayStoreParams.UTM_CAMPAIGN, campaign) + } + + uriBuilder.appendQueryParameter( + PlayStoreParams.REFERRER, + referrerBuilder.build().encodedQuery + ) + + contextActivity.startActivity(Intent(Intent.ACTION_VIEW, uriBuilder.build())) + } + + /** + * Checks if there is an activity available to handle the given intent. + * + * @param ctx Context to use for PackageManager access. + * @param intent Intent to check for available handlers. + * @return `true` if at least one activity can handle the intent, `false` otherwise. + */ + @JvmStatic + fun isAvailable(ctx: Context, intent: Intent): Boolean { + val mgr = ctx.packageManager + val list: List = mgr.queryIntentActivities( + intent, + PackageManager.MATCH_DEFAULT_ONLY + ) + return list.isNotEmpty() + } + + private fun appendPkceIfTokenRequest( + context: Context, + request: AuthorizationRequest + ): AuthorizationRequest { + val isTokenRequest = AuthorizationResponse.Type.TOKEN.toString() == request.responseType + val isSpotifyInstalled = SpotifyNativeAuthUtil.isSpotifyInstalled(context) + val isPKCESpotifyVersion = SpotifyNativeAuthUtil.isSpotifyVersionAtLeast( + context, + MIN_SPOTIFY_VERSION_FOR_TOKEN_CONVERSION + ) + val hasSpotifyVersionWithoutPKCESupportInstalled = + isSpotifyInstalled && !isPKCESpotifyVersion + if (!isTokenRequest || hasSpotifyVersionWithoutPKCESupportInstalled) { + return request + } + + return try { + val pkceInfo = PKCEInformationFactory.create() + + AuthorizationRequest.Builder( + request.clientId, + AuthorizationResponse.Type.TOKEN, + request.redirectUri + ) + .setState(request.state) + .setScopes(request.scopes) + .setCampaign(request.getCampaign()) + .setPkceInformation(pkceInfo) + .build() + } catch (e: NoSuchAlgorithmException) { + throw RuntimeException("Failed to generate PKCE information: " + e.message, e) + } + } + } +} diff --git a/auth-lib/src/main/java/com/spotify/sdk/android/auth/AuthorizationHandler.java b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AuthorizationHandler.kt similarity index 62% rename from auth-lib/src/main/java/com/spotify/sdk/android/auth/AuthorizationHandler.java rename to auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AuthorizationHandler.kt index e64c58f..2baac4d 100644 --- a/auth-lib/src/main/java/com/spotify/sdk/android/auth/AuthorizationHandler.java +++ b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AuthorizationHandler.kt @@ -19,29 +19,25 @@ * under the License. */ -package com.spotify.sdk.android.auth; +package com.spotify.sdk.android.auth -import android.app.Activity; +import android.app.Activity -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -public interface AuthorizationHandler { +interface AuthorizationHandler { interface OnCompleteListener { - void onComplete(@NonNull AuthorizationResponse response); - - void onCancel(); + fun onComplete(response: AuthorizationResponse) - void onError(@NonNull Throwable error); + fun onCancel() + fun onError(error: Throwable) } - boolean start(@NonNull Activity contextActivity, @NonNull AuthorizationRequest request); + fun start(contextActivity: Activity, request: AuthorizationRequest): Boolean - void stop(); + fun stop() - void setOnCompleteListener(@Nullable OnCompleteListener listener); + fun setOnCompleteListener(listener: OnCompleteListener?) - boolean isAuthInProgress(); + fun isAuthInProgress(): Boolean } diff --git a/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AuthorizationRequest.kt b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AuthorizationRequest.kt new file mode 100644 index 0000000..1d05ac2 --- /dev/null +++ b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AuthorizationRequest.kt @@ -0,0 +1,263 @@ +/* + * 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 + +import android.net.Uri +import android.os.Bundle +import android.os.Parcel +import android.os.Parcelable +import android.text.TextUtils +import androidx.annotation.VisibleForTesting + +/** + * An object that helps construct the request that is sent to Spotify authorization service. + * To create one use [AuthorizationRequest.Builder] + * + * @see Web API Authorization guide + */ +data class AuthorizationRequest internal constructor( + val clientId: String, + val responseType: String, + val redirectUri: String, + val state: String?, + val scopes: Array?, + val showDialog: Boolean, + val customParams: Map, + private val campaign: String?, + val pkceInformation: PKCEInformation? +) : Parcelable { + + @Suppress("DEPRECATION") + constructor(source: Parcel) : this( + clientId = source.readString() ?: throw IllegalStateException("clientId cannot be null in parcel"), + responseType = source.readString() ?: throw IllegalStateException("responseType cannot be null in parcel"), + redirectUri = source.readString() ?: throw IllegalStateException("redirectUri cannot be null in parcel"), + state = source.readString(), + scopes = source.createStringArray(), + showDialog = source.readByte() == 1.toByte(), + customParams = HashMap(), + campaign = source.readString(), + pkceInformation = source.readParcelable(PKCEInformation::class.java.classLoader) + ) { + val bundle = source.readBundle(javaClass.classLoader) ?: Bundle() + for (key in bundle.keySet()) { + bundle.getString(key)?.let { value -> + (customParams as HashMap)[key] = value + } + } + } + + fun getCustomParam(key: String): String? { + return customParams[key] + } + + fun getCampaign(): String { + return campaign?.takeUnless { TextUtils.isEmpty(it) } ?: ANDROID_SDK + } + + fun getSource(): String = SPOTIFY_SDK + + fun getMedium(): String = ANDROID_SDK + + fun toUri(): Uri { + val uriBuilder = Uri.Builder() + uriBuilder.scheme(ACCOUNTS_SCHEME) + .authority(ACCOUNTS_AUTHORITY) + .appendPath(ACCOUNTS_PATH) + .appendQueryParameter(AccountsQueryParameters.CLIENT_ID, clientId) + .appendQueryParameter(AccountsQueryParameters.RESPONSE_TYPE, responseType) + .appendQueryParameter(AccountsQueryParameters.REDIRECT_URI, redirectUri) + .appendQueryParameter(AccountsQueryParameters.SHOW_DIALOG, showDialog.toString()) + .appendQueryParameter(AccountsQueryParameters.UTM_SOURCE, getSource()) + .appendQueryParameter(AccountsQueryParameters.UTM_MEDIUM, getMedium()) + .appendQueryParameter(AccountsQueryParameters.UTM_CAMPAIGN, getCampaign()) + + if (scopes != null && scopes.isNotEmpty()) { + uriBuilder.appendQueryParameter(AccountsQueryParameters.SCOPE, scopesToString()) + } + + if (state != null) { + uriBuilder.appendQueryParameter(AccountsQueryParameters.STATE, state) + } + + if (customParams.isNotEmpty()) { + for ((key, value) in customParams) { + uriBuilder.appendQueryParameter(key, value) + } + } + + if (pkceInformation != null) { + uriBuilder.appendQueryParameter(AccountsQueryParameters.CODE_CHALLENGE, pkceInformation.challenge) + uriBuilder.appendQueryParameter(AccountsQueryParameters.CODE_CHALLENGE_METHOD, pkceInformation.codeChallengeMethod) + } + + return uriBuilder.build() + } + + private fun scopesToString(): String { + if (scopes == null) return "" + val concatScopes = StringBuilder() + for (scope in scopes) { + concatScopes.append(scope) + concatScopes.append(SCOPES_SEPARATOR) + } + return concatScopes.toString().trim() + } + + override fun describeContents(): Int = 0 + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeString(clientId) + dest.writeString(responseType) + dest.writeString(redirectUri) + dest.writeString(state) + dest.writeStringArray(scopes) + dest.writeByte(if (showDialog) 1 else 0) + dest.writeString(campaign) + dest.writeParcelable(pkceInformation, flags) + + val bundle = Bundle() + for ((key, value) in customParams) { + bundle.putString(key, value) + } + dest.writeBundle(bundle) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AuthorizationRequest + + if (clientId != other.clientId) return false + if (responseType != other.responseType) return false + if (redirectUri != other.redirectUri) return false + if (state != other.state) return false + if (scopes != null) { + if (other.scopes == null) return false + if (!scopes.contentEquals(other.scopes)) return false + } else if (other.scopes != null) return false + if (showDialog != other.showDialog) return false + if (customParams != other.customParams) return false + if (campaign != other.campaign) return false + if (pkceInformation != other.pkceInformation) return false + + return true + } + + override fun hashCode(): Int { + var result = clientId.hashCode() + result = 31 * result + responseType.hashCode() + result = 31 * result + redirectUri.hashCode() + result = 31 * result + (state?.hashCode() ?: 0) + result = 31 * result + (scopes?.contentHashCode() ?: 0) + result = 31 * result + showDialog.hashCode() + result = 31 * result + customParams.hashCode() + result = 31 * result + (campaign?.hashCode() ?: 0) + result = 31 * result + (pkceInformation?.hashCode() ?: 0) + return result + } + + /** + * Use this builder to create an [AuthorizationRequest] + * + * @see AuthorizationRequest + */ + class Builder( + private val clientId: String, + private val responseType: AuthorizationResponse.Type, + private val redirectUri: String + ) { + private var state: String? = null + private var scopes: Array? = null + private var showDialog: Boolean = false + private var campaign: String? = null + private var pkceInformation: PKCEInformation? = null + private val customParams = HashMap() + + init { + require(redirectUri.isNotEmpty()) { "Redirect URI cannot be empty" } + } + + fun setState(state: String?) = apply { + this.state = state + } + + fun setScopes(scopes: Array?) = apply { + this.scopes = scopes + } + + fun setShowDialog(showDialog: Boolean) = apply { + this.showDialog = showDialog + } + + fun setCustomParam(key: String, value: String) = apply { + require(key.isNotEmpty()) { "Custom parameter key cannot be empty" } + require(value.isNotEmpty()) { "Custom parameter value cannot be empty" } + customParams[key] = value + } + + fun setCampaign(campaign: String?) = apply { + this.campaign = campaign + } + + fun setPkceInformation(pkceInformation: PKCEInformation?) = apply { + this.pkceInformation = pkceInformation + } + + fun build(): AuthorizationRequest { + return AuthorizationRequest( + clientId, + responseType.toString(), + redirectUri, + state, + scopes, + showDialog, + customParams, + campaign, + pkceInformation + ) + } + } + + companion object { + const val ACCOUNTS_SCHEME = "https" + const val ACCOUNTS_AUTHORITY = "accounts.spotify.com" + const val ACCOUNTS_PATH = "authorize" + const val SCOPES_SEPARATOR = " " + @VisibleForTesting + const val SPOTIFY_SDK = "spotify-sdk" + @VisibleForTesting + const val ANDROID_SDK = "android-sdk" + + @JvmField + val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(source: Parcel): AuthorizationRequest { + return AuthorizationRequest(source) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } +} 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 new file mode 100644 index 0000000..4634534 --- /dev/null +++ b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/AuthorizationResponse.kt @@ -0,0 +1,212 @@ +/* + * 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 + +import android.net.Uri +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * An object that contains the parsed response from the Spotify authorization service. + * To create one use [AuthorizationResponse.Builder] or + * parse from [android.net.Uri] with [fromUri] + * + * @see Web API Authorization guide + */ +@Parcelize +data class AuthorizationResponse internal constructor( + val type: Type, + val code: String?, + val accessToken: String?, + val state: String?, + val error: String?, + val expiresIn: Int, + val refreshToken: String? +) : Parcelable { + + /** + * The type of the authorization response. + */ + enum class Type(private val type: String) { + /** + * The response is a code reply. + * + * @see Authorization code flow + */ + CODE("code"), + + /** + * The response is an implicit grant with access token. + * + * @see Implicit grant flow + */ + TOKEN("token"), + + /** + * The response is an error response. + * + * @see Web API Authorization guide + */ + ERROR("error"), + + /** + * Response doesn't contain data because auth flow was cancelled or LoginActivity killed. + */ + EMPTY("empty"), + + /** + * The response is unknown. + */ + UNKNOWN("unknown"); + + override fun toString(): String { + return type + } + } + + /** + * Use this builder to create an [AuthorizationResponse] + * + * @see AuthorizationResponse + */ + class Builder { + private var type: Type? = null + private var code: String? = null + private var accessToken: String? = null + private var state: String? = null + private var error: String? = null + private var expiresIn: Int = 0 + private var refreshToken: String? = null + + fun setType(type: Type) = apply { + this.type = type + } + + fun setCode(code: String?) = apply { + this.code = code + } + + fun setAccessToken(accessToken: String?) = apply { + this.accessToken = accessToken + } + + fun setState(state: String?) = apply { + this.state = state + } + + fun setError(error: String?) = apply { + this.error = error + } + + fun setExpiresIn(expiresIn: Int) = apply { + this.expiresIn = expiresIn + } + + fun setRefreshToken(refreshToken: String?) = apply { + this.refreshToken = refreshToken + } + + fun build(): AuthorizationResponse { + return AuthorizationResponse( + type ?: Type.UNKNOWN, + code, + accessToken, + state, + error, + expiresIn, + refreshToken + ) + } + } + + companion object { + /** + * Parses the URI returned from the Spotify accounts service. + * + * @param uri URI + * @return Authorization response. If parsing failed, this object will be populated with + * the given error codes. + */ + @JvmStatic + fun fromUri(uri: Uri?): AuthorizationResponse { + val builder = Builder() + if (uri == null) { + builder.setType(Type.EMPTY) + return builder.build() + } + + val possibleError = uri.getQueryParameter(AccountsQueryParameters.ERROR) + if (possibleError != null) { + val state = uri.getQueryParameter(AccountsQueryParameters.STATE) + builder.setError(possibleError) + builder.setState(state) + builder.setType(Type.ERROR) + return builder.build() + } + + val possibleCode = uri.getQueryParameter(AccountsQueryParameters.CODE) + if (possibleCode != null) { + val state = uri.getQueryParameter(AccountsQueryParameters.STATE) + builder.setCode(possibleCode) + builder.setState(state) + builder.setType(Type.CODE) + return builder.build() + } + + val possibleImplicit = uri.encodedFragment + if (possibleImplicit != null && possibleImplicit.isNotEmpty()) { + val parts = possibleImplicit.split("&") + var accessToken: String? = null + var state: String? = null + var expiresIn: String? = null + for (part in parts) { + val partSplit = part.split("=") + if (partSplit.size == 2) { + if (partSplit[0].startsWith(AccountsQueryParameters.ACCESS_TOKEN)) { + accessToken = Uri.decode(partSplit[1]) + } + if (partSplit[0].startsWith(AccountsQueryParameters.STATE)) { + state = Uri.decode(partSplit[1]) + } + if (partSplit[0].startsWith(AccountsQueryParameters.EXPIRES_IN)) { + expiresIn = Uri.decode(partSplit[1]) + } + } + } + builder.setAccessToken(accessToken) + builder.setState(state) + if (expiresIn != null) { + try { + builder.setExpiresIn(expiresIn.toInt()) + } catch (e: NumberFormatException) { + // Ignore + } + } + builder.setType(Type.TOKEN) + return builder.build() + } + + builder.setType(Type.UNKNOWN) + return builder.build() + } + } +} diff --git a/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/IntentExtras.kt b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/IntentExtras.kt new file mode 100644 index 0000000..d8f8a22 --- /dev/null +++ b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/IntentExtras.kt @@ -0,0 +1,29 @@ +package com.spotify.sdk.android.auth + +/* + * Constants below have their counterparts in Spotify app where + * they're used to parse messages received from SDK. + * If any of these values needs to be changed, a new protocol version needs to be created on both + * sides (auth-lib and Spotify app) and bumped accordingly. + */ +object IntentExtras { + const val KEY_CLIENT_ID = "CLIENT_ID" + const val KEY_REQUESTED_SCOPES = "SCOPES" + const val KEY_STATE = "STATE" + const val KEY_UTM_SOURCE = "UTM_SOURCE" + const val KEY_UTM_MEDIUM = "UTM_MEDIUM" + const val KEY_UTM_CAMPAIGN = "UTM_CAMPAIGN" + const val KEY_REDIRECT_URI = "REDIRECT_URI" + const val KEY_RESPONSE_TYPE = "RESPONSE_TYPE" + const val KEY_ACCESS_TOKEN = "ACCESS_TOKEN" + const val KEY_AUTHORIZATION_CODE = "AUTHORIZATION_CODE" + const val KEY_EXPIRES_IN = "EXPIRES_IN" + const val KEY_CODE_CHALLENGE = "CODE_CHALLENGE" + const val KEY_CODE_CHALLENGE_METHOD = "CODE_CHALLENGE_METHOD" + /* + * This is used to pass information about the protocol version + * to the AuthorizationActivity. + * DO NOT CHANGE THIS. + */ + const val KEY_VERSION = "VERSION" +} 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 new file mode 100644 index 0000000..7c0577d --- /dev/null +++ b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/LoginActivity.kt @@ -0,0 +1,322 @@ +/* + * 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 + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.spotify.sdk.android.auth.AuthorizationResponse.Type +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +/** + * The activity that manages the login flow. + * It should not be started directly. Instead use + * [AuthorizationClient.openLoginActivity] + */ +class LoginActivity : Activity(), AuthorizationClient.AuthorizationClientListener { + + private val authorizationClient = AuthorizationClient(this) + private val executorService: ExecutorService = Executors.newSingleThreadExecutor() + private val mainHandler = Handler(Looper.getMainLooper()) + + override fun onNewIntent(intent: Intent) { + val originalRequest = getRequestFromIntent() + super.onNewIntent(intent) + val responseUri = intent.data + + // Clear auth-in-progress state to prevent onResume from thinking user canceled + if (responseUri != null) { + authorizationClient.clearAuthInProgress() + } + + val response = AuthorizationResponse.fromUri(responseUri) + + // Check if this is a CODE response from web fallback that needs token exchange + if (response.type == Type.CODE) { + // Check if original request was for TOKEN and has PKCE info + response.code?.let { code -> + if (originalRequest != null && + originalRequest.responseType == Type.TOKEN.toString() && + originalRequest.pkceInformation != null + ) { + // Perform PKCE token exchange for web fallback + val responseBuilder = AuthorizationResponse.Builder() + .setType(Type.TOKEN) + .setState(response.state) + + performPkceTokenExchange(code, originalRequest, responseBuilder) + return // Don't complete immediately, wait for async result + } + } + } + + // Handle normal responses (TOKEN from web, errors, etc.) + authorizationClient.complete(response) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.com_spotify_sdk_login_activity) + + val request = getRequestFromIntent() + + authorizationClient.setOnCompleteListener(this) + + if (callingActivity == null) { + Log.e(TAG, NO_CALLER_ERROR) + finish() + } else if (request == null) { + Log.e(TAG, NO_REQUEST_ERROR) + setResult(RESULT_CANCELED) + finish() + } else if (savedInstanceState == null) { + Log.d(TAG, String.format("Spotify Auth starting with the request [%s]", request.toUri().toString())) + authorizationClient.authorize(request) + } + } + + private fun getRequestFromIntent(): AuthorizationRequest? { + val requestBundle = intent.getBundleExtra(EXTRA_AUTH_REQUEST) ?: return null + return requestBundle.getParcelable(REQUEST_KEY) + } + + override fun onResume() { + super.onResume() + // onResume is called (except other cases) in the case + // of browser based auth flow when user pressed back/closed the Custom Tab and + // LoginActivity came to the foreground again. + authorizationClient.notifyInCaseUserCanceledAuth() + } + + override fun onDestroy() { + authorizationClient.cancel() + authorizationClient.setOnCompleteListener(null) + executorService.shutdown() + super.onDestroy() + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { + super.onActivityResult(requestCode, resultCode, intent) + if (requestCode == REQUEST_CODE) { + val response = AuthorizationResponse.Builder() + + if (resultCode == RESULT_ERROR) { + response.setType(Type.ERROR) + + val errorMessage = if (intent == null) { + "Invalid message format" + } else { + intent.getStringExtra(EXTRA_ERROR) ?: "Unknown error" + } + response.setError(errorMessage) + + } else if (resultCode == RESULT_OK) { + @Suppress("DEPRECATION") + val data: Bundle? = intent?.getParcelableExtra(EXTRA_REPLY) + + if (data == null) { + response.setType(Type.ERROR) + response.setError("Missing response data") + } else { + val responseType = data.getString(IntentExtras.KEY_RESPONSE_TYPE, "unknown") + Log.d(TAG, "Response: $responseType") + response.setState(data.getString(IntentExtras.KEY_STATE, null)) + when (responseType) { + RESPONSE_TYPE_TOKEN -> { + val token = data.getString(IntentExtras.KEY_ACCESS_TOKEN) + val expiresIn = data.getInt(IntentExtras.KEY_EXPIRES_IN) + + response.setType(Type.TOKEN) + response.setAccessToken(token) + response.setExpiresIn(expiresIn) + } + RESPONSE_TYPE_CODE -> { + val code = data.getString(IntentExtras.KEY_AUTHORIZATION_CODE) + val originalRequest = getRequestFromIntent() + + // Check if original request was for TOKEN and has PKCE info + if (code != null && originalRequest != null && + originalRequest.responseType == Type.TOKEN.toString() + ) { + if (originalRequest.pkceInformation != null) { + // Perform PKCE token exchange + performPkceTokenExchange(code, originalRequest, response) + return // Don't complete immediately, wait for async result + } else { + throw IllegalStateException( + "Exchanging the code for a token requires PKCE parameters" + ) + } + } else { + // Regular code response + response.setType(Type.CODE) + response.setCode(code) + } + } + else -> response.setType(Type.UNKNOWN) + } + } + + } else { + response.setType(Type.EMPTY) + } + + authorizationClient.setOnCompleteListener(this) + authorizationClient.complete(response.build()) + } + } + + override fun onClientComplete(response: AuthorizationResponse) { + val resultIntent = Intent() + + // Put response into a bundle to work around classloader problems on Samsung devices + // https://stackoverflow.com/questions/28589509/android-e-parcel-class-not-found-when-unmarshalling-only-on-samsung-tab3 + Log.i(TAG, String.format("Spotify auth completing. The response is in EXTRA with key '%s'", RESPONSE_KEY)) + val bundle = Bundle() + bundle.putParcelable(RESPONSE_KEY, response) + + resultIntent.putExtra(EXTRA_AUTH_RESPONSE, bundle) + setResult(RESULT_OK, resultIntent) + finish() + } + + override fun onClientCancelled() { + // Called only when LoginActivity is destroyed and no other result is set. + Log.w(TAG, "Spotify Auth cancelled due to LoginActivity being finished") + setResult(RESULT_CANCELED) + } + + private fun performPkceTokenExchange( + code: String, + originalRequest: AuthorizationRequest, + responseBuilder: AuthorizationResponse.Builder + ) { + Log.d(TAG, "Performing PKCE token exchange for code: $code") + + executorService.execute { + try { + val pkceInfo = originalRequest.pkceInformation ?: run { + mainHandler.post { + responseBuilder.setType(Type.ERROR) + responseBuilder.setError("PKCE information is missing") + authorizationClient.setOnCompleteListener(this@LoginActivity) + authorizationClient.complete(responseBuilder.build()) + } + return@execute + } + val tokenRequest = TokenExchangeRequest.Builder() + .setClientId(originalRequest.clientId) + .setCode(code) + .setRedirectUri(originalRequest.redirectUri) + .setCodeVerifier(pkceInfo.verifier) + .build() + + val tokenResponse = tokenRequest.execute() + + // Switch back to main thread to complete the response + mainHandler.post { + if (tokenResponse.isSuccess) { + // Convert to TOKEN response + responseBuilder.setType(Type.TOKEN) + responseBuilder.setAccessToken(tokenResponse.accessToken) + responseBuilder.setExpiresIn(tokenResponse.expiresIn) + responseBuilder.setRefreshToken(tokenResponse.refreshToken) + Log.d(TAG, "PKCE token exchange successful") + } else { + // Convert to ERROR response + responseBuilder.setType(Type.ERROR) + val errorMsg = tokenResponse.error + + if (tokenResponse.errorDescription != null) + ": " + tokenResponse.errorDescription + else "" + responseBuilder.setError(errorMsg) + Log.e(TAG, "PKCE token exchange failed: $errorMsg") + } + + // Complete the authorization flow + authorizationClient.setOnCompleteListener(this@LoginActivity) + authorizationClient.complete(responseBuilder.build()) + } + + } catch (e: Exception) { + Log.e(TAG, "PKCE token exchange error", e) + + // Switch back to main thread to complete with error + mainHandler.post { + responseBuilder.setType(Type.ERROR) + responseBuilder.setError("Token exchange failed: " + e.message) + + authorizationClient.setOnCompleteListener(this@LoginActivity) + authorizationClient.complete(responseBuilder.build()) + } + } + } + } + + companion object { + const val EXTRA_REPLY = "REPLY" + const val EXTRA_ERROR = "ERROR" + + const val RESPONSE_TYPE_TOKEN = "token" + const val RESPONSE_TYPE_CODE = "code" + + private val TAG = LoginActivity::class.java.name + private const val NO_CALLER_ERROR = "Can't use LoginActivity with a null caller. " + + "Possible reasons: calling activity has a singleInstance mode " + + "or LoginActivity is in a singleInstance/singleTask mode" + + private const val NO_REQUEST_ERROR = "No authorization request" + + const val EXTRA_AUTH_REQUEST = "EXTRA_AUTH_REQUEST" + const val EXTRA_AUTH_RESPONSE = "EXTRA_AUTH_RESPONSE" + const val REQUEST_KEY = "request" + const val RESPONSE_KEY = "response" + + const val REQUEST_CODE = 1138 + + private const val RESULT_ERROR = -2 + + @JvmStatic + fun getAuthIntent(contextActivity: Activity, request: AuthorizationRequest): Intent { + // Put request into a bundle to work around classloader problems on Samsung devices + // https://stackoverflow.com/questions/28589509/android-e-parcel-class-not-found-when-unmarshalling-only-on-samsung-tab3 + val bundle = Bundle() + bundle.putParcelable(REQUEST_KEY, request) + + val intent = Intent(contextActivity, LoginActivity::class.java) + intent.putExtra(EXTRA_AUTH_REQUEST, bundle) + + return intent + } + + @JvmStatic + fun getResponseFromIntent(intent: Intent?): AuthorizationResponse? { + return intent?.getBundleExtra(EXTRA_AUTH_RESPONSE)?.getParcelable(RESPONSE_KEY) + } + } +} diff --git a/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/PKCEInformation.kt b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/PKCEInformation.kt new file mode 100644 index 0000000..79b1451 --- /dev/null +++ b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/PKCEInformation.kt @@ -0,0 +1,40 @@ +/* + * 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 + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class PKCEInformation( + val verifier: String, + val challenge: String, + val codeChallengeMethod: String +) : Parcelable { + + companion object { + @JvmStatic + fun sha256(verifier: String, challenge: String): PKCEInformation { + return PKCEInformation(verifier, challenge, "S256") + } + } +} diff --git a/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/PKCEInformationFactory.kt b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/PKCEInformationFactory.kt new file mode 100644 index 0000000..01277b6 --- /dev/null +++ b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/PKCEInformationFactory.kt @@ -0,0 +1,60 @@ +/* + * 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 + +import android.util.Base64 +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.security.SecureRandom + +object PKCEInformationFactory { + + private const val CODE_VERIFIER_LENGTH = 128 + private const val CODE_VERIFIER_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + + @JvmStatic + @Throws(NoSuchAlgorithmException::class) + fun create(): PKCEInformation { + val codeVerifier = generateCodeVerifier() + val codeChallenge = generateCodeChallenge(codeVerifier) + return PKCEInformation.sha256(codeVerifier, codeChallenge) + } + + private fun generateCodeVerifier(): String { + val secureRandom = SecureRandom() + val codeVerifier = StringBuilder() + + for (i in 0 until CODE_VERIFIER_LENGTH) { + val randomIndex = secureRandom.nextInt(CODE_VERIFIER_CHARSET.length) + codeVerifier.append(CODE_VERIFIER_CHARSET[randomIndex]) + } + + return codeVerifier.toString() + } + + @Throws(NoSuchAlgorithmException::class) + private fun generateCodeChallenge(codeVerifier: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(codeVerifier.toByteArray(Charsets.US_ASCII)) + return Base64.encodeToString(hash, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + } +} diff --git a/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/TokenExchangeRequest.kt b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/TokenExchangeRequest.kt new file mode 100644 index 0000000..ef9142d --- /dev/null +++ b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/TokenExchangeRequest.kt @@ -0,0 +1,201 @@ +/* + * 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 + +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +/** + * A utility class for exchanging an authorization code for an access token using PKCE verifier. + * This implements the OAuth 2.0 Authorization Code Grant with PKCE as specified in RFC 7636. + */ +class TokenExchangeRequest( + private val clientId: String, + private val code: String, + private val redirectUri: String, + private val codeVerifier: String +) { + + init { + require(clientId.isNotEmpty()) { "Client ID cannot be empty" } + require(code.isNotEmpty()) { "Authorization code cannot be empty" } + require(redirectUri.isNotEmpty()) { "Redirect URI cannot be empty" } + require(codeVerifier.isNotEmpty()) { "Code verifier cannot be empty" } + } + + /** + * Executes the token exchange request synchronously. + * This method performs a blocking HTTP request and should not be called on the main thread. + * + * @return TokenExchangeResponse containing the access token or error information + */ + fun execute(): TokenExchangeResponse { + var connection: HttpURLConnection? = null + try { + val url = URL(TOKEN_ENDPOINT) + connection = url.openConnection() as HttpURLConnection + + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", CONTENT_TYPE_FORM) + connection.doOutput = true + connection.connectTimeout = TIMEOUT_MS + connection.readTimeout = TIMEOUT_MS + + val requestBody = buildRequestBody() + + connection.outputStream.use { outputStream -> + try { + outputStream.write(requestBody.toByteArray(Charsets.UTF_8)) + } catch (e: Exception) { + // UTF-8 is guaranteed to be supported on all platforms + throw RuntimeException("UTF-8 encoding not supported", e) + } + outputStream.flush() + } + + val responseCode = connection.responseCode + val responseBody = readResponse(connection, responseCode >= 400) + + return TokenExchangeResponse.fromHttpResponse(responseCode, responseBody) + + } catch (e: IOException) { + return TokenExchangeResponse.fromError("network_error", "Network error: ${e.message}") + } finally { + connection?.disconnect() + } + } + + private fun buildRequestBody(): String { + return try { + "grant_type=" + URLEncoder.encode(GRANT_TYPE_AUTHORIZATION_CODE, "UTF-8") + + "&client_id=" + URLEncoder.encode(clientId, "UTF-8") + + "&code=" + URLEncoder.encode(code, "UTF-8") + + "&redirect_uri=" + URLEncoder.encode(redirectUri, "UTF-8") + + "&code_verifier=" + URLEncoder.encode(codeVerifier, "UTF-8") + } catch (e: Exception) { + // This should never happen with UTF-8 + throw RuntimeException("Failed to encode request parameters", e) + } + } + + private fun readResponse(connection: HttpURLConnection, isError: Boolean): String { + BufferedReader( + InputStreamReader( + if (isError) connection.errorStream else connection.inputStream, + "UTF-8" + ) + ).use { reader -> + val response = StringBuilder() + var line: String? + while (reader.readLine().also { line = it } != null) { + response.append(line) + } + return response.toString() + } + } + + /** + * Builder class for creating TokenExchangeRequest instances. + */ + class Builder { + private var clientId: String? = null + private var code: String? = null + private var redirectUri: String? = null + private var codeVerifier: String? = null + + /** + * Sets the client ID. + * + * @param clientId The client ID + * @return This builder instance for method chaining + */ + fun setClientId(clientId: String) = apply { + this.clientId = clientId + } + + /** + * Sets the authorization code. + * + * @param code The authorization code + * @return This builder instance for method chaining + */ + fun setCode(code: String) = apply { + this.code = code + } + + /** + * Sets the redirect URI. + * + * @param redirectUri The redirect URI + * @return This builder instance for method chaining + */ + fun setRedirectUri(redirectUri: String) = apply { + this.redirectUri = redirectUri + } + + /** + * Sets the PKCE code verifier. + * + * @param codeVerifier The code verifier + * @return This builder instance for method chaining + */ + fun setCodeVerifier(codeVerifier: String) = apply { + this.codeVerifier = codeVerifier + } + + /** + * Builds the TokenExchangeRequest. + * + * @return A new TokenExchangeRequest instance + * @throws IllegalArgumentException if any required field is null or empty + */ + fun build(): TokenExchangeRequest { + val clientIdValue = clientId + val codeValue = code + val redirectUriValue = redirectUri + val codeVerifierValue = codeVerifier + + require(clientIdValue != null) { "Client ID must be set" } + require(codeValue != null) { "Authorization code must be set" } + require(redirectUriValue != null) { "Redirect URI must be set" } + require(codeVerifierValue != null) { "Code verifier must be set" } + + return TokenExchangeRequest( + clientId = clientIdValue, + code = codeValue, + redirectUri = redirectUriValue, + codeVerifier = codeVerifierValue + ) + } + } + + companion object { + private const val TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token" + private const val CONTENT_TYPE_FORM = "application/x-www-form-urlencoded" + private const val GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code" + private const val TIMEOUT_MS = 10000 // 10 seconds + } +} diff --git a/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/TokenExchangeResponse.kt b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/TokenExchangeResponse.kt new file mode 100644 index 0000000..2bff1a2 --- /dev/null +++ b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/TokenExchangeResponse.kt @@ -0,0 +1,124 @@ +/* + * 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 + +import org.json.JSONException +import org.json.JSONObject + +/** + * Response from a token exchange request. + * Contains either the access token information or error details. + */ +data class TokenExchangeResponse private constructor( + /** True if the token exchange was successful, false otherwise */ + val isSuccess: Boolean, + /** The access token if successful, null otherwise */ + val accessToken: String?, + /** The token type (usually "Bearer") if successful, null otherwise */ + val tokenType: String?, + /** The number of seconds the access token is valid for, 0 if not provided or on error */ + val expiresIn: Int, + /** The scope of the access token if provided, null otherwise */ + val scope: String?, + /** The refresh token if provided, null otherwise */ + val refreshToken: String?, + /** The error code if the request failed, null if successful */ + val error: String?, + /** The error description if the request failed, null if successful */ + val errorDescription: String? +) { + + companion object { + /** + * Creates a TokenExchangeResponse from an HTTP response. + * + * @param responseCode The HTTP response code + * @param responseBody The response body as JSON string + * @return A TokenExchangeResponse instance + */ + @JvmStatic + fun fromHttpResponse(responseCode: Int, responseBody: String?): TokenExchangeResponse { + if (responseBody.isNullOrBlank()) { + return fromError("invalid_response", "Empty response body") + } + + return try { + val json = JSONObject(responseBody) + + if (responseCode == 200) { + // Success response + val accessToken = json.optString("access_token", null) + val tokenType = json.optString("token_type", null) + val expiresIn = json.optInt("expires_in", 0) + val scope = json.optString("scope", null) + val refreshToken = json.optString("refresh_token", null) + + if (accessToken.isNullOrEmpty()) { + return fromError("invalid_response", "Missing access_token in response") + } + + fromSuccess(accessToken, tokenType, expiresIn, scope, refreshToken) + } else { + // Error response + val error = json.optString("error", "unknown_error") + val errorDescription = json.optString("error_description", null) + fromError(error, errorDescription) + } + } catch (e: JSONException) { + fromError("invalid_response", "Invalid JSON response: ${e.message}") + } + } + + /** + * Creates a successful TokenExchangeResponse. + * + * @param accessToken The access token + * @param tokenType The token type + * @param expiresIn The expiration time in seconds + * @param scope The token scope + * @param refreshToken The refresh token (optional) + * @return A successful TokenExchangeResponse + */ + @JvmStatic + fun fromSuccess( + accessToken: String, + tokenType: String?, + expiresIn: Int, + scope: String?, + refreshToken: String? + ): TokenExchangeResponse { + return TokenExchangeResponse(true, accessToken, tokenType, expiresIn, scope, refreshToken, null, null) + } + + /** + * Creates an error TokenExchangeResponse. + * + * @param error The error code + * @param errorDescription The error description (optional) + * @return An error TokenExchangeResponse + */ + @JvmStatic + fun fromError(error: String, errorDescription: String?): TokenExchangeResponse { + return TokenExchangeResponse(false, null, null, 0, null, null, error, errorDescription) + } + } +} diff --git a/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/app/Sha1HashUtil.kt b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/app/Sha1HashUtil.kt new file mode 100644 index 0000000..a9ff110 --- /dev/null +++ b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/app/Sha1HashUtil.kt @@ -0,0 +1,35 @@ +package com.spotify.sdk.android.auth.app + +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +interface Sha1HashUtil { + fun sha1Hash(toHash: String): String? +} + +class Sha1HashUtilImpl : Sha1HashUtil { + + override fun sha1Hash(toHash: String): String? { + return try { + val digest = MessageDigest.getInstance("SHA-1") + val bytes = toHash.toByteArray(Charsets.UTF_8) + digest.update(bytes, 0, bytes.size) + val hashedBytes = digest.digest() + bytesToHex(hashedBytes) + } catch (ignored: NoSuchAlgorithmException) { + null + } + } + + private val HEX_ARRAY = "0123456789abcdef".toCharArray() + + private fun bytesToHex(bytes: ByteArray): String { + val hexChars = CharArray(bytes.size * 2) + for (j in bytes.indices) { + val v = bytes[j].toInt() and 0xFF + hexChars[j * 2] = HEX_ARRAY[v ushr 4] + hexChars[j * 2 + 1] = HEX_ARRAY[v and 0x0F] + } + return String(hexChars) + } +} diff --git a/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/app/SpotifyAuthHandler.kt b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/app/SpotifyAuthHandler.kt new file mode 100644 index 0000000..241bec6 --- /dev/null +++ b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/app/SpotifyAuthHandler.kt @@ -0,0 +1,54 @@ +/* + * 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.app + +import android.app.Activity +import com.spotify.sdk.android.auth.AuthorizationHandler +import com.spotify.sdk.android.auth.AuthorizationRequest + +class SpotifyAuthHandler : AuthorizationHandler { + + private var spotifyNativeAuthUtil: SpotifyNativeAuthUtil? = null + + override fun start(contextActivity: Activity, request: AuthorizationRequest): Boolean { + val util = SpotifyNativeAuthUtil( + contextActivity, + request, + Sha1HashUtilImpl() + ) + spotifyNativeAuthUtil = util + return util.startAuthActivity() + } + + override fun stop() { + spotifyNativeAuthUtil?.stopAuthActivity() + } + + override fun setOnCompleteListener(listener: AuthorizationHandler.OnCompleteListener?) { + // no-op + } + + override fun isAuthInProgress(): Boolean { + // not supported, always return false + return false + } +} diff --git a/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/app/SpotifyNativeAuthUtil.kt b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/app/SpotifyNativeAuthUtil.kt new file mode 100644 index 0000000..c3ed213 --- /dev/null +++ b/auth-lib/src/main/kotlin/com/spotify/sdk/android/auth/app/SpotifyNativeAuthUtil.kt @@ -0,0 +1,272 @@ +/* + * 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.app + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.Signature +import android.os.Build +import androidx.annotation.VisibleForTesting +import com.spotify.sdk.android.auth.AuthorizationRequest +import com.spotify.sdk.android.auth.IntentExtras +import com.spotify.sdk.android.auth.LoginActivity +import com.spotify.sdk.android.auth.PKCEInformation + +class SpotifyNativeAuthUtil @JvmOverloads constructor( + private val contextActivity: Activity, + private val request: AuthorizationRequest, + private val sha1HashUtil: Sha1HashUtil = Sha1HashUtilImpl() +) { + + fun startAuthActivity(): Boolean { + val intent = createAuthActivityIntent(contextActivity, sha1HashUtil) ?: return false + + intent.putExtra(IntentExtras.KEY_VERSION, PROTOCOL_VERSION) + intent.putExtra(IntentExtras.KEY_CLIENT_ID, request.clientId) + intent.putExtra(IntentExtras.KEY_REDIRECT_URI, request.redirectUri) + intent.putExtra(IntentExtras.KEY_RESPONSE_TYPE, request.responseType) + intent.putExtra(IntentExtras.KEY_REQUESTED_SCOPES, request.scopes) + intent.putExtra(IntentExtras.KEY_STATE, request.state) + intent.putExtra(IntentExtras.KEY_UTM_SOURCE, request.getSource()) + intent.putExtra(IntentExtras.KEY_UTM_CAMPAIGN, request.getCampaign()) + intent.putExtra(IntentExtras.KEY_UTM_MEDIUM, request.getMedium()) + + val pkceInfo = request.pkceInformation + if (pkceInfo != null) { + intent.putExtra(IntentExtras.KEY_CODE_CHALLENGE, pkceInfo.challenge) + intent.putExtra(IntentExtras.KEY_CODE_CHALLENGE_METHOD, pkceInfo.codeChallengeMethod) + } + + return try { + contextActivity.startActivityForResult(intent, LoginActivity.REQUEST_CODE) + true + } catch (e: ActivityNotFoundException) { + false + } + } + + fun stopAuthActivity() { + contextActivity.finishActivity(LoginActivity.REQUEST_CODE) + } + + companion object { + /* + * The version of the auth protocol. More info about this protocol in + * {@link com.spotify.sdk.android.auth.IntentExtras}. + */ + private const val PROTOCOL_VERSION = 1 + + private const val SPOTIFY_AUTH_ACTIVITY_ACTION = "com.spotify.sso.action.START_AUTH_FLOW" + private const val SPOTIFY_PACKAGE_NAME = "com.spotify.music" + private val SPOTIFY_PACKAGE_SUFFIXES = arrayOf( + ".debug", + ".canary", + ".partners", + "" + ) + + private val SPOTIFY_SIGNATURE_HASH = arrayOf( + "25a9b2d2745c098361edaa3b87936dc29a28e7f1", + "80abdd17dcc4cb3a33815d354355bf87c9378624", + "88df4d670ed5e01fc7b3eff13b63258628ff5a00", + "d834ae340d1e854c5f4092722f9788216d9221e5", + "1cbedd9e7345f64649bad2b493a20d9eea955352", + "4b3d76a2de89033ea830f476a1f815692938e33b" + ) + + /** + * Creates an intent that will launch the auth flow on the currently installed Spotify application + * @param context The context of the caller + * @return The auth Intent or null if the Spotify application couldn't be found + */ + @JvmStatic + fun createAuthActivityIntent(context: Context): Intent? { + return createAuthActivityIntent(context, Sha1HashUtilImpl()) + } + + @VisibleForTesting + @JvmStatic + fun createAuthActivityIntent(context: Context, sha1HashUtil: Sha1HashUtil): Intent? { + for (suffix in SPOTIFY_PACKAGE_SUFFIXES) { + val intent = tryResolveActivity( + context, + SPOTIFY_PACKAGE_NAME + suffix, + sha1HashUtil + ) + if (intent != null) { + return intent + } + } + return null + } + + /** + * Check if a version of the Spotify main application is installed + * + * @param context The context of the caller, used to check if the app is installed + * @return True if a Spotify app is installed, false otherwise + */ + @JvmStatic + fun isSpotifyInstalled(context: Context): Boolean { + return isSpotifyInstalled(context, Sha1HashUtilImpl()) + } + + @VisibleForTesting + @JvmStatic + fun isSpotifyInstalled(context: Context, sha1HashUtil: Sha1HashUtil): Boolean { + return createAuthActivityIntent(context, sha1HashUtil) != null + } + + /** + * Get the version code of the installed Spotify app + * + * @param context The context of the caller, used to check package info + * @return Version code of Spotify app, or -1 if not installed or signature validation fails + */ + @JvmStatic + fun getSpotifyAppVersionCode(context: Context): Int { + return getSpotifyAppVersionCode(context, Sha1HashUtilImpl()) + } + + @VisibleForTesting + @JvmStatic + fun getSpotifyAppVersionCode(context: Context, sha1HashUtil: Sha1HashUtil): Int { + for (suffix in SPOTIFY_PACKAGE_SUFFIXES) { + val packageName = SPOTIFY_PACKAGE_NAME + suffix + try { + val packageInfo = context.packageManager.getPackageInfo(packageName, 0) + + // Validate signature before returning version info + if (validateSignature(context, packageName, sha1HashUtil)) { + return packageInfo.versionCode + } + } catch (ignored: PackageManager.NameNotFoundException) { + // Try next package variant + } + } + return -1 // Not found or signature validation failed + } + + /** + * Check if Spotify app version meets minimum requirement + * + * @param context The context of the caller, used to check package info + * @param minVersionCode Minimum required version code + * @return true if installed {@code version >= minVersionCode}, false otherwise + */ + @JvmStatic + fun isSpotifyVersionAtLeast(context: Context, minVersionCode: Int): Boolean { + return isSpotifyVersionAtLeast(context, minVersionCode, Sha1HashUtilImpl()) + } + + @VisibleForTesting + @JvmStatic + fun isSpotifyVersionAtLeast(context: Context, minVersionCode: Int, sha1HashUtil: Sha1HashUtil): Boolean { + val currentVersion = getSpotifyAppVersionCode(context, sha1HashUtil) + return currentVersion >= minVersionCode + } + + private fun tryResolveActivity( + context: Context, + packageName: String, + sha1HashUtil: Sha1HashUtil + ): Intent? { + val intent = Intent(SPOTIFY_AUTH_ACTIVITY_ACTION) + intent.`package` = packageName + + val componentName = intent.resolveActivity(context.packageManager) ?: return null + + if (!validateSignature(context, componentName.packageName, sha1HashUtil)) { + return null + } + + return intent + } + + @SuppressLint("PackageManagerGetSignatures") + private fun validateSignature( + context: Context, + spotifyPackageName: String, + sha1HashUtil: Sha1HashUtil + ): Boolean { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val packageInfo = context.packageManager.getPackageInfo( + spotifyPackageName, + PackageManager.GET_SIGNING_CERTIFICATES + ) + + val signingInfo = packageInfo.signingInfo ?: return false + if (signingInfo.hasMultipleSigners()) { + validateSignatures(sha1HashUtil, signingInfo.apkContentsSigners) + } else { + validateSignatures(sha1HashUtil, signingInfo.signingCertificateHistory) + } + } else { + @Suppress("DEPRECATION") + val packageInfo = context.packageManager.getPackageInfo( + spotifyPackageName, + PackageManager.GET_SIGNATURES + ) + + @Suppress("DEPRECATION") + validateSignatures(sha1HashUtil, packageInfo.signatures) + } + } catch (ignored: PackageManager.NameNotFoundException) { + false + } + } + + private fun validateSignatures( + sha1HashUtil: Sha1HashUtil, + apkSignatures: Array? + ): Boolean { + if (apkSignatures == null || apkSignatures.isEmpty()) { + return false + } + + for (actualApkSignature in apkSignatures) { + val signatureString = actualApkSignature.toCharsString() + val sha1Signature = sha1HashUtil.sha1Hash(signatureString) + var matchesSignature = false + for (knownSpotifyHash in SPOTIFY_SIGNATURE_HASH) { + if (knownSpotifyHash.equals(sha1Signature, ignoreCase = true)) { + matchesSignature = true + break + } + } + + // Abort upon finding a non matching signature + if (!matchesSignature) { + return false + } + } + return true + } + } +} diff --git a/auth-lib/src/store/java/com/spotify/sdk/android/auth/store/PlayStoreHandler.java b/auth-lib/src/store/java/com/spotify/sdk/android/auth/store/PlayStoreHandler.java deleted file mode 100644 index a5743da..0000000 --- a/auth-lib/src/store/java/com/spotify/sdk/android/auth/store/PlayStoreHandler.java +++ /dev/null @@ -1,94 +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.store; - -import android.app.Activity; -import android.content.ComponentName; -import android.content.Intent; -import android.net.Uri; -import android.util.Log; - -import androidx.annotation.Nullable; - -import com.spotify.sdk.android.auth.AuthorizationHandler; -import com.spotify.sdk.android.auth.AuthorizationRequest; - -/** - * An AuthorizationHandler that opens the play store to download the main Spotify application - */ -public class PlayStoreHandler implements AuthorizationHandler { - - private static final String TAG = PlayStoreHandler.class.getSimpleName(); - private static final String APP_PACKAGE_NAME = "com.spotify.music"; - - @Nullable - private OnCompleteListener mListener; - - @Override - public boolean start(Activity contextActivity, AuthorizationRequest request) { - Log.d(TAG, "start"); - Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setData(Uri.parse( - "https://play.google.com/store/apps/details?id=" + APP_PACKAGE_NAME - )); - intent.setPackage("com.android.vending"); - - ComponentName componentName = intent.resolveActivity(contextActivity.getPackageManager()); - - OnCompleteListener listener = mListener; - if (componentName == null) { - if (listener != null) { - listener.onError( - new ClassNotFoundException("Couldn't find an activity to handle a play store link") - ); - } - return false; - } - - contextActivity.startActivity(intent); - - if (listener != null) { - listener.onCancel(); - } - return true; - } - - @Override - public void stop() { - Log.d(TAG, "stop"); - } - - /** - * {@link OnCompleteListener#onError(Throwable)} will be called if no play store application is installed - * {@link OnCompleteListener#onCancel()} will be called if the play store is launched - */ - @Override - public void setOnCompleteListener(@Nullable OnCompleteListener listener) { - mListener = listener; - } - - @Override - public boolean isAuthInProgress() { - // not supported, always return false - return false; - } -} diff --git a/auth-lib/src/store/java/com/spotify/sdk/android/auth/FallbackHandlerProvider.java b/auth-lib/src/store/kotlin/com/spotify/sdk/android/auth/FallbackHandlerProvider.kt similarity index 77% rename from auth-lib/src/store/java/com/spotify/sdk/android/auth/FallbackHandlerProvider.java rename to auth-lib/src/store/kotlin/com/spotify/sdk/android/auth/FallbackHandlerProvider.kt index 46e269c..5b7cde7 100644 --- a/auth-lib/src/store/java/com/spotify/sdk/android/auth/FallbackHandlerProvider.java +++ b/auth-lib/src/store/kotlin/com/spotify/sdk/android/auth/FallbackHandlerProvider.kt @@ -19,19 +19,16 @@ * under the License. */ -package com.spotify.sdk.android.auth; +package com.spotify.sdk.android.auth -import androidx.annotation.NonNull; -import com.spotify.sdk.android.auth.store.PlayStoreHandler; +import com.spotify.sdk.android.auth.store.PlayStoreHandler /** * Provides an AuthorizationHandler that opens the play store when the Spotify application is not installed */ -public class FallbackHandlerProvider { +class FallbackHandlerProvider { - @NonNull - public AuthorizationHandler provideFallback() { - return new PlayStoreHandler(); + fun provideFallback(): AuthorizationHandler { + return PlayStoreHandler() } - } diff --git a/auth-lib/src/store/kotlin/com/spotify/sdk/android/auth/store/PlayStoreHandler.kt b/auth-lib/src/store/kotlin/com/spotify/sdk/android/auth/store/PlayStoreHandler.kt new file mode 100644 index 0000000..08c46b6 --- /dev/null +++ b/auth-lib/src/store/kotlin/com/spotify/sdk/android/auth/store/PlayStoreHandler.kt @@ -0,0 +1,83 @@ +/* + * 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.store + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.util.Log +import com.spotify.sdk.android.auth.AuthorizationHandler +import com.spotify.sdk.android.auth.AuthorizationRequest + +/** + * An AuthorizationHandler that opens the play store to download the main Spotify application + */ +class PlayStoreHandler : AuthorizationHandler { + + private var listener: AuthorizationHandler.OnCompleteListener? = null + + override fun start(contextActivity: Activity, request: AuthorizationRequest): Boolean { + Log.d(TAG, "start") + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse( + "https://play.google.com/store/apps/details?id=$APP_PACKAGE_NAME" + ) + intent.`package` = "com.android.vending" + + val componentName = intent.resolveActivity(contextActivity.packageManager) + + val listener = this.listener + if (componentName == null) { + listener?.onError( + ClassNotFoundException("Couldn't find an activity to handle a play store link") + ) + return false + } + + contextActivity.startActivity(intent) + + listener?.onCancel() + return true + } + + override fun stop() { + Log.d(TAG, "stop") + } + + /** + * [OnCompleteListener.onError] will be called if no play store application is installed + * [OnCompleteListener.onCancel] will be called if the play store is launched + */ + override fun setOnCompleteListener(listener: AuthorizationHandler.OnCompleteListener?) { + this.listener = listener + } + + override fun isAuthInProgress(): Boolean { + // not supported, always return false + return false + } + + companion object { + private val TAG = PlayStoreHandler::class.java.simpleName + private const val APP_PACKAGE_NAME = "com.spotify.music" + } +} diff --git a/auth-lib/src/test/java/com/spotify/sdk/android/auth/AuthorizationClientTest.java b/auth-lib/src/test/java/com/spotify/sdk/android/auth/AuthorizationClientTest.java index e12e12e..0c95c1c 100644 --- a/auth-lib/src/test/java/com/spotify/sdk/android/auth/AuthorizationClientTest.java +++ b/auth-lib/src/test/java/com/spotify/sdk/android/auth/AuthorizationClientTest.java @@ -48,10 +48,13 @@ public class AuthorizationClientTest { @Test public void shouldLaunchIntentForPassedActivity() { - AuthorizationRequest authorizationRequest = mock(AuthorizationRequest.class); + AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder( + "test_client_id", + AuthorizationResponse.Type.TOKEN, + "to://me" + ).build(); Activity activity = mock(Activity.class); - when(authorizationRequest.toUri()).thenReturn(Uri.parse("to://me")); Mockito.doNothing().when(activity).startActivity(any(Intent.class)); ArgumentCaptor captor = ArgumentCaptor.forClass(Intent.class); @@ -59,21 +62,25 @@ public void shouldLaunchIntentForPassedActivity() { verify(activity, times(1)).startActivity(captor.capture()); assertEquals(Intent.ACTION_VIEW, captor.getValue().getAction()); - assertEquals("to://me", captor.getValue().getData().toString()); + assertEquals(authorizationRequest.toUri().toString(), captor.getValue().getData().toString()); } - @Test(expected = IllegalArgumentException.class) + @Test(expected = NullPointerException.class) public void createLoginActivityIntentShouldThrowExceptionWhenBothPassedIsNull() { AuthorizationClient.createLoginActivityIntent(null, null); } - @Test(expected = IllegalArgumentException.class) + @Test(expected = NullPointerException.class) public void createLoginActivityIntentShouldThrowExceptionWhenFirstPassedIsNull() { - AuthorizationRequest authorizationRequest = mock(AuthorizationRequest.class); + AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder( + "test_client_id", + AuthorizationResponse.Type.TOKEN, + "redirect://uri" + ).build(); AuthorizationClient.createLoginActivityIntent(null, authorizationRequest); } - @Test(expected = IllegalArgumentException.class) + @Test(expected = NullPointerException.class) public void createLoginActivityIntentShouldThrowExceptionWhenSecondPassedIsNull() { Activity activity = mock(Activity.class); AuthorizationClient.createLoginActivityIntent(activity, null); 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..c359afa 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 @@ -36,11 +36,11 @@ @RunWith(RobolectricTestRunner.class) public class AuthorizationRequestTest { - private AuthorizationResponse.Type mResponseType = AuthorizationResponse.Type.TOKEN; - private String mRedirectUri = "redirect:uri"; - private String mClientId = "12345567"; + private AuthorizationResponse.Type responseType = AuthorizationResponse.Type.TOKEN; + private String redirectUri = "redirect:uri"; + private String clientId = "12345567"; - private String mDefaultCampaign = AuthorizationRequest.ANDROID_SDK; + private String defaultCampaign = AuthorizationRequest.ANDROID_SDK; private Uri.Builder getBaseAuthUri(String clientId, String responseType, String redirectUrl, String campaign) { Uri.Builder uriBuilder = new Uri.Builder(); @@ -52,39 +52,39 @@ private Uri.Builder getBaseAuthUri(String clientId, String responseType, String .appendQueryParameter(AccountsQueryParameters.REDIRECT_URI, redirectUrl) .appendQueryParameter(AccountsQueryParameters.SHOW_DIALOG, String.valueOf(false)) .appendQueryParameter(AccountsQueryParameters.UTM_SOURCE, AuthorizationRequest.SPOTIFY_SDK) - .appendQueryParameter(AccountsQueryParameters.UTM_MEDIUM, mDefaultCampaign) + .appendQueryParameter(AccountsQueryParameters.UTM_MEDIUM, defaultCampaign) .appendQueryParameter(AccountsQueryParameters.UTM_CAMPAIGN, campaign); return uriBuilder; } - @Test(expected = IllegalArgumentException.class) + @Test(expected = NullPointerException.class) public void shouldThrowIfClientIdIsNull() { - new AuthorizationRequest.Builder(null, mResponseType, mRedirectUri); + new AuthorizationRequest.Builder(null, responseType, redirectUri); } - @Test(expected = IllegalArgumentException.class) + @Test(expected = NullPointerException.class) public void shouldThrowIfResponseTypeIsNull() { - new AuthorizationRequest.Builder(mClientId, null, mRedirectUri); + new AuthorizationRequest.Builder(clientId, null, redirectUri); } - @Test(expected = IllegalArgumentException.class) + @Test(expected = NullPointerException.class) public void shouldThrowIfRedirectUriIsNull() { - new AuthorizationRequest.Builder(mClientId, mResponseType, null); + new AuthorizationRequest.Builder(clientId, responseType, null); } @Test(expected = IllegalArgumentException.class) public void shouldThrowIfRedirectUriIsEmpty() { - new AuthorizationRequest.Builder(mClientId, mResponseType, ""); + new AuthorizationRequest.Builder(clientId, responseType, ""); } @Test public void shouldBuildCorrectUri() { - AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri).build(); + AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(clientId, responseType, redirectUri).build(); - Uri.Builder uriBuilder = getBaseAuthUri(mClientId, mResponseType.toString(), mRedirectUri, mDefaultCampaign); + Uri.Builder uriBuilder = getBaseAuthUri(clientId, responseType.toString(), redirectUri, defaultCampaign); Uri uri = uriBuilder.build(); assertEquals(uri, authorizationRequest.toUri()); @@ -95,7 +95,7 @@ public void shouldBuildCorrectUri() { public void shouldSetScopes() { String[] expectedScopes = {"scope1", "scope2"}; - AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .setScopes(expectedScopes) .build(); @@ -105,7 +105,7 @@ public void shouldSetScopes() { assertEquals(expectedScopes[0], scopes[0]); assertEquals(expectedScopes[1], scopes[1]); - Uri.Builder uriBuilder = getBaseAuthUri(mClientId, mResponseType.toString(), mRedirectUri, mDefaultCampaign); + Uri.Builder uriBuilder = getBaseAuthUri(clientId, responseType.toString(), redirectUri, defaultCampaign); uriBuilder.appendQueryParameter(AccountsQueryParameters.SCOPE, "scope1 scope2"); Uri uri = uriBuilder.build(); @@ -115,11 +115,11 @@ public void shouldSetScopes() { @Test public void shouldNotSetNullScopes() { - AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .setScopes(null) .build(); - Uri.Builder uriBuilder = getBaseAuthUri(mClientId, mResponseType.toString(), mRedirectUri, mDefaultCampaign); + Uri.Builder uriBuilder = getBaseAuthUri(clientId, responseType.toString(), redirectUri, defaultCampaign); Uri uri = uriBuilder.build(); assertEquals(uri, authorizationRequest.toUri()); @@ -127,11 +127,11 @@ public void shouldNotSetNullScopes() { @Test public void shouldNotSetEmptyScopes() { - AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .setScopes(new String[]{}) .build(); - Uri.Builder uriBuilder = getBaseAuthUri(mClientId, mResponseType.toString(), mRedirectUri, mDefaultCampaign); + Uri.Builder uriBuilder = getBaseAuthUri(clientId, responseType.toString(), redirectUri, defaultCampaign); Uri uri = uriBuilder.build(); assertEquals(uri, authorizationRequest.toUri()); @@ -141,13 +141,13 @@ public void shouldNotSetEmptyScopes() { public void shouldSetState() { String testState = "test_state"; - AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .setState(testState) .build(); assertEquals(authorizationRequest.getState(), testState); - Uri.Builder uriBuilder = getBaseAuthUri(mClientId, mResponseType.toString(), mRedirectUri, mDefaultCampaign); + Uri.Builder uriBuilder = getBaseAuthUri(clientId, responseType.toString(), redirectUri, defaultCampaign); uriBuilder.appendQueryParameter(AccountsQueryParameters.STATE, testState); Uri uri = uriBuilder.build(); @@ -159,13 +159,13 @@ public void shouldSetState() { public void shouldSetCampaign() { String testCampaign = "test_campaign"; - AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .setCampaign(testCampaign) .build(); assertEquals(authorizationRequest.getCampaign(), testCampaign); - Uri.Builder uriBuilder = getBaseAuthUri(mClientId, mResponseType.toString(), mRedirectUri, testCampaign); + Uri.Builder uriBuilder = getBaseAuthUri(clientId, responseType.toString(), redirectUri, testCampaign); Uri uri = uriBuilder.build(); assertEquals(uri, authorizationRequest.toUri()); @@ -174,12 +174,12 @@ public void shouldSetCampaign() { @Test public void shouldUseDefaultCampaign() { - AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .build(); - assertEquals(authorizationRequest.getCampaign(), mDefaultCampaign); + assertEquals(authorizationRequest.getCampaign(), defaultCampaign); - Uri.Builder uriBuilder = getBaseAuthUri(mClientId, mResponseType.toString(), mRedirectUri, mDefaultCampaign); + Uri.Builder uriBuilder = getBaseAuthUri(clientId, responseType.toString(), redirectUri, defaultCampaign); Uri uri = uriBuilder.build(); assertEquals(uri, authorizationRequest.toUri()); @@ -187,11 +187,11 @@ public void shouldUseDefaultCampaign() { @Test public void shouldNotSetNullState() { - AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .setState(null) .build(); - Uri.Builder uriBuilder = getBaseAuthUri(mClientId, mResponseType.toString(), mRedirectUri, mDefaultCampaign); + Uri.Builder uriBuilder = getBaseAuthUri(clientId, responseType.toString(), redirectUri, defaultCampaign); Uri uri = uriBuilder.build(); assertEquals(uri, authorizationRequest.toUri()); @@ -204,12 +204,12 @@ public void shouldSetCustomParams() { String customKey2 = "custom_key_2"; String customValue2 = "custom_value_2"; - AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .setCustomParam(customKey1, customValue1) .setCustomParam(customKey2, customValue2) .build(); - Uri.Builder uriBuilder = getBaseAuthUri(mClientId, mResponseType.toString(), mRedirectUri, mDefaultCampaign); + Uri.Builder uriBuilder = getBaseAuthUri(clientId, responseType.toString(), redirectUri, defaultCampaign); uriBuilder.appendQueryParameter(customKey1, customValue1); uriBuilder.appendQueryParameter(customKey2, customValue2); Uri uri = uriBuilder.build(); @@ -217,30 +217,30 @@ public void shouldSetCustomParams() { assertEquals(uri, authorizationRequest.toUri()); } - @Test(expected = IllegalArgumentException.class) + @Test(expected = NullPointerException.class) public void shouldThrowWithNullCustomParamKey() { - new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .setCustomParam(null, "testValue") .build(); } @Test(expected = IllegalArgumentException.class) public void shouldThrowWithEmptyCustomParamKey() { - new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .setCustomParam("", "testValue") .build(); } - @Test(expected = IllegalArgumentException.class) + @Test(expected = NullPointerException.class) public void shouldThrowWithNullCustomParamValue() { - new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .setCustomParam("testKey", null) .build(); } @Test(expected = IllegalArgumentException.class) public void shouldThrowWithEmptyCustomParamValue() { - new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .setCustomParam("testKey", "") .build(); } @@ -251,7 +251,7 @@ public void shouldMarshallCorrectly() { String key2 = "key_2"; AuthorizationRequest request = - new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .setState("testState") .setScopes(new String[]{"scope1", "scope2"}) .setCustomParam(key1, "value_1") @@ -279,13 +279,13 @@ public void shouldSetPkceInformation() { String challenge = "test_challenge_abcdef"; PKCEInformation pkceInfo = PKCEInformation.sha256(verifier, challenge); - AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .setPkceInformation(pkceInfo) .build(); assertEquals(pkceInfo, authorizationRequest.getPkceInformation()); - Uri.Builder uriBuilder = getBaseAuthUri(mClientId, mResponseType.toString(), mRedirectUri, mDefaultCampaign); + Uri.Builder uriBuilder = getBaseAuthUri(clientId, responseType.toString(), redirectUri, defaultCampaign); uriBuilder.appendQueryParameter(AccountsQueryParameters.CODE_CHALLENGE, pkceInfo.getChallenge()); uriBuilder.appendQueryParameter(AccountsQueryParameters.CODE_CHALLENGE_METHOD, pkceInfo.getCodeChallengeMethod()); Uri uri = uriBuilder.build(); @@ -295,13 +295,13 @@ public void shouldSetPkceInformation() { @Test public void shouldNotSetNullPkceInformation() { - AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .setPkceInformation(null) .build(); assertNull(authorizationRequest.getPkceInformation()); - Uri.Builder uriBuilder = getBaseAuthUri(mClientId, mResponseType.toString(), mRedirectUri, mDefaultCampaign); + Uri.Builder uriBuilder = getBaseAuthUri(clientId, responseType.toString(), redirectUri, defaultCampaign); Uri uri = uriBuilder.build(); assertEquals(uri, authorizationRequest.toUri()); @@ -314,7 +314,7 @@ public void shouldMarshallPkceInformationCorrectly() { PKCEInformation pkceInfo = PKCEInformation.sha256(verifier, challenge); AuthorizationRequest request = - new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .setState("testState") .setScopes(new String[]{"scope1", "scope2"}) .setPkceInformation(pkceInfo) @@ -331,10 +331,10 @@ public void shouldMarshallPkceInformationCorrectly() { assertEquals(request.getResponseType(), requestFromParcel.getResponseType()); assertArrayEquals(request.getScopes(), requestFromParcel.getScopes()); assertEquals(request.getState(), requestFromParcel.getState()); - + PKCEInformation originalPkce = request.getPkceInformation(); PKCEInformation parceledPkce = requestFromParcel.getPkceInformation(); - + assertNotNull(originalPkce); assertNotNull(parceledPkce); assertEquals(originalPkce.getVerifier(), parceledPkce.getVerifier()); @@ -345,7 +345,7 @@ public void shouldMarshallPkceInformationCorrectly() { @Test public void shouldMarshallWithoutPkceInformationCorrectly() { AuthorizationRequest request = - new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .setState("testState") .setScopes(new String[]{"scope1", "scope2"}) .build(); @@ -373,13 +373,13 @@ public void shouldIncludePkceParametersInUriWithAllFields() { String testState = "test_state"; String[] testScopes = {"scope1", "scope2"}; - AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(mClientId, mResponseType, mRedirectUri) + AuthorizationRequest authorizationRequest = new AuthorizationRequest.Builder(clientId, responseType, redirectUri) .setState(testState) .setScopes(testScopes) .setPkceInformation(pkceInfo) .build(); - Uri.Builder uriBuilder = getBaseAuthUri(mClientId, mResponseType.toString(), mRedirectUri, mDefaultCampaign); + Uri.Builder uriBuilder = getBaseAuthUri(clientId, responseType.toString(), redirectUri, defaultCampaign); uriBuilder.appendQueryParameter(AccountsQueryParameters.SCOPE, "scope1 scope2"); uriBuilder.appendQueryParameter(AccountsQueryParameters.STATE, testState); uriBuilder.appendQueryParameter(AccountsQueryParameters.CODE_CHALLENGE, pkceInfo.getChallenge()); diff --git a/auth-lib/src/test/java/com/spotify/sdk/android/auth/TokenExchangeRequestTest.java b/auth-lib/src/test/java/com/spotify/sdk/android/auth/TokenExchangeRequestTest.java index c200dee..33efb3c 100644 --- a/auth-lib/src/test/java/com/spotify/sdk/android/auth/TokenExchangeRequestTest.java +++ b/auth-lib/src/test/java/com/spotify/sdk/android/auth/TokenExchangeRequestTest.java @@ -62,7 +62,7 @@ public void shouldCreateRequestUsingBuilder() { assertNotNull(request); } - @Test(expected = IllegalArgumentException.class) + @Test(expected = NullPointerException.class) public void shouldThrowWithNullClientId() { new TokenExchangeRequest(null, TEST_CODE, TEST_REDIRECT_URI, TEST_CODE_VERIFIER); } @@ -72,7 +72,7 @@ public void shouldThrowWithEmptyClientId() { new TokenExchangeRequest("", TEST_CODE, TEST_REDIRECT_URI, TEST_CODE_VERIFIER); } - @Test(expected = IllegalArgumentException.class) + @Test(expected = NullPointerException.class) public void shouldThrowWithNullCode() { new TokenExchangeRequest(TEST_CLIENT_ID, null, TEST_REDIRECT_URI, TEST_CODE_VERIFIER); } @@ -82,7 +82,7 @@ public void shouldThrowWithEmptyCode() { new TokenExchangeRequest(TEST_CLIENT_ID, "", TEST_REDIRECT_URI, TEST_CODE_VERIFIER); } - @Test(expected = IllegalArgumentException.class) + @Test(expected = NullPointerException.class) public void shouldThrowWithNullRedirectUri() { new TokenExchangeRequest(TEST_CLIENT_ID, TEST_CODE, null, TEST_CODE_VERIFIER); } @@ -92,7 +92,7 @@ public void shouldThrowWithEmptyRedirectUri() { new TokenExchangeRequest(TEST_CLIENT_ID, TEST_CODE, "", TEST_CODE_VERIFIER); } - @Test(expected = IllegalArgumentException.class) + @Test(expected = NullPointerException.class) public void shouldThrowWithNullCodeVerifier() { new TokenExchangeRequest(TEST_CLIENT_ID, TEST_CODE, TEST_REDIRECT_URI, null); } diff --git a/auth-sample/build.gradle b/auth-sample/build.gradle deleted file mode 100644 index cb231c5..0000000 --- a/auth-sample/build.gradle +++ /dev/null @@ -1,82 +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. - */ - -apply plugin: 'com.android.application' - -android { - compileSdk 33 - buildToolsVersion = "33.0.0" - - signingConfigs { - debug { - storeFile file('keystore/example.keystore') - storePassword 'example' - keyAlias 'example_alias' - keyPassword 'example' - } - } - - defaultConfig { - applicationId "com.spotify.sdk.android.authentication.sample" - minSdkVersion 16 - targetSdkVersion 33 - versionCode 1 - versionName "1.0" - - manifestPlaceholders = [ - redirectSchemeName: "spotify-sdk", - redirectHostName: "auth", - redirectPathPattern: ".*" - ] - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - buildTypes { - debug { - debuggable true - signingConfig signingConfigs.debug - } - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt') - } - } - - lintOptions { - lintConfig file("${project.rootDir}/config/lint.xml") - quiet false - warningsAsErrors false - textReport true - textOutput 'stdout' - xmlReport false - } - namespace 'com.spotify.sdk.android.authentication.sample' -} - -dependencies { - implementation files('../auth-lib/build/outputs/aar/auth-release.aar') -// implementation 'com.spotify.android:auth:2.1.1' - - implementation 'androidx.browser:browser:1.4.0' - implementation 'androidx.appcompat:appcompat:1.1.0' - implementation 'com.google.android.material:material:1.0.0' - implementation 'com.squareup.okhttp3:okhttp:4.9.3' -} diff --git a/auth-sample/build.gradle.kts b/auth-sample/build.gradle.kts new file mode 100644 index 0000000..2d9e288 --- /dev/null +++ b/auth-sample/build.gradle.kts @@ -0,0 +1,87 @@ +/* + * 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. + */ + +plugins { + id("com.android.application") +} + +android { + compileSdk = 33 + buildToolsVersion = "33.0.0" + + signingConfigs { + getByName("debug") { + storeFile = file("keystore/example.keystore") + storePassword = "example" + keyAlias = "example_alias" + keyPassword = "example" + } + } + + defaultConfig { + applicationId = "com.spotify.sdk.android.authentication.sample" + minSdk = 16 + targetSdk = 33 + versionCode = 1 + versionName = "1.0" + + manifestPlaceholders["redirectSchemeName"] = "spotify-sdk" + manifestPlaceholders["redirectHostName"] = "auth" + manifestPlaceholders["redirectPathPattern"] = ".*" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // Specify which auth-lib flavor to use + missingDimensionStrategy("auth", "auth") + } + + buildTypes { + getByName("debug") { + isDebuggable = true + signingConfig = signingConfigs.getByName("debug") + } + getByName("release") { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android.txt")) + } + } + + lint { + lintConfig = file("${project.rootDir}/config/lint.xml") + quiet = false + warningsAsErrors = false + textReport = true + textOutput = file("stdout") + xmlReport = false + } + + namespace = "com.spotify.sdk.android.authentication.sample" +} + +dependencies { + implementation(project(":auth-lib")) +// implementation("com.spotify.android:auth:2.1.1") + + implementation("androidx.browser:browser:1.4.0") + implementation("androidx.appcompat:appcompat:1.1.0") + implementation("com.google.android.material:material:1.0.0") + implementation("com.squareup.okhttp3:okhttp:4.9.3") +} diff --git a/build.gradle b/build.gradle.kts similarity index 78% rename from build.gradle rename to build.gradle.kts index de210c1..0e81e50 100644 --- a/build.gradle +++ b/build.gradle.kts @@ -20,12 +20,16 @@ */ buildscript { + val kotlinVersion = "1.9.22" + extra["kotlin_version"] = kotlinVersion repositories { mavenCentral() google() } dependencies { - classpath 'com.android.tools.build:gradle:7.4.2' + classpath("com.android.tools.build:gradle:7.4.2") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + classpath("org.jetbrains.dokka:dokka-gradle-plugin:1.9.10") } } diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index a33be82..0000000 --- a/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -include ':auth-lib', ':auth-sample' diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..4a299ab --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +include(":auth-lib", ":auth-sample")