diff --git a/app/src/main/java/com/cornellappdev/resell/android/model/api/RetrofitInstance.kt b/app/src/main/java/com/cornellappdev/resell/android/model/api/RetrofitInstance.kt index 182ebc7..a300587 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/model/api/RetrofitInstance.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/model/api/RetrofitInstance.kt @@ -4,11 +4,17 @@ import android.util.Log import com.cornellappdev.resell.android.BuildConfig import com.cornellappdev.resell.android.model.core.UserInfoRepository import com.cornellappdev.resell.android.model.login.FirebaseAuthRepository +import com.cornellappdev.resell.android.model.login.GoogleAuthRepository +import com.cornellappdev.resell.android.ui.screens.root.ResellRootRoute +import com.cornellappdev.resell.android.viewmodel.navigation.RootNavigationRepository +import com.cornellappdev.resell.android.viewmodel.root.RootConfirmationRepository import kotlinx.coroutines.runBlocking import okhttp3.Authenticator import okhttp3.Interceptor import okhttp3.OkHttpClient +import okhttp3.Response import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.logging.HttpLoggingInterceptor import org.json.JSONObject import retrofit2.Retrofit @@ -20,6 +26,9 @@ import javax.inject.Singleton class RetrofitInstance @Inject constructor( private val firebaseAuthRepository: FirebaseAuthRepository, private val userInfoRepository: UserInfoRepository, + private val googleAuthRepository: GoogleAuthRepository, + private val rootNavigationRepository: RootNavigationRepository, + private val rootConfirmationRepository: RootConfirmationRepository, ) { private var cachedToken: String? = null @@ -65,7 +74,7 @@ class RetrofitInstance @Inject constructor( // Get the `errors` response try { - val jsonObject = JSONObject(responseBody) + val jsonObject = JSONObject(responseBody ?: "") val errors = jsonObject.optJSONArray("errors") if (errors != null) { Log.e("OkHttpErrorResponse", "Errors: $errors") @@ -76,13 +85,25 @@ class RetrofitInstance @Inject constructor( } response.newBuilder() - .body(ResponseBody.create(response.body?.contentType(), responseBody ?: "")) + .body((responseBody ?: "").toResponseBody(response.body?.contentType())) .build() } private val authenticator = Authenticator { _, response -> - // Ping firebase for a refresh. - val accessToken = runBlocking { firebaseAuthRepository.getFirebaseAccessToken() } + if (responseCount(response) >= 2) { + // Already retried once, still getting 401 — force sign out + runBlocking { + googleAuthRepository.signOut() + rootNavigationRepository.navigate(ResellRootRoute.LANDING) + rootConfirmationRepository.showError( + message = "Authentication Failed. Please try signing in again!" + ) + } + return@Authenticator null // Give up — don't retry again + } + + // Ping firebase for a refresh. Force refresh. + val accessToken = runBlocking { firebaseAuthRepository.getFirebaseAccessToken(true) } cachedToken = accessToken if (accessToken != null) { response.request.newBuilder() @@ -94,6 +115,19 @@ class RetrofitInstance @Inject constructor( } } + /** + * Helper to count how many times we've already retried this request. + */ + private fun responseCount(response: Response): Int { + var count = 1 + var priorResponse = response.priorResponse + while (priorResponse != null) { + count++ + priorResponse = priorResponse.priorResponse + } + return count + } + // Build OkHttpClient with the dynamic auth interceptor private val okHttpClient = OkHttpClient.Builder().apply { if (BuildConfig.DEBUG) { diff --git a/app/src/main/java/com/cornellappdev/resell/android/model/api/SettingsApiService.kt b/app/src/main/java/com/cornellappdev/resell/android/model/api/SettingsApiService.kt index 71be5f3..12a8b35 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/model/api/SettingsApiService.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/model/api/SettingsApiService.kt @@ -18,7 +18,7 @@ interface SettingsApiService { suspend fun sendFeedback(@Body feedback: Feedback) @POST("user") - suspend fun editUser(@Body user: EditUser) + suspend fun editUser(@Body user: EditUser): UserResponse } data class EditUser( diff --git a/app/src/main/java/com/cornellappdev/resell/android/model/api/UserApiService.kt b/app/src/main/java/com/cornellappdev/resell/android/model/api/UserApiService.kt index eee71ec..a8d7918 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/model/api/UserApiService.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/model/api/UserApiService.kt @@ -30,7 +30,7 @@ interface UserApiService { suspend fun logoutUser(@Body body: LogoutBody) @POST("user/create") - suspend fun createUser(@Body createUserBody: CreateUserBody): UserResponse + suspend fun createUser(@Body createUserBody: CreateUserBody): User } data class LogoutBody( diff --git a/app/src/main/java/com/cornellappdev/resell/android/model/login/FirebaseAuthRepository.kt b/app/src/main/java/com/cornellappdev/resell/android/model/login/FirebaseAuthRepository.kt index a49cf09..89563d6 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/model/login/FirebaseAuthRepository.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/model/login/FirebaseAuthRepository.kt @@ -38,9 +38,11 @@ class FirebaseAuthRepository @Inject constructor( * * If retrieved successfully, will also store the access token in [UserInfoRepository] * for use in the retrofit interceptor. + * + * @param forceRefresh Whether to force a firebase refresh of the access token. */ - suspend fun getFirebaseAccessToken(): String? { - val token = firebaseAuth.currentUser?.getIdToken(false)?.await()?.token + suspend fun getFirebaseAccessToken(forceRefresh: Boolean = false): String? { + val token = firebaseAuth.currentUser?.getIdToken(forceRefresh)?.await()?.token if (token == null) { Log.e("FirebaseAuthRepository", "Access token is null.") return null diff --git a/app/src/main/java/com/cornellappdev/resell/android/model/login/ResellAuthRepository.kt b/app/src/main/java/com/cornellappdev/resell/android/model/login/ResellAuthRepository.kt index 52f0b85..3fc1a4e 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/model/login/ResellAuthRepository.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/model/login/ResellAuthRepository.kt @@ -14,7 +14,7 @@ class ResellAuthRepository @Inject constructor( private val firebaseMessagingRepository: FirebaseMessagingRepository, private val googleAuthRepository: GoogleAuthRepository, ) { - suspend fun createUser(createUserBody: CreateUserBody) = + suspend fun createUser(createUserBody: CreateUserBody): User = retrofitInstance.userApi.createUser(createUserBody) /** diff --git a/app/src/main/java/com/cornellappdev/resell/android/model/settings/SettingsRepository.kt b/app/src/main/java/com/cornellappdev/resell/android/model/settings/SettingsRepository.kt index 5e3e1a3..361ef62 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/model/settings/SettingsRepository.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/model/settings/SettingsRepository.kt @@ -6,6 +6,7 @@ import com.cornellappdev.resell.android.model.api.Feedback import com.cornellappdev.resell.android.model.api.ReportPostBody import com.cornellappdev.resell.android.model.api.ReportProfileBody import com.cornellappdev.resell.android.model.api.RetrofitInstance +import com.cornellappdev.resell.android.model.api.UserResponse import com.cornellappdev.resell.android.model.core.UserInfoRepository import com.cornellappdev.resell.android.model.login.FireStoreRepository import com.cornellappdev.resell.android.model.profile.ProfileRepository @@ -64,8 +65,8 @@ class SettingsRepository @Inject constructor( venmo: String, bio: String, image: ImageBitmap? - ) { - retrofitInstance.settingsApi.editUser( + ): UserResponse { + val response = retrofitInstance.settingsApi.editUser( EditUser( username = username, venmoHandle = venmo, @@ -75,5 +76,7 @@ class SettingsRepository @Inject constructor( ) profileRepository.fetchInternalProfile(userInfoRepository.getUserId()!!) + + return response } } diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/chat/messages/MessageCardLoading.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/chat/messages/MessageCardLoading.kt new file mode 100644 index 0000000..498b001 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/chat/messages/MessageCardLoading.kt @@ -0,0 +1,84 @@ +package com.cornellappdev.resell.android.ui.components.chat.messages + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.cornellappdev.resell.android.ui.components.main.ProfilePictureView +import com.cornellappdev.resell.android.ui.theme.ResellPreview +import com.cornellappdev.resell.android.util.LocalInfiniteShimmer + +@Composable +fun MessageCardLoading(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .height(64.dp) + .fillMaxWidth() + .background(Color.White) + .padding(horizontal = 24.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row { + ProfilePictureView( + imageUrl = "", + modifier = Modifier + .fillMaxHeight() + .aspectRatio(1f) + .size(32.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + } + + Column( + modifier = Modifier + .fillMaxHeight() + .weight(1f) + .padding(end = 16.dp), + verticalArrangement = Arrangement.SpaceEvenly + ) { + Row( + modifier = Modifier.height(25.dp), + verticalAlignment = Alignment.CenterVertically + ) { + LoadingBlob(modifier = Modifier + .height(height = 20.dp) + .weight(1f)) + } + Spacer(modifier = Modifier.height(1.dp)) + LoadingBlob(modifier = Modifier.size(width = 100.dp, height = 20.dp)) + } + } +} + +@Composable +private fun LoadingBlob( + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(100.dp), + color = LocalInfiniteShimmer.current + ) {} +} + +@Preview +@Composable +private fun MessageCardLoadingPreview() = ResellPreview { + MessageCardLoading() +} diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/chat/messages/MessagesScrollLoading.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/chat/messages/MessagesScrollLoading.kt new file mode 100644 index 0000000..ff11a42 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/chat/messages/MessagesScrollLoading.kt @@ -0,0 +1,32 @@ +package com.cornellappdev.resell.android.ui.components.chat.messages + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.cornellappdev.resell.android.ui.theme.ResellPreview + +@Composable +fun MessagesScrollLoading( + modifier: Modifier = Modifier, + count: Int, +) { + LazyColumn( + contentPadding = PaddingValues( + bottom = 100.dp, + ), + modifier = modifier, + ) { + items(count = count) { + MessageCardLoading() + } + } +} + +@Preview +@Composable +private fun MessagesScrollLoadingPreview() = ResellPreview { + MessagesScrollLoading(count = 3) +} diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/chat/messages/ResellMessagesScroll.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/chat/messages/ResellMessagesScroll.kt index d48cf94..b5ddf05 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/chat/messages/ResellMessagesScroll.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/chat/messages/ResellMessagesScroll.kt @@ -18,13 +18,11 @@ fun ResellMessagesScroll( onChatPressed: (ChatHeaderData) -> Unit, listState: LazyListState, modifier: Modifier = Modifier, - paddedTop: Dp = 0.dp, ) { LazyColumn( state = listState, contentPadding = PaddingValues( bottom = 100.dp, - top = paddedTop, ), modifier = modifier, ) { diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/MessagesScreen.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/MessagesScreen.kt index c628649..d5f11e1 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/MessagesScreen.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/MessagesScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.cornellappdev.resell.android.model.classes.ResellApiState import com.cornellappdev.resell.android.ui.components.chat.messages.MessageTag +import com.cornellappdev.resell.android.ui.components.chat.messages.MessagesScrollLoading import com.cornellappdev.resell.android.ui.components.chat.messages.ResellMessagesScroll import com.cornellappdev.resell.android.ui.theme.Padding import com.cornellappdev.resell.android.ui.theme.Style @@ -69,7 +70,10 @@ fun MessagesScreen( ) { when (chatUiState.loadedState) { ResellApiState.Loading -> { - // TODO Loading State + MessagesScrollLoading( + modifier = Modifier.fillMaxSize(), + count = chatUiState.numLoadingChats + ) } ResellApiState.Error -> { diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/HomeViewModel.kt index 0edfec8..0a8ce2b 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/HomeViewModel.kt @@ -69,6 +69,10 @@ class HomeViewModel @Inject constructor( viewModelScope.launch { try { val posts = resellPostRepository.getPostsByPage(1) + + // if current filter changed since then, do nothing + if (stateValue().activeFilter != HomeFilter.RECENT) return@launch + applyMutation { copy( listings = posts.map { it.toListing() }, @@ -103,6 +107,10 @@ class HomeViewModel @Inject constructor( val posts = resellPostRepository.getPostsByFilter( filter.name ) + + // if current filter changed since then, do nothing + if (stateValue().activeFilter != filter) return@launch + applyMutation { copy( listings = posts.map { it.toListing() }, @@ -140,6 +148,10 @@ class HomeViewModel @Inject constructor( val newPage = resellPostRepository.getPostsByPage(stateValue().page).map { it.toListing() } + + // if current filter changed since then, do nothing + if (stateValue().activeFilter != HomeFilter.RECENT) return@launch + applyMutation { copy( listings = stateValue().listings + newPage, diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/MessagesViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/MessagesViewModel.kt index e77cbb1..109e557 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/MessagesViewModel.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/MessagesViewModel.kt @@ -58,6 +58,12 @@ class MessagesViewModel @Inject constructor( } } + val numLoadingChats: Int + get() = when (chatType) { + ChatType.Purchases -> 4 + ChatType.Offers -> 2 + } + val loadedState: ResellApiState get() = filteredChats.toResellApiState() diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/onboarding/LandingViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/onboarding/LandingViewModel.kt index 4fb0939..b329087 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/onboarding/LandingViewModel.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/onboarding/LandingViewModel.kt @@ -28,6 +28,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import retrofit2.HttpException import javax.inject.Inject @HiltViewModel @@ -170,6 +171,16 @@ class LandingViewModel @Inject constructor( userInfoRepository.storeUserFromUserObject(user) rootNavigationRepository.navigate(ResellRootRoute.MAIN) } + } catch (e: HttpException) { + if (e.code() == 403) { + // 403 indicates that we need to create a new user. + rootNavigationRepository.navigate(ResellRootRoute.ONBOARDING) + } + else { + Log.e("LandingViewModel", "Error getting user: ", e) + onSignInFailed(showSheet = true) + rootConfirmationRepository.showError() + } } catch (e: Exception) { Log.e("LandingViewModel", "Error getting user: ", e) onSignInFailed(showSheet = false) diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/onboarding/VenmoFieldViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/onboarding/VenmoFieldViewModel.kt index 45a7509..3be8a88 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/onboarding/VenmoFieldViewModel.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/onboarding/VenmoFieldViewModel.kt @@ -108,7 +108,7 @@ class VenmoFieldViewModel @Inject constructor( viewModelScope.launch { val googleUser = googleAuthRepository.accountOrNull()!! try { - val response = resellAuthRepository.createUser( + val user = resellAuthRepository.createUser( CreateUserBody( username = stateValue().username, netid = googleUser.email!!.split("@")[0], @@ -125,7 +125,7 @@ class VenmoFieldViewModel @Inject constructor( ) ) - userInfoRepository.storeUserFromUserObject(response.user) + userInfoRepository.storeUserFromUserObject(user) rootNavigationRepository.navigate(ResellRootRoute.MAIN) rootNavigationSheetRepository.showBottomSheet(RootSheet.Welcome) } catch (e: Exception) { diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/settings/EditProfileViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/settings/EditProfileViewModel.kt index 3c27375..3117b2a 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/settings/EditProfileViewModel.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/settings/EditProfileViewModel.kt @@ -89,13 +89,14 @@ class EditProfileViewModel @Inject constructor( viewModelScope.launch { try { - settingsRepository.editProfile( + val response = settingsRepository.editProfile( username = stateValue().username, venmo = stateValue().venmo, bio = stateValue().bio, image = stateValue().imageBitmap?.asImageBitmap() ) settingsNavigationRepository.popBackStack() + userInfoRepository.storeUserFromUserObject(response.user) confirmationRepository.showSuccess("Profile updated successfully!") } catch (e: Exception) { applyMutation {