Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ android {
"GOOGLE_AUTH_CLIENT_ID", secretsProperties["GOOGLE_AUTH_CLIENT_ID"]
)
signingConfig signingConfigs.debug
buildConfigField("boolean", "ONBOARDING_FLAG", "true")
buildConfigField("boolean", "CHECK_IN_FLAG", "true")
buildConfigField("boolean", "ONBOARDING_FLAG", "false")
buildConfigField("boolean", "CHECK_IN_FLAG", "false")
}
}
compileOptions {
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/graphql/User.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,15 @@ mutation LoginUser($netId: String!) {
refreshToken
}
}

mutation LogoutUser {
logoutUser {
success
}
}

mutation RefreshAccessToken {
refreshAccessToken {
newAccessToken
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.cornellappdev.uplift.data.repositories
package com.cornellappdev.uplift.data.auth

import android.util.Log
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
Expand All @@ -11,12 +12,12 @@ 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")
header("Authorization", "Bearer $token")
}
}.build()
return chain.proceed(request)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.cornellappdev.uplift.data.auth

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<Boolean> = _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()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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
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
Copy link
Member

@AndrewCheung360 AndrewCheung360 Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably out of scope of this PR, but for refresh tokens, if we know when refresh tokens expire from the backend and if backend has some endpoint that allows us to get a new refresh token aside from forcing the user to log in, it would probably be better UX to get the new refresh token before it expires and keep the user logged in for longer and then fall back to logging the user out for cases where maybe the user hasn't opened the app in a long time.


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")?.substringAfter("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 = 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

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
Log.e("TokenAuthenticator", "Refresh timed out or failed", e)
sessionManager.logout()
null
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) {
Copy link
Member

@AndrewCheung360 AndrewCheung360 Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: It's probably fine to have this here, but the naming of this class implies mainly handling tokens so having other user data stuff here might be a little confusing

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the above clearTokens function will now clear these data fields as well, which could be the intended behavior anyways but same "issue" as above

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think clearing the data fields makes sense since if you don't have tokens, you would get logged out and hence, you wouldn't use the user data anyway

sharedPreferences?.edit {
putInt("user_id", userId)
putString("username", username)
putString("user_email", userEmail)
}
}

fun getUserId(): Int? = sharedPreferences?.takeIf { it.contains("user_id") }?.getInt("user_id", -1)

}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Preferences>,
private val apolloClient: ApolloClient,
@Named("main") private val apolloClient: ApolloClient,
private val userInfoRepository: UserInfoRepository
){
private val _nearestGymFlow = MutableStateFlow<UpliftGym?>(null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PopularTimesQuery.Data> {
return apolloClient.query(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ import com.cornellappdev.uplift.data.models.ProfileData
import com.cornellappdev.uplift.data.models.WorkoutDomain
import java.time.Instant
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton


@Singleton
class ProfileRepository @Inject constructor(
private val userInfoRepository: UserInfoRepository,
private val apolloClient: ApolloClient
@Named("main") private val apolloClient: ApolloClient
) {
suspend fun getProfile(): Result<ProfileData> = runCatching {
val netId = userInfoRepository.getNetIdFromDataStore()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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())
Expand Down
Loading
Loading