From 8824bd799796a7dd1327d5516c0d90db039b68cf Mon Sep 17 00:00:00 2001 From: Andrew Cheung Date: Wed, 12 Nov 2025 18:00:02 -0500 Subject: [PATCH 1/3] Implemented auth integration with backend and interceptor and authenticator for token handling --- app/build.gradle.kts | 7 +- app/src/main/AndroidManifest.xml | 1 + .../hustle/data/local/auth/TokenManager.kt | 46 +++++++ .../local/auth/UserPreferencesSerializer.kt | 45 +++++++ .../hustle/data/model/user/AuthTokens.kt | 28 +++++ .../hustle/data/model/user/User.kt | 15 ++- .../hustle/data/model/user/UserPreferences.kt | 9 ++ .../hustle/data/remote/auth/AuthApiService.kt | 21 ++++ .../data/remote/auth/AuthInterceptor.kt | 25 ++++ .../data/remote/auth/TokenAuthenticator.kt | 112 ++++++++++++++++++ .../repository/{ => auth}/AuthRepository.kt | 23 +++- .../data/repository/auth/SessionManager.kt | 22 ++++ .../hustle/data/security/Crypto.kt | 68 +++++++++++ .../com/cornellappdev/hustle/di/AppModule.kt | 4 +- .../hustle/di/DataStoreModule.kt | 30 +++++ .../cornellappdev/hustle/di/NetworkModule.kt | 90 +++++++++++--- .../hustle/ui/navigation/HustleNavigation.kt | 10 ++ .../hustle/ui/viewmodels/RootViewModel.kt | 24 +++- .../onboarding/SignInScreenViewModel.kt | 2 +- .../profile/ProfileScreenViewModel.kt | 2 +- gradle/libs.versions.toml | 9 +- 21 files changed, 561 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/hustle/data/local/auth/TokenManager.kt create mode 100644 app/src/main/java/com/cornellappdev/hustle/data/local/auth/UserPreferencesSerializer.kt create mode 100644 app/src/main/java/com/cornellappdev/hustle/data/model/user/AuthTokens.kt create mode 100644 app/src/main/java/com/cornellappdev/hustle/data/model/user/UserPreferences.kt create mode 100644 app/src/main/java/com/cornellappdev/hustle/data/remote/auth/AuthApiService.kt create mode 100644 app/src/main/java/com/cornellappdev/hustle/data/remote/auth/AuthInterceptor.kt create mode 100644 app/src/main/java/com/cornellappdev/hustle/data/remote/auth/TokenAuthenticator.kt rename app/src/main/java/com/cornellappdev/hustle/data/repository/{ => auth}/AuthRepository.kt (78%) create mode 100644 app/src/main/java/com/cornellappdev/hustle/data/repository/auth/SessionManager.kt create mode 100644 app/src/main/java/com/cornellappdev/hustle/data/security/Crypto.kt create mode 100644 app/src/main/java/com/cornellappdev/hustle/di/DataStoreModule.kt 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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1b1f803..c1ef79e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Hustle.Starting" + android:usesCleartextTraffic="true" tools:targetApi="31"> +) { + 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..8145a79 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/hustle/data/remote/auth/AuthInterceptor.kt @@ -0,0 +1,25 @@ +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 + +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..8e646e7 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/hustle/data/remote/auth/TokenAuthenticator.kt @@ -0,0 +1,112 @@ +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 + +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..df40996 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/hustle/data/repository/auth/SessionManager.kt @@ -0,0 +1,22 @@ +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 + +@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..f8f0df3 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/hustle/data/security/Crypto.kt @@ -0,0 +1,68 @@ +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. + */ +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..a751a04 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,62 @@ 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.Named +import javax.inject.Qualifier import javax.inject.Singleton private const val BASE_API_URL = BuildConfig.BASE_API_URL +private const val RETROFIT_BUILDER_NAME = "RetrofitBuilder" @Module @InstallIn(SingletonComponent::class) object NetworkModule { @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 } + } + + @Provides + @Singleton + @Named(RETROFIT_BUILDER_NAME) + fun provideRetrofitBuilder(json: Json): Retrofit.Builder { + return Retrofit.Builder() + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .baseUrl(BASE_API_URL) + } + + @Provides + @Singleton + @Unauthenticated + fun provideUnauthenticatedOkHttpClient( + loggingInterceptor: HttpLoggingInterceptor + ): OkHttpClient { return OkHttpClient.Builder() .addInterceptor(loggingInterceptor) .build() @@ -40,18 +65,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( + @Named(RETROFIT_BUILDER_NAME) retrofitBuilder: Retrofit.Builder, + @Unauthenticated okHttpClient: OkHttpClient + ): Retrofit { + return retrofitBuilder.client(okHttpClient).build() + } + + @Provides + @Singleton + fun provideRetrofit( + okHttpClient: OkHttpClient, + @Named(RETROFIT_BUILDER_NAME) retrofitBuilder: Retrofit.Builder + ): Retrofit { + return retrofitBuilder.client(okHttpClient).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..1f12857 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 @@ -19,6 +20,15 @@ fun HustleNavigation( val navController = rememberNavController() val startDestination = if (isSignedIn) HomeTab else Onboarding + LaunchedEffect(isSignedIn) { + if (!isSignedIn) { + navController.navigate(Onboarding) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } + } + } + Scaffold( bottomBar = { if (isSignedIn) { 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" } From d6ba6000f154bcf580e9922341f85defa892b00f Mon Sep 17 00:00:00 2001 From: Andrew Cheung Date: Thu, 13 Nov 2025 17:41:59 -0500 Subject: [PATCH 2/3] Added some documentation descriptions for classes; Added check for onboarding route before navigation; reworked to have individual retrofit builderinstances --- app/src/main/AndroidManifest.xml | 1 - .../hustle/data/local/auth/TokenManager.kt | 4 +++ .../data/remote/auth/AuthInterceptor.kt | 3 +++ .../data/remote/auth/TokenAuthenticator.kt | 3 +++ .../data/repository/auth/SessionManager.kt | 5 +++- .../cornellappdev/hustle/di/NetworkModule.kt | 27 +++++++++---------- .../hustle/ui/navigation/HustleNavigation.kt | 3 ++- 7 files changed, 28 insertions(+), 18 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c1ef79e..1b1f803 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,7 +13,6 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Hustle.Starting" - android:usesCleartextTraffic="true" tools:targetApi="31"> 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 index 8145a79..47369eb 100644 --- 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 @@ -6,6 +6,9 @@ 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 { 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 index 8e646e7..48144fb 100644 --- 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 @@ -16,6 +16,9 @@ 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, 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 index df40996..4ad6cb1 100644 --- 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 @@ -6,8 +6,11 @@ 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(){ +class SessionManager @Inject constructor() { private val _sessionExpired = MutableSharedFlow( replay = 0, extraBufferCapacity = 1, 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 a751a04..b5a1a7c 100644 --- a/app/src/main/java/com/cornellappdev/hustle/di/NetworkModule.kt +++ b/app/src/main/java/com/cornellappdev/hustle/di/NetworkModule.kt @@ -15,16 +15,15 @@ import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory -import javax.inject.Named import javax.inject.Qualifier import javax.inject.Singleton -private const val BASE_API_URL = BuildConfig.BASE_API_URL -private const val RETROFIT_BUILDER_NAME = "RetrofitBuilder" @Module @InstallIn(SingletonComponent::class) object NetworkModule { + private const val BASE_API_URL = BuildConfig.BASE_API_URL + @Provides @Singleton fun provideJson(): Json { @@ -43,12 +42,10 @@ object NetworkModule { } } - @Provides - @Singleton - @Named(RETROFIT_BUILDER_NAME) - fun provideRetrofitBuilder(json: Json): Retrofit.Builder { - return Retrofit.Builder() - .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + private fun Retrofit.Builder.applyBaseConfig(json: Json): Retrofit.Builder { + val contentType = "application/json".toMediaType() + return this + .addConverterFactory(json.asConverterFactory(contentType)) .baseUrl(BASE_API_URL) } @@ -56,7 +53,7 @@ object NetworkModule { @Singleton @Unauthenticated fun provideUnauthenticatedOkHttpClient( - loggingInterceptor: HttpLoggingInterceptor + loggingInterceptor: HttpLoggingInterceptor, ): OkHttpClient { return OkHttpClient.Builder() .addInterceptor(loggingInterceptor) @@ -81,19 +78,19 @@ object NetworkModule { @Singleton @Unauthenticated fun provideUnauthenticatedRetrofit( - @Named(RETROFIT_BUILDER_NAME) retrofitBuilder: Retrofit.Builder, - @Unauthenticated okHttpClient: OkHttpClient + @Unauthenticated okHttpClient: OkHttpClient, + json: Json ): Retrofit { - return retrofitBuilder.client(okHttpClient).build() + return Retrofit.Builder().client(okHttpClient).applyBaseConfig(json).build() } @Provides @Singleton fun provideRetrofit( okHttpClient: OkHttpClient, - @Named(RETROFIT_BUILDER_NAME) retrofitBuilder: Retrofit.Builder + json: Json ): Retrofit { - return retrofitBuilder.client(okHttpClient).build() + return Retrofit.Builder().client(okHttpClient).applyBaseConfig(json).build() } @Provides 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 1f12857..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 @@ -19,9 +19,10 @@ fun HustleNavigation( ) { val navController = rememberNavController() val startDestination = if (isSignedIn) HomeTab else Onboarding + val currentRoute = navController.currentBackStackEntry?.destination?.route LaunchedEffect(isSignedIn) { - if (!isSignedIn) { + if (!isSignedIn && navController.currentDestination != Onboarding) { navController.navigate(Onboarding) { popUpTo(0) { inclusive = true } launchSingleTop = true From 51fa3cba34aab0de2d34a603c6f6053ef1c4addc Mon Sep 17 00:00:00 2001 From: Andrew Cheung Date: Sat, 15 Nov 2025 19:24:41 -0500 Subject: [PATCH 3/3] Added documentation linking to Phillip Lackner video about encryption --- .../main/java/com/cornellappdev/hustle/data/security/Crypto.kt | 2 ++ 1 file changed, 2 insertions(+) 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 index f8f0df3..0744dd8 100644 --- a/app/src/main/java/com/cornellappdev/hustle/data/security/Crypto.kt +++ b/app/src/main/java/com/cornellappdev/hustle/data/security/Crypto.kt @@ -11,6 +11,8 @@ 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"