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

@@ -9,6 +9,9 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
## Dependencies
- AndroidX Preferences library.
- AndroidX SplashScreen library.
- AndroidX Credential Manager library.
- Kotlin coroutines (Android dispatcher).
## Current bootstrap status
@@ -20,8 +23,8 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
- Minimum SDK: API 29.
- Compile/target SDK: API 35.
- Baseline tests:
- JVM unit test in `app/src/test/`.
- Instrumentation smoke test in `app/src/androidTest/`.
- JVM unit tests for auth URL normalization and auth error mapping in `app/src/test/`.
- Instrumentation login flow tests in `app/src/androidTest/`.
## Command-line workflow
@@ -37,6 +40,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
**Splash screen**
- The app displays a standard Android splash screen when open from a cold start.
- Current status: implemented through `Theme.Kanbn4Droid.Splash` with a temporary placeholder image resource at `app/src/main/res/drawable/splash_placeholder.xml`.
**Login view**
- It's the first screen the user sees when opening the app if no login has been successfully stored so far.
@@ -46,6 +50,7 @@ Kanbn4Droid is an unofficial app to connect to and manipulate data stored in sel
- The API key is managed using Android's own Credential Manager.
- 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.
- Current status: implemented in `MainActivity` with XML views and a temporary boards destination (`BoardsPlaceholderActivity`) while the real boards list view is still pending.
**Boards list view**
- Displays a list of boards as rounded-square cards with the board's title centered in it.

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

View File

@@ -8,6 +8,10 @@ constraintlayout = "2.2.1"
junit = "4.13.2"
androidxJunit = "1.2.1"
espressoCore = "3.6.1"
espressoIntents = "3.6.1"
coreSplashscreen = "1.0.1"
credentials = "1.3.0"
coroutines = "1.10.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -17,6 +21,11 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "espressoIntents" }
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" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }