native
Android Java
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
- Android 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.
- Git: You’ll use it to clone the base repository.
- Docker: The quickest way to stand up FusionAuth. (There are other ways).
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.
Request flow during login before FusionAuth
The login flow will look like this after FusionAuth is introduced.
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
In the root directory of the repo you’ll find a Docker compose file (docker-compose.yml
) and an environment variables configuration file (.env
). Assuming you have Docker installed on your machine, you can stand up FusionAuth up on your machine with:
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 a certain initial state.
If you ever want to reset the FusionAuth system, 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 ispassword
. - Your admin username is
admin@example.com
and the password ispassword
. - The base URL of FusionAuth
http://localhost:9011/
.
Expose FusionAuth Instance
To make sure your local FusionAuth instance is accessible to your Android app, you need to expose it to the Internet. Write down the URL ngrok gave you as you’ll need it soon.
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 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.
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 with the user’s experience and your application’s integration. This includes
- Hosted pages such as login, registration, email verification, and many more
- Email templates
- User data and custom claims in access token JWTs
Security
- Implement refresh tokens using AppAuth
- You may want to customize the token expiration times and policies in FusionAuth
- Choose password rules and a hashing algorithm that meet your security needs
Tenant and Application Management
- Model your application topology using Applications, Roles, Groups, Entities, and more
- Set up MFA, Social login, and/or SAML integrations
- Integrate with external systems using Webhooks, SCIM, and Lambdas
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.