native
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.
- (Alternatively, you can Install FusionAuth Manually).
General Architecture
While this sample application doesn't have login functionality without FusionAuth, a more typical integration will replace an existing login system with FusionAuth.
In that case, the system might look like this before FusionAuth is introduced.
Request flow during login before FusionAuth
The login flow will look like this after FusionAuth is introduced.
Request flow during login after FusionAuth
In general, you are introducing FusionAuth in order to normalize and consolidate user data. This helps make sure it is consistent and up-to-date as well as offloading your login security and functionality to FusionAuth.
Getting Started
In this section, you’ll get FusionAuth up and running and use git to create a new application.
Clone the Code
First off, grab the code from the repository and change into that directory.
git clone https://github.com/FusionAuth/fusionauth-quickstart-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 -dHere 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.comand the password ispassword. - Your admin username is
admin@example.comand the password ispassword. - 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 = 35
defaultConfig {
applicationId = "io.fusionauth.app"
minSdk = 24
targetSdk = 35
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.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.android.material:material:1.12.0")
implementation("io.fusionauth:fusionauth-android-sdk:0.2.0")
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
implementation("androidx.navigation:navigation-fragment-ktx:2.8.9")
implementation("androidx.navigation:navigation-ui-ktx:2.8.9")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")
implementation("androidx.browser:browser:1.8.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
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.2")
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.

Next Steps
This quickstart is a great way to get a proof of concept up and running quickly, but to run your application in production, there are some things you're going to want to do.
FusionAuth Customization
FusionAuth gives you the ability to customize just about everything to do with the user's experience and the integration of your application. This includes:
- Hosted pages such as login, registration, email verification, and many more.
- Email templates.
- User data and custom claims in access token JWTs.
Security
- You may want to customize the token expiration times and policies in FusionAuth.
- Choose password rules and a hashing algorithm that meet your security needs.
Tenant and Application Management
- Model your application topology using Applications, Roles, Groups, Entities, and more.
- Set up MFA, Social login, or SAML integrations.
- Integrate with external systems using Webhooks, SCIM, and Lambdas.
Troubleshooting
- I can’t log in
Make sure you have the right values at app/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.