diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b273990..9b53f55 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,7 +25,7 @@ android { defaultConfig { applicationId = "com.cornellappdev.hustle" - minSdk = 24 + minSdk = 26 targetSdk = 36 versionCode = 1 versionName = "1.0" @@ -99,8 +99,7 @@ dependencies { kapt(libs.hilt.android.compiler) // Retrofit and OkHttp Dependencies implementation(libs.retrofit) - implementation(libs.converter.moshi) - implementation(libs.moshi.kotlin) + implementation(libs.converter.kotlinx.serialization) implementation(libs.okhttp) implementation(libs.okhttp.logging) // Lint Checks @@ -123,6 +122,8 @@ dependencies { implementation(libs.coil.network.okhttp) // Splash Screen API implementation(libs.androidx.splashscreen) + // DataStore Preferences + implementation(libs.androidx.datastore.preferences) } // Allow references to generated code diff --git a/app/src/main/java/com/cornellappdev/hustle/data/local/auth/TokenManager.kt b/app/src/main/java/com/cornellappdev/hustle/data/local/auth/TokenManager.kt new file mode 100644 index 0000000..cba4f87 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/hustle/data/local/auth/TokenManager.kt @@ -0,0 +1,50 @@ +package com.cornellappdev.hustle.data.local.auth + +import androidx.datastore.core.DataStore +import com.cornellappdev.hustle.data.model.user.UserPreferences +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manages the secure storage and retrieval of authentication tokens (access and refresh tokens) + * using an encrypted DataStore. + */ +@Singleton +class TokenManager @Inject constructor( + private val userPreferencesDataStore: DataStore +) { + suspend fun getAccessToken(): String? { + return userPreferencesDataStore.data.map { + it.accessToken + }.first() + } + + suspend fun getRefreshToken(): String? { + return userPreferencesDataStore.data.map { + it.refreshToken + }.first() + } + + suspend fun saveTokens( + accessToken: String, + refreshToken: String + ) { + userPreferencesDataStore.updateData { preferences -> + preferences.copy( + accessToken = accessToken, + refreshToken = refreshToken + ) + } + } + + suspend fun clearTokens() { + userPreferencesDataStore.updateData { preferences -> + preferences.copy( + accessToken = null, + refreshToken = null + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/hustle/data/local/auth/UserPreferencesSerializer.kt b/app/src/main/java/com/cornellappdev/hustle/data/local/auth/UserPreferencesSerializer.kt new file mode 100644 index 0000000..1d1bece --- /dev/null +++ b/app/src/main/java/com/cornellappdev/hustle/data/local/auth/UserPreferencesSerializer.kt @@ -0,0 +1,45 @@ +package com.cornellappdev.hustle.data.local.auth + +import androidx.datastore.core.Serializer +import com.cornellappdev.hustle.data.model.user.UserPreferences +import com.cornellappdev.hustle.data.security.Crypto +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import java.io.InputStream +import java.io.OutputStream +import java.util.Base64 + + +/** + * Serializer object responsible for reading and writing [UserPreferences] to DataStore with encryption. + */ +object UserPreferencesSerializer : Serializer { + override val defaultValue: UserPreferences + get() = UserPreferences() + + override suspend fun readFrom(input: InputStream): UserPreferences { + val encryptedBytes = withContext(Dispatchers.IO) { + input.use { it.readBytes() } + } + if (encryptedBytes.isEmpty()) { + return defaultValue + } + val encryptedBytesDecoded = Base64.getDecoder().decode(encryptedBytes) + val decryptedBytes = Crypto.decrypt(encryptedBytesDecoded) + val decodedJsonString = decryptedBytes.decodeToString() + return Json.decodeFromString(decodedJsonString) + } + + override suspend fun writeTo(t: UserPreferences, output: OutputStream) { + val json = Json.encodeToString(t) + val bytes = json.toByteArray() + val encryptedBytes = Crypto.encrypt(bytes) + val encryptedBytesBase64 = Base64.getEncoder().encode(encryptedBytes) + withContext(Dispatchers.IO) { + output.use { + it.write(encryptedBytesBase64) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/hustle/data/model/user/AuthTokens.kt b/app/src/main/java/com/cornellappdev/hustle/data/model/user/AuthTokens.kt new file mode 100644 index 0000000..e548e50 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/hustle/data/model/user/AuthTokens.kt @@ -0,0 +1,28 @@ +package com.cornellappdev.hustle.data.model.user + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class VerifyTokenRequest( + val token: String +) + +@Serializable +data class VerifyTokenResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("refresh_token") val refreshToken: String, + val user: UserResponse +) + +@Serializable +data class RefreshTokenRequest( + @SerialName("refresh_token") val refreshToken: String +) + +@Serializable +data class RefreshTokenResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("refresh_token") val refreshToken: String +) \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/hustle/data/model/user/User.kt b/app/src/main/java/com/cornellappdev/hustle/data/model/user/User.kt index 8fcb43a..a89f4f3 100644 --- a/app/src/main/java/com/cornellappdev/hustle/data/model/user/User.kt +++ b/app/src/main/java/com/cornellappdev/hustle/data/model/user/User.kt @@ -1,10 +1,23 @@ package com.cornellappdev.hustle.data.model.user +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +//TODO: Add other fields from backend UserResponse as necessary and remove unused fields data class User( val firebaseUid: String, val email: String?, val displayName: String?, - val photoUrl: String?, + val photoUrl: String? +) + +@Serializable +data class UserResponse( + val id: String, + @SerialName("firebase_uid") val firebaseUid: String, + val email: String, + @SerialName("firstname") val firstName: String, + @SerialName("lastname") val lastName: String ) class InvalidEmailDomainException(message: String) : Exception(message) \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/hustle/data/model/user/UserPreferences.kt b/app/src/main/java/com/cornellappdev/hustle/data/model/user/UserPreferences.kt new file mode 100644 index 0000000..7c18aeb --- /dev/null +++ b/app/src/main/java/com/cornellappdev/hustle/data/model/user/UserPreferences.kt @@ -0,0 +1,9 @@ +package com.cornellappdev.hustle.data.model.user + +import kotlinx.serialization.Serializable + +@Serializable +data class UserPreferences( + val accessToken: String? = null, + val refreshToken: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/hustle/data/remote/auth/AuthApiService.kt b/app/src/main/java/com/cornellappdev/hustle/data/remote/auth/AuthApiService.kt new file mode 100644 index 0000000..672a994 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/hustle/data/remote/auth/AuthApiService.kt @@ -0,0 +1,21 @@ +package com.cornellappdev.hustle.data.remote.auth + +import com.cornellappdev.hustle.data.model.user.RefreshTokenRequest +import com.cornellappdev.hustle.data.model.user.RefreshTokenResponse +import com.cornellappdev.hustle.data.model.user.VerifyTokenRequest +import com.cornellappdev.hustle.data.model.user.VerifyTokenResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface AuthApiService { + @POST("api/verify-token") + suspend fun verifyToken( + @Body request: VerifyTokenRequest + ): Response + + @POST("api/refresh-token") + suspend fun refreshToken( + @Body request: RefreshTokenRequest + ): Response +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/hustle/data/remote/auth/AuthInterceptor.kt b/app/src/main/java/com/cornellappdev/hustle/data/remote/auth/AuthInterceptor.kt new file mode 100644 index 0000000..47369eb --- /dev/null +++ b/app/src/main/java/com/cornellappdev/hustle/data/remote/auth/AuthInterceptor.kt @@ -0,0 +1,28 @@ +package com.cornellappdev.hustle.data.remote.auth + +import com.cornellappdev.hustle.data.local.auth.TokenManager +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +/** + * [Interceptor] that adds the Authorization header with the access token to outgoing API requests. + */ +class AuthInterceptor @Inject constructor( + private val tokenManager: TokenManager +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + val accessToken = runBlocking { + tokenManager.getAccessToken() + } ?: return chain.proceed(originalRequest) + + return chain.proceed( + originalRequest.newBuilder() + .addHeader("Authorization", "Bearer $accessToken") + .build() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/hustle/data/remote/auth/TokenAuthenticator.kt b/app/src/main/java/com/cornellappdev/hustle/data/remote/auth/TokenAuthenticator.kt new file mode 100644 index 0000000..48144fb --- /dev/null +++ b/app/src/main/java/com/cornellappdev/hustle/data/remote/auth/TokenAuthenticator.kt @@ -0,0 +1,115 @@ +package com.cornellappdev.hustle.data.remote.auth + +import android.util.Log +import com.cornellappdev.hustle.data.local.auth.TokenManager +import com.cornellappdev.hustle.data.model.user.RefreshTokenRequest +import com.cornellappdev.hustle.data.model.user.VerifyTokenRequest +import com.cornellappdev.hustle.data.repository.auth.SessionManager +import com.google.firebase.auth.FirebaseAuth +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.tasks.await +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import javax.inject.Inject + +/** + * An [Authenticator] that handles token expiration and refresh logic for HTTP requests. + */ +class TokenAuthenticator @Inject constructor( + private val tokenManager: TokenManager, + private val authApiService: AuthApiService, + private val firebaseAuth: FirebaseAuth, + private val sessionManager: SessionManager +) : Authenticator { + // Mutex to ensure only one token refresh at a time + private val mutex = Mutex() + + override fun authenticate(route: Route?, response: Response): Request? { + // Prevent infinite loops by limiting the number of retries + if (getResponseCount(response) >= 3) { + return null + } + + return runBlocking { + mutex.withLock { + val currentToken = tokenManager.getAccessToken() + val failedToken = response.request.header("Authorization")?.removePrefix("Bearer ") + + // If the token has been updated since the request was made, use the new token and skip refresh flow + if (currentToken != null && currentToken != failedToken) { + return@runBlocking buildAuthRequest(response, currentToken) + } + + // 1. Try to refresh the access token using the refresh token + // 2. If that fails, try to re-authenticate with the Firebase token + // 3. If that also fails, sign the user out and notify session expiration + tryRefreshToken(response) + ?: tryFirebaseReAuthentication(response) + ?: handleAuthFailure() + } + } + } + + private suspend fun tryRefreshToken(response: Response): Request? { + val refreshToken = tokenManager.getRefreshToken() ?: return null + + return runCatching { + authApiService.refreshToken(RefreshTokenRequest(refreshToken)) + .takeIf { it.isSuccessful } + ?.body() + ?.let { tokenData -> + tokenManager.saveTokens( + accessToken = tokenData.accessToken, + refreshToken = tokenData.refreshToken + ) + buildAuthRequest(response, tokenData.accessToken) + } + }.onFailure { + Log.e(TAG, "Token refresh failed: ${it.message}") + }.getOrNull() + } + + private suspend fun tryFirebaseReAuthentication(response: Response): Request? { + val firebaseUser = firebaseAuth.currentUser ?: return null + + return runCatching { + val firebaseToken = firebaseUser.getIdToken(true).await().token ?: return null + + authApiService.verifyToken(VerifyTokenRequest(firebaseToken)) + .takeIf { it.isSuccessful } + ?.body() + ?.let { tokenData -> + tokenManager.saveTokens( + accessToken = tokenData.accessToken, + refreshToken = tokenData.refreshToken + ) + buildAuthRequest(response, tokenData.accessToken) + } + }.onFailure { + Log.e(TAG, "Firebase re-authentication failed: ${it.message}") + }.getOrNull() + } + + private suspend fun handleAuthFailure(): Request? { + tokenManager.clearTokens() + firebaseAuth.signOut() + sessionManager.notifySessionExpired() + return null + } + + private fun buildAuthRequest(response: Response, token: String): Request = + response.request.newBuilder() + .header("Authorization", "Bearer $token") + .build() + + private fun getResponseCount(response: Response): Int = + generateSequence(response) { it.priorResponse }.count() + + companion object { + private const val TAG = "TokenAuthenticator" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/hustle/data/repository/AuthRepository.kt b/app/src/main/java/com/cornellappdev/hustle/data/repository/auth/AuthRepository.kt similarity index 78% rename from app/src/main/java/com/cornellappdev/hustle/data/repository/AuthRepository.kt rename to app/src/main/java/com/cornellappdev/hustle/data/repository/auth/AuthRepository.kt index e8c5bf1..0684406 100644 --- a/app/src/main/java/com/cornellappdev/hustle/data/repository/AuthRepository.kt +++ b/app/src/main/java/com/cornellappdev/hustle/data/repository/auth/AuthRepository.kt @@ -1,4 +1,4 @@ -package com.cornellappdev.hustle.data.repository +package com.cornellappdev.hustle.data.repository.auth import android.content.Context import androidx.credentials.ClearCredentialStateRequest @@ -6,8 +6,11 @@ import androidx.credentials.CredentialManager import androidx.credentials.CustomCredential import androidx.credentials.GetCredentialRequest import com.cornellappdev.hustle.BuildConfig +import com.cornellappdev.hustle.data.local.auth.TokenManager import com.cornellappdev.hustle.data.model.user.InvalidEmailDomainException import com.cornellappdev.hustle.data.model.user.User +import com.cornellappdev.hustle.data.model.user.VerifyTokenRequest +import com.cornellappdev.hustle.data.remote.auth.AuthApiService import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential.Companion.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL @@ -32,7 +35,9 @@ interface AuthRepository { class AuthRepositoryImpl @Inject constructor( private val firebaseAuth: FirebaseAuth, private val credentialManager: CredentialManager, - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + private val tokenManager: TokenManager, + private val authApiService: AuthApiService ) : AuthRepository { private val _currentUserFlow = MutableStateFlow(null) override val currentUserFlow: StateFlow = _currentUserFlow.asStateFlow() @@ -56,6 +61,20 @@ class AuthRepositoryImpl @Inject constructor( val authCredential = GoogleAuthProvider.getCredential(googleIdTokenCredential.idToken, null) val authResult = firebaseAuth.signInWithCredential(authCredential).await() + val firebaseToken = authResult.user?.getIdToken(false)?.await()?.token + ?: throw Exception("Failed to retrieve Firebase token") + + val response = authApiService.verifyToken(VerifyTokenRequest(firebaseToken)) + if (response.isSuccessful) { + val tokenData = response.body() ?: throw Exception("Empty response body") + tokenManager.saveTokens( + accessToken = tokenData.accessToken, + refreshToken = tokenData.refreshToken + ) + } else { + throw Exception("Token verification failed with code: ${response.code()}") + } + authResult.user?.toUser() ?: throw Exception("Authentication failed") } diff --git a/app/src/main/java/com/cornellappdev/hustle/data/repository/auth/SessionManager.kt b/app/src/main/java/com/cornellappdev/hustle/data/repository/auth/SessionManager.kt new file mode 100644 index 0000000..4ad6cb1 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/hustle/data/repository/auth/SessionManager.kt @@ -0,0 +1,25 @@ +package com.cornellappdev.hustle.data.repository.auth + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manages user session state, particularly notifying when a session has expired. + */ +@Singleton +class SessionManager @Inject constructor() { + private val _sessionExpired = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val sessionExpired = _sessionExpired.asSharedFlow() + + fun notifySessionExpired() { + _sessionExpired.tryEmit(Unit) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/hustle/data/security/Crypto.kt b/app/src/main/java/com/cornellappdev/hustle/data/security/Crypto.kt new file mode 100644 index 0000000..0744dd8 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/hustle/data/security/Crypto.kt @@ -0,0 +1,70 @@ +package com.cornellappdev.hustle.data.security + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.IvParameterSpec + +/** + * Utility object for encrypting and decrypting data using Android's Keystore system. + * Used to securely store sensitive information such as user tokens. + * The following video was used as a reference for this implementation: + * https://www.youtube.com/watch?v=XMaQNN9YpKk + */ +object Crypto { + private const val KEY_ALIAS = "HUSTLE_KEY_ALIAS" + private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES + private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC + private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7 + private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING" + + private val cipher = Cipher.getInstance(TRANSFORMATION) + private val keyStore = KeyStore + .getInstance("AndroidKeyStore") + .apply { + load(null) + } + + private fun getKey(): SecretKey { + val existingKey = keyStore + .getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry + return existingKey?.secretKey ?: createKey() + } + + private fun createKey(): SecretKey { + return KeyGenerator + .getInstance(ALGORITHM) + .apply { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or + KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(BLOCK_MODE) + .setEncryptionPaddings(PADDING) + .setRandomizedEncryptionRequired(true) + .setUserAuthenticationRequired(false) + .build() + ) + } + .generateKey() + } + + fun encrypt(bytes: ByteArray): ByteArray { + cipher.init(Cipher.ENCRYPT_MODE, getKey()) + val iv = cipher.iv + val encrypted = cipher.doFinal(bytes) + return iv + encrypted + } + + fun decrypt(bytes: ByteArray): ByteArray { + val iv = bytes.copyOfRange(0, cipher.blockSize) + val data = bytes.copyOfRange(cipher.blockSize, bytes.size) + cipher.init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv)) + return cipher.doFinal(data) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/hustle/di/AppModule.kt b/app/src/main/java/com/cornellappdev/hustle/di/AppModule.kt index ce082d4..9c9088c 100644 --- a/app/src/main/java/com/cornellappdev/hustle/di/AppModule.kt +++ b/app/src/main/java/com/cornellappdev/hustle/di/AppModule.kt @@ -2,12 +2,12 @@ package com.cornellappdev.hustle.di import android.content.Context import androidx.credentials.CredentialManager -import com.cornellappdev.hustle.data.repository.AuthRepository -import com.cornellappdev.hustle.data.repository.AuthRepositoryImpl import com.cornellappdev.hustle.data.repository.ExampleRepository import com.cornellappdev.hustle.data.repository.ExampleRepositoryImpl import com.cornellappdev.hustle.data.repository.FcmTokenRepository import com.cornellappdev.hustle.data.repository.FcmTokenRepositoryImpl +import com.cornellappdev.hustle.data.repository.auth.AuthRepository +import com.cornellappdev.hustle.data.repository.auth.AuthRepositoryImpl import com.google.firebase.Firebase import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.auth diff --git a/app/src/main/java/com/cornellappdev/hustle/di/DataStoreModule.kt b/app/src/main/java/com/cornellappdev/hustle/di/DataStoreModule.kt new file mode 100644 index 0000000..6179fd1 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/hustle/di/DataStoreModule.kt @@ -0,0 +1,30 @@ +package com.cornellappdev.hustle.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.dataStore +import com.cornellappdev.hustle.data.local.auth.UserPreferencesSerializer +import com.cornellappdev.hustle.data.model.user.UserPreferences +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +private val Context.userPreferencesDataStore: DataStore by dataStore( + fileName = "user_preferences", + serializer = UserPreferencesSerializer +) + +@Module +@InstallIn(SingletonComponent::class) +object DataStoreModule { + @Provides + @Singleton + fun provideUserPreferencesDataStore( + @ApplicationContext context: Context + ): DataStore { + return context.userPreferencesDataStore + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/hustle/di/NetworkModule.kt b/app/src/main/java/com/cornellappdev/hustle/di/NetworkModule.kt index 34e5757..b5a1a7c 100644 --- a/app/src/main/java/com/cornellappdev/hustle/di/NetworkModule.kt +++ b/app/src/main/java/com/cornellappdev/hustle/di/NetworkModule.kt @@ -2,37 +2,59 @@ package com.cornellappdev.hustle.di import com.cornellappdev.hustle.BuildConfig import com.cornellappdev.hustle.data.remote.ExampleApiService -import com.squareup.moshi.Moshi -import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.cornellappdev.hustle.data.remote.auth.AuthApiService +import com.cornellappdev.hustle.data.remote.auth.AuthInterceptor +import com.cornellappdev.hustle.data.remote.auth.TokenAuthenticator import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit -import retrofit2.converter.moshi.MoshiConverterFactory +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import javax.inject.Qualifier import javax.inject.Singleton -private const val BASE_API_URL = BuildConfig.BASE_API_URL @Module @InstallIn(SingletonComponent::class) object NetworkModule { + private const val BASE_API_URL = BuildConfig.BASE_API_URL + @Provides @Singleton - fun provideMoshi(): Moshi { - return Moshi.Builder() - .add(KotlinJsonAdapterFactory()) - .build() + fun provideJson(): Json { + return Json { + ignoreUnknownKeys = true + coerceInputValues = true + isLenient = true + } } @Provides @Singleton - fun provideOkHttpClient(): OkHttpClient { - val loggingInterceptor = HttpLoggingInterceptor().apply { + fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor { + return HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } + } + + private fun Retrofit.Builder.applyBaseConfig(json: Json): Retrofit.Builder { + val contentType = "application/json".toMediaType() + return this + .addConverterFactory(json.asConverterFactory(contentType)) + .baseUrl(BASE_API_URL) + } + + @Provides + @Singleton + @Unauthenticated + fun provideUnauthenticatedOkHttpClient( + loggingInterceptor: HttpLoggingInterceptor, + ): OkHttpClient { return OkHttpClient.Builder() .addInterceptor(loggingInterceptor) .build() @@ -40,18 +62,53 @@ object NetworkModule { @Provides @Singleton - fun provideRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit { - return Retrofit.Builder() - .client(okHttpClient) - .addConverterFactory(MoshiConverterFactory.create(moshi)) - .baseUrl(BASE_API_URL) + fun provideOkHttpClient( + authenticator: TokenAuthenticator, + authInterceptor: AuthInterceptor, + loggingInterceptor: HttpLoggingInterceptor + ): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .addInterceptor(authInterceptor) + .authenticator(authenticator) .build() } + @Provides + @Singleton + @Unauthenticated + fun provideUnauthenticatedRetrofit( + @Unauthenticated okHttpClient: OkHttpClient, + json: Json + ): Retrofit { + return Retrofit.Builder().client(okHttpClient).applyBaseConfig(json).build() + } + + @Provides + @Singleton + fun provideRetrofit( + okHttpClient: OkHttpClient, + json: Json + ): Retrofit { + return Retrofit.Builder().client(okHttpClient).applyBaseConfig(json).build() + } + @Provides @Singleton fun provideExampleApiService(retrofit: Retrofit): ExampleApiService { return retrofit.create(ExampleApiService::class.java) } -} \ No newline at end of file + @Provides + @Singleton + fun provideAuthApiService( + @Unauthenticated retrofit: Retrofit + ): AuthApiService { + return retrofit.create(AuthApiService::class.java) + } + +} + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class Unauthenticated \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/hustle/ui/navigation/HustleNavigation.kt b/app/src/main/java/com/cornellappdev/hustle/ui/navigation/HustleNavigation.kt index 2afcffe..58649d6 100644 --- a/app/src/main/java/com/cornellappdev/hustle/ui/navigation/HustleNavigation.kt +++ b/app/src/main/java/com/cornellappdev/hustle/ui/navigation/HustleNavigation.kt @@ -3,6 +3,7 @@ package com.cornellappdev.hustle.ui.navigation import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController @@ -18,6 +19,16 @@ fun HustleNavigation( ) { val navController = rememberNavController() val startDestination = if (isSignedIn) HomeTab else Onboarding + val currentRoute = navController.currentBackStackEntry?.destination?.route + + LaunchedEffect(isSignedIn) { + if (!isSignedIn && navController.currentDestination != Onboarding) { + navController.navigate(Onboarding) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } + } + } Scaffold( bottomBar = { diff --git a/app/src/main/java/com/cornellappdev/hustle/ui/viewmodels/RootViewModel.kt b/app/src/main/java/com/cornellappdev/hustle/ui/viewmodels/RootViewModel.kt index c6762e0..bada9ce 100644 --- a/app/src/main/java/com/cornellappdev/hustle/ui/viewmodels/RootViewModel.kt +++ b/app/src/main/java/com/cornellappdev/hustle/ui/viewmodels/RootViewModel.kt @@ -1,7 +1,8 @@ package com.cornellappdev.hustle.ui.viewmodels import androidx.lifecycle.viewModelScope -import com.cornellappdev.hustle.data.repository.AuthRepository +import com.cornellappdev.hustle.data.repository.auth.AuthRepository +import com.cornellappdev.hustle.data.repository.auth.SessionManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -13,11 +14,17 @@ data class RootUiState( @HiltViewModel class RootViewModel @Inject constructor( - private val authRepository: AuthRepository + private val authRepository: AuthRepository, + private val sessionManager: SessionManager ) : HustleViewModel( initialUiState = RootUiState() ) { init { + collectUserSignInStatus() + collectSessionExpired() + } + + private fun collectUserSignInStatus() { viewModelScope.launch { authRepository.currentUserFlow.collect { user -> applyMutation { @@ -29,4 +36,17 @@ class RootViewModel @Inject constructor( } } } + + private fun collectSessionExpired() { + viewModelScope.launch { + sessionManager.sessionExpired.collect { + applyMutation { + copy( + isSignedIn = false, + isLoading = false + ) + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/hustle/ui/viewmodels/onboarding/SignInScreenViewModel.kt b/app/src/main/java/com/cornellappdev/hustle/ui/viewmodels/onboarding/SignInScreenViewModel.kt index 97c10a4..857abb8 100644 --- a/app/src/main/java/com/cornellappdev/hustle/ui/viewmodels/onboarding/SignInScreenViewModel.kt +++ b/app/src/main/java/com/cornellappdev/hustle/ui/viewmodels/onboarding/SignInScreenViewModel.kt @@ -1,7 +1,7 @@ package com.cornellappdev.hustle.ui.viewmodels.onboarding import androidx.lifecycle.viewModelScope -import com.cornellappdev.hustle.data.repository.AuthRepository +import com.cornellappdev.hustle.data.repository.auth.AuthRepository import com.cornellappdev.hustle.ui.viewmodels.ActionState import com.cornellappdev.hustle.ui.viewmodels.HustleViewModel import com.cornellappdev.hustle.util.viewmodels.executeActionStatefully diff --git a/app/src/main/java/com/cornellappdev/hustle/ui/viewmodels/profile/ProfileScreenViewModel.kt b/app/src/main/java/com/cornellappdev/hustle/ui/viewmodels/profile/ProfileScreenViewModel.kt index 3029271..2e15137 100644 --- a/app/src/main/java/com/cornellappdev/hustle/ui/viewmodels/profile/ProfileScreenViewModel.kt +++ b/app/src/main/java/com/cornellappdev/hustle/ui/viewmodels/profile/ProfileScreenViewModel.kt @@ -2,7 +2,7 @@ package com.cornellappdev.hustle.ui.viewmodels.profile import androidx.lifecycle.viewModelScope import com.cornellappdev.hustle.data.model.user.User -import com.cornellappdev.hustle.data.repository.AuthRepository +import com.cornellappdev.hustle.data.repository.auth.AuthRepository import com.cornellappdev.hustle.ui.viewmodels.ActionState import com.cornellappdev.hustle.ui.viewmodels.HustleViewModel import com.cornellappdev.hustle.util.viewmodels.executeActionStatefully diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8c84f19..1d371f8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,5 @@ [versions] agp = "8.13.0" -converterMoshi = "3.0.0" hiltAndroid = "2.57.1" hiltNavigationCompose = "1.3.0" kotlin = "2.2.20" @@ -11,7 +10,6 @@ espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.9.4" activityCompose = "1.11.0" composeBom = "2025.09.01" -moshiKotlin = "1.15.2" retrofit = "3.0.0" okhttp = "5.1.0" slackComposeLint = "1.4.2" @@ -23,11 +21,11 @@ googleId = "1.1.1" google-services = "4.4.3" coil = "3.3.0" splashScreen = "1.0.1" +dataStorePreferences = "1.1.7" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } -converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "converterMoshi" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroid" } junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -43,8 +41,8 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } -moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshiKotlin" } -retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +converter-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } slack-compose-lint = { module = "com.slack.lint.compose:compose-lint-checks", version.ref = "slackComposeLint" } @@ -60,6 +58,7 @@ google-id = { module = "com.google.android.libraries.identity.googleid:googleid" coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashScreen" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "dataStorePreferences" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }