native

Android Java - AppAuth

Android Java - AppAuth

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, 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.

Prerequisites

This app was built on top of AppAuth, 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

While this sample application doesn't have login functionality without FusionAuth, a more typical integration will replace an existing login system with FusionAuth.

In that case, the system might look like this before FusionAuth is introduced.

UserApplicationView HomepageClick Login LinkShow Login FormFill Out and Submit Login FormAuthenticates UserDisplay User's Account or OtherInfoUserApplication

Request flow during login before FusionAuth

The login flow will look like this after FusionAuth is introduced.

UserApplicationFusionAuthView HomepageClick Login Link (to FusionAuth)View Login FormShow Login FormFill Out and Submit Login FormAuthenticates UserGo to Redirect URIRequest the Redirect URIIs User Authenticated?User is AuthenticatedDisplay User's Account or OtherInfoUserApplicationFusionAuth

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 change into that directory.

git clone https://github.com/FusionAuth/fusionauth-quickstart-java-android-native.git
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.

docker compose up -d

Here you are using a bootstrapping feature of FusionAuth called Kickstart. When FusionAuth comes up for the first time, it will look at the kickstart/kickstart.json file and configure FusionAuth to your specified state.

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 and look around if you want to, but with Docker and Kickstart, everything will already be configured correctly.

If you want to see where the FusionAuth values came from, they can be found in the FusionAuth app. 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.

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. In summary, the process entails configuring ngrok on your local system, starting your FusionAuth instance on port 9011, and subsequently executing the following command.

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:

Configure FusionAuth Instance

Now that you have exposed your instance, you need to update the Tenant issuer to make sure it matches the given address.

Log into the FusionAuth admin UI, browse to Tenants in the sidebar, click 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 icon in the top right corner.

Navigate to Applications and click 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.

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, you can use the same configuration to integrate your existing app with FusionAuth.

git clone https://github.com/FusionAuth/openid-AppAuth-Android.git
cd openid-AppAuth-Android

Start by removing some unused files:

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.

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.

<!--
 * 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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
            android:icon="@mipmap/ic_launcher"
            android:label="@string/app_name_short"
            android:theme="@style/AppTheme"
            android:supportsRtl="false"
            android:name=".Application">

        <!-- Main activity -->
        <activity
                android:name=".LoginActivity"
                android:theme="@style/AppTheme"
                android:windowSoftInputMode="stateHidden"
                android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <activity
                android:name=".TokenActivity"
                android:theme="@style/AppTheme"
                android:windowSoftInputMode="stateHidden" >
        </activity>

        <!--
        This activity declaration is merged with the version from the library manifest.
        It demonstrates how an https redirect can be captured, in addition to or instead of
        a custom scheme.

        Generally, this should be done in conjunction with an app link declaration for Android M
        and above, for additional security and an improved user experience. See:

        https://developer.android.com/training/app-links/index.html

        The declaration from the library can be completely replaced by adding

        tools:node="replace"

        To the list of attributes on the activity element.
        -->
        <activity
            android:name="net.openid.appauth.RedirectUriReceiverActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.VIEW"/>
                <category android:name="android.intent.category.DEFAULT"/>
                <category android:name="android.intent.category.BROWSABLE"/>
                <data android:scheme="io.fusionauth.app"/>
            </intent-filter>
        </activity>
    </application>
</manifest>

Authentication

We’ll use the AppAuth Library, 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.

{
  "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 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 version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context=".LoginActivity"
    android:id="@+id/coordinator"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true" >

    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:paddingLeft="@dimen/activity_horizontal_margin"
            android:paddingRight="@dimen/activity_horizontal_margin"
            android:paddingTop="@dimen/activity_vertical_margin"
            android:paddingBottom="@dimen/activity_vertical_margin"
            android:orientation="vertical" >

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:layout_marginBottom="20dp"
                android:orientation="horizontal"
                android:weightSum="100">

                <View
                    android:layout_width="0dp"
                    android:layout_height="match_parent"
                    android:layout_weight="25"/>

                <ImageView
                    android:layout_width="0dp"
                    android:layout_height="match_parent"
                    android:layout_gravity="center"
                    android:layout_weight="50"
                    android:adjustViewBounds="true"
                    app:srcCompat="@drawable/changebank"
                    android:contentDescription="@string/openid_logo_content_description"/>

                <View
                    android:layout_width="0dp"
                    android:layout_height="match_parent"
                    android:layout_weight="25"/>
            </LinearLayout>

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center"
                android:text="@string/log_in_text"
                style="@style/Base.TextAppearance.AppCompat.Title"
                android:textColor="@color/colorAccent" />

            <!--
            displayed while the discovery document is loaded, and dynamic client registration is
            being negotiated
            -->
            <LinearLayout
                android:id="@+id/loading_container"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical"
                android:layout_marginTop="16dp"
                android:gravity="center">

                <TextView
                    android:id="@+id/loading_description"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"/>

                <ProgressBar
                    style="@style/Widget.AppCompat.ProgressBar.Horizontal"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:indeterminate="true"/>

            </LinearLayout>

            <!-- Displayed once the authorization server configuration is resolved -->
            <LinearLayout
                android:id="@+id/auth_container"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:layout_marginTop="@dimen/section_margin"
                android:layout_marginBottom="8dp"
                android:orientation="vertical">

                <Button
                    android:id="@+id/start_auth"
                    style="@style/Widget.AppCompat.Button.Colored"
                    android:text="@string/start_authorization"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_gravity="center"/>

                <com.google.android.material.textfield.TextInputLayout
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="8dp"/>


            </LinearLayout>

            <!-- displayed if there is an error. -->
            <LinearLayout
                android:id="@+id/error_container"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical"
                android:gravity="center">

                <TextView
                    android:id="@+id/error_description"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="@dimen/section_margin"
                    android:layout_marginBottom="8dp"
                    android:layout_gravity="center"
                    style="@style/Base.TextAppearance.AppCompat.Body1"/>

                <Button
                    android:id="@+id/retry"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="@string/retry_label" />

            </LinearLayout>
        </LinearLayout>
    </androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

Now modify login activity logic by replacing app/java/io/fusionauth/app/LoginActivity.java with this code.

/*
 * 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<String> mClientId = new AtomicReference<>();
    private final AtomicReference<AuthorizationRequest> mAuthRequest = new AtomicReference<>();
    private final AtomicReference<CustomTabsIntent> 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 version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context=".TokenActivity"
    android:id="@+id/coordinator"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true" >

    <ScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:paddingLeft="@dimen/activity_horizontal_margin"
            android:paddingRight="@dimen/activity_horizontal_margin"
            android:paddingTop="@dimen/activity_vertical_margin"
            android:paddingBottom="@dimen/activity_vertical_margin"
            android:orientation="vertical"
            app:layout_behavior="@string/appbar_scrolling_view_behavior" >

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:layout_marginBottom="20dp"
                android:orientation="horizontal"
                android:weightSum="100">

                <View
                    android:layout_width="0dp"
                    android:layout_height="match_parent"
                    android:layout_weight="25"/>

                <ImageView
                    android:layout_width="0dp"
                    android:layout_height="match_parent"
                    android:layout_gravity="center"
                    android:layout_weight="50"
                    android:adjustViewBounds="true"
                    app:srcCompat="@drawable/changebank"
                    android:contentDescription="@string/openid_logo_content_description"/>

                <View
                    android:layout_width="0dp"
                    android:layout_height="match_parent"
                    android:layout_weight="25"/>
            </LinearLayout>

            <!--
            displayed while token requests are occurring
            -->
            <LinearLayout
                android:id="@+id/loading_container"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical"
                android:layout_marginTop="16dp">

                <TextView
                    android:id="@+id/loading_description"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"/>

                <ProgressBar
                    style="@style/Widget.AppCompat.ProgressBar.Horizontal"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:indeterminate="true"/>

            </LinearLayout>

            <!-- Shown when authorization has failed. -->
            <LinearLayout
                android:id="@+id/not_authorized"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical">

                <TextView
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_gravity="center"
                    android:text="@string/not_authorized"
                    style="@style/Base.TextAppearance.AppCompat.Title" />

                <TextView
                    android:id="@+id/explanation"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_gravity="center"
                    android:layout_marginTop="8dp" />

                <Button
                    android:id="@+id/reauth"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_gravity="center"
                    android:text="@string/reauthorize" />

            </LinearLayout>

            <!-- Shown when the user is authorized, and there are no pending operations -->
            <LinearLayout
                android:id="@+id/authorized"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical">

                <LinearLayout
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_marginBottom="20dp"
                    android:orientation="horizontal">

                    <TextView
                        android:id="@+id/auth_granted_email"
                        style="@style/Base.TextAppearance.AppCompat.Small"
                        android:layout_width="0dp"
                        android:layout_height="wrap_content"
                        android:layout_weight="1"
                        android:layout_gravity="center" />

                    <Button
                        android:id="@+id/sign_out"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:text="@string/sign_out"
                        style="@style/Widget.AppCompat.Button.Small"/>

                </LinearLayout>

                <TextView
                    android:id="@+id/auth_granted"
                    style="@style/Base.TextAppearance.AppCompat.Title"
                    android:layout_width="match_parent"
                    android:layout_height="0dp"
                    android:layout_weight="1"
                    android:layout_gravity="center"
                    android:text="@string/auth_granted"
                    android:textColor="@color/colorAccent" />

                <TextView
                    android:id="@+id/account_balance"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:layout_gravity="center"
                    android:text="@string/account_balance"
                    android:layout_marginTop="10dp"
                    android:layout_marginBottom="10dp"
                    style="@style/Base.TextAppearance.AppCompat.Medium" />

                <TextView
                    android:id="@+id/changebank_title"
                    style="@style/Base.TextAppearance.AppCompat.Title"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:layout_marginTop="20dp"
                    android:text="@string/changebank_title"
                    android:textColor="@color/colorAccent" />

                <LinearLayout
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:layout_marginBottom="10dp"
                    android:orientation="horizontal">

                    <TextView
                        android:id="@+id/change_text_input_prepend"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_gravity="center"
                        android:text="@string/change_text_input_prepend" />

                    <com.google.android.material.textfield.TextInputEditText
                        android:id="@+id/change_text_input"
                        android:layout_width="0dp"
                        android:layout_height="match_parent"
                        android:layout_weight="1"
                        android:layout_gravity="center"
                        android:inputType="numberDecimal"
                        android:hint="@string/change_text_input_placeholder"/>

                    <Button
                        android:id="@+id/change_button"
                        style="@style/Widget.AppCompat.Button.Small"
                        android:text="@string/change_button"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_gravity="center"/>
                </LinearLayout>

                <TextView
                    android:id="@+id/change_result_text_view"
                    android:visibility="gone"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_gravity="center"
                    android:text="@string/change_result_text_view"
                    style="@style/Base.TextAppearance.AppCompat.Medium" />

                <TextView
                    android:id="@+id/no_access_token_returned"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:text="@string/no_access_token_returned"
                    android:visibility="gone"
                    style="@style/Base.TextAppearance.AppCompat.Body1" />
            </LinearLayout>
        </LinearLayout>
    </ScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

And finally, change the main screen logic by replacing the content of app/java/io/fusionauth/app/TokenActivity.java with the below.

/*
 * 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<JSONObject> 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 <a href="https://stackoverflow.com/a/24621325">StackOverflow answer</a>
     */
    private static final class MoneyChangedHandler implements TextWatcher {
        private final WeakReference<EditText> editTextWeakReference;

        public MoneyChangedHandler(EditText editText) {
            editTextWeakReference = new WeakReference<EditText>(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 version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#096324</color>
    <color name="colorPrimaryDark">#096324</color>
    <color name="colorAccent">#096324</color>
</resources>

Modify strings shown in the user interface by updating app/res/values/strings.xml.

<resources>
    <string name="app_name_short">FusionAuth</string>
    <string name="auth_granted">Welcome!</string>
    <string name="auth_granted_name">Welcome %s!</string>
    <string name="log_in_text">Please log in to manage your account</string>
    <string name="openid_logo_content_description">Changebank</string>
    <string name="no_access_token_returned">No access token returned</string>
    <string name="retry_label">Retry</string>
    <string name="not_authorized">Not authorized</string>
    <string name="start_authorization">Log in</string>
    <string name="reauthorize">Reauthorize</string>
    <string name="sign_out">Log out</string>
    <string name="changebank_title">We Make Change</string>
    <string name="account_balance">Your account balance is $100.00</string>
    <string name="change_text_input_prepend">Amount in USD: $</string>
    <string name="change_text_input_placeholder">0.00</string>
    <string name="change_button">Make change</string>
    <string name="change_result_text_view">We can make change for %s with %d nickels and %d pennies!</string>
</resources>

Add Styling

Now, add image assets to make this look like a real application with the following shell commands.

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 or create an Android Virtual Device to run the Android Emulator

Build and run the app 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

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:

Security

Tenant and Application Management

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.