diff --git a/AGENTS.md b/AGENTS.md index 5487318..d664b74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2354485..ded7adb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) } diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/ExampleInstrumentedTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/ExampleInstrumentedTest.kt deleted file mode 100644 index 1b9d833..0000000 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/ExampleInstrumentedTest.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt new file mode 100644 index 0000000..39dd69c --- /dev/null +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/LoginFlowTest.kt @@ -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 { + key = apiKey + savedKey = apiKey + return Result.success(Unit) + } + + override suspend fun getApiKey(baseUrl: String): Result = Result.success(key) + + override suspend fun invalidateApiKey(baseUrl: String): Result { + key = null + return Result.success(Unit) + } + } + + private class FakeApiClient( + private val result: AuthResult, + ) : KanbnApiClient { + override suspend fun healthCheck(baseUrl: String, apiKey: String): AuthResult = result + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4b32981..986efec 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,15 +1,22 @@ + + + diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsPlaceholderActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsPlaceholderActivity.kt new file mode 100644 index 0000000..45abfff --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/BoardsPlaceholderActivity.kt @@ -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) + } +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/MainActivity.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/MainActivity.kt index ed69202..36b6f92 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/MainActivity.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/MainActivity.kt @@ -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 } } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/ApiKeyStore.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/ApiKeyStore.kt new file mode 100644 index 0000000..5b1e271 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/ApiKeyStore.kt @@ -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 + suspend fun getApiKey(baseUrl: String): Result + suspend fun invalidateApiKey(baseUrl: String): Result +} + +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 { + return runCatching { + credentialManager.createCredential( + context, + CreatePasswordRequest(id = CREDENTIAL_ID, password = apiKey), + ) + markInvalidated(baseUrl, false) + Unit + } + } + + override suspend fun getApiKey(baseUrl: String): Result { + 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 { + 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" + } +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/AuthResult.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/AuthResult.kt new file mode 100644 index 0000000..2c3fb50 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/AuthResult.kt @@ -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.") + } + } +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt new file mode 100644 index 0000000..18c2c6b --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/KanbnApiClient.kt @@ -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() + } + } + } +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SessionPreferences.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SessionPreferences.kt new file mode 100644 index 0000000..9b24e66 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SessionPreferences.kt @@ -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" + } +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SessionStore.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SessionStore.kt new file mode 100644 index 0000000..de5a57c --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/SessionStore.kt @@ -0,0 +1,7 @@ +package space.hackenslacker.kanbn4droid.app.auth + +interface SessionStore { + fun getBaseUrl(): String? + fun saveBaseUrl(url: String) + fun clearBaseUrl() +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/UrlNormalizer.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/UrlNormalizer.kt new file mode 100644 index 0000000..f495688 --- /dev/null +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/UrlNormalizer.kt @@ -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) + } +} diff --git a/app/src/main/res/drawable/splash_placeholder.xml b/app/src/main/res/drawable/splash_placeholder.xml new file mode 100644 index 0000000..e6bb814 --- /dev/null +++ b/app/src/main/res/drawable/splash_placeholder.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/app/src/main/res/layout/activity_boards_placeholder.xml b/app/src/main/res/layout/activity_boards_placeholder.xml new file mode 100644 index 0000000..d5e6c2a --- /dev/null +++ b/app/src/main/res/layout/activity_boards_placeholder.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 0b9e209..7b55240 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,18 +1,104 @@ + android:layout_height="match_parent" + android:padding="24dp" + android:visibility="invisible"> + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 024366a..88043a4 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -2,4 +2,12 @@ diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml new file mode 100644 index 0000000..402cbda --- /dev/null +++ b/app/src/main/res/values-v31/themes.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 27147ae..1cc60ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,20 @@ Kanbn4Droid + Sign in to your Kan.bn server + Base URL + https://kan.bn/ + https://kan.bn/ + API key + Enter your API key + Sign in + Checking server and signing in... + Boards view coming soon + Base URL is required + Base URL must start with http:// or https:// + Enter a valid server URL + API key is required + Cannot reach server. Check your connection and URL. + Authentication failed. Check your API key. + Unexpected error. Please try again. diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index a7047da..88043a4 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -4,4 +4,10 @@ diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/ExampleUnitTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/ExampleUnitTest.kt deleted file mode 100644 index 8282226..0000000 --- a/app/src/test/java/space/hackenslacker/kanbn4droid/app/ExampleUnitTest.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/AuthResultMappingTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/AuthResultMappingTest.kt new file mode 100644 index 0000000..e866223 --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/AuthResultMappingTest.kt @@ -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, + ) + } +} diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/UrlNormalizerTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/UrlNormalizerTest.kt new file mode 100644 index 0000000..a7f21a5 --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/UrlNormalizerTest.kt @@ -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) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 59f8c1b..aacfbb2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }