diff --git a/build.gradle b/build.gradle index 9614486c..68f8c01c 100644 --- a/build.gradle +++ b/build.gradle @@ -20,9 +20,6 @@ buildscript { repositories { google() jcenter() - maven { - url = "https://androidx.dev/snapshots/builds/12815573/artifacts/repository" - } } dependencies { classpath 'com.android.tools.build:gradle:8.7.3' @@ -34,11 +31,6 @@ allprojects { google() jcenter() - // AndroidX snapshots repository - maven { - url = "https://androidx.dev/snapshots/builds/12815573/artifacts/repository" - } - // Repository for DexMaker maven { url = "https://linkedin.jfrog.io/artifactory/open-source/" diff --git a/demos/custom-tabs-auth-tab/.gitignore b/demos/custom-tabs-auth-tab/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/demos/custom-tabs-auth-tab/.gitignore @@ -0,0 +1 @@ +/build diff --git a/demos/custom-tabs-auth-tab/build.gradle b/demos/custom-tabs-auth-tab/build.gradle new file mode 100644 index 00000000..43fb98d4 --- /dev/null +++ b/demos/custom-tabs-auth-tab/build.gradle @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Google LLC + * + * 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. + */ + +apply plugin: 'com.android.application' + +android { + namespace 'com.google.androidbrowserhelper.demos.customtabsauthtab' + compileSdkVersion 35 + defaultConfig { + applicationId "com.google.androidbrowserhelper.demos.customtabsauthtab" + minSdkVersion 26 + targetSdkVersion 35 + versionCode 1 + versionName "1.0" + } + + buildTypes { + release { + minifyEnabled false + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} + +dependencies { + implementation project(path: ':androidbrowserhelper') + implementation fileTree(dir: "libs", include: ["*.jar"]) + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'androidx.activity:activity:1.9.3' + implementation 'androidx.browser:browser:1.9.0-alpha01' + implementation 'com.google.android.material:material:1.12.0' + implementation 'androidx.annotation:annotation:1.9.1' + implementation 'androidx.constraintlayout:constraintlayout:2.2.0' +} diff --git a/demos/custom-tabs-auth-tab/src/main/AndroidManifest.xml b/demos/custom-tabs-auth-tab/src/main/AndroidManifest.xml new file mode 100644 index 00000000..828ba710 --- /dev/null +++ b/demos/custom-tabs-auth-tab/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/AuthManager.java b/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/AuthManager.java new file mode 100644 index 00000000..75755fcb --- /dev/null +++ b/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/AuthManager.java @@ -0,0 +1,106 @@ +package com.google.androidbrowserhelper.demos.customtabsauthtab; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.annotation.NonNull; +import androidx.browser.auth.AuthTabIntent; +import androidx.annotation.OptIn; +import androidx.browser.auth.ExperimentalAuthTab; + +import java.io.IOException; +import java.util.UUID; + +/** + * This class helps managing an authentication flow. It was created with the goal of demonstrating + * how to use Custom Tabs Auth Tab to handle auth and is not meant as a complete implementation + * of the OAuth protocol. We recommend checking out https://github.com/openid/AppAuth-Android for + * a comprehensive implementation of the OAuth protocol. + */ + +@OptIn(markerClass = ExperimentalAuthTab.class) +public class AuthManager { + private static final String TAG = "OAuthManager"; + + private final String mClientId; + private final String mClientSecret; + private final String mAuthorizationEndpoint; + private final String mRedirectScheme; + + public interface OAuthCallback { + void auth(String accessToken, String scope, String tokenType); + } + + public AuthManager(String clientId, String clientSecret, String authorizationEndpoint, + String redirectScheme) { + mClientId = clientId; + mClientSecret = clientSecret; + mAuthorizationEndpoint = authorizationEndpoint; + mRedirectScheme = redirectScheme; + } + + public void authorize(Context context, ActivityResultLauncher launcher, String scope) { + // Generate a random state. + String state = UUID.randomUUID().toString(); + + // Save the state so we can verify later. + SharedPreferences preferences = + context.getSharedPreferences("OAUTH_STORAGE", Context.MODE_PRIVATE); + preferences.edit() + .putString("OAUTH_STATE", state) + .apply(); + + // Create an authorization URI to the OAuth Endpoint. + Uri uri = Uri.parse(mAuthorizationEndpoint) + .buildUpon() + .appendQueryParameter("response_type", "code") + .appendQueryParameter("client_id", mClientId) + .appendQueryParameter("scope", scope) + .appendQueryParameter("state", state) + .build(); + + // Open the Authorization URI in a Chrome Custom Auth Tab. + AuthTabIntent authTabIntent = new AuthTabIntent.Builder().build(); + authTabIntent.launch(launcher, uri, mRedirectScheme); + } + + public void continueAuthFlow(@NonNull Context context, Uri uri, @NonNull OAuthCallback callback) { + String code = uri.getQueryParameter("code"); + SharedPreferences preferences = + context.getSharedPreferences("OAUTH_STORAGE", Context.MODE_PRIVATE); + String state = preferences.getString("OAUTH_STATE", ""); + Uri tokenUri = Uri.parse("https://github.com/login/oauth/access_token") + .buildUpon() + .appendQueryParameter("client_id", mClientId) + .appendQueryParameter("client_secret", mClientSecret) + .appendQueryParameter("code", code) + .appendQueryParameter("state", state) + .build(); + + // Run the network request off the UI thread. + new Thread(() -> { + try { + String response = Utils.fetch(tokenUri); + // The response is a query-string. We concatenate with a valid domain to be + // able to easily parse and extract values. + Uri responseUri = Uri.parse("http://example.com?" + response); + String accessToken = responseUri.getQueryParameter("access_token"); + String tokenType = responseUri.getQueryParameter("token_type"); + String scope = responseUri.getQueryParameter("scope"); + + // Invoke the callback in the main thread. + new Handler(Looper.getMainLooper()).post( + () -> callback.auth(accessToken, scope, tokenType)); + + } catch (IOException e) { + Log.e(TAG, "Error requesting access token: " + e.getMessage()); + } + }).start(); + } +} diff --git a/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/GithubApi.java b/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/GithubApi.java new file mode 100644 index 00000000..1d62cdca --- /dev/null +++ b/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/GithubApi.java @@ -0,0 +1,43 @@ +package com.google.androidbrowserhelper.demos.customtabsauthtab; + +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; + +public class GithubApi { + private static final String TAG = "GithubAPI"; + private static final String API_ENDPOINT = "https://api.github.com/user"; + private static final String AUTH_HEADER_KEY = "Authorization"; + + public interface UserCallback { + void onUserData(String username); + } + + public static void requestGithubUsername(String token, UserCallback callback) { + new Thread(() -> { + try { + Uri uri = Uri.parse(API_ENDPOINT); + Map headers = + Collections.singletonMap(AUTH_HEADER_KEY, "token " + token); + String response = Utils.fetch(uri, headers); + JSONObject user = new JSONObject(response); + String username = user.getString("name"); + + // Invoke the callback in the main thread. + new Handler(Looper.getMainLooper()).post(() -> { + callback.onUserData(username); + }); + } catch (IOException | JSONException ex) { + Log.e(TAG, "Error fetching GitHub user: " + ex.getMessage()); + } + }).start(); + } +} diff --git a/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/MainActivity.java b/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/MainActivity.java new file mode 100644 index 00000000..3e4e1a75 --- /dev/null +++ b/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/MainActivity.java @@ -0,0 +1,101 @@ +/* + * Copyright 2024 Google LLC + * + * 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.google.androidbrowserhelper.demos.customtabsauthtab; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.annotation.OptIn; +import androidx.appcompat.app.AppCompatActivity; +import androidx.browser.auth.AuthTabIntent; +import androidx.browser.auth.ExperimentalAuthTab; + +@OptIn(markerClass = ExperimentalAuthTab.class) +public class MainActivity extends AppCompatActivity { + private static final String TAG = "MainActivity"; + + private static final String AUTHORIZATION_ENDPOINT = "https://github.com/login/oauth/authorize"; + private static final String CLIENT_ID = ""; + private static final String CLIENT_SECRET = ""; + private static final String REDIRECT_SCHEME = "auth"; + + private static final AuthManager O_AUTH_MANAGER = + new AuthManager(CLIENT_ID, CLIENT_SECRET, AUTHORIZATION_ENDPOINT, REDIRECT_SCHEME); + + private final ActivityResultLauncher mLauncher = + AuthTabIntent.registerActivityResultLauncher(this, this::handleAuthResult); + + private Button mLoginButton; + private TextView mUserText; + private ProgressBar mProgressBar; + private boolean mLoggedIn; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + mLoginButton = findViewById(R.id.login_button); + mUserText = findViewById(R.id.user_text); + mProgressBar = findViewById(R.id.progress_bar); + + Intent intent = getIntent(); + if (intent != null) { + Uri data = intent.getData(); + if (data != null && data.getHost() != null + && data.getHost().startsWith("callback")) { + mProgressBar.setVisibility(View.VISIBLE); + mLoginButton.setEnabled(false); + completeAuth(data); + } + } + } + + public void login(View v) { + if (mLoggedIn) { + mLoginButton.setText(R.string.login); + mUserText.setText(R.string.logged_out); + mLoggedIn = false; + } else { + O_AUTH_MANAGER.authorize(this, mLauncher, "user"); + } + } + + private void handleAuthResult(AuthTabIntent.AuthResult result) { + if (result.resultCode == AuthTabIntent.RESULT_OK) { + completeAuth(result.resultUri); + } + } + + private void completeAuth(Uri uri) { + O_AUTH_MANAGER.continueAuthFlow(this, uri, (accessToken, scope, tokenType) -> { + GithubApi.requestGithubUsername(accessToken, (username -> { + mLoginButton.setText(R.string.logout); + mLoginButton.setEnabled(true); + mProgressBar.setVisibility(View.INVISIBLE); + mUserText.setText(getString(R.string.logged_in, username)); + mLoggedIn = true; + })); + }); + } +} \ No newline at end of file diff --git a/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/Utils.java b/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/Utils.java new file mode 100644 index 00000000..ebd29121 --- /dev/null +++ b/demos/custom-tabs-auth-tab/src/main/java/com/google/androidbrowserhelper/demos/customtabsauthtab/Utils.java @@ -0,0 +1,49 @@ +package com.google.androidbrowserhelper.demos.customtabsauthtab; + +import android.net.Uri; + +import androidx.annotation.NonNull; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Collections; +import java.util.Map; + +public class Utils { + public static String fetch(@NonNull Uri uri) throws IOException { + return fetch(uri, Collections.emptyMap()); + } + + public static String fetch(@NonNull Uri uri, Map headers) throws IOException { + HttpURLConnection connection = null; + try { + URL url = new URL(uri.toString()); + connection = (HttpURLConnection)url.openConnection(); + for(Map.Entry entry: headers.entrySet()) { + connection.setRequestProperty(entry.getKey(), entry.getValue()); + } + connection.setDoOutput(true); + return inputStreamToString(connection.getInputStream()); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + @NonNull + public static String inputStreamToString(@NonNull InputStream inputStream) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + String line; + StringBuilder builder = new StringBuilder(); + while ((line = reader.readLine()) != null) { + builder.append(line).append('\n'); + } + return builder.toString(); + } + } +} \ No newline at end of file diff --git a/demos/custom-tabs-auth-tab/src/main/res/drawable/ic_launcher_background.xml b/demos/custom-tabs-auth-tab/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..9e983cb3 --- /dev/null +++ b/demos/custom-tabs-auth-tab/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demos/custom-tabs-auth-tab/src/main/res/drawable/ic_launcher_foreground.xml b/demos/custom-tabs-auth-tab/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..9be1fda8 --- /dev/null +++ b/demos/custom-tabs-auth-tab/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/demos/custom-tabs-auth-tab/src/main/res/layout/activity_main.xml b/demos/custom-tabs-auth-tab/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..ad1f1ec3 --- /dev/null +++ b/demos/custom-tabs-auth-tab/src/main/res/layout/activity_main.xml @@ -0,0 +1,42 @@ + + + + +