Docker: The quickest way to stand up FusionAuth. Ensure you also have docker compose installed.
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-kotlin-android-native.git
cd fusionauth-quickstart-kotlin-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.
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>
We’ll use the FusionAuth Android SDK , which simplifies integrating with FusionAuth and creating a secure web application.
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"]
}
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"
}
}
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/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.
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.
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.
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/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.