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