diff --git a/authentication/build.gradle.kts b/authentication/build.gradle.kts index 709f3454..6caaab60 100644 --- a/authentication/build.gradle.kts +++ b/authentication/build.gradle.kts @@ -36,6 +36,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.spotless) } @@ -60,9 +61,9 @@ android { } dependencies { - implementation(libs.core.ktx) implementation(libs.appCompat) implementation(libs.material) + implementation(project(":core")) testImplementation(libs.junit.junit) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.espresso.espresso.core) diff --git a/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/AuthContext.kt b/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/AuthContext.kt index f4c34ffb..dc1aabc8 100644 --- a/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/AuthContext.kt +++ b/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/AuthContext.kt @@ -15,6 +15,9 @@ */ package com.uber.sdk2.auth.api.request +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + /** * Represents the context of the authentication request needed for Uber to authenticate the user. * @@ -23,9 +26,9 @@ package com.uber.sdk2.auth.api.request * @param prefillInfo The prefill information to be used for the authentication. * @param scopes The scopes to request for the authentication. */ +@Parcelize data class AuthContext( val authDestination: AuthDestination, val authType: AuthType, val prefillInfo: PrefillInfo?, - val scopes: String?, -) +) : Parcelable diff --git a/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/AuthDestination.kt b/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/AuthDestination.kt index 2df5a2d9..18b32690 100644 --- a/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/AuthDestination.kt +++ b/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/AuthDestination.kt @@ -15,8 +15,12 @@ */ package com.uber.sdk2.auth.api.request +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + /** Represents the destination app to authenticate the user. */ -sealed class AuthDestination { +@Parcelize +sealed class AuthDestination : Parcelable { /** * Authenticating within the same app by using a system webview, a.k.a Custom Tabs. If custom tabs * are unavailable the authentication flow will be launched in the system browser app. @@ -25,7 +29,8 @@ sealed class AuthDestination { /** * Authenticating via one of the family of Uber apps using the Single Sign-On (SSO) flow in the - * order of priority mentioned. + * order of priority mentioned. If none of the apps are available we will fall back to the [InApp] + * flow * * @param appPriority The order of the apps to use for the SSO flow. */ diff --git a/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/AuthType.kt b/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/AuthType.kt index 93fbaf04..3d7c42a9 100644 --- a/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/AuthType.kt +++ b/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/AuthType.kt @@ -15,15 +15,19 @@ */ package com.uber.sdk2.auth.api.request +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + /** * Represents the type of authentication to perform. * * @see AuthContext */ -sealed class AuthType { +@Parcelize +sealed class AuthType(val grantType: String) : Parcelable { /** The authorization code flow. */ - data object AuthCode : AuthType() + data object AuthCode : AuthType("code") /** The proof key for code exchange (PKCE) flow. This is the recommended flow for mobile apps. */ - data object PKCE : AuthType() + data object PKCE : AuthType("code") } diff --git a/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/CrossApp.kt b/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/CrossApp.kt index f26ac193..7fa6c03c 100644 --- a/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/CrossApp.kt +++ b/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/CrossApp.kt @@ -15,14 +15,25 @@ */ package com.uber.sdk2.auth.api.request -/** Provides different apps that could be used for authentication using SSO flow. */ -sealed class CrossApp { +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Provides different apps that could be used for authentication using SSO flow. + * + * @param packages The list of packages that could be used for authentication. + */ +@Parcelize +sealed class CrossApp(val packages: List) : Parcelable { /** The Eats app. */ - data object Eats : CrossApp() + data object Eats : + CrossApp(listOf("com.ubercab.eats", "com.ubercab.eats.exo", "com.ubercab.eats.internal")) /** The Rider app. */ - data object Rider : CrossApp() + data object Rider : + CrossApp(listOf("com.ubercab", "com.ubercab.presidio.exo", "com.ubercab.rider.internal")) /** The Driver app. */ - data object Driver : CrossApp() + data object Driver : + CrossApp(listOf("com.ubercab.driver", "com.ubercab.driver.exo", "com.ubercab.driver.internal")) } diff --git a/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/PrefillInfo.kt b/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/PrefillInfo.kt index 98c21160..be65950f 100644 --- a/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/PrefillInfo.kt +++ b/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/PrefillInfo.kt @@ -15,6 +15,9 @@ */ package com.uber.sdk2.auth.api.request +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + /** * Provides a way to prefill the user's information in the authentication flow. * @@ -23,9 +26,10 @@ package com.uber.sdk2.auth.api.request * @param lastName The last name to prefill. * @param phoneNumber The phone number to prefill. */ +@Parcelize data class PrefillInfo( val email: String, val firstName: String, val lastName: String, val phoneNumber: String, -) +) : Parcelable diff --git a/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/SsoConfig.kt b/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/SsoConfig.kt new file mode 100644 index 00000000..498e3da1 --- /dev/null +++ b/authentication/src/main/kotlin/com/uber/sdk2/auth/api/request/SsoConfig.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2024. Uber Technologies + * + * Licensed 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 + * + * https://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.uber.sdk2.auth.api.request + +import android.content.Context +import android.content.res.Resources +import android.text.TextUtils +import com.uber.sdk2.auth.api.exception.AuthException +import com.uber.sdk2.core.config.UriConfig.CLIENT_ID_PARAM +import com.uber.sdk2.core.config.UriConfig.REDIRECT_PARAM +import com.uber.sdk2.core.config.UriConfig.SCOPE_PARAM +import java.io.IOException +import java.nio.charset.Charset +import okio.Buffer +import okio.BufferedSource +import okio.Okio +import org.json.JSONException +import org.json.JSONObject + +data class SsoConfig(val clientId: String, val redirectUri: String, val scope: String? = null) + +object SsoConfigProvider { + fun getSsoConfig(context: Context): SsoConfig { + val resources: Resources = context.resources + val resourceId = resources.getIdentifier(SSO_CONFIG_FILE, "raw", context.packageName) + val configSource: BufferedSource = + Okio.buffer(Okio.source(resources.openRawResource(resourceId))) + val configData = Buffer() + try { + configSource.readAll(configData) + val configJson = JSONObject(configData.readString(Charset.forName("UTF-8"))) + val clientId = getRequiredConfigString(configJson, CLIENT_ID_PARAM) + val scope = getConfigString(configJson, SCOPE_PARAM) + val redirectUri = getRequiredConfigString(configJson, REDIRECT_PARAM) + return SsoConfig(clientId, redirectUri, scope) + } catch (ex: IOException) { + throw AuthException.ClientError("Failed to read configuration: " + ex.message) + } catch (ex: JSONException) { + throw AuthException.ClientError("Failed to read configuration: " + ex.message) + } + } + + @Throws(AuthException::class) + private fun getRequiredConfigString(configJson: JSONObject, propName: String): String { + return getConfigString(configJson, propName) + ?: throw AuthException.ClientError( + "$propName is required but not specified in the configuration" + ) + } + + private fun getConfigString(configJson: JSONObject, propName: String): String? { + var value: String = configJson.optString(propName) ?: return null + value = value.trim { it <= ' ' } + return if (TextUtils.isEmpty(value)) { + null + } else value + } + + private const val SSO_CONFIG_FILE = "sso_config" +} diff --git a/build.gradle.kts b/build.gradle.kts index 2b86aeb9..c2960a6c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,6 +35,7 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.mavenPublish) apply false + alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.dokka) alias(libs.plugins.spotless) } diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 00000000..2065fed8 --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024. Uber Technologies + * + * Licensed 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 + * + * https://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 { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.uber.sdk2.core" + buildFeatures.buildConfig = true + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String", "VERSION_NAME", "\"${project.property("VERSION_NAME").toString()}\"") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } +} + +dependencies { + implementation(libs.chrometabs) + implementation(libs.core.ktx) + implementation(libs.appCompat) + implementation(libs.material) + testImplementation(libs.junit.junit) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.espresso.espresso.core) +} diff --git a/core/proguard-rules.pro b/core/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/core/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/core/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/src/main/kotlin/com/uber/sdk2/core/config/UriConfig.kt b/core/src/main/kotlin/com/uber/sdk2/core/config/UriConfig.kt new file mode 100644 index 00000000..60bedf96 --- /dev/null +++ b/core/src/main/kotlin/com/uber/sdk2/core/config/UriConfig.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2024. Uber Technologies + * + * Licensed 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 + * + * https://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.uber.sdk2.core.config + +import android.net.Uri +import com.uber.sdk2.core.BuildConfig +import com.uber.sdk2.core.config.UriConfig.EndpointRegion.DEFAULT +import com.uber.sdk2.core.config.UriConfig.Environment.API +import com.uber.sdk2.core.config.UriConfig.Environment.AUTH +import com.uber.sdk2.core.config.UriConfig.Scheme.HTTPS +import java.util.Locale + +object UriConfig { + + enum class Scheme(val scheme: String) { + HTTP("http"), + HTTPS("https") + } + + /** + * An Uber API Environment. See [Sandbox](https://developer.uber.com/v1/sandbox) for more + * information. + */ + enum class Environment(val subDomain: String) { + API("api"), + AUTH("auth"), + } + + enum class EndpointRegion( + /** @return domain to use. */ + val domain: String + ) { + DEFAULT("uber.com") + } + + fun assembleUri( + clientId: String, + responseType: String, + redirectUri: String, + scopes: String? = null, + ): Uri { + val builder = Uri.Builder() + builder + .scheme(HTTPS.scheme) + .authority(AUTH.subDomain + "." + DEFAULT.domain) + .appendEncodedPath(PATH) + .appendQueryParameter(CLIENT_ID_PARAM, clientId) + .appendQueryParameter(RESPONSE_TYPE_PARAM, responseType.lowercase(Locale.US)) + .appendQueryParameter(REDIRECT_PARAM, redirectUri) + .appendQueryParameter(SCOPE_PARAM, scopes) + .appendQueryParameter(SDK_VERSION_PARAM, BuildConfig.VERSION_NAME) + return builder.build() + } + + /** Gets the endpoint host used to hit the Uber API. */ + fun getEndpointHost(): String = "${HTTPS.scheme}://$API.${DEFAULT.domain}" + + /** Gets the login host used to sign in to the Uber API. */ + fun getAuthHost(): String = "${HTTPS.scheme}://$AUTH.${DEFAULT.domain}" + + const val CLIENT_ID_PARAM = "client_id" + const val PATH = "oauth/v2/universal/authorize" + const val REDIRECT_PARAM = "redirect_uri" + const val RESPONSE_TYPE_PARAM = "response_type" + const val SCOPE_PARAM = "scope" + const val PLATFORM_PARAM = "sdk" + const val SDK_VERSION_PARAM = "sdk_version" + const val CODE_CHALLENGE_PARAM = "code_challenge" + const val REQUEST_URI = "request_uri" +} diff --git a/core/src/main/kotlin/com/uber/sdk2/core/utils/CustomTabsHelper.kt b/core/src/main/kotlin/com/uber/sdk2/core/utils/CustomTabsHelper.kt new file mode 100644 index 00000000..e377a612 --- /dev/null +++ b/core/src/main/kotlin/com/uber/sdk2/core/utils/CustomTabsHelper.kt @@ -0,0 +1,187 @@ +/* + * Copyright (C) 2024. Uber Technologies + * + * Licensed 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 + * + * https://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.uber.sdk2.core.utils + +import android.app.Activity +import android.content.ComponentName +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.CustomTabsClient +import androidx.browser.customtabs.CustomTabsIntent +import androidx.browser.customtabs.CustomTabsServiceConnection + +/** Helper class for Custom Tabs. */ +object CustomTabsHelper { + private var connection: CustomTabsServiceConnection? = null + + /** + * Opens the URL on a Custom Tab if possible. Otherwise fallsback to opening it on a WebView. + * + * @param context The host context. + * @param customTabsIntent a CustomTabsIntent to be used if Custom Tabs is available. + * @param uri the Uri to be opened. + * @param fallback a CustomTabFallback to be used if Custom Tabs is not available. + */ + fun openCustomTab( + context: Context, + customTabsIntent: CustomTabsIntent, + uri: Uri, + fallback: CustomTabFallback?, + ) { + val packageName = getPackageNameToUse(context) + if (packageName != null) { + connection = + object : CustomTabsServiceConnection() { + override fun onCustomTabsServiceConnected( + componentName: ComponentName?, + client: CustomTabsClient, + ) { + client.warmup(0L) // This prevents backgrounding after redirection + customTabsIntent.intent.setPackage(packageName) + customTabsIntent.intent.setData(uri) + customTabsIntent.launchUrl(context, uri) + } + + override fun onServiceDisconnected(name: ComponentName?) {} + } + CustomTabsClient.bindCustomTabsService(context, packageName, connection) + } else + fallback?.openUri(context, uri) + ?: Log.e( + UBER_AUTH_LOG_TAG, + "Use of openCustomTab without Customtab support or a fallback set", + ) + } + + /** Called to clean up the CustomTab when the parentActivity is destroyed. */ + fun onDestroy(parentActivity: Activity) { + connection?.let { parentActivity.unbindService(it) } + connection = null + } + + /** + * Goes through all apps that handle VIEW intents and have a warmup service. Picks the one chosen + * by the user if there is one, otherwise makes a best effort to return a valid package name. + * + * This is **not** threadsafe. + * + * @param context [Context] to use for accessing [PackageManager]. + * @return The package name recommended to use for connecting to custom tabs related components. + */ + private fun getPackageNameToUse(context: Context): String? { + if (packageNameToUse != null) return packageNameToUse + val pm: PackageManager = context.packageManager + // Get default VIEW intent handler. + val activityIntent = Intent(Intent.ACTION_VIEW, Uri.parse("http://www.example.com")) + val defaultViewHandlerInfo: ResolveInfo? = pm.resolveActivity(activityIntent, 0) + var defaultViewHandlerPackageName: String? = null + if (defaultViewHandlerInfo != null) { + defaultViewHandlerPackageName = defaultViewHandlerInfo.activityInfo.packageName + } + + // Get all apps that can handle VIEW intents. + val resolvedActivityList: List = pm.queryIntentActivities(activityIntent, 0) + val packagesSupportingCustomTabs: MutableList = ArrayList() + for (info in resolvedActivityList) { + val serviceIntent = Intent() + serviceIntent.setAction(ACTION_CUSTOM_TABS_CONNECTION) + serviceIntent.setPackage(info.activityInfo.packageName) + if (pm.resolveService(serviceIntent, 0) != null) { + packagesSupportingCustomTabs.add(info.activityInfo.packageName) + } + } + + // Now packagesSupportingCustomTabs contains all apps that can handle both VIEW intents + // and service calls. + packageNameToUse = + when { + packagesSupportingCustomTabs.isEmpty() -> null + packagesSupportingCustomTabs.size == 1 -> packagesSupportingCustomTabs[0] + !TextUtils.isEmpty(defaultViewHandlerPackageName) && + !hasSpecializedHandlerIntents(context, activityIntent) && + packagesSupportingCustomTabs.contains(defaultViewHandlerPackageName) -> + defaultViewHandlerPackageName + packagesSupportingCustomTabs.contains(STABLE_PACKAGE) -> STABLE_PACKAGE + packagesSupportingCustomTabs.contains(BETA_PACKAGE) -> BETA_PACKAGE + packagesSupportingCustomTabs.contains(DEV_PACKAGE) -> DEV_PACKAGE + packagesSupportingCustomTabs.contains(LOCAL_PACKAGE) -> LOCAL_PACKAGE + } + return packageNameToUse + } + + /** + * Used to check whether there is a specialized handler for a given intent. + * + * @param intent The intent to check with. + * @return Whether there is a specialized handler for the given intent. + */ + private fun hasSpecializedHandlerIntents(context: Context, intent: Intent): Boolean { + try { + val pm: PackageManager = context.packageManager + val handlers: List = + pm.queryIntentActivities(intent, PackageManager.GET_RESOLVED_FILTER) + if (handlers.isEmpty()) { + return false + } + handlers.forEach { resolveInfo -> + resolveInfo.filter?.let { filter -> + if ( + filter.countDataAuthorities() != 0 && + filter.countDataPaths() != 0 && + resolveInfo.activityInfo != null + ) { + return true // A suitable handler is found, return true immediately + } + } + } + } catch (e: RuntimeException) { + Log.e(TAG, "Runtime exception while getting specialized handlers") + } + return false + } + + /** Fallback that uses browser */ + class BrowserFallback : CustomTabFallback { + override fun openUri(context: Context, uri: Uri?) { + val intent = Intent(Intent.ACTION_VIEW, uri) + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } + } + + /** To be used as a fallback to open the Uri when Custom Tabs is not available. */ + interface CustomTabFallback { + /** + * @param context The Context that wants to open the Uri. + * @param uri The uri to be opened by the fallback. + */ + fun openUri(context: Context, uri: Uri?) + } + + private const val TAG = "CustomTabsHelper" + private const val STABLE_PACKAGE = "com.android.chrome" + private const val BETA_PACKAGE = "com.chrome.beta" + private const val ACTION_CUSTOM_TABS_CONNECTION = + "android.support.customtabs.action.CustomTabsService" + private var packageNameToUse: String? = null + + private const val UBER_AUTH_LOG_TAG = "UberAuth" +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 024ba04b..83754ace 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } [libraries] jsr305 = { module = "com.google.code.findbugs:jsr305", version.ref = "jsr305" }