refactor: store API keys in app preferences

This commit is contained in:
2026-03-15 21:42:31 -04:00
parent 30f9ac6b98
commit 5016704627
10 changed files with 214 additions and 85 deletions

View File

@@ -24,9 +24,9 @@ import com.google.android.material.textfield.TextInputLayout
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
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.KanbnApiClient
import space.hackenslacker.kanbn4droid.app.auth.PreferencesApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.SessionPreferences
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
import space.hackenslacker.kanbn4droid.app.boards.BoardSummary
@@ -257,7 +257,7 @@ class BoardsActivity : AppCompatActivity() {
protected fun provideApiKeyStore(): ApiKeyStore {
return MainActivity.dependencies.apiKeyStoreFactory?.invoke(this)
?: CredentialManagerApiKeyStore(this)
?: PreferencesApiKeyStore(this)
}
protected fun provideApiClient(): KanbnApiClient {

View File

@@ -19,10 +19,11 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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.CredentialManagerApiKeyStore
import space.hackenslacker.kanbn4droid.app.auth.HttpKanbnApiClient
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.SessionStore
import space.hackenslacker.kanbn4droid.app.auth.UrlNormalizer
@@ -100,15 +101,17 @@ class MainActivity : AppCompatActivity() {
return false
}
return when (apiClient.healthCheck(storedBaseUrl, storedApiKey)) {
return when (val authResult = apiClient.healthCheck(storedBaseUrl, storedApiKey)) {
AuthResult.Success -> {
openBoards()
true
}
is AuthResult.Failure -> {
withContext(Dispatchers.IO) {
apiKeyStore.invalidateApiKey(storedBaseUrl)
if (authResult.reason == AuthFailureReason.Authentication) {
withContext(Dispatchers.IO) {
apiKeyStore.invalidateApiKey(storedBaseUrl)
}
}
false
}
@@ -200,7 +203,7 @@ class MainActivity : AppCompatActivity() {
protected fun provideApiKeyStore(): ApiKeyStore {
return dependencies.apiKeyStoreFactory?.invoke(this)
?: CredentialManagerApiKeyStore(this)
?: PreferencesApiKeyStore(this)
}
protected fun provideApiClient(): KanbnApiClient {

View File

@@ -1,13 +1,6 @@
package space.hackenslacker.kanbn4droid.app.auth
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 {
suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit>
@@ -15,60 +8,33 @@ interface ApiKeyStore {
suspend fun invalidateApiKey(baseUrl: String): Result<Unit>
}
class CredentialManagerApiKeyStore(
private val context: Context,
private val credentialManager: CredentialManager = CredentialManager.create(context),
class PreferencesApiKeyStore(
context: Context,
) : ApiKeyStore {
private val invalidatedPreferences: SharedPreferences =
context.getSharedPreferences(INVALIDATED_PREFS_NAME, Context.MODE_PRIVATE)
private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
return runCatching {
credentialManager.createCredential(
context,
CreatePasswordRequest(id = CREDENTIAL_ID, password = apiKey),
)
markInvalidated(baseUrl, false)
preferences.edit().putString(API_KEY_PREFERENCE_KEY, apiKey).apply()
Unit
}
}
override suspend fun getApiKey(baseUrl: String): Result<String?> {
return runCatching {
if (isInvalidated(baseUrl)) {
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
preferences.getString(API_KEY_PREFERENCE_KEY, null)
}
}
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
return runCatching {
markInvalidated(baseUrl, true)
credentialManager.clearCredentialState(ClearCredentialStateRequest())
preferences.edit().remove(API_KEY_PREFERENCE_KEY).apply()
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 const val INVALIDATED_PREFS_NAME = "kanbn_invalidated_api_keys"
private const val CREDENTIAL_ID = "space.hackenslacker.kanbn4droid.api_key"
private const val PREFERENCES_NAME = "kanbn_api_key_store"
private const val API_KEY_PREFERENCE_KEY = "api_key"
}
}

View File

@@ -1,18 +1,39 @@
package space.hackenslacker.kanbn4droid.app.auth
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
enum class AuthFailureReason {
Authentication,
Connectivity,
Server,
Unexpected,
}
sealed interface AuthResult {
data object Success : AuthResult
data class Failure(val message: String) : AuthResult
data class Failure(
val message: String,
val reason: AuthFailureReason,
) : AuthResult
}
object AuthErrorMapper {
fun fromHttpCode(code: Int): AuthResult.Failure {
return when (code) {
401, 403 -> AuthResult.Failure("Authentication failed. Check your API key.")
else -> AuthResult.Failure("Server error: $code")
401,
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) {
is SocketTimeoutException,
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,
)
}
}
}