From 52e38335b31bf1f4cbe8c4c141cb9e20117d0eae Mon Sep 17 00:00:00 2001 From: Preston Williams Date: Sat, 21 Mar 2026 17:06:16 -0400 Subject: [PATCH 1/8] handled session management --- app/src/main/graphql/User.graphql | 12 ++++ .../CapacityRemindersRepository.kt | 3 +- .../data/repositories/CheckInRepository.kt | 3 +- .../repositories/PopularTimesRepository.kt | 3 +- .../data/repositories/ReportRepository.kt | 3 +- .../data/repositories/SessionManager.kt | 31 +++++++++ .../data/repositories/TokenAuthenticator.kt | 65 +++++++++++++++++++ .../uplift/data/repositories/TokenManager.kt | 11 ++++ .../data/repositories/UpliftApiRepository.kt | 3 +- .../data/repositories/UserInfoRepository.kt | 41 +++++++++++- .../com/cornellappdev/uplift/di/AppModule.kt | 25 ++++++- .../uplift/ui/MainNavigationWrapper.kt | 17 ++++- .../viewmodels/nav/RootNavigationViewModel.kt | 26 ++++---- .../viewmodels/onboarding/LoginViewModel.kt | 20 ++++-- .../onboarding/ProfileCreationViewModel.kt | 2 +- 15 files changed, 232 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/uplift/data/repositories/SessionManager.kt create mode 100644 app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenAuthenticator.kt diff --git a/app/src/main/graphql/User.graphql b/app/src/main/graphql/User.graphql index 09439e63..39b2f0f3 100644 --- a/app/src/main/graphql/User.graphql +++ b/app/src/main/graphql/User.graphql @@ -57,3 +57,15 @@ mutation LoginUser($netId: String!) { refreshToken } } + +mutation LogoutUser { + logoutUser { + success + } +} + +mutation RefreshAccessToken { + refreshAccessToken { + newAccessToken + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/CapacityRemindersRepository.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/CapacityRemindersRepository.kt index ab12e679..25935095 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/CapacityRemindersRepository.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/CapacityRemindersRepository.kt @@ -7,12 +7,13 @@ import com.cornellappdev.uplift.DeleteCapacityReminderMutation import com.cornellappdev.uplift.EditCapacityReminderMutation import com.cornellappdev.uplift.data.mappers.toResult import javax.inject.Inject +import javax.inject.Named import javax.inject.Singleton @Singleton class CapacityRemindersRepository @Inject constructor( - private val apolloClient: ApolloClient, + @Named("main") private val apolloClient: ApolloClient, private val dataStoreRepository: DatastoreRepository, ) { /** diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/CheckInRepository.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/CheckInRepository.kt index d0384d1f..ed388566 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/CheckInRepository.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/CheckInRepository.kt @@ -30,13 +30,14 @@ import com.apollographql.apollo.ApolloClient import com.cornellappdev.uplift.LogWorkoutMutation import java.time.Instant import javax.inject.Inject +import javax.inject.Named import javax.inject.Singleton @Singleton class CheckInRepository @Inject constructor( val upliftApiRepository: UpliftApiRepository, private val dataStore: DataStore, - private val apolloClient: ApolloClient, + @Named("main") private val apolloClient: ApolloClient, private val userInfoRepository: UserInfoRepository ){ private val _nearestGymFlow = MutableStateFlow(null) diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/PopularTimesRepository.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/PopularTimesRepository.kt index cf5e11dd..012ab8e5 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/PopularTimesRepository.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/PopularTimesRepository.kt @@ -4,11 +4,12 @@ import com.apollographql.apollo.ApolloClient import com.cornellappdev.uplift.PopularTimesQuery import com.cornellappdev.uplift.data.mappers.toResult import javax.inject.Inject +import javax.inject.Named import javax.inject.Singleton @Singleton class PopularTimesRepository @Inject constructor( - private val apolloClient: ApolloClient + @Named("main") private val apolloClient: ApolloClient ) { suspend fun getPopularTimes(facilityId: Int): Result { return apolloClient.query( diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/ReportRepository.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/ReportRepository.kt index d1bd2976..9130ca79 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/ReportRepository.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/ReportRepository.kt @@ -4,6 +4,7 @@ import com.apollographql.apollo.ApolloClient import com.cornellappdev.uplift.CreateReportMutation import com.cornellappdev.uplift.data.mappers.toResult import javax.inject.Inject +import javax.inject.Named import javax.inject.Singleton /** @@ -12,7 +13,7 @@ import javax.inject.Singleton */ @Singleton class ReportRepository @Inject constructor( - private val apolloClient: ApolloClient + @Named("main") private val apolloClient: ApolloClient ) { /** * @param createdAt The time the report was created. diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/SessionManager.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/SessionManager.kt new file mode 100644 index 00000000..a526d51a --- /dev/null +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/SessionManager.kt @@ -0,0 +1,31 @@ +package com.cornellappdev.uplift.data.repositories + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SessionManager @Inject constructor( + private val tokenManager: TokenManager +) { + // A reactive flow that the UI can collect + private val _isLoggedIn = MutableStateFlow(tokenManager.getAccessToken() != null) + val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() + + // Call this after LoginUser or CreateUser mutations succeed + fun startSession(userId: Int, name: String, email: String, access: String, refresh: String) { + tokenManager.saveTokens(access, refresh) + tokenManager.saveUserSession(userId, name, email) + _isLoggedIn.value = true + } + + // Call this for manual logout or when refresh fails + fun logout() { + tokenManager.clearTokens() + _isLoggedIn.value = false + } + + val userId: Int? get() = tokenManager.getUserId() +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenAuthenticator.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenAuthenticator.kt new file mode 100644 index 00000000..a7ba7a0c --- /dev/null +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenAuthenticator.kt @@ -0,0 +1,65 @@ +package com.cornellappdev.uplift.data.repositories + +import com.apollographql.apollo.ApolloClient +import com.cornellappdev.uplift.RefreshAccessTokenMutation +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import javax.inject.Inject +import javax.inject.Named + +class TokenAuthenticator @Inject constructor( + private val tokenManager: TokenManager, + private val sessionManager: SessionManager, + @Named("refresh") private val apolloClient: ApolloClient +) : Authenticator { + + override fun authenticate(route: Route?, response: Response): Request? { + val refreshToken = tokenManager.getRefreshToken() ?: return null + + synchronized(this) { + // Check if the token was already refreshed by another thread + // while this request was waiting for the lock. + val currentToken = tokenManager.getAccessToken() + val requestToken = response.request.header("Authorization")?.removePrefix("Bearer ") + + if (currentToken != requestToken && currentToken != null) { + return response.request.newBuilder() + .header("Authorization", "Bearer $currentToken") + .build() + } + + // 3. Since OkHttp's Authenticator is synchronous but Apollo is suspend-based, + // we use runBlocking to wait for the refresh mutation result. + return runBlocking { + try { + val mutationResponse = apolloClient.mutation(RefreshAccessTokenMutation()) + // We manually add the Refresh Token to this specific call + // because the "refresh" ApolloClient has no interceptor. + .addHttpHeader("Authorization", "Bearer $refreshToken") + .execute() + + val newAccessToken = mutationResponse.data?.refreshAccessToken?.newAccessToken + + if (newAccessToken != null) { + tokenManager.saveTokens(newAccessToken, refreshToken) + + // Retry the original request with the new Access Token + response.request.newBuilder() + .header("Authorization", "Bearer $newAccessToken") + .build() + } else { + // Refresh failed (e.g., refresh token expired on backend) + sessionManager.logout() + null + } + } catch (e: Exception) { + // Network error or server down during refresh + null + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenManager.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenManager.kt index 3c387aec..a5736fc4 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenManager.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenManager.kt @@ -62,4 +62,15 @@ class TokenManager @Inject constructor(@ApplicationContext private val context: fun clearTokens() { sharedPreferences?.edit { clear() } } + + fun saveUserSession(userId: Int, username: String, userEmail: String) { + sharedPreferences?.edit { + putInt("user_id", userId) + putString("username", username) + putString("user_email", userEmail) + } + } + + fun getUserId(): Int? = sharedPreferences?.getInt("user_id", -1) // Use when userID is needed + } diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/UpliftApiRepository.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/UpliftApiRepository.kt index c8fa9ab8..2ddeafe4 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/UpliftApiRepository.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/UpliftApiRepository.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton +import javax.inject.Named /** * A repository dealing with all API backend connection in Uplift. @@ -31,7 +32,7 @@ import javax.inject.Singleton */ @Singleton class UpliftApiRepository @Inject constructor( - private val apolloClient: ApolloClient + @Named("main") private val apolloClient: ApolloClient ) { private val gymQuery = apolloClient.query(GymListQuery()) private val classQuery = apolloClient.query(ClassListQuery()) diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt index b674864d..db0951aa 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt @@ -18,13 +18,14 @@ import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.GoogleAuthProvider import kotlinx.coroutines.tasks.await +import javax.inject.Named @Singleton class UserInfoRepository @Inject constructor( private val firebaseAuth: FirebaseAuth, - private val apolloClient: ApolloClient, + @Named("main") private val apolloClient: ApolloClient, private val dataStore: DataStore, - private val tokenManager: TokenManager + private val sessionManager: SessionManager ){ suspend fun createUser(email: String, name: String, netId: String, skip: Boolean, goal: Int): Boolean { @@ -54,7 +55,13 @@ class UserInfoRepository @Inject constructor( } val accessToken = loginData.accessToken val refreshToken = loginData.refreshToken - tokenManager.saveTokens(accessToken, refreshToken) + sessionManager.startSession( + userId = id.toIntOrNull() ?: -1, + name = name, + email = email, + access = accessToken, + refresh = refreshToken + ) if (!skip) { val numericId = id.toIntOrNull() if (!uploadGoal(numericId, goal)) { @@ -73,6 +80,34 @@ class UserInfoRepository @Inject constructor( } } + suspend fun loginUser(netId: String) : Boolean { + return try { + val loginResponse = apolloClient.mutation( + LoginUserMutation( + netId = netId + ) + ).execute() + val loginData = loginResponse.data?.loginUser + val userInfo = getUserByNetId(netId) + if (loginData?.accessToken != null && loginData?.refreshToken != null && userInfo != null) { + sessionManager.startSession( + userId = userInfo.id.toIntOrNull() ?: -1, + name = userInfo.name, + email = userInfo.email, + access = loginData.accessToken, + refresh = loginData.refreshToken + ) + true + } else { + Log.e("UserInfoRepository", "Login failed: Missing tokens or user info; ${loginResponse.errors}") + false + } + } catch (e: Exception) { + Log.e("UserInfoRepository", "Error logging in: $e") + false + } + } + suspend fun uploadGoal(id:Int?, goal: Int): Boolean { if (id == null) { Log.e("UserInfoRepository", "Failed to set goal: non-numeric user ID '$id'") diff --git a/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt b/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt index bbd77c6d..c00bf217 100644 --- a/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt +++ b/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt @@ -4,11 +4,13 @@ import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.network.okHttpClient import com.cornellappdev.uplift.BuildConfig import com.cornellappdev.uplift.data.repositories.AuthInterceptor +import com.cornellappdev.uplift.data.repositories.TokenAuthenticator import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient +import javax.inject.Named import javax.inject.Singleton @@ -18,15 +20,34 @@ object AppModule { @Provides @Singleton - fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient { + @Named("refresh") // Use a named annotation to distinguish them + fun provideRefreshApolloClient(): ApolloClient { + // This client does NOT have interceptors to avoid loops + val okHttpClient = OkHttpClient.Builder().build() + + return ApolloClient.Builder() + .serverUrl(BuildConfig.BACKEND_URL) + .okHttpClient(okHttpClient) + .build() + } + + @Provides + @Singleton + @Named("main") + fun provideOkHttpClient( + authInterceptor: AuthInterceptor, + tokenAuthenticator: TokenAuthenticator + ): OkHttpClient { return OkHttpClient.Builder() .addInterceptor(authInterceptor) + .authenticator(tokenAuthenticator) .build() } @Provides @Singleton - fun provideApolloClient(okHttpClient: OkHttpClient): ApolloClient { + @Named("main") + fun provideApolloClient(@Named("main") okHttpClient: OkHttpClient): ApolloClient { return ApolloClient.Builder() .serverUrl(BuildConfig.BACKEND_URL) .okHttpClient(okHttpClient) diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt b/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt index 5373026c..b0da6d41 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt @@ -12,6 +12,7 @@ import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,6 +32,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import com.cornellappdev.uplift.data.repositories.SessionManager import com.cornellappdev.uplift.ui.components.general.CheckInPopUp import com.cornellappdev.uplift.ui.components.general.ConfettiBurst import com.cornellappdev.uplift.ui.nav.BottomNavScreens @@ -68,7 +70,7 @@ import kotlinx.serialization.Serializable /** * The main navigation controller for the app. - */ +*/ @Composable fun MainNavigationWrapper( // Note: For future view models, please add them to the screen they are used in instead of here. @@ -76,6 +78,9 @@ fun MainNavigationWrapper( gymDetailViewModel: GymDetailViewModel = hiltViewModel(), classDetailViewModel: ClassDetailViewModel = hiltViewModel(), rootNavigationViewModel: RootNavigationViewModel = hiltViewModel(), + sessionManager: SessionManager = hiltViewModel().let { + hiltViewModel().sessionManager + } ) { @@ -102,6 +107,16 @@ fun MainNavigationWrapper( systemUiController.setStatusBarColor(PRIMARY_YELLOW) + val isLoggedIn by sessionManager.isLoggedIn.collectAsState() + + LaunchedEffect(isLoggedIn) { + if (!isLoggedIn && ONBOARDING_FLAG) { + navController.navigate(UpliftRootRoute.Onboarding) { + popUpTo(0) + } + } + } + //TODO: Try to consolidate launched effects into one with consumeIn function that takes in coroutine scope LaunchedEffect(rootNavigationUiState.navEvent) { rootNavigationUiState.navEvent?.consumeSuspend { diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/nav/RootNavigationViewModel.kt b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/nav/RootNavigationViewModel.kt index 846ce670..e9ae70a1 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/nav/RootNavigationViewModel.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/nav/RootNavigationViewModel.kt @@ -1,6 +1,7 @@ package com.cornellappdev.uplift.ui.viewmodels.nav import androidx.lifecycle.viewModelScope +import com.cornellappdev.uplift.data.repositories.SessionManager import com.cornellappdev.uplift.data.repositories.UserInfoRepository import com.cornellappdev.uplift.ui.UpliftRootRoute import com.cornellappdev.uplift.ui.nav.RootNavigationRepository @@ -14,7 +15,8 @@ import javax.inject.Inject @HiltViewModel class RootNavigationViewModel @Inject constructor( rootNavigationRepository: RootNavigationRepository, - private val userInfoRepository: UserInfoRepository + private val userInfoRepository: UserInfoRepository, + val sessionManager: SessionManager ) : UpliftViewModel( initialUiState = RootNavigationUiState() ) { @@ -22,7 +24,7 @@ class RootNavigationViewModel @Inject constructor( val navEvent: UIEvent? = null, val popBackStack: UIEvent? = null, val navigateUp: UIEvent? = null, - val startDestination: UpliftRootRoute = UpliftRootRoute.Home + val startDestination: UpliftRootRoute = if (ONBOARDING_FLAG) UpliftRootRoute.Onboarding else UpliftRootRoute.Home ) init { @@ -46,18 +48,14 @@ class RootNavigationViewModel @Inject constructor( } viewModelScope.launch { - val hasSkipped = userInfoRepository.getSkipFromDataStore() - var hasUser = false - if (userInfoRepository.hasFirebaseUser()) { - val user = userInfoRepository.getFirebaseUser() - val email = user?.email - val netId = email?.substringBefore('@') - hasUser = netId?.let { userInfoRepository.hasUser(it) } ?: false - } - applyMutation { - copy( - startDestination = if (hasSkipped || hasUser || !ONBOARDING_FLAG) UpliftRootRoute.Home else UpliftRootRoute.Onboarding - ) + sessionManager.isLoggedIn.collect { loggedIn -> + val hasSkipped = userInfoRepository.getSkipFromDataStore() + val shouldShowHome = loggedIn || hasSkipped || !ONBOARDING_FLAG + applyMutation { + copy( + startDestination = if (shouldShowHome) UpliftRootRoute.Home else UpliftRootRoute.Onboarding + ) + } } } } diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/LoginViewModel.kt index aa0b3dcf..9dab3091 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/LoginViewModel.kt @@ -5,6 +5,7 @@ import androidx.credentials.Credential import androidx.credentials.CustomCredential import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.cornellappdev.uplift.data.repositories.SessionManager import com.cornellappdev.uplift.data.repositories.UserInfoRepository import com.cornellappdev.uplift.ui.UpliftRootRoute import com.cornellappdev.uplift.ui.nav.RootNavigationRepository @@ -19,6 +20,7 @@ import javax.inject.Inject class LoginViewModel @Inject constructor( private val userInfoRepository: UserInfoRepository, private val rootNavigationRepository: RootNavigationRepository, + private val sessionManager: SessionManager ) : ViewModel() { fun onSignInWithGoogle(credential: Credential) { @@ -42,13 +44,17 @@ class LoginViewModel @Inject constructor( return@launch } when { - userInfoRepository.hasUser(netId) -> rootNavigationRepository.navigate( - UpliftRootRoute.Home - ) - - userInfoRepository.hasFirebaseUser() -> rootNavigationRepository.navigate( - UpliftRootRoute.ProfileCreation - ) + userInfoRepository.hasUser(netId) -> { + val success = userInfoRepository.loginUser(netId) + if (success) { + Log.d("LoginViewModel", "User logged in successfully") + } else { + userInfoRepository.signOut() + } + } + userInfoRepository.hasFirebaseUser() -> { + rootNavigationRepository.navigate(UpliftRootRoute.ProfileCreation) + } //TODO: Handle error else -> { Log.e("Error", "Unexpected credential") diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/ProfileCreationViewModel.kt b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/ProfileCreationViewModel.kt index 9331c5d9..41256efd 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/ProfileCreationViewModel.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/ProfileCreationViewModel.kt @@ -48,7 +48,7 @@ class ProfileCreationViewModel @Inject constructor( val isSkipped = state.isGoalSkipped val goal = if (isSkipped) 0 else state.goal.toInt() if (userInfoRepository.createUser(email, name, netId, isSkipped, goal)) { - navigateToHome() + Log.e("ProfileCreationViewModel", "User created successfully") } else { //TODO: Add error handling Log.e("Error", "User not created") From b55c2f9ca181d31e20ea86705200004bc605e89d Mon Sep 17 00:00:00 2001 From: Preston Williams Date: Mon, 23 Mar 2026 11:43:30 -0400 Subject: [PATCH 2/8] deleted unused imports --- .../uplift/ui/viewmodels/onboarding/LoginViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/LoginViewModel.kt index 60db2590..9859f465 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/LoginViewModel.kt @@ -5,7 +5,6 @@ import androidx.credentials.Credential import androidx.credentials.CustomCredential import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.cornellappdev.uplift.data.repositories.SessionManager import com.cornellappdev.uplift.data.repositories.UserInfoRepository import com.cornellappdev.uplift.ui.UpliftRootRoute import com.cornellappdev.uplift.ui.nav.RootNavigationRepository From a49fda671981a05bd44b632bcff19b97a4a54fd8 Mon Sep 17 00:00:00 2001 From: Preston Williams Date: Mon, 23 Mar 2026 15:39:31 -0400 Subject: [PATCH 3/8] small testing change --- .../uplift/ui/viewmodels/onboarding/ProfileCreationViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/ProfileCreationViewModel.kt b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/ProfileCreationViewModel.kt index 41256efd..f5e21df4 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/ProfileCreationViewModel.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/onboarding/ProfileCreationViewModel.kt @@ -48,7 +48,7 @@ class ProfileCreationViewModel @Inject constructor( val isSkipped = state.isGoalSkipped val goal = if (isSkipped) 0 else state.goal.toInt() if (userInfoRepository.createUser(email, name, netId, isSkipped, goal)) { - Log.e("ProfileCreationViewModel", "User created successfully") + Log.d("ProfileCreationViewModel", "User created successfully") } else { //TODO: Add error handling Log.e("Error", "User not created") From 8e5acaf626eb0422f8d24581ba872449f618f62b Mon Sep 17 00:00:00 2001 From: Preston Williams Date: Mon, 23 Mar 2026 16:02:24 -0400 Subject: [PATCH 4/8] fixed testing and unsafe casting --- .../data/repositories/TokenAuthenticator.kt | 3 +++ .../uplift/data/repositories/TokenManager.kt | 2 +- .../data/repositories/UserInfoRepository.kt | 20 +++++++++++++------ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenAuthenticator.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenAuthenticator.kt index a7ba7a0c..1d7cf2e8 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenAuthenticator.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenAuthenticator.kt @@ -1,5 +1,6 @@ package com.cornellappdev.uplift.data.repositories +import android.util.Log import com.apollographql.apollo.ApolloClient import com.cornellappdev.uplift.RefreshAccessTokenMutation import kotlinx.coroutines.runBlocking @@ -57,6 +58,8 @@ class TokenAuthenticator @Inject constructor( } } catch (e: Exception) { // Network error or server down during refresh + Log.e("TokenAuthenticator", "Error refreshing token", e) + sessionManager.logout() null } } diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenManager.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenManager.kt index a5736fc4..e49e31f5 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenManager.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenManager.kt @@ -71,6 +71,6 @@ class TokenManager @Inject constructor(@ApplicationContext private val context: } } - fun getUserId(): Int? = sharedPreferences?.getInt("user_id", -1) // Use when userID is needed + fun getUserId(): Int? = sharedPreferences?.takeIf { it.contains("user_id") }?.getInt("user_id", -1) } diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt index efe0664d..2fcbd20a 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt @@ -47,7 +47,11 @@ class UserInfoRepository @Inject constructor( netId = netId ) ).execute() - val id = userFields.id + val id = userFields.id.toIntOrNull() + if (id == null) { + Log.e("UserInfoRepository", "Failed to set goal: non-numeric user ID '$id'") + return false + } val loginData = loginResponse.data?.loginUser if (loginData?.accessToken == null || loginData.refreshToken == null) { Log.e("UserInfoRepository", "Login failed after creation: ${loginResponse.errors}") @@ -56,15 +60,14 @@ class UserInfoRepository @Inject constructor( val accessToken = loginData.accessToken val refreshToken = loginData.refreshToken sessionManager.startSession( - userId = id.toIntOrNull() ?: -1, + userId = id, name = name, email = email, access = accessToken, refresh = refreshToken ) if (!skip) { - val numericId = id.toIntOrNull() - if (!uploadGoal(numericId, goal)) { + if (!uploadGoal(id, goal)) { return false } } @@ -96,9 +99,14 @@ class UserInfoRepository @Inject constructor( ).execute() val loginData = loginResponse.data?.loginUser val userInfo = getUserByNetId(netId) - if (loginData?.accessToken != null && loginData?.refreshToken != null && userInfo != null) { + if (loginData?.accessToken != null && loginData.refreshToken != null && userInfo != null) { + val id = userInfo.id.toIntOrNull() + if (id == null) { + Log.e("UserInfoRepository", "Failed to log in: non-numeric user ID '$id'") + return false + } sessionManager.startSession( - userId = userInfo.id.toIntOrNull() ?: -1, + userId = id, name = userInfo.name, email = userInfo.email, access = loginData.accessToken, From 93819e6936acfcd77d47b0f374c12dd64da6fdf1 Mon Sep 17 00:00:00 2001 From: Preston Williams Date: Tue, 24 Mar 2026 15:38:44 -0400 Subject: [PATCH 5/8] moved token and session stuff to own folder --- .../uplift/data/{repositories => auth}/AuthInterceptor.kt | 8 +++++--- .../uplift/data/{repositories => auth}/SessionManager.kt | 2 +- .../data/{repositories => auth}/TokenAuthenticator.kt | 4 +++- .../uplift/data/{repositories => auth}/TokenManager.kt | 6 +++--- .../uplift/data/repositories/UserInfoRepository.kt | 1 + .../main/java/com/cornellappdev/uplift/di/AppModule.kt | 4 ++-- .../com/cornellappdev/uplift/ui/MainNavigationWrapper.kt | 2 +- .../uplift/ui/viewmodels/nav/RootNavigationViewModel.kt | 2 +- 8 files changed, 17 insertions(+), 12 deletions(-) rename app/src/main/java/com/cornellappdev/uplift/data/{repositories => auth}/AuthInterceptor.kt (74%) rename app/src/main/java/com/cornellappdev/uplift/data/{repositories => auth}/SessionManager.kt (95%) rename app/src/main/java/com/cornellappdev/uplift/data/{repositories => auth}/TokenAuthenticator.kt (94%) rename app/src/main/java/com/cornellappdev/uplift/data/{repositories => auth}/TokenManager.kt (98%) diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/AuthInterceptor.kt b/app/src/main/java/com/cornellappdev/uplift/data/auth/AuthInterceptor.kt similarity index 74% rename from app/src/main/java/com/cornellappdev/uplift/data/repositories/AuthInterceptor.kt rename to app/src/main/java/com/cornellappdev/uplift/data/auth/AuthInterceptor.kt index a0bd4adb..496f197f 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/AuthInterceptor.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/auth/AuthInterceptor.kt @@ -1,5 +1,7 @@ -package com.cornellappdev.uplift.data.repositories +package com.cornellappdev.uplift.data.auth +import android.util.Log +import com.cornellappdev.uplift.data.auth.TokenManager import okhttp3.Interceptor import okhttp3.Response import javax.inject.Inject @@ -11,7 +13,7 @@ class AuthInterceptor @Inject constructor( ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val token = tokenManager.getAccessToken() - android.util.Log.d("AuthInterceptor", "token present = ${token != null}") + Log.d("AuthInterceptor", "token present = ${token != null}") val request = chain.request().newBuilder().apply { if (token != null) { addHeader("Authorization", "Bearer $token") @@ -19,4 +21,4 @@ class AuthInterceptor @Inject constructor( }.build() return chain.proceed(request) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/SessionManager.kt b/app/src/main/java/com/cornellappdev/uplift/data/auth/SessionManager.kt similarity index 95% rename from app/src/main/java/com/cornellappdev/uplift/data/repositories/SessionManager.kt rename to app/src/main/java/com/cornellappdev/uplift/data/auth/SessionManager.kt index a526d51a..8b287ee2 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/SessionManager.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/auth/SessionManager.kt @@ -1,4 +1,4 @@ -package com.cornellappdev.uplift.data.repositories +package com.cornellappdev.uplift.data.auth import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenAuthenticator.kt b/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenAuthenticator.kt similarity index 94% rename from app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenAuthenticator.kt rename to app/src/main/java/com/cornellappdev/uplift/data/auth/TokenAuthenticator.kt index 1d7cf2e8..f70c6617 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenAuthenticator.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenAuthenticator.kt @@ -1,8 +1,10 @@ -package com.cornellappdev.uplift.data.repositories +package com.cornellappdev.uplift.data.auth import android.util.Log import com.apollographql.apollo.ApolloClient import com.cornellappdev.uplift.RefreshAccessTokenMutation +import com.cornellappdev.uplift.data.auth.SessionManager +import com.cornellappdev.uplift.data.auth.TokenManager import kotlinx.coroutines.runBlocking import okhttp3.Authenticator import okhttp3.Request diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenManager.kt b/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenManager.kt similarity index 98% rename from app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenManager.kt rename to app/src/main/java/com/cornellappdev/uplift/data/auth/TokenManager.kt index e49e31f5..6e393641 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/TokenManager.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenManager.kt @@ -1,11 +1,11 @@ -package com.cornellappdev.uplift.data.repositories +package com.cornellappdev.uplift.data.auth import android.content.Context import android.content.SharedPreferences import android.util.Log +import androidx.core.content.edit import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey -import androidx.core.content.edit import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton @@ -73,4 +73,4 @@ class TokenManager @Inject constructor(@ApplicationContext private val context: fun getUserId(): Int? = sharedPreferences?.takeIf { it.contains("user_id") }?.getInt("user_id", -1) -} +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt index 2fcbd20a..ad60690d 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt @@ -11,6 +11,7 @@ import com.cornellappdev.uplift.CreateUserMutation import com.cornellappdev.uplift.GetUserByNetIdQuery import com.cornellappdev.uplift.LoginUserMutation import com.cornellappdev.uplift.SetWorkoutGoalsMutation +import com.cornellappdev.uplift.data.auth.SessionManager import kotlinx.coroutines.flow.map; import kotlinx.coroutines.flow.firstOrNull import com.cornellappdev.uplift.data.models.UserInfo diff --git a/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt b/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt index c00bf217..bbc0a27c 100644 --- a/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt +++ b/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt @@ -3,8 +3,8 @@ package com.cornellappdev.uplift.di import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.network.okHttpClient import com.cornellappdev.uplift.BuildConfig -import com.cornellappdev.uplift.data.repositories.AuthInterceptor -import com.cornellappdev.uplift.data.repositories.TokenAuthenticator +import com.cornellappdev.uplift.data.auth.AuthInterceptor +import com.cornellappdev.uplift.data.auth.TokenAuthenticator import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt b/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt index e02ced05..1643a358 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt @@ -32,7 +32,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import com.cornellappdev.uplift.data.repositories.SessionManager +import com.cornellappdev.uplift.data.auth.SessionManager import com.cornellappdev.uplift.ui.components.general.CheckInPopUp import com.cornellappdev.uplift.ui.components.general.ConfettiBurst import com.cornellappdev.uplift.ui.nav.BottomNavScreens diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/nav/RootNavigationViewModel.kt b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/nav/RootNavigationViewModel.kt index e9ae70a1..cc3b91ae 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/nav/RootNavigationViewModel.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/nav/RootNavigationViewModel.kt @@ -1,7 +1,7 @@ package com.cornellappdev.uplift.ui.viewmodels.nav import androidx.lifecycle.viewModelScope -import com.cornellappdev.uplift.data.repositories.SessionManager +import com.cornellappdev.uplift.data.auth.SessionManager import com.cornellappdev.uplift.data.repositories.UserInfoRepository import com.cornellappdev.uplift.ui.UpliftRootRoute import com.cornellappdev.uplift.ui.nav.RootNavigationRepository From 85f38b67e0e9ac30b476fb2eb66965a0d0ea4447 Mon Sep 17 00:00:00 2001 From: Preston Williams Date: Tue, 24 Mar 2026 16:07:08 -0400 Subject: [PATCH 6/8] added timeouts --- .../uplift/data/auth/AuthInterceptor.kt | 3 +-- .../uplift/data/auth/TokenAuthenticator.kt | 17 ++++++++++------- .../com/cornellappdev/uplift/di/AppModule.kt | 6 +++++- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/uplift/data/auth/AuthInterceptor.kt b/app/src/main/java/com/cornellappdev/uplift/data/auth/AuthInterceptor.kt index 496f197f..9579636a 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/auth/AuthInterceptor.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/auth/AuthInterceptor.kt @@ -1,7 +1,6 @@ package com.cornellappdev.uplift.data.auth import android.util.Log -import com.cornellappdev.uplift.data.auth.TokenManager import okhttp3.Interceptor import okhttp3.Response import javax.inject.Inject @@ -16,7 +15,7 @@ class AuthInterceptor @Inject constructor( Log.d("AuthInterceptor", "token present = ${token != null}") val request = chain.request().newBuilder().apply { if (token != null) { - addHeader("Authorization", "Bearer $token") + header("Authorization", "Bearer $token") } }.build() return chain.proceed(request) diff --git a/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenAuthenticator.kt b/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenAuthenticator.kt index f70c6617..87908cbd 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenAuthenticator.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenAuthenticator.kt @@ -6,6 +6,7 @@ import com.cornellappdev.uplift.RefreshAccessTokenMutation import com.cornellappdev.uplift.data.auth.SessionManager import com.cornellappdev.uplift.data.auth.TokenManager import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import okhttp3.Authenticator import okhttp3.Request import okhttp3.Response @@ -26,7 +27,7 @@ class TokenAuthenticator @Inject constructor( // Check if the token was already refreshed by another thread // while this request was waiting for the lock. val currentToken = tokenManager.getAccessToken() - val requestToken = response.request.header("Authorization")?.removePrefix("Bearer ") + val requestToken = response.request.header("Authorization")?.substringAfter("Bearer ") if (currentToken != requestToken && currentToken != null) { return response.request.newBuilder() @@ -38,11 +39,13 @@ class TokenAuthenticator @Inject constructor( // we use runBlocking to wait for the refresh mutation result. return runBlocking { try { - val mutationResponse = apolloClient.mutation(RefreshAccessTokenMutation()) - // We manually add the Refresh Token to this specific call - // because the "refresh" ApolloClient has no interceptor. - .addHttpHeader("Authorization", "Bearer $refreshToken") - .execute() + val mutationResponse = withTimeout(10000L) { + apolloClient.mutation(RefreshAccessTokenMutation()) + // We manually add the Refresh Token to this specific call + // because the "refresh" ApolloClient has no interceptor. + .addHttpHeader("Authorization", "Bearer $refreshToken") + .execute() + } val newAccessToken = mutationResponse.data?.refreshAccessToken?.newAccessToken @@ -60,7 +63,7 @@ class TokenAuthenticator @Inject constructor( } } catch (e: Exception) { // Network error or server down during refresh - Log.e("TokenAuthenticator", "Error refreshing token", e) + Log.e("TokenAuthenticator", "Refresh timed out or failed", e) sessionManager.logout() null } diff --git a/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt b/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt index bbc0a27c..43761590 100644 --- a/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt +++ b/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt @@ -10,6 +10,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit import javax.inject.Named import javax.inject.Singleton @@ -23,7 +24,10 @@ object AppModule { @Named("refresh") // Use a named annotation to distinguish them fun provideRefreshApolloClient(): ApolloClient { // This client does NOT have interceptors to avoid loops - val okHttpClient = OkHttpClient.Builder().build() + val okHttpClient = OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() return ApolloClient.Builder() .serverUrl(BuildConfig.BACKEND_URL) From 598ebda33fde70b94b8e5edcb80d5bf85c7f7fff Mon Sep 17 00:00:00 2001 From: Preston Williams Date: Wed, 25 Mar 2026 13:49:30 -0400 Subject: [PATCH 7/8] added global scope and fixed suggestions --- .../uplift/data/auth/SessionManager.kt | 21 +++++++++----- .../uplift/data/auth/TokenAuthenticator.kt | 19 +++++++++--- .../uplift/data/auth/TokenManager.kt | 9 +++++- .../data/repositories/UserInfoRepository.kt | 29 ++++++++++--------- .../com/cornellappdev/uplift/di/AppModule.kt | 14 +++++++++ .../uplift/ui/MainNavigationWrapper.kt | 10 ++----- .../viewmodels/nav/RootNavigationViewModel.kt | 17 ++++++++++- 7 files changed, 86 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/uplift/data/auth/SessionManager.kt b/app/src/main/java/com/cornellappdev/uplift/data/auth/SessionManager.kt index 8b287ee2..f48191c9 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/auth/SessionManager.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/auth/SessionManager.kt @@ -1,30 +1,37 @@ package com.cornellappdev.uplift.data.auth -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import javax.inject.Inject import javax.inject.Singleton +import com.cornellappdev.uplift.di.AppModule.ApplicationScope @Singleton class SessionManager @Inject constructor( - private val tokenManager: TokenManager + private val tokenManager: TokenManager, + @ApplicationScope private val upliftScope: CoroutineScope ) { // A reactive flow that the UI can collect - private val _isLoggedIn = MutableStateFlow(tokenManager.getAccessToken() != null) - val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() + val isLoggedIn: StateFlow = tokenManager.tokenFlow + .map { token -> token != null } + .stateIn( + scope = upliftScope, + started = SharingStarted.Eagerly, + initialValue = tokenManager.getAccessToken() != null + ) // Call this after LoginUser or CreateUser mutations succeed fun startSession(userId: Int, name: String, email: String, access: String, refresh: String) { tokenManager.saveTokens(access, refresh) tokenManager.saveUserSession(userId, name, email) - _isLoggedIn.value = true } // Call this for manual logout or when refresh fails fun logout() { tokenManager.clearTokens() - _isLoggedIn.value = false } val userId: Int? get() = tokenManager.getUserId() diff --git a/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenAuthenticator.kt b/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenAuthenticator.kt index 87908cbd..c37e22cc 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenAuthenticator.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenAuthenticator.kt @@ -3,8 +3,6 @@ package com.cornellappdev.uplift.data.auth import android.util.Log import com.apollographql.apollo.ApolloClient import com.cornellappdev.uplift.RefreshAccessTokenMutation -import com.cornellappdev.uplift.data.auth.SessionManager -import com.cornellappdev.uplift.data.auth.TokenManager import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import okhttp3.Authenticator @@ -21,6 +19,10 @@ class TokenAuthenticator @Inject constructor( ) : Authenticator { override fun authenticate(route: Route?, response: Response): Request? { + if (responseCount(response) >= 2) { + return null + } + val refreshToken = tokenManager.getRefreshToken() ?: return null synchronized(this) { @@ -29,7 +31,7 @@ class TokenAuthenticator @Inject constructor( val currentToken = tokenManager.getAccessToken() val requestToken = response.request.header("Authorization")?.substringAfter("Bearer ") - if (currentToken != requestToken && currentToken != null) { + if (currentToken != null && currentToken != requestToken ) { return response.request.newBuilder() .header("Authorization", "Bearer $currentToken") .build() @@ -49,7 +51,7 @@ class TokenAuthenticator @Inject constructor( val newAccessToken = mutationResponse.data?.refreshAccessToken?.newAccessToken - if (newAccessToken != null) { + if (newAccessToken != null && newAccessToken != requestToken) { tokenManager.saveTokens(newAccessToken, refreshToken) // Retry the original request with the new Access Token @@ -70,4 +72,13 @@ class TokenAuthenticator @Inject constructor( } } } + + private fun responseCount(response: Response?): Int { + var result = 1 + // Traverse the chain of prior responses + while (response?.priorResponse != null) { + result++ + } + return result + } } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenManager.kt b/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenManager.kt index 6e393641..3f5e099b 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenManager.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenManager.kt @@ -7,6 +7,8 @@ import androidx.core.content.edit import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject import javax.inject.Singleton @@ -34,6 +36,9 @@ class TokenManager @Inject constructor(@ApplicationContext private val context: } } + private val _tokenFlow = MutableStateFlow(getAccessToken()) + val tokenFlow = _tokenFlow.asStateFlow() + private fun createEncryptedPrefs(): SharedPreferences { val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) @@ -53,6 +58,7 @@ class TokenManager @Inject constructor(@ApplicationContext private val context: putString("access_token", accessToken) putString("refresh_token", refreshToken) } + _tokenFlow.value = accessToken } fun getAccessToken(): String? = sharedPreferences?.getString("access_token", null) @@ -61,6 +67,7 @@ class TokenManager @Inject constructor(@ApplicationContext private val context: fun clearTokens() { sharedPreferences?.edit { clear() } + _tokenFlow.value = null } fun saveUserSession(userId: Int, username: String, userEmail: String) { @@ -73,4 +80,4 @@ class TokenManager @Inject constructor(@ApplicationContext private val context: fun getUserId(): Int? = sharedPreferences?.takeIf { it.contains("user_id") }?.getInt("user_id", -1) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt b/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt index ad60690d..385700db 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/repositories/UserInfoRepository.kt @@ -50,7 +50,7 @@ class UserInfoRepository @Inject constructor( ).execute() val id = userFields.id.toIntOrNull() if (id == null) { - Log.e("UserInfoRepository", "Failed to set goal: non-numeric user ID '$id'") + Log.e("UserInfoRepository", "Failed to set goal: non-numeric user ID '$userFields.id'") return false } val loginData = loginResponse.data?.loginUser @@ -60,15 +60,8 @@ class UserInfoRepository @Inject constructor( } val accessToken = loginData.accessToken val refreshToken = loginData.refreshToken - sessionManager.startSession( - userId = id, - name = name, - email = email, - access = accessToken, - refresh = refreshToken - ) if (!skip) { - if (!uploadGoal(id, goal)) { + if (!uploadGoal(id, goal, accessToken)) { return false } } @@ -83,6 +76,13 @@ class UserInfoRepository @Inject constructor( goalSkip = skip, goal = goal ) + sessionManager.startSession( + userId = id, + name = name, + email = email, + access = accessToken, + refresh = refreshToken + ) Log.d("UserInfoRepositoryImpl", "User created successfully") return true } catch (e: Exception) { @@ -103,7 +103,7 @@ class UserInfoRepository @Inject constructor( if (loginData?.accessToken != null && loginData.refreshToken != null && userInfo != null) { val id = userInfo.id.toIntOrNull() if (id == null) { - Log.e("UserInfoRepository", "Failed to log in: non-numeric user ID '$id'") + Log.e("UserInfoRepository", "Failed to log in: non-numeric user ID '$userInfo.id'") return false } sessionManager.startSession( @@ -124,18 +124,21 @@ class UserInfoRepository @Inject constructor( } } - suspend fun uploadGoal(id:Int?, goal: Int): Boolean { + suspend fun uploadGoal(id:Int?, goal: Int, manualToken: String? = null): Boolean { if (id == null) { Log.e("UserInfoRepository", "Failed to set goal: non-numeric user ID '$id'") return false } - val goalResponse = apolloClient.mutation( + val call = apolloClient.mutation( SetWorkoutGoalsMutation( userId = id, workoutGoal = goal ) ) - .execute() + if (manualToken != null) { + call.addHttpHeader("Authorization", "Bearer $manualToken") + } + val goalResponse = call.execute() if (goalResponse.hasErrors()) { Log.e("UserInfoRepository", "Failed to set goal: ${goalResponse.errors}") return false diff --git a/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt b/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt index 43761590..c36ef77d 100644 --- a/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt +++ b/app/src/main/java/com/cornellappdev/uplift/di/AppModule.kt @@ -9,9 +9,13 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import okhttp3.OkHttpClient import java.util.concurrent.TimeUnit import javax.inject.Named +import javax.inject.Qualifier import javax.inject.Singleton @@ -19,6 +23,16 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object AppModule { + @Retention(AnnotationRetention.BINARY) + @Qualifier + annotation class ApplicationScope + + @Provides + @Singleton + @ApplicationScope + fun provideApplicationScope(): CoroutineScope = + CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + @Provides @Singleton @Named("refresh") // Use a named annotation to distinguish them diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt b/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt index 1643a358..74e41b00 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt @@ -12,7 +12,6 @@ import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -32,7 +31,6 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import com.cornellappdev.uplift.data.auth.SessionManager import com.cornellappdev.uplift.ui.components.general.CheckInPopUp import com.cornellappdev.uplift.ui.components.general.ConfettiBurst import com.cornellappdev.uplift.ui.nav.BottomNavScreens @@ -78,14 +76,12 @@ fun MainNavigationWrapper( gymDetailViewModel: GymDetailViewModel = hiltViewModel(), classDetailViewModel: ClassDetailViewModel = hiltViewModel(), rootNavigationViewModel: RootNavigationViewModel = hiltViewModel(), - sessionManager: SessionManager = hiltViewModel().let { - hiltViewModel().sessionManager - } - ) { val rootNavigationUiState = rootNavigationViewModel.collectUiStateValue() val startDestination = rootNavigationUiState.startDestination + val sessionManager = rootNavigationViewModel.sessionManager + val navController = rememberNavController() val systemUiController: SystemUiController = rememberSystemUiController() @@ -106,7 +102,7 @@ fun MainNavigationWrapper( systemUiController.setStatusBarColor(PRIMARY_YELLOW) - val isLoggedIn by sessionManager.isLoggedIn.collectAsState() + val isLoggedIn = rootNavigationViewModel.collectUiStateValue().isLoggedIn LaunchedEffect(isLoggedIn) { if (!isLoggedIn && ONBOARDING_FLAG) { diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/nav/RootNavigationViewModel.kt b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/nav/RootNavigationViewModel.kt index cc3b91ae..b7283352 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/nav/RootNavigationViewModel.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/viewmodels/nav/RootNavigationViewModel.kt @@ -18,9 +18,20 @@ class RootNavigationViewModel @Inject constructor( private val userInfoRepository: UserInfoRepository, val sessionManager: SessionManager ) : UpliftViewModel( - initialUiState = RootNavigationUiState() + initialUiState = RootNavigationUiState( + isLoggedIn = sessionManager.isLoggedIn.value, + startDestination = if (!ONBOARDING_FLAG) { + UpliftRootRoute.Home + } else if (sessionManager.isLoggedIn.value) { + UpliftRootRoute.Home + } + else { + UpliftRootRoute.Onboarding + } + ) ) { data class RootNavigationUiState( + val isLoggedIn: Boolean = false, val navEvent: UIEvent? = null, val popBackStack: UIEvent? = null, val navigateUp: UIEvent? = null, @@ -49,6 +60,10 @@ class RootNavigationViewModel @Inject constructor( viewModelScope.launch { sessionManager.isLoggedIn.collect { loggedIn -> + applyMutation { + copy(isLoggedIn = loggedIn) + } + val hasSkipped = userInfoRepository.getSkipFromDataStore() val shouldShowHome = loggedIn || hasSkipped || !ONBOARDING_FLAG applyMutation { From e13dbc59127292731fea92f1fa8eb9ece4e2165f Mon Sep 17 00:00:00 2001 From: Preston Williams Date: Wed, 25 Mar 2026 14:02:17 -0400 Subject: [PATCH 8/8] fixed coderabbit suggestions --- .../uplift/data/auth/TokenAuthenticator.kt | 12 ++++++++---- .../cornellappdev/uplift/ui/MainNavigationWrapper.kt | 9 ++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenAuthenticator.kt b/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenAuthenticator.kt index c37e22cc..634a2920 100644 --- a/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenAuthenticator.kt +++ b/app/src/main/java/com/cornellappdev/uplift/data/auth/TokenAuthenticator.kt @@ -11,7 +11,9 @@ import okhttp3.Response import okhttp3.Route import javax.inject.Inject import javax.inject.Named +import javax.inject.Singleton +@Singleton class TokenAuthenticator @Inject constructor( private val tokenManager: TokenManager, private val sessionManager: SessionManager, @@ -75,10 +77,12 @@ class TokenAuthenticator @Inject constructor( private fun responseCount(response: Response?): Int { var result = 1 + var current = response // Traverse the chain of prior responses - while (response?.priorResponse != null) { - result++ + while (current?.priorResponse != null) { + result++ + current = current.priorResponse + } + return result } - return result - } } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt b/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt index 74e41b00..cce66b8f 100644 --- a/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt +++ b/app/src/main/java/com/cornellappdev/uplift/ui/MainNavigationWrapper.kt @@ -13,6 +13,9 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -80,8 +83,6 @@ fun MainNavigationWrapper( val rootNavigationUiState = rootNavigationViewModel.collectUiStateValue() val startDestination = rootNavigationUiState.startDestination - val sessionManager = rootNavigationViewModel.sessionManager - val navController = rememberNavController() val systemUiController: SystemUiController = rememberSystemUiController() @@ -103,13 +104,15 @@ fun MainNavigationWrapper( systemUiController.setStatusBarColor(PRIMARY_YELLOW) val isLoggedIn = rootNavigationViewModel.collectUiStateValue().isLoggedIn + var wasLoggedIn by rememberSaveable { mutableStateOf(isLoggedIn) } LaunchedEffect(isLoggedIn) { - if (!isLoggedIn && ONBOARDING_FLAG) { + if (wasLoggedIn && !isLoggedIn && ONBOARDING_FLAG) { navController.navigate(UpliftRootRoute.Onboarding) { popUpTo(0) } } + wasLoggedIn = isLoggedIn } //TODO: Try to consolidate launched effects into one with consumeIn function that takes in coroutine scope