# FusionAuth QuickStart for Android Java - AppAuth
QuickStart integration of a Java-based Android app with FusionAuth.
> For the index of this section of the site, see [llms.txt](https://4002.18-224-161-104.sslip.io/docs/llms.txt)

In this QuickStart, you are going to build an Android app with Java and integrate it with FusionAuth. You'll be building it for [ChangeBank](https://www.youtube.com/watch?v=CXDxNCzUspM), a global leader in converting dollars into coins. It'll have areas reserved for users who have logged in as well as public facing sections.
The Docker Compose file and source code for a complete application are available at [https://github.com/FusionAuth/fusionauth-quickstart-java-android-native](https://github.com/FusionAuth/fusionauth-quickstart-java-android-native).
## Prerequisites
* [Android Studio](https://developer.android.com/studio): The official IDE for Android will help you develop and install necessary tools to set it up.
* There, you need to [install at least JDK 17](https://developer.android.com/build/jdks).
* [Git](https://git-scm.com/site): You'll use it to clone the base repository.
* [Docker](https://www.docker.com): The quickest way to stand up FusionAuth. (There are [other ways](https://4002.18-224-161-104.sslip.io/docs/get-started/download-and-install.md)).
This app was built on top of [AppAuth](https://github.com/openid/AppAuth-Android), which is an open source client SDK for communicating with OAuth 2.0 and OpenID Connect providers. AppAuth supports Android API 16 (Jellybean) and above.
## General Architecture
Here's a typical application login flow before FusionAuth:
```mermaid
sequenceDiagram
participant User
participant App as Application
User ->> App : View Homepage
User ->> App : Click Login Link
App ->> User : Show Login Form
User ->> App : Fill Out and Submit Login Form
App ->> App : Authenticates User
App ->> User : Display User's Account or Other Info
```
Request flow during login before FusionAuth
And here's the same application login flow after introducing FusionAuth:
```mermaid
sequenceDiagram
participant User
participant App as Application
participant FusionAuth
User ->> App : View Homepage
User ->> App : Click Login Link (to FusionAuth)
User ->> FusionAuth : View Login Form
FusionAuth ->> User : Show Login Form
User ->> FusionAuth : Fill Out and Submit Login Form
FusionAuth ->> FusionAuth : Authenticates User
FusionAuth ->> User: Go to Redirect URI
User ->> App: Request the Redirect URI
App ->> FusionAuth : Is User Authenticated?
FusionAuth ->> App : User is Authenticated
App ->> User : Display User's Account or Other Info
```
Request flow during login after FusionAuth
In general, you are introducing FusionAuth in order to normalize and consolidate user data. This helps make sure it is consistent and up-to-date as well as offloading your login security and functionality to FusionAuth.
## Getting Started
In this section, you'll get FusionAuth up and running and use `git` to create a new application.
### Clone the Code
First off, grab the code from the repository and navigate into that directory:
```shell-session
$ git clone https://github.com/FusionAuth/fusionauth-quickstart-java-android-native.git
```
```shell-session
$ cd fusionauth-quickstart-java-android-native
```
### Run FusionAuth via Docker
You'll find a Docker Compose file (`docker-compose.yml`) and an environment variables configuration file (`.env`) in the root directory of the repo.
Assuming you have Docker installed, you can stand up FusionAuth on your machine with the following.
```shell-session
$ docker compose up -d
```
Here you are using a bootstrapping feature of FusionAuth called [Kickstart](https://4002.18-224-161-104.sslip.io/docs/get-started/download-and-install/development/kickstart.md). When FusionAuth comes up for the first time, it will look at the `kickstart/kickstart.json` file and configure FusionAuth to your specified state.
Note
If you ever want to reset the FusionAuth application, you need to delete the volumes created by Docker Compose by executing `docker compose down -v`, then re-run `docker compose up -d`.
FusionAuth will be initially configured with these settings:
* Your client Id is `e9fdb985-9173-4e01-9d73-ac2d60d1dc8e`.
* Your client secret is `super-secret-secret-that-should-be-regenerated-for-production`.
* Your example username is `richard@example.com` and the password is `password`.
* Your admin username is `admin@example.com` and the password is `password`.
* The base URL of FusionAuth is `http://localhost:9011/`.
You can log in to the [FusionAuth admin UI](http://localhost:9011/admin) and look around if you want to, but with Docker and Kickstart, everything will already be configured correctly.
Note
If you want to see where the FusionAuth values came from, they can be found in the [FusionAuth app](http://localhost:9011/admin). The tenant Id is found on the Tenants page. To see the Client Id and Client Secret, go to the Applications page and click the `View` icon under the actions for the ChangeBank application. You'll find the Client Id and Client Secret values in the `OAuth configuration` section.
Caution
The `.env` file contains passwords. In a real application, always add this file to your `.gitignore` file and never commit secrets to version control.
### Set Up A Public URL For FusionAuth
Your FusionAuth instance is now running on a different machine (your computer) than the mobile app will run (either a real device or an emulator), which means that it won't be able to access `localhost`.
If the device and your computer are not connected to the same network or if you have something that blocks connections (like a firewall), learn [how to expose a local FusionAuth instance to the internet](https://4002.18-224-161-104.sslip.io/docs/get-started/download-and-install/development/exposing-instance.md). In summary, the process entails configuring [ngrok](https://ngrok.com/download) on your local system, [starting your FusionAuth instance on port 9011](#run-fusionauth-via-docker), and subsequently executing the following command.
```shell
ngrok http --request-header-add 'X-Forwarded-Port:443' 9011
```
This will generate a public URL that you can use to access FusionAuth when developing the app.
If the device (either real or emulator) and your computer are connected to the same network, you can use the local IP Address for your machine (for example, `192.168.15.2`). Here are a few articles to help you find your IP address, depending on the operating system you are running:
* [Linux](https://linuxize.com/post/how-to-find-ip-address-linux/#find-your-public-ip-address)
* [Mac](https://support.apple.com/guide/mac-help/find-your-computers-name-and-network-address-mchlp1177/mac)
* [Windows](https://support.microsoft.com/en-us/windows/find-your-ip-address-in-windows-f21a9bbc-c582-55cd-35e0-73431160a1b9)
### Configure FusionAuth Instance
Now that you have [exposed your instance](#set-up-a-public-url-for-fusionauth), you need to update the Tenant issuer to make sure it matches the given address.
Log into the [FusionAuth admin UI](http://localhost:9011/admin), browse to `Tenants` in the sidebar, click edit on the **Default** tenant to edit it. Paste the complete address *(with protocol and domain)* you copied from ngrok into the **Issuer** field (e.g. `https://6d1e-2804-431-c7c9-739-4703-98a7-4b81-5ba6.ngrok-free.app`). Save the application by clicking the save icon in the top right corner.
Navigate to Applications and click edit on the **Example Android App** application. Click on the JWT tab, change both `Access token signing key` and `Id token signing key` to `Auto generate a new key on save...` and save the application.
Note
You must create new keys after modifying the `Tenant` because the **Issuer** field is embedded in the key.
### Create your Android App
Now you are going to create an Android app. While this section builds a simple Android app on top of the [AppAuth demo app](https://github.com/openid/AppAuth-Android), you can use the same configuration to integrate your existing app with FusionAuth.
```shell-session
$ git clone https://github.com/FusionAuth/openid-AppAuth-Android.git && \
cd openid-AppAuth-Android
```
Start by removing some unused files:
```shell-session
$ rm app/java/io/fusionauth/app/BrowserSelectionAdapter.java && \
rm app/res/layout/browser_selector_layout.xml
```
Change the package namespace in `app/build.gradle`. You can replace the entire file with below contents.
```groovy
apply plugin: 'com.android.application'
apply plugin: 'checkstyle'
apply from: '../config/android-common.gradle'
apply from: '../config/keystore.gradle'
android {
namespace 'io.fusionauth.app'
defaultConfig {
applicationId 'io.fusionauth.app'
project.archivesBaseName = 'fusionauth-demoapp'
vectorDrawables.useSupportLibrary = true
// Make sure this is consistent with the redirect URI used in res/raw/auth_config.json,
// or specify additional redirect URIs in AndroidManifest.xml
manifestPlaceholders = [
'appAuthRedirectScheme': 'io.fusionauth.app'
]
}
signingConfigs {
debugAndRelease {
storeFile file("${rootDir}/appauth.keystore")
storePassword "appauth"
keyAlias "appauth"
keyPassword "appauth"
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
lintOptions {
lintConfig = file("${projectDir}/lint.xml")
}
buildTypes {
debug {
signingConfig signingConfigs.debugAndRelease
}
release {
signingConfig signingConfigs.debugAndRelease
}
}
}
dependencies {
implementation "net.openid:appauth:0.11.1"
implementation "androidx.appcompat:appcompat:${project.androidXVersions.appcompat}"
implementation "androidx.annotation:annotation:${project.androidXVersions.annotation}"
implementation "com.google.android.material:material:${project.googleVersions.material}"
implementation "com.github.bumptech.glide:glide:${project.googleVersions.glide}"
implementation "com.squareup.okio:okio:${project.okioVersion}"
implementation "joda-time:joda-time:${project.jodaVersion}"
annotationProcessor "com.github.bumptech.glide:compiler:${project.googleVersions.glide}"
}
apply from: '../config/style.gradle'
```
Replace `app/AndroidManifest.xml` as well.
```xml
```
## Authentication
We'll use the [AppAuth Library](https://github.com/openid/AppAuth-Android), which simplifies integrating with FusionAuth and creating a secure web application.
### Configure AppAuth
Modify `app/res/raw/auth_config.json` to use the values provisioned by Kickstart. Update the `discovery_uri` value; change `https://[YOUR-NGROK-MAIN-DOMAIN]` to the URL you wrote when [exposing your instance](#set-up-a-public-url-for-fusionauth).
```json
{
"client_id": "e9fdb985-9173-4e01-9d73-ac2d60d1dc8e",
"redirect_uri": "io.fusionauth.app:/oauth2redirect",
"end_session_redirect_uri": "io.fusionauth.app:/oauth2redirect",
"authorization_scope": "openid email profile offline_access",
"discovery_uri": "https://[YOUR-NGROK-MAIN-DOMAIN]/.well-known/openid-configuration/d7d09513-a3f5-401c-9685-34ab6c552453",
"https_required": true
}
```
### Change Activities
An [Activity](https://developer.android.com/guide/components/activities/intro-activities) is a screen for your app, combining the User Interface as well as the logic to handle it. Start by changing the login activity layout at `app/res/layout/activity_login.xml`. Replace it with the below XML.
```xml
```
Now modify login activity logic by replacing `app/java/io/fusionauth/app/LoginActivity.java` with this code.
```java
/*
* Copyright 2015 The AppAuth for Android Authors. All Rights Reserved.
*
* 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
*
* 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 io.fusionauth.app;
import android.annotation.TargetApi;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.util.Log;
import android.view.View;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.TextView;
import androidx.annotation.AnyThread;
import androidx.annotation.ColorRes;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.appcompat.app.AppCompatActivity;
import androidx.browser.customtabs.CustomTabsIntent;
import com.google.android.material.snackbar.Snackbar;
import net.openid.appauth.AppAuthConfiguration;
import net.openid.appauth.AuthState;
import net.openid.appauth.AuthorizationException;
import net.openid.appauth.AuthorizationRequest;
import net.openid.appauth.AuthorizationService;
import net.openid.appauth.AuthorizationServiceConfiguration;
import net.openid.appauth.ClientSecretBasic;
import net.openid.appauth.RegistrationRequest;
import net.openid.appauth.RegistrationResponse;
import net.openid.appauth.ResponseTypeValues;
import net.openid.appauth.browser.AnyBrowserMatcher;
import net.openid.appauth.browser.BrowserMatcher;
import java.util.Collections;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/**
* Demonstrates the usage of the AppAuth to authorize a user with an OAuth2 / OpenID Connect
* provider. Based on the configuration provided in `res/raw/auth_config.json`, the code
* contained here will:
*
* - Retrieve an OpenID Connect discovery document for the provider, or use a local static
* configuration.
* - Utilize dynamic client registration, if no static client id is specified.
* - Initiate the authorization request using the built-in heuristics or a user-selected browser.
*
* _NOTE_: From a clean checkout of this project, the authorization service is not configured.
* Edit `res/raw/auth_config.json` to provide the required configuration properties. See the
* README.md in the app/ directory for configuration instructions, and the adjacent IDP-specific
* instructions.
*/
public final class LoginActivity extends AppCompatActivity {
private static final String TAG = "LoginActivity";
private static final String EXTRA_FAILED = "failed";
private static final int RC_AUTH = 100;
private AuthorizationService mAuthService;
private AuthStateManager mAuthStateManager;
private Configuration mConfiguration;
private final AtomicReference mClientId = new AtomicReference<>();
private final AtomicReference mAuthRequest = new AtomicReference<>();
private final AtomicReference mAuthIntent = new AtomicReference<>();
private CountDownLatch mAuthIntentLatch = new CountDownLatch(1);
private ExecutorService mExecutor;
@NonNull
private BrowserMatcher mBrowserMatcher = AnyBrowserMatcher.INSTANCE;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mExecutor = Executors.newSingleThreadExecutor();
mAuthStateManager = AuthStateManager.getInstance(this);
mConfiguration = Configuration.getInstance(this);
if (mAuthStateManager.getCurrent().isAuthorized()
&& !mConfiguration.hasConfigurationChanged()) {
Log.i(TAG, "User is already authenticated, proceeding to token activity");
startActivity(new Intent(this, TokenActivity.class));
finish();
return;
}
setContentView(R.layout.activity_login);
findViewById(R.id.retry).setOnClickListener((View view) ->
mExecutor.submit(this::initializeAppAuth));
findViewById(R.id.start_auth).setOnClickListener((View view) -> startAuth());
if (!mConfiguration.isValid()) {
displayError(mConfiguration.getConfigurationError(), false);
return;
}
if (mConfiguration.hasConfigurationChanged()) {
// discard any existing authorization state due to the change of configuration
Log.i(TAG, "Configuration change detected, discarding old state");
mAuthStateManager.replace(new AuthState());
mConfiguration.acceptConfiguration();
}
if (getIntent().getBooleanExtra(EXTRA_FAILED, false)) {
displayAuthCancelled();
}
displayLoading("Initializing");
mExecutor.submit(this::initializeAppAuth);
}
@Override
protected void onStart() {
super.onStart();
if (mExecutor.isShutdown()) {
mExecutor = Executors.newSingleThreadExecutor();
}
}
@Override
protected void onStop() {
super.onStop();
mExecutor.shutdownNow();
}
@Override
protected void onDestroy() {
super.onDestroy();
if (mAuthService != null) {
mAuthService.dispose();
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
displayAuthOptions();
if (resultCode == RESULT_CANCELED) {
displayAuthCancelled();
} else {
Intent intent = new Intent(this, TokenActivity.class);
intent.putExtras(data.getExtras());
startActivity(intent);
}
}
@MainThread
void startAuth() {
displayLoading("Making authorization request");
// WrongThread inference is incorrect for lambdas
// noinspection WrongThread
mExecutor.submit(this::doAuth);
}
/**
* Initializes the authorization service configuration if necessary, either from the local
* static values or by retrieving an OpenID discovery document.
*/
@WorkerThread
private void initializeAppAuth() {
Log.i(TAG, "Initializing AppAuth");
recreateAuthorizationService();
if (mAuthStateManager.getCurrent().getAuthorizationServiceConfiguration() != null) {
// configuration is already created, skip to client initialization
Log.i(TAG, "auth config already established");
initializeClient();
return;
}
// if we are not using discovery, build the authorization service configuration directly
// from the static configuration values.
if (mConfiguration.getDiscoveryUri() == null) {
Log.i(TAG, "Creating auth config from res/raw/auth_config.json");
AuthorizationServiceConfiguration config = new AuthorizationServiceConfiguration(
mConfiguration.getAuthEndpointUri(),
mConfiguration.getTokenEndpointUri(),
mConfiguration.getRegistrationEndpointUri(),
mConfiguration.getEndSessionEndpoint());
mAuthStateManager.replace(new AuthState(config));
initializeClient();
return;
}
// WrongThread inference is incorrect for lambdas
// noinspection WrongThread
runOnUiThread(() -> displayLoading("Retrieving discovery document"));
Log.i(TAG, "Retrieving OpenID discovery doc");
AuthorizationServiceConfiguration.fetchFromUrl(
mConfiguration.getDiscoveryUri(),
this::handleConfigurationRetrievalResult,
mConfiguration.getConnectionBuilder());
}
@MainThread
private void handleConfigurationRetrievalResult(
AuthorizationServiceConfiguration config,
AuthorizationException ex) {
if (config == null) {
Log.i(TAG, "Failed to retrieve discovery document", ex);
displayError("Failed to retrieve discovery document: " + ex.getMessage(), true);
return;
}
Log.i(TAG, "Discovery document retrieved");
mAuthStateManager.replace(new AuthState(config));
mExecutor.submit(this::initializeClient);
}
/**
* Initiates a dynamic registration request if a client ID is not provided by the static
* configuration.
*/
@WorkerThread
private void initializeClient() {
if (mConfiguration.getClientId() != null) {
Log.i(TAG, "Using static client ID: " + mConfiguration.getClientId());
// use a statically configured client ID
mClientId.set(mConfiguration.getClientId());
runOnUiThread(this::initializeAuthRequest);
return;
}
RegistrationResponse lastResponse =
mAuthStateManager.getCurrent().getLastRegistrationResponse();
if (lastResponse != null) {
Log.i(TAG, "Using dynamic client ID: " + lastResponse.clientId);
// already dynamically registered a client ID
mClientId.set(lastResponse.clientId);
runOnUiThread(this::initializeAuthRequest);
return;
}
// WrongThread inference is incorrect for lambdas
// noinspection WrongThread
runOnUiThread(() -> displayLoading("Dynamically registering client"));
Log.i(TAG, "Dynamically registering client");
RegistrationRequest registrationRequest = new RegistrationRequest.Builder(
mAuthStateManager.getCurrent().getAuthorizationServiceConfiguration(),
Collections.singletonList(mConfiguration.getRedirectUri()))
.setTokenEndpointAuthenticationMethod(ClientSecretBasic.NAME)
.build();
mAuthService.performRegistrationRequest(
registrationRequest,
this::handleRegistrationResponse);
}
@MainThread
private void handleRegistrationResponse(
RegistrationResponse response,
AuthorizationException ex) {
mAuthStateManager.updateAfterRegistration(response, ex);
if (response == null) {
Log.i(TAG, "Failed to dynamically register client", ex);
displayErrorLater("Failed to register client: " + ex.getMessage(), true);
return;
}
Log.i(TAG, "Dynamically registered client: " + response.clientId);
mClientId.set(response.clientId);
initializeAuthRequest();
}
/**
* Performs the authorization request, using the browser selected in the spinner
*/
@WorkerThread
private void doAuth() {
try {
mAuthIntentLatch.await();
} catch (InterruptedException ex) {
Log.w(TAG, "Interrupted while waiting for auth intent");
}
Intent intent = mAuthService.getAuthorizationRequestIntent(
mAuthRequest.get(),
mAuthIntent.get());
startActivityForResult(intent, RC_AUTH);
}
private void recreateAuthorizationService() {
if (mAuthService != null) {
Log.i(TAG, "Discarding existing AuthService instance");
mAuthService.dispose();
}
mAuthService = createAuthorizationService();
mAuthRequest.set(null);
mAuthIntent.set(null);
}
private AuthorizationService createAuthorizationService() {
Log.i(TAG, "Creating authorization service");
AppAuthConfiguration.Builder builder = new AppAuthConfiguration.Builder();
builder.setBrowserMatcher(mBrowserMatcher);
builder.setConnectionBuilder(mConfiguration.getConnectionBuilder());
return new AuthorizationService(this, builder.build());
}
@MainThread
private void displayLoading(String loadingMessage) {
findViewById(R.id.loading_container).setVisibility(View.VISIBLE);
findViewById(R.id.auth_container).setVisibility(View.GONE);
findViewById(R.id.error_container).setVisibility(View.GONE);
((TextView)findViewById(R.id.loading_description)).setText(loadingMessage);
}
@MainThread
private void displayError(String error, boolean recoverable) {
findViewById(R.id.error_container).setVisibility(View.VISIBLE);
findViewById(R.id.loading_container).setVisibility(View.GONE);
findViewById(R.id.auth_container).setVisibility(View.GONE);
((TextView)findViewById(R.id.error_description)).setText(error);
findViewById(R.id.retry).setVisibility(recoverable ? View.VISIBLE : View.GONE);
}
// WrongThread inference is incorrect in this case
@SuppressWarnings("WrongThread")
@AnyThread
private void displayErrorLater(final String error, final boolean recoverable) {
runOnUiThread(() -> displayError(error, recoverable));
}
@MainThread
private void initializeAuthRequest() {
createAuthRequest();
warmUpBrowser();
displayAuthOptions();
}
@MainThread
private void displayAuthOptions() {
findViewById(R.id.auth_container).setVisibility(View.VISIBLE);
findViewById(R.id.loading_container).setVisibility(View.GONE);
findViewById(R.id.error_container).setVisibility(View.GONE);
AuthState state = mAuthStateManager.getCurrent();
AuthorizationServiceConfiguration config = state.getAuthorizationServiceConfiguration();
}
private void displayAuthCancelled() {
Snackbar.make(findViewById(R.id.coordinator),
"Authorization canceled",
Snackbar.LENGTH_SHORT)
.show();
}
private void warmUpBrowser() {
mAuthIntentLatch = new CountDownLatch(1);
mExecutor.execute(() -> {
Log.i(TAG, "Warming up browser instance for auth request");
CustomTabsIntent.Builder intentBuilder =
mAuthService.createCustomTabsIntentBuilder(mAuthRequest.get().toUri());
intentBuilder.setToolbarColor(getColorCompat(R.color.colorPrimary));
mAuthIntent.set(intentBuilder.build());
mAuthIntentLatch.countDown();
});
}
private void createAuthRequest() {
AuthorizationRequest.Builder authRequestBuilder = new AuthorizationRequest.Builder(
mAuthStateManager.getCurrent().getAuthorizationServiceConfiguration(),
mClientId.get(),
ResponseTypeValues.CODE,
mConfiguration.getRedirectUri())
.setScope(mConfiguration.getScope());
mAuthRequest.set(authRequestBuilder.build());
}
@TargetApi(Build.VERSION_CODES.M)
@SuppressWarnings("deprecation")
private int getColorCompat(@ColorRes int color) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return getColor(color);
} else {
return getResources().getColor(color);
}
}
}
```
Update the main screen layout in the file `app/res/layout/activity_token.xml`.
```xml
```
And finally, change the main screen logic by replacing the content of `app/java/io/fusionauth/app/TokenActivity.java` with the below.
```java
/*
* Copyright 2015 The AppAuth for Android Authors. All Rights Reserved.
*
* 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
*
* 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 io.fusionauth.app;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.Log;
import android.view.View;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.MainThread;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.appcompat.app.AppCompatActivity;
import com.google.android.material.snackbar.Snackbar;
import net.openid.appauth.AppAuthConfiguration;
import net.openid.appauth.AuthState;
import net.openid.appauth.AuthorizationException;
import net.openid.appauth.AuthorizationResponse;
import net.openid.appauth.AuthorizationService;
import net.openid.appauth.AuthorizationServiceConfiguration;
import net.openid.appauth.AuthorizationServiceDiscovery;
import net.openid.appauth.ClientAuthentication;
import net.openid.appauth.EndSessionRequest;
import net.openid.appauth.IdToken;
import net.openid.appauth.TokenRequest;
import net.openid.appauth.TokenResponse;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.HttpURLConnection;
import java.nio.charset.Charset;
import java.text.NumberFormat;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;
import okio.Okio;
/**
* Displays the authorized state of the user. This activity is provided with the outcome of the
* authorization flow, which it uses to negotiate the final authorized state,
* by performing an authorization code exchange if necessary. After this, the activity provides
* additional post-authorization operations if available, such as fetching user info.
*/
public class TokenActivity extends AppCompatActivity {
private static final String TAG = "TokenActivity";
private static final String KEY_USER_INFO = "userInfo";
private static final int END_SESSION_REQUEST_CODE = 911;
private AuthorizationService mAuthService;
private AuthStateManager mStateManager;
private final AtomicReference mUserInfoJson = new AtomicReference<>();
private ExecutorService mExecutor;
private Configuration mConfiguration;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mStateManager = AuthStateManager.getInstance(this);
mExecutor = Executors.newSingleThreadExecutor();
mConfiguration = Configuration.getInstance(this);
Configuration config = Configuration.getInstance(this);
if (config.hasConfigurationChanged()) {
Toast.makeText(
this,
"Configuration change detected",
Toast.LENGTH_SHORT)
.show();
signOut();
return;
}
mAuthService = new AuthorizationService(
this,
new AppAuthConfiguration.Builder()
.setConnectionBuilder(config.getConnectionBuilder())
.build());
setContentView(R.layout.activity_token);
displayLoading("Restoring state...");
if (savedInstanceState != null) {
try {
mUserInfoJson.set(new JSONObject(savedInstanceState.getString(KEY_USER_INFO)));
} catch (JSONException ex) {
Log.e(TAG, "Failed to parse saved user info JSON, discarding", ex);
}
}
}
@Override
protected void onStart() {
super.onStart();
if (mExecutor.isShutdown()) {
mExecutor = Executors.newSingleThreadExecutor();
}
AuthState authState = mStateManager.getCurrent();
if (authState.isAuthorized()) {
fetchUserInfoAndDisplayAuthorized(authState.getAccessToken());
return;
}
// the stored AuthState is incomplete, so check if we are currently receiving the result of
// the authorization flow from the browser.
AuthorizationResponse response = AuthorizationResponse.fromIntent(getIntent());
AuthorizationException ex = AuthorizationException.fromIntent(getIntent());
if (response != null || ex != null) {
mStateManager.updateAfterAuthorization(response, ex);
}
if (response != null && response.authorizationCode != null) {
// authorization code exchange is required
mStateManager.updateAfterAuthorization(response, ex);
exchangeAuthorizationCode(response);
} else if (ex != null) {
displayNotAuthorized("Authorization flow failed: " + ex.getMessage());
} else {
displayNotAuthorized("No authorization state retained - reauthorization required");
}
}
@Override
protected void onSaveInstanceState(Bundle state) {
super.onSaveInstanceState(state);
// user info is retained to survive activity restarts, such as when rotating the
// device or switching apps. This isn't essential, but it helps provide a less
// jarring UX when these events occur - data does not just disappear from the view.
if (mUserInfoJson.get() != null) {
state.putString(KEY_USER_INFO, mUserInfoJson.toString());
}
}
@Override
protected void onDestroy() {
super.onDestroy();
mAuthService.dispose();
mExecutor.shutdownNow();
}
@MainThread
private void displayNotAuthorized(String explanation) {
findViewById(R.id.not_authorized).setVisibility(View.VISIBLE);
findViewById(R.id.authorized).setVisibility(View.GONE);
findViewById(R.id.loading_container).setVisibility(View.GONE);
((TextView)findViewById(R.id.explanation)).setText(explanation);
findViewById(R.id.reauth).setOnClickListener((View view) -> signOut());
}
@MainThread
private void displayLoading(String message) {
findViewById(R.id.loading_container).setVisibility(View.VISIBLE);
findViewById(R.id.authorized).setVisibility(View.GONE);
findViewById(R.id.not_authorized).setVisibility(View.GONE);
((TextView)findViewById(R.id.loading_description)).setText(message);
}
@MainThread
private void displayAuthorized() {
findViewById(R.id.authorized).setVisibility(View.VISIBLE);
findViewById(R.id.not_authorized).setVisibility(View.GONE);
findViewById(R.id.loading_container).setVisibility(View.GONE);
AuthState state = mStateManager.getCurrent();
TextView noAccessTokenReturnedView = (TextView) findViewById(R.id.no_access_token_returned);
if (state.getAccessToken() == null) {
noAccessTokenReturnedView.setVisibility(View.VISIBLE);
} else {
// Logging out if token is expired
Long expiresAt = state.getAccessTokenExpirationTime();
if ((expiresAt != null) && (expiresAt < System.currentTimeMillis())) {
signOut();
return;
}
}
EditText changeTextInput = findViewById(R.id.change_text_input);
changeTextInput.addTextChangedListener(new MoneyChangedHandler(changeTextInput));
findViewById(R.id.sign_out).setOnClickListener((View view) -> endSession());
findViewById(R.id.change_button).setOnClickListener((View view) -> makeChange());
String name = "";
String email = "";
// Retrieving name and email from the /me endpoint response
JSONObject userInfo = mUserInfoJson.get();
if (userInfo != null) {
try {
if (userInfo.has("given_name")) {
name = userInfo.getString("given_name");
}
if (userInfo.has("email")) {
email = userInfo.getString("email");
}
} catch (JSONException ex) {
Log.e(TAG, "Failed to read userinfo JSON", ex);
}
}
// Fallback for name and email
if ((name.isEmpty()) || (email.isEmpty())) {
IdToken idToken = state.getParsedIdToken();
if (idToken != null) {
Object claim = idToken.additionalClaims.get("email");
if (claim != null) {
email = claim.toString();
if (name.isEmpty()) {
name = email;
}
}
}
}
if (!name.isEmpty()) {
TextView welcomeView = (TextView) findViewById(R.id.auth_granted);
String welcomeTemplate = getResources().getString(R.string.auth_granted_name);
welcomeView.setText(String.format(welcomeTemplate, name));
}
((TextView) findViewById(R.id.auth_granted_email)).setText(email);
}
@MainThread
private void exchangeAuthorizationCode(AuthorizationResponse authorizationResponse) {
displayLoading("Exchanging authorization code");
performTokenRequest(
authorizationResponse.createTokenExchangeRequest(),
this::handleCodeExchangeResponse);
}
@MainThread
private void performTokenRequest(
TokenRequest request,
AuthorizationService.TokenResponseCallback callback) {
ClientAuthentication clientAuthentication;
try {
clientAuthentication = mStateManager.getCurrent().getClientAuthentication();
} catch (ClientAuthentication.UnsupportedAuthenticationMethod ex) {
Log.d(TAG, "Token request cannot be made, client authentication for the token "
+ "endpoint could not be constructed (%s)", ex);
displayNotAuthorized("Client authentication method is unsupported");
return;
}
mAuthService.performTokenRequest(
request,
clientAuthentication,
callback);
}
@WorkerThread
private void handleCodeExchangeResponse(
@Nullable TokenResponse tokenResponse,
@Nullable AuthorizationException authException) {
mStateManager.updateAfterTokenResponse(tokenResponse, authException);
if ((tokenResponse == null) || (!mStateManager.getCurrent().isAuthorized())) {
String details = "";
if (authException != null) {
if (authException.error != null) {
details = authException.error;
} else {
final Throwable cause = authException.getCause();
if (cause != null) {
details = cause.getMessage();
}
}
}
final String message = "Authorization Code exchange failed"
+ ((details.length() > 0) ? ": " + details : "");
// WrongThread inference is incorrect for lambdas
//noinspection WrongThread
runOnUiThread(() -> displayNotAuthorized(message));
return;
}
fetchUserInfoAndDisplayAuthorized(tokenResponse.accessToken);
}
private void fetchUserInfoAndDisplayAuthorized(String accessToken) {
AuthorizationServiceDiscovery discovery =
mStateManager.getCurrent()
.getAuthorizationServiceConfiguration()
.discoveryDoc;
Uri userInfoEndpoint = Uri.parse(discovery.getUserinfoEndpoint().toString());
mExecutor.submit(() -> {
try {
HttpURLConnection conn = mConfiguration.getConnectionBuilder().openConnection(
userInfoEndpoint);
conn.setRequestProperty("Authorization", "Bearer " + accessToken);
conn.setInstanceFollowRedirects(false);
String response = Okio.buffer(Okio.source(conn.getInputStream()))
.readString(Charset.forName("UTF-8"));
mUserInfoJson.set(new JSONObject(response));
} catch (IOException ioEx) {
Log.e(TAG, "Network error when querying userinfo endpoint", ioEx);
showSnackbar("Fetching user info failed");
} catch (JSONException jsonEx) {
Log.e(TAG, "Failed to parse userinfo response");
showSnackbar("Failed to parse user info");
}
runOnUiThread(this::displayAuthorized);
});
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == END_SESSION_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
signOut();
finish();
} else {
displayEndSessionCancelled();
}
}
private void displayEndSessionCancelled() {
Snackbar.make(findViewById(R.id.coordinator),
"Sign out canceled",
Snackbar.LENGTH_SHORT)
.show();
}
@MainThread
private void showSnackbar(String message) {
Snackbar.make(findViewById(R.id.coordinator),
message,
Snackbar.LENGTH_SHORT)
.show();
}
@MainThread
private void endSession() {
AuthState currentState = mStateManager.getCurrent();
AuthorizationServiceConfiguration config =
currentState.getAuthorizationServiceConfiguration();
if ((config == null) || (config.endSessionEndpoint == null)) {
signOut();
return;
}
Intent endSessionIntent = mAuthService.getEndSessionRequestIntent(
new EndSessionRequest.Builder(config)
.setIdTokenHint(currentState.getIdToken())
.setPostLogoutRedirectUri(mConfiguration.getEndSessionRedirectUri())
.build());
startActivityForResult(endSessionIntent, END_SESSION_REQUEST_CODE);
}
@MainThread
private void makeChange() {
String value = ((EditText) findViewById(R.id.change_text_input))
.getText()
.toString()
.trim();
if (value.length() == 0) {
return;
}
float floatValue = Float.parseFloat(value);
if (floatValue < 0) {
return;
}
float cents = floatValue * 100;
int nickels = (int) Math.floor(cents / 5);
int pennies = (int) (cents % 5);
TextView textView = findViewById(R.id.change_result_text_view);
String changeTemplate = getResources().getString(R.string.change_result_text_view);
textView.setText(
String.format(
changeTemplate,
NumberFormat.getCurrencyInstance().format(floatValue),
nickels,
pennies
)
);
textView.setVisibility(View.VISIBLE);
}
@MainThread
private void signOut() {
// discard the authorization and token state, but retain the configuration and
// dynamic client registration (if applicable), to save from retrieving them again.
AuthState currentState = mStateManager.getCurrent();
AuthState clearedState =
new AuthState(currentState.getAuthorizationServiceConfiguration());
if (currentState.getLastRegistrationResponse() != null) {
clearedState.update(currentState.getLastRegistrationResponse());
}
mStateManager.replace(clearedState);
Intent mainIntent = new Intent(this, LoginActivity.class);
mainIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
startActivity(mainIntent);
finish();
}
/**
* @see StackOverflow answer
*/
private static final class MoneyChangedHandler implements TextWatcher {
private final WeakReference editTextWeakReference;
public MoneyChangedHandler(EditText editText) {
editTextWeakReference = new WeakReference(editText);
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
@Override
public void afterTextChanged(Editable editable) {
EditText editText = editTextWeakReference.get();
if (editText == null) {
return;
}
String s = editable.toString();
if (s.isEmpty()) {
return;
}
editText.removeTextChangedListener(this);
String cleanString = s.replaceAll("[,.]", "");
String parsed = new BigDecimal(cleanString)
.setScale(2, RoundingMode.FLOOR)
.divide(new BigDecimal(100), RoundingMode.FLOOR)
.toString();
editText.setText(parsed);
editText.setSelection(parsed.length());
editText.addTextChangedListener(this);
}
}
}
```
## App Customization
In this section, you'll update the look and feel of your native application to match the ChangeBank banking styling.
### Change Colors and Strings
Change the application colors in `app/res/values/colors.xml` to the ones used by Changebank.
```xml
#096324#096324#096324
```
Modify strings shown in the user interface by updating `app/res/values/strings.xml`.
```xml
FusionAuthWelcome!Welcome %s!Please log in to manage your accountChangebankNo access token returnedRetryNot authorizedLog inReauthorizeLog outWe Make ChangeYour account balance is $100.00Amount in USD: $0.00Make changeWe can make change for %s with %d nickels and %d pennies!
```
### Add Styling
Now, add image assets to make this look like a real application with the following shell commands.
```shell-session
$ curl -o app/res/drawable/changebank.png https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-java-android-native/main/complete-application/app/res/drawable/changebank.png && \
curl -o app/res/mipmap-hdpi/ic_launcher.png https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-java-android-native/main/complete-application/app/res/mipmap-hdpi/ic_launcher.png && \
curl -o app/res/mipmap-hdpi/ic_launcher.png https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-java-android-native/main/complete-application/app/res/mipmap-hdpi/ic_launcher.png && \
curl -o app/res/mipmap-mdpi/ic_launcher.png https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-java-android-native/main/complete-application/app/res/mipmap-mdpi/ic_launcher.png && \
curl -o app/res/mipmap-xhdpi/ic_launcher.png https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-java-android-native/main/complete-application/app/res/mipmap-xhdpi/ic_launcher.png && \
curl -o app/res/mipmap-xxhdpi/ic_launcher.png https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-java-android-native/main/complete-application/app/res/mipmap-xxhdpi/ic_launcher.png && \
curl -o app/res/mipmap-xxxhdpi/ic_launcher.png https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-java-android-native/main/complete-application/app/res/mipmap-xxxhdpi/ic_launcher.png
```
Once you've created these files, you can test the application out.
## Run the App
You can either [connect a hardware device](https://developer.android.com/studio/run/device) or create an Android Virtual Device to run the [Android Emulator](https://developer.android.com/studio/run/emulator)
[Build and run the app](https://developer.android.com/studio/run/) following Android Studio guidelines.
## Made it this far? Want a free t-shirt? We got ya.
Thank you for spending some time getting familiar with FusionAuth.
\*Offer only valid in the United States and Canada, while supplies last.
[fusionauth tshirt](https://go.fusionauth.io/tshirt)
## Next Steps
This QuickStart is a great way to get a proof of concept up and running quickly, but to run your application in production, there are some things you're going to want to do.
### FusionAuth Customization
FusionAuth gives you the ability to customize just about everything to do with the user's experience and the integration of your application. This includes:
* [Hosted pages](https://4002.18-224-161-104.sslip.io/docs/theme.md) such as login, registration, email verification, and many more.
* [Email templates](https://4002.18-224-161-104.sslip.io/docs/email/email-templates.md).
* [User data and custom claims in access token JWTs](https://4002.18-224-161-104.sslip.io/articles/tokens/jwt-components-explained.md).
### Security
* Implement refresh tokens using [AppAuth](https://github.com/openid/AppAuth-Android).
* You may want to customize the [token expiration times and policies](https://4002.18-224-161-104.sslip.io/docs/lifecycle/authenticate-users/oauth.md) in FusionAuth.
* Choose [password rules](https://4002.18-224-161-104.sslip.io/docs/get-started/core-concepts/types/tenants.md#password) and a [hashing algorithm](https://4002.18-224-161-104.sslip.io/docs/reference/password-hashes.md) that meet your security needs.
### Tenant and Application Management
* Model your application topology using [Applications](https://4002.18-224-161-104.sslip.io/docs/get-started/core-concepts/types/applications.md), [Roles](https://4002.18-224-161-104.sslip.io/docs/get-started/core-concepts/types/roles.md), [Groups](https://4002.18-224-161-104.sslip.io/docs/get-started/core-concepts/types/groups.md), [Entities](https://4002.18-224-161-104.sslip.io/docs/get-started/core-concepts/types/groups.md), and more.
* Set up [MFA](https://4002.18-224-161-104.sslip.io/docs/lifecycle/authenticate-users/multi-factor-authentication.md), [Social login](https://4002.18-224-161-104.sslip.io/docs/lifecycle/authenticate-users/identity-providers.md), or [SAML](https://4002.18-224-161-104.sslip.io/docs/lifecycle/authenticate-users/identity-providers/overview-samlv2.md) integrations.
* Integrate with external systems using [Webhooks](https://4002.18-224-161-104.sslip.io/docs/extend/events-and-webhooks.md), [SCIM](https://4002.18-224-161-104.sslip.io/docs/lifecycle/migrate-users/scim.md), and [Lambdas](https://4002.18-224-161-104.sslip.io/docs/extend/code/lambdas.md).
## Troubleshooting
* I can't log in
Make sure you have the right values at `app/res/raw/auth_config.json`. Double-check the `Issuer` in the **Tenant** to make sure it matches the public URL that FusionAuth is running at.
Related
[Get Started FusionAuth QuickStart for Android Kotlin - Fusionauth SDK QuickStart integration of a Kotlin-based Android app with FusionAuth Android SDK. android quickstartandroid loginandroid oauth](https://4002.18-224-161-104.sslip.io/docs/get-started/quickstarts/app/quickstart-kotlin-android-native.md)
[Get Started FusionAuth QuickStart for Flutter QuickStart integration of Flutter mobile application with FusionAuth. mobile app](https://4002.18-224-161-104.sslip.io/docs/get-started/quickstarts/app/quickstart-flutter-native.md)
[Get Started FusionAuth QuickStart for React Native QuickStart integration of React Native application with FusionAuth. mobile app](https://4002.18-224-161-104.sslip.io/docs/get-started/quickstarts/app/quickstart-react-native.md)
---
## Other pages in App
> For the full index of this section, see [Get Started](https://4002.18-224-161-104.sslip.io/docs/llms-get-started.txt).
- [App](https://4002.18-224-161-104.sslip.io/docs/get-started/quickstarts/app.md): Explore step-by-step application QuickStart guides from FusionAuth to seamlessly integrate authentication into your application.
- [FusionAuth QuickStart for Flutter](https://4002.18-224-161-104.sslip.io/docs/get-started/quickstarts/app/quickstart-flutter-native.md): QuickStart integration of Flutter mobile application with FusionAuth.
- [FusionAuth QuickStart for Android Kotlin - Fusionauth SDK](https://4002.18-224-161-104.sslip.io/docs/get-started/quickstarts/app/quickstart-kotlin-android-native.md): QuickStart integration of a Kotlin-based Android app with FusionAuth Android SDK.
- [FusionAuth QuickStart for React Native](https://4002.18-224-161-104.sslip.io/docs/get-started/quickstarts/app/quickstart-react-native.md): QuickStart integration of React Native application with FusionAuth.
- [FusionAuth QuickStart for iOS Swift - AppAuth](https://4002.18-224-161-104.sslip.io/docs/get-started/quickstarts/app/quickstart-swift-ios-native-appauth.md): QuickStart integration of a Swift and SwiftUI-based iOS app with FusionAuth.
- [FusionAuth QuickStart for iOS Swift - FusionAuth SDK](https://4002.18-224-161-104.sslip.io/docs/get-started/quickstarts/app/quickstart-swift-ios-native.md): QuickStart integration of a Swift-based iOS app with FusionAuth Swift SDK for iOS.