Implement Google->Firebase->Backend auth flow
- Add authGoogleNew GraphQL mutation and token exchange in AuthServiceImpl - Add ApiTokenStore and SharedPreferencesApiTokenStore for API token persistence - Add ApolloAuthInterceptor to inject Bearer token on GraphQL requests - Introduce FirebaseSessionManager for testable Firebase auth orchestration - Update LoginViewModel to surface backend auth errors - Add unit tests for Firebase failure, backend failure, and auth state Made-with: Cursor
This commit is contained in:
@@ -78,7 +78,12 @@ class LoginViewModel @Inject constructor(
|
|||||||
if (signInResult.isSuccess) {
|
if (signInResult.isSuccess) {
|
||||||
_actions.tryEmit(Action.OpenManage)
|
_actions.tryEmit(Action.OpenManage)
|
||||||
} else {
|
} else {
|
||||||
_actions.tryEmit(Action.ShowMessage(appContext.getString(R.string.toast_oauth_failed)))
|
val message = signInResult.exceptionOrNull()?.message
|
||||||
|
_actions.tryEmit(
|
||||||
|
Action.ShowMessage(
|
||||||
|
message ?: appContext.getString(R.string.toast_oauth_failed)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ ksp = "2.0.21-1.0.27"
|
|||||||
google-services = "4.4.4"
|
google-services = "4.4.4"
|
||||||
androidxCredentials = "1.5.0"
|
androidxCredentials = "1.5.0"
|
||||||
googleid = "1.1.1"
|
googleid = "1.1.1"
|
||||||
|
kotlinxCoroutinesTest = "1.9.0"
|
||||||
|
mockk = "1.13.13"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -55,6 +57,8 @@ androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navig
|
|||||||
androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "androidxCredentials" }
|
androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "androidxCredentials" }
|
||||||
androidx-credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "androidxCredentials" }
|
androidx-credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "androidxCredentials" }
|
||||||
googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" }
|
googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" }
|
||||||
|
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
|
||||||
|
mockk-jvm = { group = "io.mockk", name = "mockk-jvm", version.ref = "mockk" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@@ -62,4 +62,6 @@ dependencies {
|
|||||||
ksp(libs.hilt.compiler)
|
ksp(libs.hilt.compiler)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
testImplementation(libs.mockk.jvm)
|
||||||
}
|
}
|
||||||
|
|||||||
10
sdk/src/main/graphql/AuthGoogleNew.graphql
Normal file
10
sdk/src/main/graphql/AuthGoogleNew.graphql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
mutation AuthGoogleNew($accessToken: String!) {
|
||||||
|
authGoogleNew(input: { accessToken: $accessToken }) {
|
||||||
|
token
|
||||||
|
name
|
||||||
|
email
|
||||||
|
roles
|
||||||
|
_id
|
||||||
|
regDate
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package org.db3.airmq.sdk.auth
|
||||||
|
|
||||||
|
interface ApiTokenStore {
|
||||||
|
fun getToken(): String?
|
||||||
|
fun saveToken(token: String): Result<Unit>
|
||||||
|
fun clearToken(): Result<Unit>
|
||||||
|
}
|
||||||
@@ -1,25 +1,26 @@
|
|||||||
package org.db3.airmq.sdk.auth
|
package org.db3.airmq.sdk.auth
|
||||||
|
|
||||||
import com.google.android.gms.tasks.Task
|
import com.apollographql.apollo.ApolloClient
|
||||||
import com.google.firebase.auth.FirebaseAuth
|
|
||||||
import com.google.firebase.auth.FirebaseUser
|
|
||||||
import com.google.firebase.auth.GoogleAuthProvider
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.coroutines.resume
|
import org.db3.airmq.sdk.AuthGoogleNewMutation
|
||||||
import kotlin.coroutines.resumeWithException
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import org.db3.airmq.sdk.auth.model.AuthProvider
|
import org.db3.airmq.sdk.auth.model.AuthProvider
|
||||||
import org.db3.airmq.sdk.auth.model.User
|
import org.db3.airmq.sdk.auth.model.User
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class FirebaseAuthService @Inject constructor(
|
class FirebaseAuthService @Inject constructor(
|
||||||
private val firebaseAuth: FirebaseAuth
|
private val firebaseSessionManager: FirebaseSessionManager,
|
||||||
|
private val apolloClient: ApolloClient,
|
||||||
|
private val apiTokenStore: ApiTokenStore
|
||||||
) : AuthService {
|
) : AuthService {
|
||||||
|
|
||||||
override suspend fun getUser(): User? = firebaseAuth.currentUser?.toUser()
|
override suspend fun getUser(): User? = firebaseSessionManager.getUser()
|
||||||
|
|
||||||
override suspend fun isAuthenticated(): Boolean = firebaseAuth.currentUser != null
|
override suspend fun isAuthenticated(): Boolean {
|
||||||
|
val hasFirebaseUser = firebaseSessionManager.isSignedIn()
|
||||||
|
val hasApiToken = !apiTokenStore.getToken().isNullOrBlank()
|
||||||
|
return hasFirebaseUser && hasApiToken
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun signIn(provider: AuthProvider, token: String): Result<User> = runCatching {
|
override suspend fun signIn(provider: AuthProvider, token: String): Result<User> = runCatching {
|
||||||
require(token.isNotBlank()) { "ID token is required for Google sign-in." }
|
require(token.isNotBlank()) { "ID token is required for Google sign-in." }
|
||||||
@@ -29,32 +30,26 @@ class FirebaseAuthService @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun signOut(): Result<Unit> = runCatching {
|
override suspend fun signOut(): Result<Unit> = runCatching {
|
||||||
firebaseAuth.signOut()
|
firebaseSessionManager.signOut()
|
||||||
|
apiTokenStore.clearToken().getOrThrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun signInWithGoogle(token: String): User {
|
private suspend fun signInWithGoogle(token: String): User {
|
||||||
val credential = GoogleAuthProvider.getCredential(token, null)
|
val firebaseSessionUser = firebaseSessionManager.signInWithGoogle(token)
|
||||||
val authResult = firebaseAuth.signInWithCredential(credential).awaitResult()
|
val apiToken = exchangeFirebaseTokenWithBackend(firebaseSessionUser.firebaseAccessToken)
|
||||||
val firebaseUser = authResult.user ?: error("Google sign-in completed without a Firebase user.")
|
apiTokenStore.saveToken(apiToken).getOrThrow()
|
||||||
return firebaseUser.toUser()
|
return firebaseSessionUser.user
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun FirebaseUser.toUser(): User = User(
|
private suspend fun exchangeFirebaseTokenWithBackend(firebaseAccessToken: String): String {
|
||||||
userId = uid,
|
val response = apolloClient
|
||||||
email = email,
|
.mutation(AuthGoogleNewMutation(accessToken = firebaseAccessToken))
|
||||||
displayName = displayName,
|
.execute()
|
||||||
isAuthenticated = true
|
response.exception?.let { throw it }
|
||||||
)
|
response.errors?.firstOrNull()?.let { gqlError ->
|
||||||
}
|
throw IllegalStateException(gqlError.message)
|
||||||
|
}
|
||||||
private suspend fun <T> Task<T>.awaitResult(): T = suspendCancellableCoroutine { continuation ->
|
return response.data?.authGoogleNew?.token
|
||||||
addOnSuccessListener { result ->
|
?: error("Backend auth exchange succeeded without API token.")
|
||||||
continuation.resume(result)
|
|
||||||
}
|
|
||||||
addOnFailureListener { exception ->
|
|
||||||
continuation.resumeWithException(exception)
|
|
||||||
}
|
|
||||||
addOnCanceledListener {
|
|
||||||
continuation.cancel()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package org.db3.airmq.sdk.auth
|
||||||
|
|
||||||
|
import org.db3.airmq.sdk.auth.model.User
|
||||||
|
|
||||||
|
interface FirebaseSessionManager {
|
||||||
|
suspend fun getUser(): User?
|
||||||
|
suspend fun isSignedIn(): Boolean
|
||||||
|
suspend fun signInWithGoogle(idToken: String): FirebaseSessionUser
|
||||||
|
suspend fun signOut()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class FirebaseSessionUser(
|
||||||
|
val user: User,
|
||||||
|
val firebaseAccessToken: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package org.db3.airmq.sdk.auth
|
||||||
|
|
||||||
|
import com.google.android.gms.tasks.Task
|
||||||
|
import com.google.firebase.auth.FirebaseAuth
|
||||||
|
import com.google.firebase.auth.FirebaseUser
|
||||||
|
import com.google.firebase.auth.GoogleAuthProvider
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import org.db3.airmq.sdk.auth.model.User
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class FirebaseSessionManagerImpl @Inject constructor(
|
||||||
|
private val firebaseAuth: FirebaseAuth
|
||||||
|
) : FirebaseSessionManager {
|
||||||
|
|
||||||
|
override suspend fun getUser(): User? = firebaseAuth.currentUser?.toUser()
|
||||||
|
|
||||||
|
override suspend fun isSignedIn(): Boolean = firebaseAuth.currentUser != null
|
||||||
|
|
||||||
|
override suspend fun signInWithGoogle(idToken: String): FirebaseSessionUser {
|
||||||
|
val credential = GoogleAuthProvider.getCredential(idToken, null)
|
||||||
|
val authResult = firebaseAuth.signInWithCredential(credential).awaitResult()
|
||||||
|
val firebaseUser = authResult.user ?: error("Google sign-in completed without a Firebase user.")
|
||||||
|
val firebaseAccessToken = firebaseUser.getIdToken(true).awaitResult().token
|
||||||
|
?: error("Firebase token exchange completed without an ID token.")
|
||||||
|
return FirebaseSessionUser(
|
||||||
|
user = firebaseUser.toUser(),
|
||||||
|
firebaseAccessToken = firebaseAccessToken
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun signOut() {
|
||||||
|
firebaseAuth.signOut()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun FirebaseUser.toUser(): User = User(
|
||||||
|
userId = uid,
|
||||||
|
email = email,
|
||||||
|
displayName = displayName,
|
||||||
|
isAuthenticated = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> Task<T>.awaitResult(): T = suspendCancellableCoroutine { continuation ->
|
||||||
|
addOnSuccessListener { result ->
|
||||||
|
continuation.resume(result)
|
||||||
|
}
|
||||||
|
addOnFailureListener { exception ->
|
||||||
|
continuation.resumeWithException(exception)
|
||||||
|
}
|
||||||
|
addOnCanceledListener {
|
||||||
|
continuation.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package org.db3.airmq.sdk.auth
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SharedPreferencesApiTokenStore @Inject constructor(
|
||||||
|
@ApplicationContext context: Context
|
||||||
|
) : ApiTokenStore {
|
||||||
|
|
||||||
|
private val sharedPreferences =
|
||||||
|
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
override fun getToken(): String? = sharedPreferences.getString(KEY_API_TOKEN, null)
|
||||||
|
|
||||||
|
override fun saveToken(token: String): Result<Unit> = runCatching {
|
||||||
|
require(token.isNotBlank()) { "API token cannot be blank." }
|
||||||
|
sharedPreferences.edit().putString(KEY_API_TOKEN, token).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearToken(): Result<Unit> = runCatching {
|
||||||
|
sharedPreferences.edit().remove(KEY_API_TOKEN).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val PREFERENCES_NAME = "airmq_auth"
|
||||||
|
private const val KEY_API_TOKEN = "api_token"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,6 @@ import dagger.hilt.components.SingletonComponent
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import org.db3.airmq.sdk.auth.ApiTokenStore
|
import org.db3.airmq.sdk.auth.ApiTokenStore
|
||||||
import org.db3.airmq.sdk.auth.AuthService
|
import org.db3.airmq.sdk.auth.AuthService
|
||||||
import org.db3.airmq.sdk.auth.BackendAuthGateway
|
|
||||||
import org.db3.airmq.sdk.auth.BackendAuthGatewayImpl
|
|
||||||
import org.db3.airmq.sdk.auth.FirebaseAuthService
|
import org.db3.airmq.sdk.auth.FirebaseAuthService
|
||||||
import org.db3.airmq.sdk.auth.FirebaseSessionManager
|
import org.db3.airmq.sdk.auth.FirebaseSessionManager
|
||||||
import org.db3.airmq.sdk.auth.FirebaseSessionManagerImpl
|
import org.db3.airmq.sdk.auth.FirebaseSessionManagerImpl
|
||||||
@@ -58,10 +56,6 @@ abstract class SDKBindModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindFirebaseSessionManager(impl: FirebaseSessionManagerImpl): FirebaseSessionManager
|
abstract fun bindFirebaseSessionManager(impl: FirebaseSessionManagerImpl): FirebaseSessionManager
|
||||||
|
|
||||||
@Binds
|
|
||||||
@Singleton
|
|
||||||
abstract fun bindBackendAuthGateway(impl: BackendAuthGatewayImpl): BackendAuthGateway
|
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindMapService(impl: MapServiceImpl): MapService
|
abstract fun bindMapService(impl: MapServiceImpl): MapService
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package org.db3.airmq.sdk.network
|
||||||
|
|
||||||
|
import com.apollographql.apollo.api.ApolloRequest
|
||||||
|
import com.apollographql.apollo.api.ApolloResponse
|
||||||
|
import com.apollographql.apollo.api.Operation
|
||||||
|
import com.apollographql.apollo.interceptor.ApolloInterceptor
|
||||||
|
import com.apollographql.apollo.interceptor.ApolloInterceptorChain
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import org.db3.airmq.sdk.auth.ApiTokenStore
|
||||||
|
|
||||||
|
class ApolloAuthInterceptor(
|
||||||
|
private val apiTokenStore: ApiTokenStore
|
||||||
|
) : ApolloInterceptor {
|
||||||
|
override fun <D : Operation.Data> intercept(
|
||||||
|
request: ApolloRequest<D>,
|
||||||
|
chain: ApolloInterceptorChain
|
||||||
|
): Flow<ApolloResponse<D>> {
|
||||||
|
val token = apiTokenStore.getToken()
|
||||||
|
val requestWithAuth = if (token.isNullOrBlank()) {
|
||||||
|
request
|
||||||
|
} else {
|
||||||
|
request.newBuilder()
|
||||||
|
.addHttpHeader(name = AUTH_HEADER, value = "$BEARER_PREFIX $token")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
return chain.proceed(requestWithAuth)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val AUTH_HEADER = "Authorization"
|
||||||
|
private const val BEARER_PREFIX = "Bearer"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package org.db3.airmq.sdk.auth
|
||||||
|
|
||||||
|
import com.apollographql.apollo.ApolloClient
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.db3.airmq.sdk.AuthGoogleNewMutation
|
||||||
|
import org.db3.airmq.sdk.auth.model.AuthProvider
|
||||||
|
import org.db3.airmq.sdk.auth.model.User
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FirebaseAuthServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun signIn_google_failsWhenFirebaseSignInFails() = runTest {
|
||||||
|
val sessionManager = FakeFirebaseSessionManager(signInError = IllegalStateException("firebase failed"))
|
||||||
|
val apolloClient = mockk<ApolloClient>()
|
||||||
|
val tokenStore = FakeApiTokenStore()
|
||||||
|
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore)
|
||||||
|
|
||||||
|
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
|
||||||
|
|
||||||
|
assertTrue(result.isFailure)
|
||||||
|
assertEquals("firebase failed", result.exceptionOrNull()?.message)
|
||||||
|
assertEquals(null, tokenStore.storedToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun signIn_google_failsWhenBackendExchangeFails() = runTest {
|
||||||
|
val sessionManager = FakeFirebaseSessionManager(
|
||||||
|
signInResult = FirebaseSessionUser(
|
||||||
|
user = User("uid-1", "user@airmq.cc", "User", true),
|
||||||
|
firebaseAccessToken = "firebase-token"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val apolloClient = mockk<ApolloClient>()
|
||||||
|
val tokenStore = FakeApiTokenStore()
|
||||||
|
mockAuthGoogleNewError(apolloClient, IllegalStateException("backend failed"))
|
||||||
|
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore)
|
||||||
|
|
||||||
|
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
|
||||||
|
|
||||||
|
assertTrue(result.isFailure)
|
||||||
|
assertEquals("backend failed", result.exceptionOrNull()?.message)
|
||||||
|
assertEquals(null, tokenStore.storedToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isAuthenticated_trueOnlyWhenFirebaseAndApiTokenPresent() = runTest {
|
||||||
|
val sessionManager = FakeFirebaseSessionManager(isSignedIn = true)
|
||||||
|
val apolloClient = mockk<ApolloClient>()
|
||||||
|
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
||||||
|
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore)
|
||||||
|
|
||||||
|
assertTrue(service.isAuthenticated())
|
||||||
|
|
||||||
|
tokenStore.storedToken = null
|
||||||
|
assertFalse(service.isAuthenticated())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mockAuthGoogleNewError(apolloClient: ApolloClient, error: Throwable) {
|
||||||
|
val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleNewMutation.Data>>()
|
||||||
|
every { apolloClient.mutation(any<AuthGoogleNewMutation>()) } returns mockCall
|
||||||
|
coEvery { mockCall.execute() } throws error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeFirebaseSessionManager(
|
||||||
|
private val signInResult: FirebaseSessionUser? = null,
|
||||||
|
private val signInError: Throwable? = null,
|
||||||
|
private val isSignedIn: Boolean = true
|
||||||
|
) : FirebaseSessionManager {
|
||||||
|
override suspend fun getUser(): User? = signInResult?.user
|
||||||
|
|
||||||
|
override suspend fun isSignedIn(): Boolean = isSignedIn
|
||||||
|
|
||||||
|
override suspend fun signInWithGoogle(idToken: String): FirebaseSessionUser {
|
||||||
|
signInError?.let { throw it }
|
||||||
|
return signInResult ?: error("No signInResult configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun signOut() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeApiTokenStore(
|
||||||
|
var storedToken: String? = null,
|
||||||
|
private val saveError: Throwable? = null
|
||||||
|
) : ApiTokenStore {
|
||||||
|
override fun getToken(): String? = storedToken
|
||||||
|
|
||||||
|
override fun saveToken(token: String): Result<Unit> {
|
||||||
|
saveError?.let { return Result.failure(it) }
|
||||||
|
storedToken = token
|
||||||
|
return Result.success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearToken(): Result<Unit> {
|
||||||
|
storedToken = null
|
||||||
|
return Result.success(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user