diff --git a/appkotlin/AndroidManifest.xml b/appkotlin/AndroidManifest.xml
new file mode 100644
index 00000000..5197f241
--- /dev/null
+++ b/appkotlin/AndroidManifest.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/appkotlin/build.gradle b/appkotlin/build.gradle
new file mode 100644
index 00000000..c7940836
--- /dev/null
+++ b/appkotlin/build.gradle
@@ -0,0 +1,65 @@
+apply plugin: 'com.android.application'
+apply plugin: 'checkstyle'
+apply plugin: 'kotlin-android'
+apply from: '../config/android-common.gradle'
+apply from: '../config/keystore.gradle'
+
+android {
+ defaultConfig {
+ applicationId 'net.openid.appauthdemokotlin'
+ project.archivesBaseName = 'appauth-demoapp-kotlin'
+ vectorDrawables.useSupportLibrary = true
+
+ // Make sure this is consistent with the redirect URI used in res/raw/auth_config.json,
+ // or specify additional redirect URIs in AndroidManifest.xml
+ manifestPlaceholders = [
+ 'appAuthRedirectScheme': 'net.openid.appauthdemo'
+ ]
+ }
+
+ buildFeatures {
+ viewBinding true
+ }
+
+ signingConfigs {
+ debugAndRelease {
+ storeFile file("${rootDir}/appauth.keystore")
+ storePassword "appauth"
+ keyAlias "appauth"
+ keyPassword "appauth"
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ buildTypes {
+ debug {
+ signingConfig signingConfigs.debugAndRelease
+ }
+ release {
+ signingConfig signingConfigs.debugAndRelease
+ }
+ }
+}
+
+dependencies {
+ implementation project(':library')
+ implementation "androidx.appcompat:appcompat:${project.androidXVersions.appcompat}"
+ implementation "androidx.annotation:annotation:${project.androidXVersions.annotation}"
+ implementation "com.google.android.material:material:${project.googleVersions.material}"
+ implementation "com.github.bumptech.glide:glide:${project.googleVersions.glide}"
+ implementation "com.squareup.okio:okio:${project.okioVersion}"
+ implementation "joda-time:joda-time:${project.jodaVersion}"
+
+ annotationProcessor "com.github.bumptech.glide:compiler:${project.googleVersions.glide}"
+ implementation "androidx.core:core-ktx:+"
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
+}
+
+apply from: '../config/style.gradle'
+repositories {
+ mavenCentral()
+}
diff --git a/appkotlin/java/net/openid/appauthdemokotlin/AuthStateManager.kt b/appkotlin/java/net/openid/appauthdemokotlin/AuthStateManager.kt
new file mode 100644
index 00000000..b48d4374
--- /dev/null
+++ b/appkotlin/java/net/openid/appauthdemokotlin/AuthStateManager.kt
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2016 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 net.openid.appauthdemokotlin
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.util.Log
+import androidx.annotation.AnyThread
+import org.json.JSONException
+import net.openid.appauth.*
+import java.lang.ref.WeakReference
+import java.util.concurrent.atomic.AtomicReference
+import java.util.concurrent.locks.ReentrantLock
+
+class AuthStateManager private constructor(context: Context) {
+ private val preferences: SharedPreferences = context.getSharedPreferences(STORE_NAME, Context.MODE_PRIVATE)
+ private val preferencesLock: ReentrantLock = ReentrantLock()
+ private val currentAuthState: AtomicReference = AtomicReference()
+
+ @get:AnyThread
+ val current: AuthState
+ get() {
+ if (currentAuthState.get() != null) {
+ return currentAuthState.get()
+ }
+ val state = readState()
+ return if (currentAuthState.compareAndSet(null, state)) {
+ state
+ } else {
+ currentAuthState.get()
+ }
+ }
+
+ @AnyThread
+ fun replace(state: AuthState): AuthState {
+ writeState(state)
+ currentAuthState.set(state)
+ return state
+ }
+
+ @AnyThread
+ fun updateAfterAuthorization(
+ response: AuthorizationResponse?,
+ ex: AuthorizationException?): AuthState {
+ val current = current
+ current.update(response, ex)
+ return replace(current)
+ }
+
+ @AnyThread
+ fun updateAfterTokenResponse(
+ response: TokenResponse?,
+ ex: AuthorizationException?): AuthState {
+ val current = current
+ current.update(response, ex)
+ return replace(current)
+ }
+
+ @AnyThread
+ fun updateAfterRegistration(
+ response: RegistrationResponse?,
+ ex: AuthorizationException?): AuthState {
+ val current = current
+ if (ex != null) {
+ return current
+ }
+ current.update(response)
+ return replace(current)
+ }
+
+ @AnyThread
+ private fun readState(): AuthState {
+ preferencesLock.lock()
+ return try {
+ val currentState = preferences.getString(KEY_STATE, null)
+ ?: return AuthState()
+ try {
+ AuthState.jsonDeserialize(currentState)
+ } catch (ex: JSONException) {
+ Log.w(TAG, "Failed to deserialize stored auth state - discarding")
+ AuthState()
+ }
+ } finally {
+ preferencesLock.unlock()
+ }
+ }
+
+ @AnyThread
+ private fun writeState(state: AuthState?) {
+ preferencesLock.lock()
+ try {
+ val editor = preferences.edit()
+ if (state == null) {
+ editor.remove(KEY_STATE)
+ } else {
+ editor.putString(KEY_STATE, state.jsonSerializeString())
+ }
+ check(editor.commit()) { "Failed to write state to shared prefs" }
+ } finally {
+ preferencesLock.unlock()
+ }
+ }
+
+ companion object {
+ private val INSTANCE_REF = AtomicReference(WeakReference(null))
+ private const val TAG = "AuthStateManager"
+ private const val STORE_NAME = "AuthState"
+ private const val KEY_STATE = "state"
+
+ @AnyThread
+ fun getInstance(context: Context): AuthStateManager {
+ var manager = INSTANCE_REF.get().get()
+ if (manager == null) {
+ manager = AuthStateManager(context.applicationContext)
+ INSTANCE_REF.set(WeakReference(manager))
+ }
+ return manager
+ }
+ }
+
+}
diff --git a/appkotlin/java/net/openid/appauthdemokotlin/Configuration.kt b/appkotlin/java/net/openid/appauthdemokotlin/Configuration.kt
new file mode 100644
index 00000000..a261d573
--- /dev/null
+++ b/appkotlin/java/net/openid/appauthdemokotlin/Configuration.kt
@@ -0,0 +1,219 @@
+/*
+ * Copyright 2016 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 net.openid.appauthdemokotlin
+
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.content.res.Resources
+import android.net.Uri
+import android.text.TextUtils
+import okio.Buffer
+import okio.Okio
+import net.openid.appauth.connectivity.ConnectionBuilder
+import net.openid.appauth.connectivity.DefaultConnectionBuilder
+import org.json.JSONException
+import org.json.JSONObject
+import java.io.IOException
+import java.lang.ref.WeakReference
+import java.nio.charset.Charset
+
+/**
+ * Reads and validates the demo app configuration from `res/raw/auth_config.json`. Configuration
+ * changes are detected by comparing the hash of the last known configuration to the read
+ * configuration. When a configuration change is detected, the app state is reset.
+ */
+class Configuration(private val mContext: Context) {
+ private val preferences: SharedPreferences = mContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ private val resources: Resources = mContext.resources
+ private var configJson: JSONObject? = null
+ private var configHash: String? = null
+
+ /**
+ * Returns a description of the configuration error, if the configuration is invalid.
+ */
+ var configurationError: String? = null
+ var clientId: String? = null
+ var scope: String? = null
+ var redirectUri: Uri? = null
+ var endSessionUri: Uri? = null
+ var discoveryUri: Uri? = null
+ var authEndpointUri: Uri? = null
+ var tokenEndpointUri: Uri? = null
+ var endSessionEndpoint: Uri? = null
+ var registrationEndpointUri: Uri? = null
+ var userInfoEndpointUri: Uri? = null
+ private var isHttpsRequired = false
+
+ /**
+ * Indicates whether the configuration has changed from the last known valid state.
+ */
+ fun hasConfigurationChanged(): Boolean {
+ val lastHash = lastKnownConfigHash
+ return configHash != lastHash
+ }
+
+ /**
+ * Indicates whether the current configuration is valid.
+ */
+ val isValid: Boolean
+ get() = configurationError == null
+
+ /**
+ * Indicates that the current configuration should be accepted as the "last known valid"
+ * configuration.
+ */
+ fun acceptConfiguration() {
+ preferences.edit().putString(KEY_LAST_HASH, configHash).apply()
+ }
+
+ val connectionBuilder: ConnectionBuilder
+ get() = DefaultConnectionBuilder.INSTANCE
+
+ private val lastKnownConfigHash: String?
+ get() = preferences.getString(KEY_LAST_HASH, null)
+
+ @Throws(InvalidConfigurationException::class)
+ private fun readConfiguration() {
+ val configSource = Okio.buffer(Okio.source(resources.openRawResource(R.raw.auth_config)))
+ val configData = Buffer()
+ configJson = try {
+ configSource.readAll(configData)
+ JSONObject(configData.readString(Charset.forName("UTF-8")))
+ } catch (ex: IOException) {
+ throw InvalidConfigurationException(
+ "Failed to read configuration: " + ex.message)
+ } catch (ex: JSONException) {
+ throw InvalidConfigurationException(
+ "Unable to parse configuration: " + ex.message)
+ }
+ configHash = configData.sha256().base64()
+ clientId = getConfigString("client_id")
+ scope = getRequiredConfigString("authorization_scope")
+ redirectUri = getRequiredConfigUri("redirect_uri")
+ endSessionUri = getRequiredConfigUri("end_session_uri")
+ if (!isRedirectUriRegistered) {
+ throw InvalidConfigurationException(
+ "redirect_uri is not handled by any activity in this app! "
+ + "Ensure that the appAuthRedirectScheme in your build.gradle file "
+ + "is correctly configured, or that an appropriate intent filter "
+ + "exists in your app manifest.")
+ }
+ if (getConfigString("discovery_uri") == null) {
+ authEndpointUri = getRequiredConfigWebUri("authorization_endpoint_uri")
+ tokenEndpointUri = getRequiredConfigWebUri("token_endpoint_uri")
+ userInfoEndpointUri = getRequiredConfigWebUri("user_info_endpoint_uri")
+ endSessionEndpoint = getRequiredConfigUri("end_session_endpoint")
+ if (clientId == null) {
+ registrationEndpointUri = getRequiredConfigWebUri("registration_endpoint_uri")
+ }
+ } else {
+ discoveryUri = getRequiredConfigWebUri("discovery_uri")
+ }
+ isHttpsRequired = configJson!!.optBoolean("https_required", true)
+ }
+
+ private fun getConfigString(propName: String?): String? {
+ var value = configJson!!.optString(propName) ?: return null
+ value = value.trim { it <= ' ' }
+ return if (TextUtils.isEmpty(value)) {
+ null
+ } else value
+ }
+
+ @Throws(InvalidConfigurationException::class)
+ fun getRequiredConfigString(propName: String): String {
+ return getConfigString(propName)
+ ?: throw InvalidConfigurationException(
+ "$propName is required but not specified in the configuration")
+ }
+
+ @Throws(InvalidConfigurationException::class)
+ fun getRequiredConfigUri(propName: String): Uri {
+ val uriStr = getRequiredConfigString(propName)
+ val uri: Uri = try {
+ Uri.parse(uriStr)
+ } catch (ex: Throwable) {
+ throw InvalidConfigurationException("$propName could not be parsed", ex)
+ }
+ if (!uri.isHierarchical || !uri.isAbsolute) {
+ throw InvalidConfigurationException(
+ "$propName must be hierarchical and absolute")
+ }
+ if (!TextUtils.isEmpty(uri.encodedUserInfo)) {
+ throw InvalidConfigurationException("$propName must not have user info")
+ }
+ if (!TextUtils.isEmpty(uri.encodedQuery)) {
+ throw InvalidConfigurationException("$propName must not have query parameters")
+ }
+ if (!TextUtils.isEmpty(uri.encodedFragment)) {
+ throw InvalidConfigurationException("$propName must not have a fragment")
+ }
+ return uri
+ }
+
+ @Throws(InvalidConfigurationException::class)
+ fun getRequiredConfigWebUri(propName: String): Uri {
+ val uri = getRequiredConfigUri(propName)
+ val scheme = uri.scheme
+ if (TextUtils.isEmpty(scheme) || !("http" == scheme || "https" == scheme)) {
+ throw InvalidConfigurationException(
+ "$propName must have an http or https scheme")
+ }
+ return uri
+ }
+
+ // ensure that the redirect URI declared in the configuration is handled by some activity
+ // in the app, by querying the package manager speculatively
+ private val isRedirectUriRegistered: Boolean
+ get() {
+ // ensure that the redirect URI declared in the configuration is handled by some activity
+ // in the app, by querying the package manager speculatively
+ val redirectIntent = Intent()
+ redirectIntent.setPackage(mContext.packageName)
+ redirectIntent.action = Intent.ACTION_VIEW
+ redirectIntent.addCategory(Intent.CATEGORY_BROWSABLE)
+ redirectIntent.data = redirectUri
+ return mContext.packageManager.queryIntentActivities(redirectIntent, 0).isNotEmpty()
+ }
+
+ class InvalidConfigurationException : Exception {
+ internal constructor(reason: String?) : super(reason)
+ internal constructor(reason: String?, cause: Throwable?) : super(reason, cause)
+ }
+
+ companion object {
+ //private const val TAG = "Configuration"
+ private const val PREFS_NAME = "config"
+ private const val KEY_LAST_HASH = "lastHash"
+ private var sInstance = WeakReference(null)
+ fun getInstance(context: Context): Configuration {
+ var config = sInstance.get()
+ if (config == null) {
+ config = Configuration(context)
+ sInstance = WeakReference(config)
+ }
+ return config
+ }
+ }
+
+ init {
+ try {
+ readConfiguration()
+ } catch (ex: InvalidConfigurationException) {
+ configurationError = ex.message
+ }
+ }
+}
\ No newline at end of file
diff --git a/appkotlin/java/net/openid/appauthdemokotlin/LoginActivity.kt b/appkotlin/java/net/openid/appauthdemokotlin/LoginActivity.kt
new file mode 100644
index 00000000..ff17fbbb
--- /dev/null
+++ b/appkotlin/java/net/openid/appauthdemokotlin/LoginActivity.kt
@@ -0,0 +1,438 @@
+/*
+ * Copyright 2016 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 net.openid.appauthdemokotlin
+
+import android.app.PendingIntent
+import android.content.Intent
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.text.Editable
+import android.text.TextUtils
+import android.text.TextWatcher
+import android.util.Log
+import android.view.View
+import androidx.annotation.MainThread
+import androidx.annotation.WorkerThread
+import androidx.appcompat.app.AppCompatActivity
+import androidx.browser.customtabs.CustomTabsIntent
+import com.google.android.material.snackbar.Snackbar
+import net.openid.appauth.*
+import net.openid.appauth.browser.AnyBrowserMatcher
+import net.openid.appauth.browser.BrowserMatcher
+import net.openid.appauthdemokotlin.databinding.ActivityLoginBinding
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Demonstrates the usage of the AppAuth to authorize a user with an OAuth2 / OpenID Connect
+ * provider. Based on the configuration provided in `res/raw/auth_config.json`, the code
+ * contained here will:
+ *
+ * - Retrieve an OpenID Connect discovery document for the provider, or use a local static
+ * configuration.
+ * - Utilize dynamic client registration, if no static client id is specified.
+ * - Initiate the authorization request using the built-in heuristics or a user-selected browser.
+ *
+ * _NOTE_: From a clean checkout of this project, the authorization service is not configured.
+ * Edit `res/values/auth_config.xml` to provide the required configuration properties. See the
+ * README.md in the app/ directory for configuration instructions, and the adjacent IDP-specific
+ * instructions.
+ */
+class LoginActivity : AppCompatActivity() {
+ private var authService: AuthorizationService? = null
+ private lateinit var authStateManager: AuthStateManager
+ private lateinit var configuration: Configuration
+ private val clientId = AtomicReference()
+ private val authRequest = AtomicReference()
+ private val authIntent = AtomicReference()
+ private var authIntentLatch = CountDownLatch(1)
+ private lateinit var executor: ExecutorService
+ private var usePendingIntents = false
+ private var browserMatcher: BrowserMatcher = AnyBrowserMatcher.INSTANCE
+ private lateinit var binding: ActivityLoginBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ executor = Executors.newSingleThreadExecutor()
+ authStateManager = AuthStateManager.getInstance(this)
+ configuration = Configuration.getInstance(this)
+ if (authStateManager.current.isAuthorized
+ && !configuration.hasConfigurationChanged()
+ ) {
+ Log.i(TAG, "User is already authenticated, proceeding to token activity")
+ startActivity(Intent(this, TokenActivity::class.java))
+ finish()
+ return
+ }
+ binding = ActivityLoginBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ setContentView(R.layout.activity_login)
+ binding.retry.setOnClickListener { executor.submit { initializeAppAuth() } }
+ binding.startAuth.setOnClickListener { startAuth() }
+ binding.loginHintValue.addTextChangedListener(
+ LoginHintChangeHandler()
+ )
+ if (!configuration.isValid) {
+ displayError(configuration.configurationError, false)
+ return
+ }
+ if (configuration.hasConfigurationChanged()) {
+ // discard any existing authorization state due to the change of configuration
+ Log.i(TAG, "Configuration change detected, discarding old state")
+ authStateManager.replace(AuthState())
+ configuration.acceptConfiguration()
+ }
+ if (intent.getBooleanExtra(EXTRA_FAILED, false)) {
+ Snackbar.make(binding.coordinator, "Authorization canceled", Snackbar.LENGTH_SHORT)
+ .show()
+ }
+ displayLoading("Initializing")
+ executor.submit { initializeAppAuth() }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ if (executor.isShutdown) {
+ executor = Executors.newSingleThreadExecutor()
+ }
+ }
+
+ override fun onStop() {
+ var n = 10
+ var i = false
+ super.onStop()
+ executor.shutdownNow()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ if (authService != null) {
+ authService!!.dispose()
+ }
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ displayAuthOptions()
+ if (resultCode == RESULT_CANCELED) {
+ Snackbar.make(binding.coordinator, "Authorization canceled", Snackbar.LENGTH_SHORT)
+ .show()
+ } else {
+ val intent = Intent(this, TokenActivity::class.java)
+ intent.putExtras(data!!.extras!!)
+ startActivity(intent)
+ }
+ }
+
+ @MainThread
+ fun startAuth() {
+ displayLoading("Making authorization request")
+ usePendingIntents = binding.pendingIntentsCheckbox.isChecked
+ executor.submit { doAuth() }
+ }
+
+ /**
+ * Initializes the authorization service configuration if necessary, either from the local
+ * static values or by retrieving an OpenID discovery document.
+ */
+ @WorkerThread
+ private fun initializeAppAuth() {
+ Log.i(TAG, "Initializing AppAuth")
+ recreateAuthorizationService()
+ if (authStateManager.current.authorizationServiceConfiguration != null) {
+ // configuration is already created, skip to client initialization
+ Log.i(TAG, "auth config already established")
+ initializeClient()
+ return
+ }
+
+ // if we are not using discovery, build the authorization service configuration directly
+ // from the static configuration values.
+ if (configuration.discoveryUri == null) {
+ Log.i(TAG, "Creating auth config from res/raw/auth_config.json")
+ val config = AuthorizationServiceConfiguration(
+ configuration.authEndpointUri!!,
+ configuration.tokenEndpointUri!!,
+ configuration.endSessionEndpoint,
+ configuration.registrationEndpointUri
+ )
+ authStateManager.replace(AuthState(config))
+ initializeClient()
+ return
+ }
+
+ // WrongThread inference is incorrect for lambdas
+ // noinspection WrongThread
+ runOnUiThread { displayLoading("Retrieving discovery document") }
+ Log.i(TAG, "Retrieving OpenID discovery doc")
+ AuthorizationServiceConfiguration.fetchFromUrl(
+ configuration.discoveryUri!!,
+ { config: AuthorizationServiceConfiguration?, ex: AuthorizationException? ->
+ handleConfigurationRetrievalResult(
+ config,
+ ex
+ )
+ },
+ configuration.connectionBuilder
+ )
+ }
+
+ @MainThread
+ private fun handleConfigurationRetrievalResult(
+ config: AuthorizationServiceConfiguration?,
+ ex: AuthorizationException?
+ ) {
+ if (config == null) {
+ Log.i(TAG, "Failed to retrieve discovery document", ex)
+ displayError("Failed to retrieve discovery document: " + ex!!.message, true)
+ return
+ }
+ Log.i(TAG, "Discovery document retrieved")
+ authStateManager.replace(AuthState(config))
+ executor.submit { initializeClient() }
+ }
+
+ /**
+ * Initiates a dynamic registration request if a client ID is not provided by the static
+ * configuration.
+ */
+ @WorkerThread
+ private fun initializeClient() {
+ if (configuration.clientId != null) {
+ Log.i(TAG, "Using static client ID: " + configuration.clientId)
+ // use a statically configured client ID
+ clientId.set(configuration.clientId)
+ runOnUiThread { initializeAuthRequest() }
+ return
+ }
+ val lastResponse = authStateManager.current.lastRegistrationResponse
+ if (lastResponse != null) {
+ Log.i(TAG, "Using dynamic client ID: " + lastResponse.clientId)
+ clientId.set(lastResponse.clientId)
+ runOnUiThread { initializeAuthRequest() }
+ return
+ }
+
+ runOnUiThread { displayLoading("Dynamically registering client") }
+ Log.i(TAG, "Dynamically registering client")
+ val registrationRequest = RegistrationRequest.Builder(
+ authStateManager.current.authorizationServiceConfiguration!!,
+ listOf(configuration.redirectUri)
+ )
+ .setTokenEndpointAuthenticationMethod(ClientSecretBasic.NAME)
+ .build()
+ authService!!.performRegistrationRequest(
+ registrationRequest
+ ) { response: RegistrationResponse?, ex: AuthorizationException? ->
+ handleRegistrationResponse(
+ response,
+ ex
+ )
+ }
+ }
+
+ @MainThread
+ private fun handleRegistrationResponse(
+ response: RegistrationResponse?,
+ ex: AuthorizationException?
+ ) {
+ authStateManager.updateAfterRegistration(response, ex)
+ if (response == null) {
+ Log.i(TAG, "Failed to dynamically register client", ex)
+ runOnUiThread { displayError("Failed to register client: " + ex!!.message, true) }
+ return
+ }
+ Log.i(TAG, "Dynamically registered client: " + response.clientId)
+ clientId.set(response.clientId)
+ initializeAuthRequest()
+ }
+
+
+ /**
+ * Performs the authorization request, using the browser selected in the spinner,
+ * and a user-provided `login_hint` if available.
+ */
+ @WorkerThread
+ private fun doAuth() {
+ try {
+ authIntentLatch.await()
+ } catch (ex: InterruptedException) {
+ Log.w(TAG, "Interrupted while waiting for auth intent")
+ }
+ if (usePendingIntents) {
+ val completionIntent = Intent(this, TokenActivity::class.java)
+ val cancelIntent = Intent(this, LoginActivity::class.java)
+ cancelIntent.putExtra(EXTRA_FAILED, true)
+ cancelIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
+ authService!!.performAuthorizationRequest(
+ authRequest.get()!!,
+ PendingIntent.getActivity(this, 0, completionIntent, 0),
+ PendingIntent.getActivity(this, 0, cancelIntent, 0),
+ authIntent.get()!!
+ )
+ } else {
+ val intent = authService!!.getAuthorizationRequestIntent(
+ authRequest.get()!!,
+ authIntent.get()!!
+ )
+ startActivityForResult(intent, RC_AUTH)
+ }
+ }
+
+ private fun recreateAuthorizationService() {
+ if (authService != null) {
+ Log.i(TAG, "Discarding existing AuthService instance")
+ authService!!.dispose()
+ }
+ authService = createAuthorizationService()
+ authRequest.set(null)
+ authIntent.set(null)
+ }
+
+ private fun createAuthorizationService(): AuthorizationService {
+ Log.i(TAG, "Creating authorization service")
+ val builder = AppAuthConfiguration.Builder()
+ builder.setBrowserMatcher(browserMatcher)
+ builder.setConnectionBuilder(configuration.connectionBuilder)
+ return AuthorizationService(this, builder.build())
+ }
+
+ @MainThread
+ private fun displayLoading(loadingMessage: String) {
+ binding.loadingContainer.visibility = View.VISIBLE
+ binding.authContainer.visibility = View.GONE
+ binding.errorContainer.visibility = View.GONE
+ binding.loadingDescription.text = loadingMessage
+ }
+
+ @MainThread
+ private fun displayError(error: String?, recoverable: Boolean) {
+ binding.errorContainer.visibility = View.VISIBLE
+ binding.loadingContainer.visibility = View.GONE
+ binding.authContainer.visibility = View.GONE
+ binding.errorDescription.text = error
+ binding.retry.visibility = if (recoverable) View.VISIBLE else View.GONE
+ }
+
+ @MainThread
+ private fun initializeAuthRequest() {
+ createAuthRequest(loginHint)
+ warmUpBrowser()
+ displayAuthOptions()
+ }
+
+ @MainThread
+ private fun displayAuthOptions() {
+ binding.authContainer.visibility = View.VISIBLE
+ binding.loadingContainer.visibility = View.GONE
+ binding.errorContainer.visibility = View.GONE
+ val state = authStateManager.current
+ val config = state.authorizationServiceConfiguration
+ var authEndpointStr: String = if (config!!.discoveryDoc != null) {
+ "Discovered auth endpoint: \n"
+ } else {
+ "Static auth endpoint: \n"
+ }
+ authEndpointStr += config.authorizationEndpoint
+ binding.authEndpoint.text = authEndpointStr
+ var clientIdStr: String = if (state.lastRegistrationResponse != null) {
+ "Dynamic client ID: \n"
+ } else {
+ "Static client ID: \n"
+ }
+ clientIdStr += clientId
+ binding.clientId.text = clientIdStr
+ }
+
+ private fun warmUpBrowser() {
+ authIntentLatch = CountDownLatch(1)
+ executor.execute {
+ Log.i(TAG, "Warming up browser instance for auth request")
+ val intentBuilder =
+ authService!!.createCustomTabsIntentBuilder(authRequest.get()!!.toUri())
+ authIntent.set(intentBuilder.build())
+ authIntentLatch.countDown()
+ }
+ }
+
+ private fun createAuthRequest(loginHint: String?) {
+ Log.i(TAG, "Creating auth request for login hint: $loginHint")
+ val authRequestBuilder = AuthorizationRequest.Builder(
+ authStateManager.current.authorizationServiceConfiguration!!,
+ clientId.get()!!,
+ ResponseTypeValues.CODE,
+ configuration.redirectUri!!
+ )
+ .setScope(configuration.scope)
+ if (!TextUtils.isEmpty(loginHint)) {
+ authRequestBuilder.setLoginHint(loginHint)
+ }
+ authRequest.set(authRequestBuilder.build())
+ }
+
+ private val loginHint: String
+ get() = binding.loginHintValue
+ .text
+ .toString()
+ .trim { it <= ' ' }
+
+ /**
+ * Responds to changes in the login hint. After a "debounce" delay, warms up the browser
+ * for a request with the new login hint; this avoids constantly re-initializing the
+ * browser while the user is typing.
+ */
+ inner class LoginHintChangeHandler internal constructor() : TextWatcher {
+ private val debounceDelayMs = 500
+ private val mHandler: Handler = Handler(Looper.getMainLooper())
+ private var task: RecreateAuthRequestTask
+ override fun beforeTextChanged(cs: CharSequence, start: Int, count: Int, after: Int) {}
+ override fun onTextChanged(cs: CharSequence, start: Int, before: Int, count: Int) {
+ task.cancel()
+ task = RecreateAuthRequestTask()
+ mHandler.postDelayed(task, debounceDelayMs.toLong())
+ }
+
+ override fun afterTextChanged(ed: Editable) {}
+
+ init {
+ task = RecreateAuthRequestTask()
+ }
+ }
+
+ inner class RecreateAuthRequestTask : Runnable {
+ private val mCanceled = AtomicBoolean()
+ override fun run() {
+ if (mCanceled.get()) {
+ return
+ }
+ createAuthRequest(loginHint)
+ warmUpBrowser()
+ }
+
+ fun cancel() {
+ mCanceled.set(true)
+ }
+ }
+
+ companion object {
+ private const val TAG = "LoginActivity"
+ private const val EXTRA_FAILED = "failed"
+ private const val RC_AUTH = 100
+ }
+}
\ No newline at end of file
diff --git a/appkotlin/java/net/openid/appauthdemokotlin/TokenActivity.kt b/appkotlin/java/net/openid/appauthdemokotlin/TokenActivity.kt
new file mode 100644
index 00000000..d9aef76f
--- /dev/null
+++ b/appkotlin/java/net/openid/appauthdemokotlin/TokenActivity.kt
@@ -0,0 +1,394 @@
+/*
+ * Copyright 2016 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 net.openid.appauthdemokotlin
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.widget.Toast
+import androidx.annotation.MainThread
+import androidx.annotation.WorkerThread
+import androidx.appcompat.app.AppCompatActivity
+import com.bumptech.glide.Glide
+import com.google.android.material.snackbar.Snackbar
+import net.openid.appauth.*
+import net.openid.appauth.AuthorizationService.TokenResponseCallback
+import net.openid.appauth.ClientAuthentication.UnsupportedAuthenticationMethod
+import net.openid.appauthdemokotlin.databinding.ActivityTokenBinding
+import okio.Okio
+import org.json.JSONException
+import org.json.JSONObject
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.MalformedURLException
+import java.net.URL
+import java.nio.charset.Charset
+import java.text.SimpleDateFormat
+import java.util.*
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * 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 and refreshing
+ * access tokens.
+ */
+class TokenActivity : AppCompatActivity() {
+ private lateinit var authService: AuthorizationService
+ private lateinit var stateManager: AuthStateManager
+ private val userInfoJson = AtomicReference()
+ private lateinit var executor: ExecutorService
+ private lateinit var configuration: Configuration
+ private lateinit var binding: ActivityTokenBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ stateManager = AuthStateManager.getInstance(this)
+ executor = Executors.newSingleThreadExecutor()
+ configuration = Configuration.getInstance(this)
+ val config: Configuration = Configuration.getInstance(this)
+ if (config.hasConfigurationChanged()) {
+ Toast.makeText(
+ this,
+ "Configuration change detected",
+ Toast.LENGTH_SHORT
+ )
+ .show()
+ signOut()
+ return
+ }
+ authService = AuthorizationService(
+ this,
+ AppAuthConfiguration.Builder()
+ .setConnectionBuilder(config.connectionBuilder)
+ .build()
+ )
+ binding = ActivityTokenBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ displayLoading("Restoring state...")
+ if (savedInstanceState != null) {
+ try {
+ userInfoJson.set(JSONObject(savedInstanceState.getString(KEY_USER_INFO)!!))
+ } catch (ex: JSONException) {
+ Log.e(TAG, "Failed to parse saved user info JSON, discarding", ex)
+ }
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ if (executor.isShutdown) {
+ executor = Executors.newSingleThreadExecutor()
+ }
+ if (stateManager.current.isAuthorized) {
+ displayAuthorized()
+ return
+ }
+
+ // the stored AuthState is incomplete, so check if we are currently receiving the result of
+ // the authorization flow from the browser.
+ val response = AuthorizationResponse.fromIntent(intent)
+ val ex = AuthorizationException.fromIntent(intent)
+ if (response != null || ex != null) {
+ stateManager.updateAfterAuthorization(response, ex)
+ }
+ when {
+ response?.authorizationCode != null -> {
+ // authorization code exchange is required
+ stateManager.updateAfterAuthorization(response, ex)
+ exchangeAuthorizationCode(response)
+ }
+ ex != null -> {
+ displayNotAuthorized("Authorization flow failed: " + ex.message)
+ }
+ else -> {
+ displayNotAuthorized("No authorization state retained - reauthorization required")
+ }
+ }
+ }
+
+ 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 (userInfoJson.get() != null) {
+ state.putString(KEY_USER_INFO, userInfoJson.toString())
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ authService.dispose()
+ executor.shutdownNow()
+ }
+
+ @MainThread
+ private fun displayNotAuthorized(explanation: String) {
+ binding.notAuthorized.visibility = View.VISIBLE
+ binding.authorized.visibility = View.GONE
+ binding.loadingContainer.visibility = View.GONE
+ binding.explanation.text = explanation
+ binding.reauth.setOnClickListener { signOut() }
+ }
+
+ @MainThread
+ private fun displayLoading(message: String) {
+ binding.loadingContainer.visibility = View.VISIBLE
+ binding.authorized.visibility = View.GONE
+ binding.notAuthorized.visibility = View.GONE
+ binding.loadingDescription.text = message
+ }
+
+ @MainThread
+ private fun displayAuthorized() {
+ binding.authorized.visibility = View.VISIBLE
+ binding.notAuthorized.visibility = View.GONE
+ binding.loadingContainer.visibility = View.GONE
+ val state = stateManager.current
+ val refreshTokenInfoView = binding.refreshTokenInfo
+ refreshTokenInfoView.setText(if (state.refreshToken == null) R.string.no_refresh_token_returned else R.string.refresh_token_returned)
+ val idTokenInfoView = binding.idTokenInfo
+ idTokenInfoView.setText(if (state.idToken == null) R.string.no_id_token_returned else R.string.id_token_returned)
+ val accessTokenInfoView = binding.accessTokenInfo
+ if (state.accessToken == null) {
+ accessTokenInfoView.setText(R.string.no_access_token_returned)
+ } else {
+ val expiresAt = state.accessTokenExpirationTime
+ when {
+ expiresAt == null -> {
+ accessTokenInfoView.setText(R.string.no_access_token_expiry)
+ }
+ expiresAt < System.currentTimeMillis() -> {
+ accessTokenInfoView.setText(R.string.access_token_expired)
+ }
+ else -> {
+ val template = resources.getString(R.string.access_token_expires_at)
+ accessTokenInfoView.text = String.format(
+ template,
+ SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(expiresAt)
+ )
+ }
+ }
+ }
+ val refreshTokenButton = binding.refreshToken
+ refreshTokenButton.visibility = if (state.refreshToken != null) View.VISIBLE else View.GONE
+ refreshTokenButton.setOnClickListener { refreshAccessToken() }
+ val viewProfileButton = binding.viewProfile
+ val discoveryDoc = state.authorizationServiceConfiguration!!.discoveryDoc
+ if ((discoveryDoc == null || discoveryDoc.userinfoEndpoint == null)
+ && configuration.userInfoEndpointUri == null
+ ) {
+ viewProfileButton.visibility = View.GONE
+ } else {
+ viewProfileButton.visibility = View.VISIBLE
+ viewProfileButton.setOnClickListener { fetchUserInfo() }
+ }
+ binding.signOut.setOnClickListener { endSession() }
+ val userInfoCard = binding.userinfoCard
+ val userInfo = userInfoJson.get()
+ if (userInfo == null) {
+ userInfoCard.visibility = View.INVISIBLE
+ } else {
+ try {
+ var name: String? = "???"
+ if (userInfo.has("name")) {
+ name = userInfo.getString("name")
+ }
+ binding.userinfoName.text = name
+ if (userInfo.has("picture")) {
+ Glide.with(this@TokenActivity)
+ .load(Uri.parse(userInfo.getString("picture")))
+ .fitCenter()
+ .into(binding.userinfoProfile)
+ }
+ binding.userinfoJson.text = userInfoJson.toString()
+ userInfoCard.visibility = View.VISIBLE
+ } catch (ex: JSONException) {
+ Log.e(TAG, "Failed to read userinfo JSON", ex)
+ }
+ }
+ }
+
+ @MainThread
+ private fun refreshAccessToken() {
+ displayLoading("Refreshing access token")
+ performTokenRequest(stateManager.current.createTokenRefreshRequest()) { tokenResponse: TokenResponse?,
+ authException: AuthorizationException? ->
+ handleAccessTokenResponse(tokenResponse, authException)
+ }
+ }
+
+ @MainThread
+ private fun exchangeAuthorizationCode(authorizationResponse: AuthorizationResponse) {
+ displayLoading("Exchanging authorization code")
+ performTokenRequest(
+ authorizationResponse.createTokenExchangeRequest()
+ ) { tokenResponse: TokenResponse?,
+ authException: AuthorizationException? ->
+ handleCodeExchangeResponse(tokenResponse, authException)
+ }
+ }
+
+ @MainThread
+ private fun performTokenRequest(
+ request: TokenRequest,
+ callback: TokenResponseCallback
+ ) {
+ val clientAuthentication: ClientAuthentication = try {
+ stateManager.current.clientAuthentication
+ } catch (ex: UnsupportedAuthenticationMethod) {
+ Log.d(
+ TAG, "Token request cannot be made, client authentication for the token "
+ + "endpoint could not be constructed (%s)", ex
+ )
+ displayNotAuthorized("Client authentication method is unsupported")
+ return
+ }
+ authService.performTokenRequest(
+ request,
+ clientAuthentication,
+ callback
+ )
+ }
+
+ @WorkerThread
+ private fun handleAccessTokenResponse(
+ tokenResponse: TokenResponse?,
+ authException: AuthorizationException?
+ ) {
+ stateManager.updateAfterTokenResponse(tokenResponse, authException)
+ runOnUiThread { displayAuthorized() }
+ }
+
+ @WorkerThread
+ private fun handleCodeExchangeResponse(
+ tokenResponse: TokenResponse?,
+ authException: AuthorizationException?
+ ) {
+ stateManager.updateAfterTokenResponse(tokenResponse, authException)
+ if (!stateManager.current.isAuthorized) {
+ val message = ("Authorization Code exchange failed"
+ + if (authException != null) authException.error else "")
+ runOnUiThread { displayNotAuthorized(message) }
+ } else {
+ runOnUiThread { displayAuthorized() }
+ }
+ }
+
+ /**
+ * Demonstrates the use of [AuthState.performActionWithFreshTokens] to retrieve
+ * user info from the IDP's user info endpoint. This callback will negotiate a new access
+ * token / id token for use in a follow-up action, or provide an error if this fails.
+ */
+ @MainThread
+ private fun fetchUserInfo() {
+ displayLoading("Fetching user info")
+ stateManager.current.performActionWithFreshTokens(authService) { accessToken: String?, idToken: String?,
+ ex: AuthorizationException? ->
+ this.fetchUserInfo(accessToken, idToken, ex)
+ }
+ }
+
+ @MainThread
+ private fun fetchUserInfo(accessToken: String?, idToken: String?, ex: AuthorizationException?) {
+ if (ex != null) {
+ Log.e(TAG, "Token refresh failed when fetching user info")
+ userInfoJson.set(null)
+ runOnUiThread { displayAuthorized() }
+ return
+ }
+ val discovery = stateManager.current.authorizationServiceConfiguration!!.discoveryDoc
+ val userInfoEndpoint: URL = try {
+ if (configuration.userInfoEndpointUri != null) {
+ URL(configuration.userInfoEndpointUri.toString())
+ } else {
+ URL(discovery!!.userinfoEndpoint.toString())
+ }
+ } catch (urlEx: MalformedURLException) {
+ Log.e(TAG, "Failed to construct user info endpoint URL", urlEx)
+ userInfoJson.set(null)
+ runOnUiThread { displayAuthorized() }
+ return
+ }
+ executor.submit {
+ try {
+ val conn = userInfoEndpoint.openConnection() as HttpURLConnection
+ conn.setRequestProperty("Authorization", "Bearer $accessToken")
+ conn.instanceFollowRedirects = false
+ val response = Okio.buffer(Okio.source(conn.inputStream))
+ .readString(Charset.forName("UTF-8"))
+ userInfoJson.set(JSONObject(response))
+ } catch (ioEx: IOException) {
+ Log.e(TAG, "Network error when querying userinfo endpoint", ioEx)
+ Snackbar.make(binding.coordinator, "Fetching user info failed", Snackbar.LENGTH_SHORT).show()
+ } catch (jsonEx: JSONException) {
+ Log.e(TAG, "Failed to parse userinfo response")
+ Snackbar.make(binding.coordinator, "Failed to parse userinfo response", Snackbar.LENGTH_SHORT).show()
+ }
+ runOnUiThread { displayAuthorized() }
+ }
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ if (requestCode == END_SESSION_REQUEST_CODE && resultCode == RESULT_OK) {
+ signOut()
+ finish()
+ } else {
+ Snackbar.make(binding.coordinator, "Sign out canceled", Snackbar.LENGTH_SHORT)
+ .show()
+ }
+ }
+
+ @MainThread
+ private fun endSession() {
+ val endSessionEnter = authService.getEndSessionRequestIntent(
+ EndSessionRequest.Builder(
+ stateManager.current.authorizationServiceConfiguration!!,
+ stateManager.current.idToken!!,
+ configuration.endSessionUri!!
+ ).build()
+ )
+ startActivityForResult(endSessionEnter, END_SESSION_REQUEST_CODE)
+ }
+
+ @MainThread
+ private fun signOut() {
+ // discard the authorization and token state, but retain the configuration and
+ // dynamic client registration (if applicable), to save from retrieving them again.
+ val currentState = stateManager.current
+ val clearedState = AuthState(currentState.authorizationServiceConfiguration!!)
+ if (currentState.lastRegistrationResponse != null) {
+ clearedState.update(currentState.lastRegistrationResponse)
+ }
+ stateManager.replace(clearedState)
+ val mainIntent = Intent(this, LoginActivity::class.java)
+ mainIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
+ startActivity(mainIntent)
+ finish()
+ }
+
+ companion object {
+ private const val TAG = "TokenActivity"
+ private const val KEY_USER_INFO = "userInfo"
+ private const val END_SESSION_REQUEST_CODE = 911
+ }
+}
\ No newline at end of file
diff --git a/appkotlin/res/drawable/appauth_96dp.xml b/appkotlin/res/drawable/appauth_96dp.xml
new file mode 100644
index 00000000..f193811a
--- /dev/null
+++ b/appkotlin/res/drawable/appauth_96dp.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/appkotlin/res/drawable/unknown_user_48dp.xml b/appkotlin/res/drawable/unknown_user_48dp.xml
new file mode 100644
index 00000000..8f0ba063
--- /dev/null
+++ b/appkotlin/res/drawable/unknown_user_48dp.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/appkotlin/res/layout/activity_login.xml b/appkotlin/res/layout/activity_login.xml
new file mode 100644
index 00000000..ffa20908
--- /dev/null
+++ b/appkotlin/res/layout/activity_login.xml
@@ -0,0 +1,174 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/appkotlin/res/layout/activity_token.xml b/appkotlin/res/layout/activity_token.xml
new file mode 100644
index 00000000..847faa61
--- /dev/null
+++ b/appkotlin/res/layout/activity_token.xml
@@ -0,0 +1,195 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/appkotlin/res/mipmap-hdpi/ic_launcher.png b/appkotlin/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..f2fcc6b8
Binary files /dev/null and b/appkotlin/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/appkotlin/res/mipmap-mdpi/ic_launcher.png b/appkotlin/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..59185a67
Binary files /dev/null and b/appkotlin/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/appkotlin/res/mipmap-xhdpi/ic_launcher.png b/appkotlin/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..105ffb0e
Binary files /dev/null and b/appkotlin/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/appkotlin/res/mipmap-xxhdpi/ic_launcher.png b/appkotlin/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..2fc3858d
Binary files /dev/null and b/appkotlin/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/appkotlin/res/mipmap-xxxhdpi/ic_launcher.png b/appkotlin/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..312d0544
Binary files /dev/null and b/appkotlin/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/appkotlin/res/raw/auth_config.json b/appkotlin/res/raw/auth_config.json
new file mode 100644
index 00000000..1796efab
--- /dev/null
+++ b/appkotlin/res/raw/auth_config.json
@@ -0,0 +1,12 @@
+{
+ "client_id": "",
+ "redirect_uri": "net.openid.appauthdemo:/oauth2redirect",
+ "end_session_uri":"",
+ "authorization_scope": "openid email profile",
+ "discovery_uri": "",
+ "authorization_endpoint_uri": "",
+ "token_endpoint_uri": "",
+ "registration_endpoint_uri": "",
+ "user_info_endpoint_uri": "",
+ "https_required": true
+}
diff --git a/appkotlin/res/values/colors.xml b/appkotlin/res/values/colors.xml
new file mode 100644
index 00000000..b3f8a193
--- /dev/null
+++ b/appkotlin/res/values/colors.xml
@@ -0,0 +1,7 @@
+
+
+ #00aeef
+ #00A2DF
+ #FFAB00
+
+
diff --git a/appkotlin/res/values/dimens.xml b/appkotlin/res/values/dimens.xml
new file mode 100644
index 00000000..5b3df276
--- /dev/null
+++ b/appkotlin/res/values/dimens.xml
@@ -0,0 +1,7 @@
+
+
+ 16dp
+ 16dp
+ 16dp
+
+
diff --git a/appkotlin/res/values/strings.xml b/appkotlin/res/values/strings.xml
new file mode 100644
index 00000000..253b9b92
--- /dev/null
+++ b/appkotlin/res/values/strings.xml
@@ -0,0 +1,31 @@
+
+ AppAuth
+ Authorization granted
+ OpenID AppAuth Demo
+ AppAuth Logo
+ Refresh token
+ Access token expires at: %s
+ No access token returned
+ No refresh token returned
+ Refresh token returned
+ No ID token returned
+ ID token returned
+ View user info
+ User profile picture
+ Account ID (e.g. test@example.com)
+ The Account ID is optional. If specified, it is transmitted as a login_hint parameter in the authorization request.
+ Authorization options:
+ Use browser:
+ Browser app icon
+ AppAuth heuristic selection
+ "%1$s (custom tab)"
+ Retry
+ Not authorized
+ Start authorization
+ Reauthorize
+ Sign out
+ Access time has no defined expiry
+ Access token has expired
+ Authorization settings in use:
+ Use PendingIntent\'s for completion
+
diff --git a/appkotlin/res/values/styles.xml b/appkotlin/res/values/styles.xml
new file mode 100644
index 00000000..4e88d0a3
--- /dev/null
+++ b/appkotlin/res/values/styles.xml
@@ -0,0 +1,7 @@
+
+
+
diff --git a/build.gradle b/build.gradle
index 4cdfe04c..5329fe7c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -3,6 +3,7 @@ import org.ajoberstar.grgit.Grgit
apply from: 'config/keystore.gradle'
buildscript {
+ ext.kotlin_version = '1.4.30'
repositories {
jcenter()
google()
diff --git a/settings.gradle b/settings.gradle
index 33069973..bb82491e 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1 +1 @@
-include ':app', ':library'
+include ':app', ':appkotlin', ':library'