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.
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.
In this section, you’ll get FusionAuth up and running and use git
to create a new application.
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
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:
e9fdb985-9173-4e01-9d73-ac2d60d1dc8e
.super-secret-secret-that-should-be-regenerated-for-production
.richard@example.com
and the password is password
.admin@example.com
and the password is password
.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.
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:
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.
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>
We’ll use the AppAuth Library, which simplifies integrating with FusionAuth and creating a secure web application.
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
}
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);
}
}
}
In this section, you’ll update the look and feel of your native application to match the ChangeBank banking styling.
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>
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.
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.
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 gives you the ability to customize just about everything to do with the user's experience and the integration of your application. This includes:
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.