feat: add splash screen and login auth flow
This commit is contained in:
@@ -1,16 +0,0 @@
|
||||
package space.hackenslacker.kanbn4droid.app
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("space.hackenslacker.kanbn4droid.app", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package space.hackenslacker.kanbn4droid.app
|
||||
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
|
||||
import androidx.test.espresso.action.ViewActions.replaceText
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.intent.Intents
|
||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import space.hackenslacker.kanbn4droid.app.auth.ApiKeyStore
|
||||
import space.hackenslacker.kanbn4droid.app.auth.AuthResult
|
||||
import space.hackenslacker.kanbn4droid.app.auth.KanbnApiClient
|
||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LoginFlowTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
MainActivity.dependencies.clear()
|
||||
Intents.init()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Intents.release()
|
||||
MainActivity.dependencies.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsLoginWithDefaultUrlWhenNoStoredSession() {
|
||||
MainActivity.dependencies.sessionStoreFactory = { InMemorySessionStore() }
|
||||
MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore(null) }
|
||||
MainActivity.dependencies.apiClientFactory = { FakeApiClient(AuthResult.Success) }
|
||||
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
|
||||
onView(withId(R.id.baseUrlInput)).check(matches(withText("https://kan.bn/")))
|
||||
onView(withId(R.id.apiKeyInput)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.signInButton)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun autoLoginSuccessSkipsLoginAndNavigatesToBoards() {
|
||||
val sessionStore = InMemorySessionStore("https://kan.bn/")
|
||||
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
|
||||
MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore("kan_key") }
|
||||
MainActivity.dependencies.apiClientFactory = { FakeApiClient(AuthResult.Success) }
|
||||
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
|
||||
Intents.intended(hasComponent(BoardsPlaceholderActivity::class.java.name))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidStoredSessionFallsBackToLoginAndKeepsUrl() {
|
||||
val sessionStore = InMemorySessionStore("https://kan.bn/")
|
||||
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
|
||||
MainActivity.dependencies.apiKeyStoreFactory = { InMemoryApiKeyStore("bad-key") }
|
||||
MainActivity.dependencies.apiClientFactory = {
|
||||
FakeApiClient(AuthResult.Failure("Authentication failed. Check your API key."))
|
||||
}
|
||||
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
|
||||
onView(withId(R.id.baseUrlInput)).check(matches(withText("https://kan.bn/")))
|
||||
onView(withId(R.id.signInButton)).check(matches(isDisplayed()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manualSignInSuccessNavigatesAndStoresSession() {
|
||||
val sessionStore = InMemorySessionStore()
|
||||
val keyStore = InMemoryApiKeyStore(null)
|
||||
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
|
||||
MainActivity.dependencies.apiKeyStoreFactory = { keyStore }
|
||||
MainActivity.dependencies.apiClientFactory = { FakeApiClient(AuthResult.Success) }
|
||||
|
||||
ActivityScenario.launch(MainActivity::class.java)
|
||||
|
||||
onView(withId(R.id.baseUrlInput)).perform(replaceText("https://kan.bn"), closeSoftKeyboard())
|
||||
onView(withId(R.id.apiKeyInput)).perform(replaceText("kan_new"), closeSoftKeyboard())
|
||||
onView(withId(R.id.signInButton)).perform(click())
|
||||
|
||||
Intents.intended(hasComponent(BoardsPlaceholderActivity::class.java.name))
|
||||
assertEquals("https://kan.bn/", sessionStore.getBaseUrl())
|
||||
assertEquals("kan_new", keyStore.savedKey)
|
||||
}
|
||||
|
||||
private class InMemorySessionStore(
|
||||
private var baseUrl: String? = null,
|
||||
) : SessionStore {
|
||||
override fun getBaseUrl(): String? = baseUrl
|
||||
|
||||
override fun saveBaseUrl(url: String) {
|
||||
baseUrl = url
|
||||
}
|
||||
|
||||
override fun clearBaseUrl() {
|
||||
baseUrl = null
|
||||
}
|
||||
}
|
||||
|
||||
private class InMemoryApiKeyStore(
|
||||
private var key: String?,
|
||||
) : ApiKeyStore {
|
||||
var savedKey: String? = null
|
||||
|
||||
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
|
||||
key = apiKey
|
||||
savedKey = apiKey
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(key)
|
||||
|
||||
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
|
||||
key = null
|
||||
return Result.success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeApiClient(
|
||||
private val result: AuthResult,
|
||||
) : KanbnApiClient {
|
||||
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = result
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.Kanbn4Droid">
|
||||
<activity
|
||||
android:name=".BoardsPlaceholderActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:theme="@style/Theme.Kanbn4Droid.Splash"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package space.hackenslacker.kanbn4droid.app
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
||||
class BoardsPlaceholderActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_boards_placeholder)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,230 @@
|
||||
package space.hackenslacker.kanbn4droid.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
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.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.SessionPreferences
|
||||
import space.hackenslacker.kanbn4droid.app.auth.SessionStore
|
||||
import space.hackenslacker.kanbn4droid.app.auth.UrlNormalizer
|
||||
import space.hackenslacker.kanbn4droid.app.auth.UrlValidationResult
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private val shouldKeepSplash = AtomicBoolean(true)
|
||||
private val mainScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
private lateinit var sessionStore: SessionStore
|
||||
private lateinit var apiKeyStore: ApiKeyStore
|
||||
private lateinit var apiClient: KanbnApiClient
|
||||
|
||||
private lateinit var loginContainer: View
|
||||
private lateinit var baseUrlInputLayout: TextInputLayout
|
||||
private lateinit var apiKeyInputLayout: TextInputLayout
|
||||
private lateinit var baseUrlInput: TextInputEditText
|
||||
private lateinit var apiKeyInput: TextInputEditText
|
||||
private lateinit var errorText: TextView
|
||||
private lateinit var statusText: TextView
|
||||
private lateinit var loginProgress: ProgressBar
|
||||
private lateinit var signInButton: MaterialButton
|
||||
|
||||
private var startupJob: Job? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val splashScreen = installSplashScreen()
|
||||
splashScreen.setKeepOnScreenCondition { shouldKeepSplash.get() }
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
sessionStore = provideSessionStore()
|
||||
apiKeyStore = provideApiKeyStore()
|
||||
apiClient = provideApiClient()
|
||||
|
||||
bindViews()
|
||||
setLoginEnabled(false)
|
||||
statusText.visibility = View.VISIBLE
|
||||
loginProgress.visibility = View.VISIBLE
|
||||
|
||||
startupJob = mainScope.launch {
|
||||
val autoLoginSucceeded = attemptAutoLogin()
|
||||
shouldKeepSplash.set(false)
|
||||
if (!autoLoginSucceeded) {
|
||||
showLoginView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindViews() {
|
||||
loginContainer = findViewById(R.id.loginContainer)
|
||||
baseUrlInputLayout = findViewById(R.id.baseUrlInputLayout)
|
||||
apiKeyInputLayout = findViewById(R.id.apiKeyInputLayout)
|
||||
baseUrlInput = findViewById(R.id.baseUrlInput)
|
||||
apiKeyInput = findViewById(R.id.apiKeyInput)
|
||||
errorText = findViewById(R.id.errorText)
|
||||
statusText = findViewById(R.id.statusText)
|
||||
loginProgress = findViewById(R.id.loginProgress)
|
||||
signInButton = findViewById(R.id.signInButton)
|
||||
|
||||
signInButton.setOnClickListener {
|
||||
submitLogin()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun attemptAutoLogin(): Boolean {
|
||||
val storedBaseUrl = sessionStore.getBaseUrl() ?: return false
|
||||
val keyResult = withContext(Dispatchers.IO) {
|
||||
apiKeyStore.getApiKey(storedBaseUrl)
|
||||
}
|
||||
|
||||
val storedApiKey = keyResult.getOrNull().orEmpty()
|
||||
if (storedApiKey.isBlank()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return when (apiClient.healthCheck(storedBaseUrl, storedApiKey)) {
|
||||
AuthResult.Success -> {
|
||||
openBoardsPlaceholder()
|
||||
true
|
||||
}
|
||||
|
||||
is AuthResult.Failure -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
apiKeyStore.invalidateApiKey(storedBaseUrl)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLoginView() {
|
||||
loginContainer.visibility = View.VISIBLE
|
||||
baseUrlInput.setText(sessionStore.getBaseUrl() ?: getString(R.string.default_base_url))
|
||||
apiKeyInput.setText("")
|
||||
statusText.visibility = View.GONE
|
||||
loginProgress.visibility = View.GONE
|
||||
clearErrors()
|
||||
setLoginEnabled(true)
|
||||
}
|
||||
|
||||
private fun submitLogin() {
|
||||
clearErrors()
|
||||
|
||||
val rawBaseUrl = baseUrlInput.text?.toString().orEmpty()
|
||||
val baseUrlValidation = UrlNormalizer.normalize(rawBaseUrl)
|
||||
if (baseUrlValidation is UrlValidationResult.Invalid) {
|
||||
baseUrlInputLayout.error = baseUrlValidation.message
|
||||
return
|
||||
}
|
||||
|
||||
val apiKey = apiKeyInput.text?.toString().orEmpty()
|
||||
if (apiKey.isBlank()) {
|
||||
apiKeyInputLayout.error = getString(R.string.api_key_required)
|
||||
return
|
||||
}
|
||||
|
||||
val normalizedBaseUrl = (baseUrlValidation as UrlValidationResult.Valid).normalizedUrl
|
||||
setLoginEnabled(false)
|
||||
errorText.visibility = View.GONE
|
||||
statusText.visibility = View.VISIBLE
|
||||
loginProgress.visibility = View.VISIBLE
|
||||
|
||||
startupJob?.cancel()
|
||||
startupJob = mainScope.launch {
|
||||
when (val authResult = apiClient.healthCheck(normalizedBaseUrl, apiKey)) {
|
||||
AuthResult.Success -> {
|
||||
val saveKeyResult = withContext(Dispatchers.IO) {
|
||||
apiKeyStore.saveApiKey(normalizedBaseUrl, apiKey)
|
||||
}
|
||||
if (saveKeyResult.isSuccess) {
|
||||
sessionStore.saveBaseUrl(normalizedBaseUrl)
|
||||
openBoardsPlaceholder()
|
||||
} else {
|
||||
loginProgress.visibility = View.GONE
|
||||
statusText.visibility = View.GONE
|
||||
errorText.text = getString(R.string.unexpected_error)
|
||||
errorText.visibility = View.VISIBLE
|
||||
setLoginEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
is AuthResult.Failure -> {
|
||||
loginProgress.visibility = View.GONE
|
||||
statusText.visibility = View.GONE
|
||||
errorText.text = authResult.message
|
||||
errorText.visibility = View.VISIBLE
|
||||
setLoginEnabled(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearErrors() {
|
||||
baseUrlInputLayout.error = null
|
||||
apiKeyInputLayout.error = null
|
||||
errorText.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun setLoginEnabled(enabled: Boolean) {
|
||||
baseUrlInputLayout.isEnabled = enabled
|
||||
apiKeyInputLayout.isEnabled = enabled
|
||||
signInButton.isEnabled = enabled
|
||||
}
|
||||
|
||||
private fun openBoardsPlaceholder() {
|
||||
startActivity(Intent(this, BoardsPlaceholderActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
|
||||
protected open fun provideSessionStore(): SessionStore {
|
||||
return dependencies.sessionStoreFactory?.invoke(this) ?: SessionPreferences(applicationContext)
|
||||
}
|
||||
|
||||
protected open fun provideApiKeyStore(): ApiKeyStore {
|
||||
return dependencies.apiKeyStoreFactory?.invoke(this)
|
||||
?: CredentialManagerApiKeyStore(this)
|
||||
}
|
||||
|
||||
protected open fun provideApiClient(): KanbnApiClient {
|
||||
return dependencies.apiClientFactory?.invoke() ?: HttpKanbnApiClient()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val dependencies = TestDependencies()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
mainScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
class TestDependencies {
|
||||
var sessionStoreFactory: ((AppCompatActivity) -> SessionStore)? = null
|
||||
var apiKeyStoreFactory: ((AppCompatActivity) -> ApiKeyStore)? = null
|
||||
var apiClientFactory: (() -> KanbnApiClient)? = null
|
||||
|
||||
fun clear() {
|
||||
sessionStoreFactory = null
|
||||
apiKeyStoreFactory = null
|
||||
apiClientFactory = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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>
|
||||
suspend fun getApiKey(baseUrl: String): Result<String?>
|
||||
suspend fun invalidateApiKey(baseUrl: String): Result<Unit>
|
||||
}
|
||||
|
||||
class CredentialManagerApiKeyStore(
|
||||
private val context: Context,
|
||||
private val credentialManager: CredentialManager = CredentialManager.create(context),
|
||||
) : ApiKeyStore {
|
||||
private val invalidatedPreferences: SharedPreferences =
|
||||
context.getSharedPreferences(INVALIDATED_PREFS_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)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
|
||||
return runCatching {
|
||||
markInvalidated(baseUrl, true)
|
||||
credentialManager.clearCredentialState(ClearCredentialStateRequest())
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package space.hackenslacker.kanbn4droid.app.auth
|
||||
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
|
||||
sealed interface AuthResult {
|
||||
data object Success : AuthResult
|
||||
data class Failure(val message: String) : 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")
|
||||
}
|
||||
}
|
||||
|
||||
fun fromException(throwable: Throwable): AuthResult.Failure {
|
||||
return when (throwable) {
|
||||
is SocketTimeoutException,
|
||||
is UnknownHostException,
|
||||
-> AuthResult.Failure("Cannot reach server. Check your connection and URL.")
|
||||
|
||||
else -> AuthResult.Failure("Unexpected error. Please try again.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package space.hackenslacker.kanbn4droid.app.auth
|
||||
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
interface KanbnApiClient {
|
||||
suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult
|
||||
}
|
||||
|
||||
class HttpKanbnApiClient : KanbnApiClient {
|
||||
|
||||
override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val endpoint = "${baseUrl.trimEnd('/')}/api/v1/health"
|
||||
val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
connectTimeout = 10_000
|
||||
readTimeout = 10_000
|
||||
setRequestProperty("x-api-key", apiKey)
|
||||
}
|
||||
|
||||
try {
|
||||
val code = connection.responseCode
|
||||
if (code in 200..299) {
|
||||
AuthResult.Success
|
||||
} else {
|
||||
AuthErrorMapper.fromHttpCode(code)
|
||||
}
|
||||
} catch (throwable: Throwable) {
|
||||
AuthErrorMapper.fromException(throwable)
|
||||
} finally {
|
||||
try {
|
||||
connection.inputStream?.close()
|
||||
} catch (_: IOException) {
|
||||
}
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package space.hackenslacker.kanbn4droid.app.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
class SessionPreferences(context: Context) : SessionStore {
|
||||
private val preferences: SharedPreferences =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
override fun getBaseUrl(): String? = preferences.getString(KEY_BASE_URL, null)
|
||||
|
||||
override fun saveBaseUrl(url: String) {
|
||||
preferences.edit().putString(KEY_BASE_URL, url).apply()
|
||||
}
|
||||
|
||||
override fun clearBaseUrl() {
|
||||
preferences.edit().remove(KEY_BASE_URL).apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val PREFS_NAME = "kanbn_session"
|
||||
private const val KEY_BASE_URL = "base_url"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package space.hackenslacker.kanbn4droid.app.auth
|
||||
|
||||
interface SessionStore {
|
||||
fun getBaseUrl(): String?
|
||||
fun saveBaseUrl(url: String)
|
||||
fun clearBaseUrl()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package space.hackenslacker.kanbn4droid.app.auth
|
||||
|
||||
import java.net.URI
|
||||
|
||||
sealed interface UrlValidationResult {
|
||||
data class Valid(val normalizedUrl: String) : UrlValidationResult
|
||||
data class Invalid(val message: String) : UrlValidationResult
|
||||
}
|
||||
|
||||
object UrlNormalizer {
|
||||
fun normalize(rawUrl: String): UrlValidationResult {
|
||||
val trimmed = rawUrl.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
return UrlValidationResult.Invalid("Base URL is required")
|
||||
}
|
||||
|
||||
val uri = try {
|
||||
URI(trimmed)
|
||||
} catch (_: Exception) {
|
||||
return UrlValidationResult.Invalid("Enter a valid server URL")
|
||||
}
|
||||
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
?: return UrlValidationResult.Invalid("Base URL must start with http:// or https://")
|
||||
if (scheme != "http" && scheme != "https") {
|
||||
return UrlValidationResult.Invalid("Base URL must start with http:// or https://")
|
||||
}
|
||||
|
||||
val host = uri.host
|
||||
if (host.isNullOrBlank()) {
|
||||
return UrlValidationResult.Invalid("Enter a valid server URL")
|
||||
}
|
||||
|
||||
if (uri.port > 65535) {
|
||||
return UrlValidationResult.Invalid("Enter a valid server URL")
|
||||
}
|
||||
|
||||
val normalized = buildString {
|
||||
append(scheme)
|
||||
append("://")
|
||||
append(host)
|
||||
if (uri.port != -1) {
|
||||
append(":")
|
||||
append(uri.port)
|
||||
}
|
||||
val path = uri.path ?: ""
|
||||
if (path.isNotBlank() && path != "/") {
|
||||
append(path.trimEnd('/'))
|
||||
}
|
||||
append('/')
|
||||
}
|
||||
|
||||
return UrlValidationResult.Valid(normalized)
|
||||
}
|
||||
}
|
||||
15
app/src/main/res/drawable/splash_placeholder.xml
Normal file
15
app/src/main/res/drawable/splash_placeholder.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<path
|
||||
android:fillColor="#1D5D9B"
|
||||
android:pathData="M16,16h76a8,8 0 0 1 8,8v60a8,8 0 0 1 -8,8h-76a8,8 0 0 1 -8,-8v-60a8,8 0 0 1 8,-8z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M33,34h42v8h-42zM33,50h30v8h-30zM33,66h36v8h-36z" />
|
||||
|
||||
</vector>
|
||||
19
app/src/main/res/layout/activity_boards_placeholder.xml
Normal file
19
app/src/main/res/layout/activity_boards_placeholder.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="24dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/boardsPlaceholderText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/boards_placeholder"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,18 +1,104 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/loginContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
android:layout_height="match_parent"
|
||||
android:padding="24dp"
|
||||
android:visibility="invisible">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleText"
|
||||
android:layout_width="wrap_content"
|
||||
android:id="@+id/loginTitleText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_name"
|
||||
android:text="@string/login_title"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/baseUrlInputLayout"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:hint="@string/base_url_label"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/loginTitleText">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/baseUrlInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:autofillHints="url"
|
||||
android:hint="@string/base_url_hint"
|
||||
android:inputType="textUri"
|
||||
android:maxLines="1" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/apiKeyInputLayout"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:hint="@string/api_key_label"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/baseUrlInputLayout">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/apiKeyInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:autofillHints="password"
|
||||
android:hint="@string/api_key_hint"
|
||||
android:inputType="textPassword"
|
||||
android:maxLines="1" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/errorText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:textColor="?attr/colorError"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/apiKeyInputLayout" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusText"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/logging_in"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/errorText" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/loginProgress"
|
||||
style="@style/Widget.AppCompat.ProgressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/statusText" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/signInButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/sign_in"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/loginProgress" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
@@ -2,4 +2,12 @@
|
||||
<resources>
|
||||
|
||||
<style name="Base.Theme.Kanbn4Droid" parent="Theme.MaterialComponents.DayNight.NoActionBar" />
|
||||
|
||||
<style name="Theme.Kanbn4Droid" parent="Base.Theme.Kanbn4Droid" />
|
||||
|
||||
<style name="Theme.Kanbn4Droid.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_placeholder</item>
|
||||
<item name="windowSplashScreenBackground">?attr/colorPrimary</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.Kanbn4Droid</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
10
app/src/main/res/values-v31/themes.xml
Normal file
10
app/src/main/res/values-v31/themes.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Kanbn4Droid.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_placeholder</item>
|
||||
<item name="windowSplashScreenBackground">?attr/colorPrimary</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.Kanbn4Droid</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
@@ -1,4 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Kanbn4Droid</string>
|
||||
<string name="login_title">Sign in to your Kan.bn server</string>
|
||||
<string name="base_url_label">Base URL</string>
|
||||
<string name="base_url_hint">https://kan.bn/</string>
|
||||
<string name="default_base_url">https://kan.bn/</string>
|
||||
<string name="api_key_label">API key</string>
|
||||
<string name="api_key_hint">Enter your API key</string>
|
||||
<string name="sign_in">Sign in</string>
|
||||
<string name="logging_in">Checking server and signing in...</string>
|
||||
<string name="boards_placeholder">Boards view coming soon</string>
|
||||
<string name="base_url_required">Base URL is required</string>
|
||||
<string name="base_url_scheme_error">Base URL must start with http:// or https://</string>
|
||||
<string name="base_url_invalid">Enter a valid server URL</string>
|
||||
<string name="api_key_required">API key is required</string>
|
||||
<string name="network_unreachable">Cannot reach server. Check your connection and URL.</string>
|
||||
<string name="auth_failed">Authentication failed. Check your API key.</string>
|
||||
<string name="unexpected_error">Unexpected error. Please try again.</string>
|
||||
</resources>
|
||||
|
||||
@@ -4,4 +4,10 @@
|
||||
<style name="Base.Theme.Kanbn4Droid" parent="Theme.MaterialComponents.DayNight.NoActionBar" />
|
||||
|
||||
<style name="Theme.Kanbn4Droid" parent="Base.Theme.Kanbn4Droid" />
|
||||
|
||||
<style name="Theme.Kanbn4Droid.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_placeholder</item>
|
||||
<item name="windowSplashScreenBackground">?attr/colorPrimary</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.Kanbn4Droid</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package space.hackenslacker.kanbn4droid.app
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun additionIsCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package space.hackenslacker.kanbn4droid.app.auth
|
||||
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class AuthResultMappingTest {
|
||||
|
||||
@Test
|
||||
fun mapsUnauthorizedCodesToAuthFailureMessage() {
|
||||
assertEquals(
|
||||
"Authentication failed. Check your API key.",
|
||||
AuthErrorMapper.fromHttpCode(401).message,
|
||||
)
|
||||
assertEquals(
|
||||
"Authentication failed. Check your API key.",
|
||||
AuthErrorMapper.fromHttpCode(403).message,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsTimeoutAndUnknownHostToConnectivityMessage() {
|
||||
assertEquals(
|
||||
"Cannot reach server. Check your connection and URL.",
|
||||
AuthErrorMapper.fromException(SocketTimeoutException()).message,
|
||||
)
|
||||
assertEquals(
|
||||
"Cannot reach server. Check your connection and URL.",
|
||||
AuthErrorMapper.fromException(UnknownHostException()).message,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsOtherHttpCodesToServerCodeMessage() {
|
||||
assertEquals("Server error: 500", AuthErrorMapper.fromHttpCode(500).message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsUnexpectedExceptionsToGenericMessage() {
|
||||
assertEquals(
|
||||
"Unexpected error. Please try again.",
|
||||
AuthErrorMapper.fromException(IllegalStateException()).message,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package space.hackenslacker.kanbn4droid.app.auth
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class UrlNormalizerTest {
|
||||
|
||||
@Test
|
||||
fun defaultUrlRemainsStandardKanAddress() {
|
||||
val result = UrlNormalizer.normalize("https://kan.bn/")
|
||||
|
||||
assertTrue(result is UrlValidationResult.Valid)
|
||||
assertEquals("https://kan.bn/", (result as UrlValidationResult.Valid).normalizedUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsHttpOrHttpsWithPort() {
|
||||
val httpsResult = UrlNormalizer.normalize("https://example.com:8080")
|
||||
val httpResult = UrlNormalizer.normalize("http://example.com")
|
||||
|
||||
assertEquals(
|
||||
"https://example.com:8080/",
|
||||
(httpsResult as UrlValidationResult.Valid).normalizedUrl,
|
||||
)
|
||||
assertEquals("http://example.com/", (httpResult as UrlValidationResult.Valid).normalizedUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsUnsupportedScheme() {
|
||||
val result = UrlNormalizer.normalize("ftp://kan.bn")
|
||||
|
||||
assertEquals(
|
||||
"Base URL must start with http:// or https://",
|
||||
(result as UrlValidationResult.Invalid).message,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsMalformedHost() {
|
||||
val result = UrlNormalizer.normalize("https://")
|
||||
|
||||
assertEquals("Enter a valid server URL", (result as UrlValidationResult.Invalid).message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trimsSpacesAndEnsuresTrailingSlash() {
|
||||
val result = UrlNormalizer.normalize(" https://kan.bn/api/v1 ")
|
||||
|
||||
assertEquals("https://kan.bn/api/v1/", (result as UrlValidationResult.Valid).normalizedUrl)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user