feat: add splash screen and login auth flow

This commit is contained in:
2026-03-15 19:58:33 -04:00
parent 75e9ad1da2
commit 6ca5c1abaa
24 changed files with 889 additions and 34 deletions

View File

@@ -44,11 +44,16 @@ kotlin {
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.credentials)
implementation(libs.androidx.credentials.play.services.auth)
implementation(libs.material)
implementation(libs.androidx.constraintlayout)
implementation(libs.kotlinx.coroutines.android)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.espresso.intents)
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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" />

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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"
}
}

View File

@@ -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.")
}
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -0,0 +1,7 @@
package space.hackenslacker.kanbn4droid.app.auth
interface SessionStore {
fun getBaseUrl(): String?
fun saveBaseUrl(url: String)
fun clearBaseUrl()
}

View File

@@ -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)
}
}

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)
}
}

View File

@@ -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,
)
}
}

View File

@@ -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)
}
}