refactor: store API keys in app preferences
This commit is contained in:
@@ -10,7 +10,6 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
|
|||||||
|
|
||||||
- AndroidX Preferences library.
|
- AndroidX Preferences library.
|
||||||
- AndroidX SplashScreen library.
|
- AndroidX SplashScreen library.
|
||||||
- AndroidX Credential Manager library.
|
|
||||||
- Kotlin coroutines (Android dispatcher).
|
- Kotlin coroutines (Android dispatcher).
|
||||||
|
|
||||||
## Current bootstrap status
|
## Current bootstrap status
|
||||||
@@ -48,10 +47,12 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
|
|||||||
- The view has fields to request the user for an instance base URL (http or https, with or without port number) and an API key.
|
- The view has fields to request the user for an instance base URL (http or https, with or without port number) and an API key.
|
||||||
- The default base URL is that of the standard Kan.bn instance at https://kan.bn/
|
- The default base URL is that of the standard Kan.bn instance at https://kan.bn/
|
||||||
- The view tries to check connection with the server at the given base URL using the Kan.bn API healthcheck endpoint.
|
- The view tries to check connection with the server at the given base URL using the Kan.bn API healthcheck endpoint.
|
||||||
- The API key is managed using Android's own Credential Manager.
|
- The API key is stored in app preferences together with the base URL.
|
||||||
|
- No migration is performed from prior Credential Manager storage, so users must re-enter their API key one time after upgrading.
|
||||||
- On success, the view stores the URL and API key pair in preferences and moves over to the boards view.
|
- On success, the view stores the URL and API key pair in preferences and moves over to the boards view.
|
||||||
- If there is a URL and API Key pair stored, the view tries to authenticate the user through the API automatically and proceeds to the boards view instantly without showing the login screen if successful.
|
- If there is a URL and API Key pair stored, the view tries to authenticate the user through the API automatically and proceeds to the boards view instantly without showing the login screen if successful.
|
||||||
- Current status: implemented in `MainActivity` with XML views and a temporary boards destination (`BoardsPlaceholderActivity`) while the real boards list view is still pending.
|
- If startup authentication fails due to invalid credentials then the stored API key is invalidated; transient connectivity/server failures keep the stored key and return to login.
|
||||||
|
- Current status: implemented in `MainActivity` with XML views and navigation into `BoardsActivity`.
|
||||||
|
|
||||||
**Boards list view**
|
**Boards list view**
|
||||||
- Displays a list of boards as rounded-square cards with the board's title centered in it.
|
- Displays a list of boards as rounded-square cards with the board's title centered in it.
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ dependencies {
|
|||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.androidx.credentials)
|
|
||||||
implementation(libs.androidx.credentials.play.services.auth)
|
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
implementation(libs.androidx.constraintlayout)
|
implementation(libs.androidx.constraintlayout)
|
||||||
implementation(libs.androidx.activity.ktx)
|
implementation(libs.androidx.activity.ktx)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import org.junit.Assert.assertEquals
|
|||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.AuthFailureReason
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||||
@@ -65,16 +66,70 @@ class LoginFlowTest {
|
|||||||
@Test
|
@Test
|
||||||
fun invalidStoredSessionFallsBackToLoginAndKeepsUrl() {
|
fun invalidStoredSessionFallsBackToLoginAndKeepsUrl() {
|
||||||
val sessionStore = InMemorySessionStore("https://kan.bn/")
|
val sessionStore = InMemorySessionStore("https://kan.bn/")
|
||||||
|
val keyStore = InMemoryApiKeyStore("bad-key")
|
||||||
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
|
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
|
||||||
MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore("bad-key") }
|
MainActivity.dependencies.apiKeyStoreFactory = { keyStore }
|
||||||
MainActivity.dependencies.apiClientFactory = {
|
MainActivity.dependencies.apiClientFactory = {
|
||||||
FakeApiClient(AuthResult.Failure("Authentication failed. Check your API key."))
|
FakeApiClient(
|
||||||
|
AuthResult.Failure(
|
||||||
|
message = "Authentication failed. Check your API key.",
|
||||||
|
reason = AuthFailureReason.Authentication,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ActivityScenario.launch(MainActivity::class.java)
|
ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
|
||||||
onView(withId(R.id.baseUrlInput)).check(matches(withText("https://kan.bn/")))
|
onView(withId(R.id.baseUrlInput)).check(matches(withText("https://kan.bn/")))
|
||||||
onView(withId(R.id.signInButton)).check(matches(isDisplayed()))
|
onView(withId(R.id.signInButton)).check(matches(isDisplayed()))
|
||||||
|
assertEquals(1, keyStore.invalidationCount)
|
||||||
|
assertEquals(null, keyStore.currentKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun connectivityFailureDuringAutoLoginFallsBackWithoutInvalidatingKey() {
|
||||||
|
val sessionStore = InMemorySessionStore("https://kan.bn/")
|
||||||
|
val keyStore = InMemoryApiKeyStore("bad-key")
|
||||||
|
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
|
||||||
|
MainActivity.dependencies.apiKeyStoreFactory = { keyStore }
|
||||||
|
MainActivity.dependencies.apiClientFactory = {
|
||||||
|
FakeApiClient(
|
||||||
|
AuthResult.Failure(
|
||||||
|
message = "Cannot reach server. Check your connection and URL.",
|
||||||
|
reason = AuthFailureReason.Connectivity,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
|
||||||
|
onView(withId(R.id.baseUrlInput)).check(matches(withText("https://kan.bn/")))
|
||||||
|
onView(withId(R.id.signInButton)).check(matches(isDisplayed()))
|
||||||
|
assertEquals(0, keyStore.invalidationCount)
|
||||||
|
assertEquals("bad-key", keyStore.currentKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serverFailureDuringAutoLoginFallsBackWithoutInvalidatingKey() {
|
||||||
|
val sessionStore = InMemorySessionStore("https://kan.bn/")
|
||||||
|
val keyStore = InMemoryApiKeyStore("bad-key")
|
||||||
|
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
|
||||||
|
MainActivity.dependencies.apiKeyStoreFactory = { keyStore }
|
||||||
|
MainActivity.dependencies.apiClientFactory = {
|
||||||
|
FakeApiClient(
|
||||||
|
AuthResult.Failure(
|
||||||
|
message = "Server error: 500",
|
||||||
|
reason = AuthFailureReason.Server,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivityScenario.launch(MainActivity::class.java)
|
||||||
|
|
||||||
|
onView(withId(R.id.baseUrlInput)).check(matches(withText("https://kan.bn/")))
|
||||||
|
onView(withId(R.id.signInButton)).check(matches(isDisplayed()))
|
||||||
|
assertEquals(0, keyStore.invalidationCount)
|
||||||
|
assertEquals("bad-key", keyStore.currentKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -114,6 +169,9 @@ class LoginFlowTest {
|
|||||||
private var key: String?,
|
private var key: String?,
|
||||||
) : ApiKeyStore {
|
) : ApiKeyStore {
|
||||||
var savedKey: String? = null
|
var savedKey: String? = null
|
||||||
|
var invalidationCount: Int = 0
|
||||||
|
val currentKey: String?
|
||||||
|
get() = key
|
||||||
|
|
||||||
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
|
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
|
||||||
key = apiKey
|
key = apiKey
|
||||||
@@ -124,6 +182,7 @@ class LoginFlowTest {
|
|||||||
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(key)
|
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(key)
|
||||||
|
|
||||||
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
|
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
|
||||||
|
invalidationCount += 1
|
||||||
key = null
|
key = null
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package space.hackenslacker.kanbn4droid.app.auth
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class PreferencesApiKeyStoreTest {
|
||||||
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
private val store = PreferencesApiKeyStore(context)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun clearStore() = runBlocking {
|
||||||
|
store.invalidateApiKey("setup").getOrThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun saveThenGetReturnsKey() = runBlocking {
|
||||||
|
val saveResult = store.saveApiKey("https://kan.bn/", "kan_key")
|
||||||
|
val getResult = store.getApiKey("https://kan.bn/")
|
||||||
|
|
||||||
|
assertEquals(true, saveResult.isSuccess)
|
||||||
|
assertEquals("kan_key", getResult.getOrNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invalidateClearsKey() = runBlocking {
|
||||||
|
store.saveApiKey("https://kan.bn/", "kan_key").getOrThrow()
|
||||||
|
|
||||||
|
val invalidateResult = store.invalidateApiKey("https://kan.bn/")
|
||||||
|
val getResult = store.getApiKey("https://kan.bn/")
|
||||||
|
|
||||||
|
assertEquals(true, invalidateResult.isSuccess)
|
||||||
|
assertNull(getResult.getOrNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getReturnsNullWhenAbsent() = runBlocking {
|
||||||
|
val getResult = store.getApiKey("https://kan.bn/")
|
||||||
|
|
||||||
|
assertNull(getResult.getOrNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun blankAndMalformedBaseUrlStillWork() = runBlocking {
|
||||||
|
val saveBlankResult = store.saveApiKey("", "kan_key")
|
||||||
|
val getMalformedResult = store.getApiKey("not a url")
|
||||||
|
val invalidateMalformedResult = store.invalidateApiKey("::://")
|
||||||
|
val getAfterInvalidateResult = store.getApiKey(" ")
|
||||||
|
|
||||||
|
assertEquals(true, saveBlankResult.isSuccess)
|
||||||
|
assertEquals("kan_key", getMalformedResult.getOrNull())
|
||||||
|
assertEquals(true, invalidateMalformedResult.isSuccess)
|
||||||
|
assertNull(getAfterInvalidateResult.getOrNull())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,9 +24,9 @@ import com.google.android.material.textfield.TextInputLayout
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.CredentialManagerApiKeyStore
|
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.PreferencesApiKeyStore
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
|
import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||||
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
|
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
|
||||||
@@ -257,7 +257,7 @@ class BoardsActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
protected fun provideApiKeyStore(): ApiKeyStore {
|
protected fun provideApiKeyStore(): ApiKeyStore {
|
||||||
return MainActivity.dependencies.apiKeyStoreFactory?.invoke(this)
|
return MainActivity.dependencies.apiKeyStoreFactory?.invoke(this)
|
||||||
?: CredentialManagerApiKeyStore(this)
|
?: PreferencesApiKeyStore(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun provideApiClient(): KanbnApiClient {
|
protected fun provideApiClient(): KanbnApiClient {
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.AuthFailureReason
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.CredentialManagerApiKeyStore
|
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||||
|
import space.hackenslacker.kanbn4droid.app.auth.PreferencesApiKeyStore
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
|
import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||||
import space.hackenslacker.kanbn4droid.app.auth.UrlNormalizer
|
import space.hackenslacker.kanbn4droid.app.auth.UrlNormalizer
|
||||||
@@ -100,16 +101,18 @@ class MainActivity : AppCompatActivity() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return when (apiClient.healthCheck(storedBaseUrl, storedApiKey)) {
|
return when (val authResult = apiClient.healthCheck(storedBaseUrl, storedApiKey)) {
|
||||||
AuthResult.Success -> {
|
AuthResult.Success -> {
|
||||||
openBoards()
|
openBoards()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
is AuthResult.Failure -> {
|
is AuthResult.Failure -> {
|
||||||
|
if (authResult.reason == AuthFailureReason.Authentication) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
apiKeyStore.invalidateApiKey(storedBaseUrl)
|
apiKeyStore.invalidateApiKey(storedBaseUrl)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +203,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
protected fun provideApiKeyStore(): ApiKeyStore {
|
protected fun provideApiKeyStore(): ApiKeyStore {
|
||||||
return dependencies.apiKeyStoreFactory?.invoke(this)
|
return dependencies.apiKeyStoreFactory?.invoke(this)
|
||||||
?: CredentialManagerApiKeyStore(this)
|
?: PreferencesApiKeyStore(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun provideApiClient(): KanbnApiClient {
|
protected fun provideApiClient(): KanbnApiClient {
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
package space.hackenslacker.kanbn4droid.app.auth
|
package space.hackenslacker.kanbn4droid.app.auth
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.credentials.ClearCredentialStateRequest
|
|
||||||
import androidx.credentials.CreatePasswordRequest
|
|
||||||
import androidx.credentials.CredentialManager
|
|
||||||
import androidx.credentials.GetCredentialRequest
|
|
||||||
import androidx.credentials.GetPasswordOption
|
|
||||||
import androidx.credentials.PasswordCredential
|
|
||||||
|
|
||||||
interface ApiKeyStore {
|
interface ApiKeyStore {
|
||||||
suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit>
|
suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit>
|
||||||
@@ -15,60 +8,33 @@ interface ApiKeyStore {
|
|||||||
suspend fun invalidateApiKey(baseUrl: String): Result<Unit>
|
suspend fun invalidateApiKey(baseUrl: String): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|
||||||
class CredentialManagerApiKeyStore(
|
class PreferencesApiKeyStore(
|
||||||
private val context: Context,
|
context: Context,
|
||||||
private val credentialManager: CredentialManager = CredentialManager.create(context),
|
|
||||||
) : ApiKeyStore {
|
) : ApiKeyStore {
|
||||||
private val invalidatedPreferences: SharedPreferences =
|
private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||||
context.getSharedPreferences(INVALIDATED_PREFS_NAME, Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
|
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
credentialManager.createCredential(
|
preferences.edit().putString(API_KEY_PREFERENCE_KEY, apiKey).apply()
|
||||||
context,
|
|
||||||
CreatePasswordRequest(id = CREDENTIAL_ID, password = apiKey),
|
|
||||||
)
|
|
||||||
markInvalidated(baseUrl, false)
|
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getApiKey(baseUrl: String): Result<String?> {
|
override suspend fun getApiKey(baseUrl: String): Result<String?> {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
if (isInvalidated(baseUrl)) {
|
preferences.getString(API_KEY_PREFERENCE_KEY, null)
|
||||||
return@runCatching null
|
|
||||||
}
|
|
||||||
|
|
||||||
val response = credentialManager.getCredential(
|
|
||||||
context,
|
|
||||||
GetCredentialRequest(listOf(GetPasswordOption())),
|
|
||||||
)
|
|
||||||
|
|
||||||
val credential = response.credential as? PasswordCredential ?: return@runCatching null
|
|
||||||
if (credential.id == CREDENTIAL_ID) credential.password else null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
|
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
markInvalidated(baseUrl, true)
|
preferences.edit().remove(API_KEY_PREFERENCE_KEY).apply()
|
||||||
credentialManager.clearCredentialState(ClearCredentialStateRequest())
|
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isInvalidated(baseUrl: String): Boolean {
|
|
||||||
return invalidatedPreferences.getBoolean(baseUrl.invalidatedKey(), false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun markInvalidated(baseUrl: String, invalidated: Boolean) {
|
|
||||||
invalidatedPreferences.edit().putBoolean(baseUrl.invalidatedKey(), invalidated).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.invalidatedKey(): String = "invalidated:$this"
|
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
private const val INVALIDATED_PREFS_NAME = "kanbn_invalidated_api_keys"
|
private const val PREFERENCES_NAME = "kanbn_api_key_store"
|
||||||
private const val CREDENTIAL_ID = "space.hackenslacker.kanbn4droid.api_key"
|
private const val API_KEY_PREFERENCE_KEY = "api_key"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,39 @@
|
|||||||
package space.hackenslacker.kanbn4droid.app.auth
|
package space.hackenslacker.kanbn4droid.app.auth
|
||||||
|
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.ConnectException
|
||||||
import java.net.SocketTimeoutException
|
import java.net.SocketTimeoutException
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
|
|
||||||
|
enum class AuthFailureReason {
|
||||||
|
Authentication,
|
||||||
|
Connectivity,
|
||||||
|
Server,
|
||||||
|
Unexpected,
|
||||||
|
}
|
||||||
|
|
||||||
sealed interface AuthResult {
|
sealed interface AuthResult {
|
||||||
data object Success : AuthResult
|
data object Success : AuthResult
|
||||||
data class Failure(val message: String) : AuthResult
|
data class Failure(
|
||||||
|
val message: String,
|
||||||
|
val reason: AuthFailureReason,
|
||||||
|
) : AuthResult
|
||||||
}
|
}
|
||||||
|
|
||||||
object AuthErrorMapper {
|
object AuthErrorMapper {
|
||||||
fun fromHttpCode(code: Int): AuthResult.Failure {
|
fun fromHttpCode(code: Int): AuthResult.Failure {
|
||||||
return when (code) {
|
return when (code) {
|
||||||
401, 403 -> AuthResult.Failure("Authentication failed. Check your API key.")
|
401,
|
||||||
else -> AuthResult.Failure("Server error: $code")
|
403,
|
||||||
|
-> AuthResult.Failure(
|
||||||
|
message = "Authentication failed. Check your API key.",
|
||||||
|
reason = AuthFailureReason.Authentication,
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> AuthResult.Failure(
|
||||||
|
message = "Server error: $code",
|
||||||
|
reason = AuthFailureReason.Server,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,9 +41,17 @@ object AuthErrorMapper {
|
|||||||
return when (throwable) {
|
return when (throwable) {
|
||||||
is SocketTimeoutException,
|
is SocketTimeoutException,
|
||||||
is UnknownHostException,
|
is UnknownHostException,
|
||||||
-> AuthResult.Failure("Cannot reach server. Check your connection and URL.")
|
is ConnectException,
|
||||||
|
is IOException,
|
||||||
|
-> AuthResult.Failure(
|
||||||
|
message = "Cannot reach server. Check your connection and URL.",
|
||||||
|
reason = AuthFailureReason.Connectivity,
|
||||||
|
)
|
||||||
|
|
||||||
else -> AuthResult.Failure("Unexpected error. Please try again.")
|
else -> AuthResult.Failure(
|
||||||
|
message = "Unexpected error. Please try again.",
|
||||||
|
reason = AuthFailureReason.Unexpected,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package space.hackenslacker.kanbn4droid.app.auth
|
package space.hackenslacker.kanbn4droid.app.auth
|
||||||
|
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.ConnectException
|
||||||
import java.net.SocketTimeoutException
|
import java.net.SocketTimeoutException
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -9,38 +11,51 @@ class AuthResultMappingTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun mapsUnauthorizedCodesToAuthFailureMessage() {
|
fun mapsUnauthorizedCodesToAuthFailureMessage() {
|
||||||
assertEquals(
|
val unauthorized = AuthErrorMapper.fromHttpCode(401)
|
||||||
"Authentication failed. Check your API key.",
|
assertEquals("Authentication failed. Check your API key.", unauthorized.message)
|
||||||
AuthErrorMapper.fromHttpCode(401).message,
|
assertEquals(AuthFailureReason.Authentication, unauthorized.reason)
|
||||||
)
|
|
||||||
assertEquals(
|
val forbidden = AuthErrorMapper.fromHttpCode(403)
|
||||||
"Authentication failed. Check your API key.",
|
assertEquals("Authentication failed. Check your API key.", forbidden.message)
|
||||||
AuthErrorMapper.fromHttpCode(403).message,
|
assertEquals(AuthFailureReason.Authentication, forbidden.reason)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun mapsTimeoutAndUnknownHostToConnectivityMessage() {
|
fun mapsTimeoutAndUnknownHostToConnectivityMessage() {
|
||||||
assertEquals(
|
val timeoutFailure = AuthErrorMapper.fromException(SocketTimeoutException())
|
||||||
"Cannot reach server. Check your connection and URL.",
|
assertEquals("Cannot reach server. Check your connection and URL.", timeoutFailure.message)
|
||||||
AuthErrorMapper.fromException(SocketTimeoutException()).message,
|
assertEquals(AuthFailureReason.Connectivity, timeoutFailure.reason)
|
||||||
)
|
|
||||||
assertEquals(
|
val unknownHostFailure = AuthErrorMapper.fromException(UnknownHostException())
|
||||||
"Cannot reach server. Check your connection and URL.",
|
assertEquals("Cannot reach server. Check your connection and URL.", unknownHostFailure.message)
|
||||||
AuthErrorMapper.fromException(UnknownHostException()).message,
|
assertEquals(AuthFailureReason.Connectivity, unknownHostFailure.reason)
|
||||||
)
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapsConnectExceptionToConnectivityMessage() {
|
||||||
|
val connectFailure = AuthErrorMapper.fromException(ConnectException())
|
||||||
|
assertEquals("Cannot reach server. Check your connection and URL.", connectFailure.message)
|
||||||
|
assertEquals(AuthFailureReason.Connectivity, connectFailure.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapsIOExceptionToConnectivityMessage() {
|
||||||
|
val ioFailure = AuthErrorMapper.fromException(IOException())
|
||||||
|
assertEquals("Cannot reach server. Check your connection and URL.", ioFailure.message)
|
||||||
|
assertEquals(AuthFailureReason.Connectivity, ioFailure.reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun mapsOtherHttpCodesToServerCodeMessage() {
|
fun mapsOtherHttpCodesToServerCodeMessage() {
|
||||||
assertEquals("Server error: 500", AuthErrorMapper.fromHttpCode(500).message)
|
val serverFailure = AuthErrorMapper.fromHttpCode(500)
|
||||||
|
assertEquals("Server error: 500", serverFailure.message)
|
||||||
|
assertEquals(AuthFailureReason.Server, serverFailure.reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun mapsUnexpectedExceptionsToGenericMessage() {
|
fun mapsUnexpectedExceptionsToGenericMessage() {
|
||||||
assertEquals(
|
val unexpectedFailure = AuthErrorMapper.fromException(IllegalStateException())
|
||||||
"Unexpected error. Please try again.",
|
assertEquals("Unexpected error. Please try again.", unexpectedFailure.message)
|
||||||
AuthErrorMapper.fromException(IllegalStateException()).message,
|
assertEquals(AuthFailureReason.Unexpected, unexpectedFailure.reason)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ espressoCore = "3.6.1"
|
|||||||
espressoIntents = "3.6.1"
|
espressoIntents = "3.6.1"
|
||||||
espressoContrib = "3.6.1"
|
espressoContrib = "3.6.1"
|
||||||
coreSplashscreen = "1.0.1"
|
coreSplashscreen = "1.0.1"
|
||||||
credentials = "1.3.0"
|
|
||||||
coroutines = "1.10.1"
|
coroutines = "1.10.1"
|
||||||
lifecycle = "2.8.7"
|
lifecycle = "2.8.7"
|
||||||
swiperefreshlayout = "1.1.0"
|
swiperefreshlayout = "1.1.0"
|
||||||
@@ -29,8 +28,6 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co
|
|||||||
androidx-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "espressoIntents" }
|
androidx-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "espressoIntents" }
|
||||||
androidx-espresso-contrib = { group = "androidx.test.espresso", name = "espresso-contrib", version.ref = "espressoContrib" }
|
androidx-espresso-contrib = { group = "androidx.test.espresso", name = "espresso-contrib", version.ref = "espressoContrib" }
|
||||||
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" }
|
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" }
|
||||||
androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "credentials" }
|
|
||||||
androidx-credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credentials" }
|
|
||||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
|
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
|
|||||||
Reference in New Issue
Block a user