native

Android Kotlin - Fusionauth SDK (Beta)

Android Kotlin - Fusionauth SDK (Beta)

This quickstart is built on top of FusionAuth Android SDK demonstrating the different functionalities of the SDK available.

In this quickstart, you are going to build an Android app with Kotlin 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-kotlin-android-native.

Prerequisites

You will need the following things properly installed on your computer.

  • Android Studio: The official IDE for Android helps you develop and install the necessary tools to set it up.
    • At least Java 17 (which you can install via Android Studio)
  • Docker: The quickest way to stand up FusionAuth. Ensure you also have docker compose installed.

General Architecture

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

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

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

Request flow during login before FusionAuth

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

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

Request flow during login after FusionAuth

In general, you are introducing FusionAuth in order to normalize and consolidate user data. This helps make sure it is consistent and up-to-date as well as offloading your login security and functionality to FusionAuth.

Getting Started

In this section, you’ll get FusionAuth up and running and use git to create a new application.

Clone the Code

First off, grab the code from the repository and change into that directory.

git clone https://github.com/FusionAuth/fusionauth-quickstart-kotlin-android-native.git
cd fusionauth-quickstart-kotlin-android-native

Run FusionAuth via Docker

You'll find a Docker Compose file (docker-compose.yml) and an environment variables configuration file (.env) in the root directory of the repo.

Assuming you have Docker installed, you can stand up FusionAuth on your machine with the following.

docker compose up -d

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

If you ever want to reset the FusionAuth application, you need to delete the volumes created by Docker Compose by executing docker compose down -v, then re-run docker compose up -d.

FusionAuth will be initially configured with these settings:

  • Your client Id is e9fdb985-9173-4e01-9d73-ac2d60d1dc8e.
  • Your client secret is super-secret-secret-that-should-be-regenerated-for-production.
  • Your example username is richard@example.com and the password is password.
  • Your admin username is admin@example.com and the password is password.
  • The base URL of FusionAuth is http://localhost:9011/.

You can log in to the FusionAuth admin UI and look around if you want to, but with Docker and Kickstart, everything will already be configured correctly.

If you want to see where the FusionAuth values came from, they can be found in the FusionAuth app. The tenant Id is found on the Tenants page. To see the Client Id and Client Secret, go to the Applications page and click the View icon under the actions for the ChangeBank application. You'll find the Client Id and Client Secret values in the OAuth configuration section.

The .env file contains passwords. In a real application, always add this file to your .gitignore file and never commit secrets to version control.

Create your Android App

If you want to skip the step by step creation of the Android App open the ./complete-application/ folder in Android Studio.

Open Android Studio and select New Project. Choose No Activity and click Next.

You can set Name to FusionAuth Android SDK Quickstart, Package name to io.fusionauth.sdk and Save location as per your preference. Keep the Language as Kotlin, Minimum API level as API 24 ("Nougat"; Android 7.0) and Build configuration language as Kotlin DSL.

Click Finish.

Wait until Android Studio has finished creating and indexing the project.

Add the FusionAuth SDK as a dependency to your project by changing the app/build.gradle.kts file. You can replace the entire file with below contents.

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "io.fusionauth.sdk"
    compileSdk = 34

    defaultConfig {
        applicationId = "io.fusionauth.app"
        minSdk = 24
        targetSdk = 34
        versionCode = 1
        versionName = "1.0.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

        // 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"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = "1.8"
    }
    buildFeatures {
        viewBinding = true
    }
}

dependencies {
    implementation("androidx.core:core-ktx:1.13.1")
    implementation("androidx.appcompat:appcompat:1.7.0")
    implementation("com.google.android.material:material:1.12.0")
    implementation("io.fusionauth:fusionauth-android-sdk:0.1.7")
    implementation("androidx.constraintlayout:constraintlayout:2.1.4")
    implementation("androidx.navigation:navigation-fragment-ktx:2.7.7")
    implementation("androidx.navigation:navigation-ui-ktx:2.7.7")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
    implementation("androidx.browser:browser:1.8.0")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
    annotationProcessor("androidx.room:room-compiler:2.6.1")
    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.2.1")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")

    //Espresso dependencies
    androidTestImplementation("androidx.test:runner:1.6.1")
    androidTestImplementation("androidx.test:rules:1.6.1")
    androidTestImplementation("androidx.test.espresso:espresso-intents:3.6.1")
    androidTestImplementation("androidx.test.espresso:espresso-contrib:3.6.1")

    //UIAutomator dependency
    androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
}

Replace app/src/main/AndroidManifest.xml as well which defines all Activities used later.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

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

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name_short"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme"
        android:usesCleartextTraffic="true"
        tools:targetApi="31">
        <activity
            android:name=".LoginActivity"
            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:windowSoftInputMode="stateHidden" />
    </application>

</manifest>

Authentication

We’ll use the FusionAuth Android SDK , which simplifies integrating with FusionAuth and creating a secure web application.

Configure FusionAuth

Create app/src/main/res/raw/fusionauth_config.json to use the values provisioned by Kickstart.

{
  "fusionAuthUrl": "http://10.0.2.2:9011",
  "clientId": "e9fdb985-9173-4e01-9d73-ac2d60d1dc8e",
  "allowUnsecureConnection": true,
  "additionalScopes": ["email", "profile"]
}

Change Activities

An Activity is a screen for your app, combining the User Interface as well as the logic to handle it. Start by creating the login activity layout at app/src/main/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 creating app/src/main/java/io/fusionauth/sdk/LoginActivity.kt 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.sdk

import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.TextView
import androidx.annotation.MainThread
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import io.fusionauth.mobilesdk.AuthorizationConfiguration
import io.fusionauth.mobilesdk.AuthorizationManager
import io.fusionauth.mobilesdk.oauth.OAuthAuthorizeOptions
import io.fusionauth.mobilesdk.exceptions.AuthorizationException
import io.fusionauth.mobilesdk.storage.SharedPreferencesStorage
import kotlinx.coroutines.launch

/**
 * Demonstrates the usage of FusionAuth to authorize a user with an OAuth2 / OpenID Connect
 * provider. Based on the configuration provided in `res/raw/fusionauth_config.json`.
 */
class LoginActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        AuthorizationManager.initialize(
            AuthorizationConfiguration.fromResources(this, R.raw.fusionauth_config),
            SharedPreferencesStorage(this)
        )

        if (AuthorizationManager.isAuthenticated()) {
            Log.i(TAG, "User is already authenticated, proceeding to token activity")
            startActivity(Intent(this, TokenActivity::class.java))
            finish()
            return
        }

        setContentView(R.layout.activity_login)

        findViewById<View>(R.id.retry).setOnClickListener {
            startAuth()
        }
        findViewById<View>(R.id.start_auth).setOnClickListener {
            startAuth()
        }

        if (AuthorizationManager.oAuth(this@LoginActivity).isCancelled(intent)) {
            displayAuthCancelled()
        }

        if (AuthorizationManager.oAuth(this@LoginActivity).isLoggedOut(intent)) {
            displayLoggedOut()
        }

        displayAuthOptions()
    }

    override fun onDestroy() {
        super.onDestroy()

        AuthorizationManager.dispose()
    }

    @MainThread
    fun startAuth() {
        displayLoading("Making authorization request")

        lifecycleScope.launch {
            try {
                displayLoading("Making authorization request")
                AuthorizationManager
                    .oAuth(this@LoginActivity)
                    .authorize(
                        Intent(this@LoginActivity, TokenActivity::class.java),
                        OAuthAuthorizeOptions(
                            cancelIntent = Intent(this@LoginActivity, LoginActivity::class.java)
                                .setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP),
                            state = "state-${System.currentTimeMillis()}"
                        )
                    )
            } catch (e: AuthorizationException) {
                Log.e(TAG, "Error while authorizing", e)
                displayError(e.message ?: "Error while authorizing", true)
            }
        }
    }

    @MainThread
    private fun displayLoading(loadingMessage: String) {
        findViewById<View>(R.id.loading_container).visibility = View.VISIBLE
        findViewById<View>(R.id.auth_container).visibility = View.GONE
        findViewById<View>(R.id.error_container).visibility = View.GONE

        (findViewById<View>(R.id.loading_description) as TextView).text = loadingMessage
    }

    @MainThread
    private fun displayError(error: String, recoverable: Boolean) {
        findViewById<View>(R.id.error_container).visibility = View.VISIBLE
        findViewById<View>(R.id.loading_container).visibility = View.GONE
        findViewById<View>(R.id.auth_container).visibility = View.GONE

        (findViewById<View>(R.id.error_description) as TextView).text = error
        findViewById<View>(R.id.retry).visibility = if (recoverable) View.VISIBLE else View.GONE
    }

    @MainThread
    private fun displayAuthOptions() {
        findViewById<View>(R.id.auth_container).visibility = View.VISIBLE
        findViewById<View>(R.id.loading_container).visibility = View.GONE
        findViewById<View>(R.id.error_container).visibility = View.GONE
    }

    private fun displayAuthCancelled() {
        Snackbar.make(
            findViewById(R.id.coordinator),
            "Authorization canceled",
            Snackbar.LENGTH_SHORT
        )
            .show()
    }

    private fun displayLoggedOut() {
        Snackbar.make(
            findViewById(R.id.coordinator),
            "Logged out",
            Snackbar.LENGTH_SHORT
        )
            .show()
    }

    companion object {
        private const val TAG = "LoginActivity"
    }
}

Create the main screen layout in the file app/src/main/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"/>

                    <Button
                            android:id="@+id/refresh_token"
                            android:layout_width="wrap_content"
                            android:layout_height="wrap_content"
                            android:text="@string/refresh_token"
                            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, create the main screen logic by creating app/src/main/java/io/fusionauth/sdk/TokenActivity.kt 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.sdk

import android.content.Intent
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 androidx.annotation.MainThread
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import io.fusionauth.mobilesdk.AuthorizationConfiguration
import io.fusionauth.mobilesdk.AuthorizationManager
import io.fusionauth.mobilesdk.FusionAuthState
import io.fusionauth.mobilesdk.UserInfo
import io.fusionauth.mobilesdk.exceptions.AuthorizationException
import io.fusionauth.mobilesdk.storage.SharedPreferencesStorage
import kotlinx.coroutines.launch
import org.json.JSONException
import java.io.IOException
import java.lang.ref.WeakReference
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.NumberFormat
import java.util.concurrent.atomic.AtomicReference
import java.util.logging.Logger
import kotlin.math.floor

/**
 * 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.
 */
@Suppress("TooManyFunctions")
class TokenActivity : AppCompatActivity() {
    private val mUserInfo = AtomicReference<UserInfo?>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        AuthorizationManager.initialize(
            AuthorizationConfiguration.fromResources(this, R.raw.fusionauth_config),
            SharedPreferencesStorage(this)
        )

        setContentView(R.layout.activity_token)
        displayLoading("Restoring state...")
    }

    override fun onStart() {
        super.onStart()

        if (intent.getBooleanExtra("endSession", false)) {
            Log.i(TAG, "Ending session")
            return
        }

        Logger.getLogger(TAG).info("Checking for authorization response")
        if (AuthorizationManager.isAuthenticated()) {
            fetchUserInfoAndDisplayAuthorized(/*authState.getAccessToken()*/)
            return
        }

        lifecycleScope.launch {
            displayLoading("Exchanging authorization code")
            try {
                val authState: FusionAuthState = AuthorizationManager.oAuth(this@TokenActivity)
                    .handleRedirect(intent)
                Log.i(TAG, authState.toString())
                fetchUserInfoAndDisplayAuthorized()
            } catch (ex: AuthorizationException) {
                Log.e(TAG, "Failed to exchange authorization code", ex)
                displayNotAuthorized("Authorization failed")
            }
        }
    }

    override fun onSaveInstanceState(state: Bundle) {
        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 (mUserInfo.get() != null) {
            state.putString(KEY_USER_INFO, mUserInfo.toString())
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        AuthorizationManager.dispose()
    }

    @MainThread
    private fun displayNotAuthorized(explanation: String) {
        findViewById<View>(R.id.not_authorized).visibility = View.VISIBLE
        findViewById<View>(R.id.authorized).visibility = View.GONE
        findViewById<View>(R.id.loading_container).visibility = View.GONE

        (findViewById<View>(R.id.explanation) as TextView).text = explanation
        findViewById<View>(R.id.reauth).setOnClickListener { signOut() }
    }

    @MainThread
    private fun displayLoading(message: String) {
        findViewById<View>(R.id.loading_container).visibility = View.VISIBLE
        findViewById<View>(R.id.authorized).visibility = View.GONE
        findViewById<View>(R.id.not_authorized).visibility = View.GONE

        (findViewById<View>(R.id.loading_description) as TextView).text = message
    }

    @MainThread
    private fun displayAuthorized() {
        findViewById<View>(R.id.authorized).visibility = View.VISIBLE
        findViewById<View>(R.id.not_authorized).visibility = View.GONE
        findViewById<View>(R.id.loading_container).visibility = View.GONE

        val noAccessTokenReturnedView = findViewById<View>(R.id.no_access_token_returned) as TextView
        if (AuthorizationManager.getAccessToken() == null) {
            noAccessTokenReturnedView.visibility = View.VISIBLE
        } else {
            // Logging out if token is expired
            if (AuthorizationManager.isAccessTokenExpired()) {
                signOut()
                return
            }
        }

        val changeTextInput: EditText = findViewById(R.id.change_text_input)
        changeTextInput.addTextChangedListener(MoneyChangedHandler(changeTextInput))
        findViewById<View>(R.id.sign_out).setOnClickListener {
            endSession()
        }
        findViewById<View>(R.id.change_button).setOnClickListener { makeChange() }
        findViewById<View>(R.id.refresh_token).setOnClickListener {
            refreshToken()
        }

        var name = ""
        var email = ""

        // Retrieving name and email from the /me endpoint response
        val userInfo = mUserInfo.get()
        if (userInfo != null) {
            try {
                name = userInfo.given_name.orEmpty()
                email = userInfo.email.orEmpty()
            } catch (ex: JSONException) {
                Log.e(TAG, "Failed to read userinfo JSON", ex)
            }
        }

        // Retrieve email from ID token if not available from User Info endpoint
        email.ifEmpty {
            AuthorizationManager.getParsedIdToken()?.email.orEmpty()
        }

        // Fallback for name
        name = name.ifEmpty { email }

        if (name.isNotEmpty()) {
            val welcomeView = findViewById<View>(R.id.auth_granted) as TextView
            val welcomeTemplate: String = resources.getString(R.string.auth_granted_name)
            welcomeView.text = String.format(welcomeTemplate, name)
        }

        (findViewById<View>(R.id.auth_granted_email) as TextView).text = email
    }

    private fun refreshToken() {
        lifecycleScope.launch {
            try {
                val authState = AuthorizationManager.freshAccessToken(this@TokenActivity, true)
                Log.i(TAG, "Refreshed access token: $authState")
            } catch (ex: AuthorizationException) {
                Log.e(TAG, "Failed to refresh token", ex)
                showSnackbar("Failed to refresh token")
            }
        }
    }

    private fun fetchUserInfoAndDisplayAuthorized() {
        lifecycleScope.launch {
            try {
                val userInfo = AuthorizationManager.oAuth(this@TokenActivity).getUserInfo()
                mUserInfo.set(userInfo)
            } catch (ioEx: IOException) {
                Log.e(TAG, "Network error when querying userinfo endpoint", ioEx)
                showSnackbar("Fetching user info failed")
            } catch (jsonEx: JSONException) {
                Log.e(TAG, "Failed to parse userinfo response", jsonEx)
                showSnackbar("Failed to parse user info")
            }

            runOnUiThread { this@TokenActivity.displayAuthorized() }
        }
    }

    @MainThread
    private fun showSnackbar(message: String) {
        Snackbar.make(
            findViewById(R.id.coordinator),
            message,
            Snackbar.LENGTH_SHORT
        )
            .show()
    }

    @MainThread
    private fun endSession() {
        lifecycleScope.launch {
            intent.putExtra("endSession", true)
            AuthorizationManager
                .oAuth(this@TokenActivity)
                .logout(
                    Intent(this@TokenActivity, LoginActivity::class.java)
                        .setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
                )
        }
    }

    @Suppress("MagicNumber")
    @MainThread
    private fun makeChange() {
        val value: String = (findViewById<View>(R.id.change_text_input) as EditText)
            .text
            .toString()
            .trim { it <= ' ' }

        if (value.isEmpty()) {
            return
        }

        val floatValue = value.toFloat()
        if (floatValue < 0) {
            return
        }
        val cents = floatValue * 100
        val nickels = floor((cents / 5).toDouble()).toInt()
        val pennies = (cents % 5).toInt()
        val textView: TextView = findViewById(R.id.change_result_text_view)
        val changeTemplate: String = resources.getString(R.string.change_result_text_view)
        textView.text = String.format(
            changeTemplate,
            NumberFormat.getCurrencyInstance().format(floatValue.toDouble()),
            nickels,
            pennies
        )
        textView.visibility = View.VISIBLE
    }

    @MainThread
    private fun signOut() {
        AuthorizationManager.clearState()

        val mainIntent = Intent(this, LoginActivity::class.java)
        mainIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
        startActivity(mainIntent)
        finish()
    }

    /**
     * @see [StackOverflow answer](https://stackoverflow.com/a/24621325)
     */
    private class MoneyChangedHandler(editText: EditText) : TextWatcher {
        private val editTextWeakReference: WeakReference<EditText> = WeakReference<EditText>(editText)

        override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit

        override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) = Unit

        @Suppress("MagicNumber")
        override fun afterTextChanged(editable: Editable) {
            val editText: EditText = editTextWeakReference.get() ?: return
            val s: String = editable.toString()
            if (s.isEmpty()) {
                return
            }

            editText.removeTextChangedListener(this)

            val cleanString = s.replace("[,.]".toRegex(), "")
            val parsed = BigDecimal(cleanString)
                .setScale(2, RoundingMode.FLOOR)
                .divide(BigDecimal(100), RoundingMode.FLOOR)
                .toString()
            editText.setText(parsed)
            editText.setSelection(parsed.length)

            editText.addTextChangedListener(this)
        }
    }

    companion object {
        private const val TAG = "TokenActivity"

        private const val KEY_USER_INFO = "userInfo"
    }
}

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/src/main/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/src/main/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="refresh_token">Refresh token</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 %1$s with %2$d nickels and %3$d pennies!</string>
</resources>

Update the theme in app/src/main/res/values/themes.xml.

<resources>
    <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
    </style>
</resources>

Create the dimension file in app/src/main/res/values/dimens.xml.

<resources>
    <!-- Default screen margins, per the Android Design guidelines. -->
    <dimen name="activity_horizontal_margin">16dp</dimen>
    <dimen name="activity_vertical_margin">16dp</dimen>
    <dimen name="section_margin">16dp</dimen>
    <dimen name="fab_margin">48dp</dimen>
</resources>

Finally, remove the night theme by deleting app/src/main/res/values-night directory.

Add Styling

Now, add image assets to make this look like a real application with the following shell commands, run in the root of your project.

curl -o app/src/main/res/drawable/changebank.png https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-kotlin-android-native/main/complete-application/app/src/main/res/drawable/changebank.png
curl -o app/src/main/res/drawable/ic_launcher_background.xml https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-kotlin-android-native/main/complete-application/app/src/main/res/drawable/ic_launcher_background.xml
curl -o app/src/main/res/drawable/ic_launcher_foreground.xml https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-kotlin-android-native/main/complete-application/app/src/main/res/drawable/ic_launcher_foreground.xml
curl -o app/src/main/res/mipmap-hdpi/ic_launcher.webp https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-kotlin-android-native/main/complete-application/app/src/main/res/mipmap-hdpi/ic_launcher.webp
curl -o app/src/main/res/mipmap-hdpi/ic_launcher_round.webp https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-kotlin-android-native/main/complete-application/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
curl -o app/src/main/res/mipmap-mdpi/ic_launcher.webp https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-kotlin-android-native/main/complete-application/app/src/main/res/mipmap-mdpi/ic_launcher.webp
curl -o app/src/main/res/mipmap-mdpi/ic_launcher_round.webp https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-kotlin-android-native/main/complete-application/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
curl -o app/src/main/res/mipmap-xhdpi/ic_launcher.webp https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-kotlin-android-native/main/complete-application/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
curl -o app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-kotlin-android-native/main/complete-application/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
curl -o app/src/main/res/mipmap-xxhdpi/ic_launcher.webp https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-kotlin-android-native/main/complete-application/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
curl -o app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-kotlin-android-native/main/complete-application/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
curl -o app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-kotlin-android-native/main/complete-application/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
curl -o app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp https://raw.githubusercontent.com/FusionAuth/fusionauth-quickstart-kotlin-android-native/main/complete-application/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp

Once you’ve created these files, you can test the application out.

Run the App

The quickstart app is configured to run on an Android Virtual Device using the Android Emulator

Build and run the app following Android Studio guidelines.

Made it this far? Want a free t-shirt? We got ya.

Thank you for spending some time getting familiar with FusionAuth.

*Offer only valid in the United States and Canada, while supplies last.

fusionauth tshirt

Next Steps

This quickstart is a great way to get a proof of concept up and running quickly, but to run your application in production, there are some things you're going to want to do.

FusionAuth Customization

FusionAuth gives you the ability to customize just about everything to do with the user's experience and the integration of your application. This includes:

Security

Tenant and Application Management

Troubleshooting

  • I can’t log in

Make sure you have the right values at app/src/main/res/raw/fusionauth_config.json. Double-check the Issuer in the Tenant to make sure it matches the URL that FusionAuth is running at.