diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9fe52df..006c248 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,6 +93,7 @@ dependencies { implementation(libs.androidx.constraintlayout) implementation(libs.androidx.runtime.android) testImplementation(libs.junit) + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") debugImplementation("androidx.compose.ui:ui-tooling") androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) @@ -108,10 +109,11 @@ dependencies { apollo { service("service") { packageName.set("com.example.score") - introspection { - endpointUrl.set("\"${secrets.getProperty("API_URL_DEV")}\"") - schemaFile.set(file("src/main/graphql/schema.graphqls")) + secrets.getProperty("API_URL_DEV")?.takeIf { it.isNotBlank() }?.let { apiUrl -> + introspection { + endpointUrl.set(apiUrl) + schemaFile.set(file("src/main/graphql/schema.graphqls")) + } } } } - diff --git a/app/src/main/graphql/FragmentedGame.graphql b/app/src/main/graphql/FragmentedGame.graphql index 4f5d398..cbef1c3 100644 --- a/app/src/main/graphql/FragmentedGame.graphql +++ b/app/src/main/graphql/FragmentedGame.graphql @@ -1,33 +1,26 @@ query PagedGames($limit: Int!, $offset: Int!) { games(limit: $limit, offset: $offset) { - id - city - date - gender - location - opponentId - result - sport - state - time - scoreBreakdown - utcDate - team { - id - color - image - name - } - boxScore { - team - period - time - description - scorer - assist - scoreBy - corScore - oppScore - } + ...GameListItem + } +} + +query InitialGames($startDate: DateTime!, $endDate: DateTime!) { + gamesByDate(startDate: $startDate, endDate: $endDate) { + ...GameListItem + } +} + +fragment GameListItem on GameType { + id + city + date + gender + result + sport + time + team { + color + image + name } } diff --git a/app/src/main/graphql/schema.graphqls b/app/src/main/graphql/schema.graphqls index e1ebfad..e6c5c91 100644 --- a/app/src/main/graphql/schema.graphqls +++ b/app/src/main/graphql/schema.graphqls @@ -9,7 +9,7 @@ type Query { game(id: String!): GameType - gameByData(city: String!, date: String!, gender: String!, location: String, opponentId: String!, sport: String!, state: String!, time: String!, ticketLink: String): GameType + gameByData(city: String!, date: String!, gender: String!, location: String, opponentId: String!, sport: String!, state: String!, time: String!): GameType gamesBySport(sport: String!): [GameType] @@ -17,6 +17,13 @@ type Query { gamesBySportGender(sport: String!, gender: String!): [GameType] + gamesByDate(startDate: DateTime!, endDate: DateTime!): [GameType] + + """ + Current user's favorited games (requires auth). + """ + myFavoritedGames: [GameType] + teams: [TeamType] team(id: String!): TeamType @@ -55,9 +62,11 @@ Attributes: - id: The YouTube video ID (optional). - title: The title of the video. - description: The description of the video. - - thumbnail: The URL of the video's thumbnail. + - thumbnail: The URL of the video's thumbnail. (optional) - url: The URL to the video. - published_at: The date and time the video was published. + - duration: The duration of the video (optional). + - sportsType: The sport type extracted from the video title. """ type YoutubeVideoType { id: String @@ -68,11 +77,15 @@ type YoutubeVideoType { thumbnail: String! - b64Thumbnail: String! + b64Thumbnail: String url: String! publishedAt: String! + + duration: String + + sportsType: String } """ @@ -181,6 +194,13 @@ type TeamType { name: String! } +""" +The `DateTime` scalar type represents a DateTime +value as specified by +[iso8601](https://en.wikipedia.org/wiki/ISO_8601). +""" +scalar DateTime + type Mutation { """ Creates a new game. @@ -195,12 +215,42 @@ type Mutation { """ Creates a new youtube video. """ - createYoutubeVideo(b64Thumbnail: String!, description: String!, id: String!, publishedAt: String!, thumbnail: String!, title: String!, url: String!): CreateYoutubeVideo + createYoutubeVideo(b64Thumbnail: String, description: String!, duration: String!, id: String!, publishedAt: String!, thumbnail: String!, title: String!, url: String!): CreateYoutubeVideo """ Creates a new article. """ createArticle(image: String, publishedAt: String!, slug: String!, sportsType: String!, title: String!, url: String!): CreateArticle + + """ + Login by net_id; returns access_token and refresh_token. + """ + loginUser("User's net ID (e.g. Cornell netid)." netId: String!): LoginUser + + """ + Create a new user by net_id; returns access_token and refresh_token (no separate login needed). + """ + signupUser("Email address." email: String, "Display name." name: String, "User's net ID (e.g. Cornell netid)." netId: String!): SignupUser + + """ + Exchange a valid refresh token (in Authorization header) for a new access_token. + """ + refreshAccessToken: RefreshAccessToken + + """ + Revoke the current token (access or refresh). Send token in Authorization header. + """ + logoutUser: LogoutUser + + """ + Add a game to the current user's favorites (requires auth). + """ + addFavoriteGame("ID of the game to add to favorites." gameId: String!): AddFavoriteGame + + """ + Remove a game from the current user's favorites (requires auth). + """ + removeFavoriteGame("ID of the game to remove from favorites." gameId: String!): RemoveFavoriteGame } type CreateGame { @@ -219,6 +269,34 @@ type CreateArticle { article: ArticleType } +type LoginUser { + accessToken: String + + refreshToken: String +} + +type SignupUser { + accessToken: String + + refreshToken: String +} + +type RefreshAccessToken { + newAccessToken: String +} + +type LogoutUser { + success: Boolean +} + +type AddFavoriteGame { + success: Boolean +} + +type RemoveFavoriteGame { + success: Boolean +} + """ A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation and subscription operations. """ diff --git a/app/src/main/java/com/cornellappdev/score/model/GameListItemMappers.kt b/app/src/main/java/com/cornellappdev/score/model/GameListItemMappers.kt new file mode 100644 index 0000000..208105f --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/model/GameListItemMappers.kt @@ -0,0 +1,30 @@ +package com.cornellappdev.score.model + +import com.cornellappdev.score.util.parseColor +import com.cornellappdev.score.util.parseResultScore +import com.example.score.fragment.GameListItem + +fun GameListItem.isMens(): Boolean = gender == "Mens" + +fun GameListItem.toGame(): Game? { + val gameTeam = team ?: return null + val imageUrl = gameTeam.image ?: return null + val scores = result?.split(",")?.getOrNull(1)?.split("-") + val fallbackScores = parseResultScore(result) + return Game( + id = id ?: "", + teamLogo = imageUrl, + time = time, + teamName = gameTeam.name, + teamColor = parseColor(gameTeam.color).copy(alpha = 0.4f * 255), + gender = if (isMens()) "Men's" else "Women's", + sport = sport, + date = date, + city = city, + cornellScore = scores?.getOrNull(0)?.toNumberOrNull() ?: fallbackScores?.first, + otherScore = scores?.getOrNull(1)?.toNumberOrNull() ?: fallbackScores?.second + ) +} + +private fun String.toNumberOrNull(): Number? = + if (contains(".")) toFloatOrNull() else toIntOrNull() diff --git a/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt b/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt index 1e9d91b..01aca94 100644 --- a/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt +++ b/app/src/main/java/com/cornellappdev/score/model/ScoreRepository.kt @@ -3,36 +3,40 @@ package com.cornellappdev.score.model import android.util.Log import com.apollographql.apollo.ApolloClient import com.cornellappdev.score.util.isValidSport -import com.cornellappdev.score.util.parseColor -import com.cornellappdev.score.util.parseResultScore import com.example.score.GameByIdQuery -import com.example.score.GamesQuery +import com.example.score.InitialGamesQuery import com.example.score.PagedGamesQuery +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.sync.Mutex +import java.time.LocalDate import kotlinx.coroutines.withTimeout import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds -private const val TIMEOUT_TIME_MILLIS = 5000L +private const val TAG = "ScoreRepository" +private val GAME_DETAILS_TIMEOUT = 5.seconds private const val PAGE_LIMIT = 100 private const val MAX_RETRIES = 3 -private const val PAGE_TIMEOUT_MILLIS = 3000L +private val PAGE_TIMEOUT = 3.seconds /** * This is a singleton responsible for fetching and caching all data for Score. - * Right now, it makes a network request for all possible games. In the future, - * we should limit this to games only in a certain time range, to prevent the - * app from slowing down and improve load times. + * Publishes a small date window first, then loads the full game history. */ @Singleton class ScoreRepository @Inject constructor( private val apolloClient: ApolloClient, private val appScope: CoroutineScope, ) { + private val gamesFetchMutex = Mutex() + private val _upcomingGamesFlow = MutableStateFlow>>(ApiResponse.Loading) val upcomingGamesFlow = _upcomingGamesFlow.asStateFlow() @@ -41,78 +45,49 @@ class ScoreRepository @Inject constructor( MutableStateFlow>(ApiResponse.Loading) val currentGamesFlow = _currentGameFlow.asStateFlow() - /** - * Asynchronously fetches the list of games from the API. Once finished, will send down - * `upcomingGamesFlow` to be observed. - */ - fun fetchGamesPrev() = appScope.launch { - _upcomingGamesFlow.value = ApiResponse.Loading - try { - val result = - withTimeout(TIMEOUT_TIME_MILLIS) { - apolloClient.query((GamesQuery())).execute().toResult() - } - - if (result.isSuccess) { - val games = result.getOrNull() - - val gamesList: List = - games?.games?.filter { game -> isValidSport(game?.sport ?: "") } - ?.mapNotNull { game -> - /** - * The final scores in the past game cards are obtained by parsing a String - * result from the GameQuery, which is oftentimes in the format - * Result, CornellScore-OpponentScore (e.g. "W, 2-1"). Not all of the strings - * are in this format (e.g. 4th of 6, 1498 points for women's Swimming and - * Diving), but in this case, the cornellScore and otherScore parameters of - * the game and associated card should be null, and as of right now, - * null-scored games are filtered out. - */ - val scores = game?.result?.split(",")?.getOrNull(1)?.split("-") - val cornellScore = scores?.getOrNull(0)?.toNumberOrNull() - val otherScore = scores?.getOrNull(1)?.toNumberOrNull() - game?.team?.image?.let { - Game( - id = game.id ?: "", // Should never be null - teamLogo = it, - teamName = game.team.name, - time = game.time, - teamColor = parseColor(game.team.color).copy(alpha = 0.4f * 255), - gender = if (game.gender == "Mens") "Men's" else "Women's", - sport = game.sport, - date = game.date, - city = game.city, - cornellScore = cornellScore, - otherScore = otherScore - ) - } - } ?: emptyList() - _upcomingGamesFlow.value = ApiResponse.Success(gamesList) - } else { - _upcomingGamesFlow.value = ApiResponse.Error - } - - } catch (e: Exception) { - Log.e("ScoreRepository", "Error fetching posts: ", e) - _upcomingGamesFlow.value = ApiResponse.Error - } - } - + /** Publishes nearby games first, then loads history while preserving results on failure. */ fun fetchGames() = appScope.launch { + gamesFetchMutex.lock() + val previousSuccess = _upcomingGamesFlow.value as? ApiResponse.Success _upcomingGamesFlow.value = ApiResponse.Loading val allGames = mutableListOf() var offset = 0 var retries = 0 + var initialWindow = true + var historyComplete = false try { + // The page count is unknown, and retries must reuse the current offset. while (true) { - val pageResult = runCatching { - withTimeout(PAGE_TIMEOUT_MILLIS) { - apolloClient.query( - PagedGamesQuery(limit = PAGE_LIMIT, offset = offset) - ).execute().data?.games + val pageResult = try { + withTimeoutOrNull(PAGE_TIMEOUT) { + if (initialWindow) { + val today = LocalDate.now() + apolloClient.query( + InitialGamesQuery( + today.minusDays(7).atStartOfDay().toString(), + today.plusDays(30).atStartOfDay().toString() + ) + ).execute().toResult().getOrNull()?.gamesByDate + ?.map { it?.gameListItem } + } else { + apolloClient.query( + PagedGamesQuery(limit = PAGE_LIMIT, offset = offset) + ).execute().toResult().getOrNull()?.games + ?.map { it?.gameListItem } + } } - }.getOrNull() + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + + // A failed or empty date window falls back to the full fetch. + if (initialWindow && pageResult.isNullOrEmpty()) { + initialWindow = false + continue + } if (pageResult == null) { if (retries < MAX_RETRIES) { @@ -124,6 +99,7 @@ class ScoreRepository @Inject constructor( } if (pageResult.isEmpty()) { + historyComplete = true break } @@ -131,44 +107,40 @@ class ScoreRepository @Inject constructor( val pageGames: List = pageResult .filterNotNull() - .filter { gql -> isValidSport(gql.sport ?: "") } - .mapNotNull { graphqlGame -> - val scores = graphqlGame.result?.split(",")?.getOrNull(1)?.split("-") - val cornellScore = scores?.getOrNull(0)?.toNumberOrNull() - ?: parseResultScore(graphqlGame.result)?.first - val otherScore = scores?.getOrNull(1)?.toNumberOrNull() ?: parseResultScore( - graphqlGame.result - )?.second - graphqlGame.team?.image?.let { imageUrl -> - Game( - id = graphqlGame.id ?: "", - teamLogo = imageUrl, - time = graphqlGame.time, - teamName = graphqlGame.team.name, - teamColor = parseColor(graphqlGame.team.color).copy(alpha = 0.4f * 255), - gender = if (graphqlGame.gender == "Mens") "Men's" else "Women's", - sport = graphqlGame.sport, - date = graphqlGame.date, - city = graphqlGame.city, - cornellScore = cornellScore, - otherScore = otherScore - ) - } - } + .filter { isValidSport(it.sport) } + .mapNotNull { it.toGame() } allGames.addAll(pageGames) - if (pageResult.size < PAGE_LIMIT) break + if (initialWindow) { + if (allGames.isNotEmpty()) { + _upcomingGamesFlow.value = ApiResponse.Success(allGames.toList()) + } + initialWindow = false + continue + } + + if (pageResult.size < PAGE_LIMIT) { + historyComplete = true + break + } offset += PAGE_LIMIT } _upcomingGamesFlow.value = - if (allGames.isNotEmpty()) ApiResponse.Success(allGames) - else ApiResponse.Error + if (allGames.isNotEmpty()) ApiResponse.Success(allGames.asReversed().distinctBy { it.id }.asReversed()) + else if (historyComplete) ApiResponse.Success(emptyList()) + else previousSuccess ?: ApiResponse.Error + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - Log.e("ScoreRepository", "Error fetching upcoming games", e) - _upcomingGamesFlow.value = ApiResponse.Error + Log.e(TAG, "Error fetching upcoming games", e) + if (_upcomingGamesFlow.value !is ApiResponse.Success) { + _upcomingGamesFlow.value = previousSuccess ?: ApiResponse.Error + } + } finally { + gamesFetchMutex.unlock() } } @@ -177,11 +149,11 @@ class ScoreRepository @Inject constructor( * `currentGamesFlow` to be observed. */ fun getGameById(id: String) = appScope.launch { - Log.d("ScoreRepository", "Fetching game with id: $id") + Log.d(TAG, "Fetching game with id: $id") _currentGameFlow.value = ApiResponse.Loading try { val result = - withTimeout(TIMEOUT_TIME_MILLIS) { + withTimeout(GAME_DETAILS_TIMEOUT) { apolloClient.query(GameByIdQuery(id)).execute().toResult() } @@ -191,16 +163,8 @@ class ScoreRepository @Inject constructor( } ?: _currentGameFlow.update { ApiResponse.Error } } catch (e: Exception) { - Log.e("ScoreRepository", "Error fetching game with id: ${id}: ", e) + Log.e(TAG, "Error fetching game with id: ${id}: ", e) _currentGameFlow.value = ApiResponse.Error } } } - -fun String.toNumberOrNull(): Number? { - return when { - this.contains(".") -> this.toFloatOrNull() // Try converting to Float if there's a decimal - else -> this.toIntOrNull() // Otherwise, try converting to Int - } -} - diff --git a/app/src/test/java/com/cornellappdev/score/model/ScoreRepositoryTest.kt b/app/src/test/java/com/cornellappdev/score/model/ScoreRepositoryTest.kt new file mode 100644 index 0000000..bd81675 --- /dev/null +++ b/app/src/test/java/com/cornellappdev/score/model/ScoreRepositoryTest.kt @@ -0,0 +1,107 @@ +package com.cornellappdev.score.model + +import com.apollographql.apollo.ApolloClient +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest +import okhttp3.mockwebserver.Dispatcher +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +class ScoreRepositoryTest { + private lateinit var server: MockWebServer + private lateinit var client: ApolloClient + private lateinit var scope: CoroutineScope + private lateinit var repository: ScoreRepository + private val requests = AtomicInteger() + @Volatile private var fail = false + @Volatile private var empty = false + private var historyStarted: CountDownLatch? = null + private var releaseHistory: CountDownLatch? = null + + @Before fun setup() { + server = MockWebServer() + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val body = request.body.readUtf8() + requests.incrementAndGet() + val field = if (body.contains("InitialGames")) "gamesByDate" else "games" + if (field == "games") { + historyStarted?.countDown() + releaseHistory?.await(2, TimeUnit.SECONDS) + } + val games = if (empty) "[]" else """[{"__typename":"GameType","id":"1","city":"Ithaca","date":"2026-09-23","gender":"Mens","result":null,"sport":"Baseball","time":null,"team":{"__typename":"TeamType","name":"Opponent","image":"https://example.com/logo.png","color":"#FFFFFF"}}]""" + val response = if (fail) """{"errors":[{"message":"Unavailable"}]}""" + else """{"data":{"$field":$games}}""" + return MockResponse().setHeader("Content-Type", "application/json").setBody(response) + } + } + server.start() + client = ApolloClient.Builder().serverUrl(server.url("/").toString()).build() + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + repository = ScoreRepository(client, scope) + } + + @After fun teardown() { + releaseHistory?.countDown() + scope.cancel() + client.close() + server.shutdown() + } + + @Test fun failedRefreshRestoresPreviousGames() = runBlocking { + withTimeout(10.seconds) { + repository.fetchGames().join() + val previous = repository.upcomingGamesFlow.value + assertTrue(previous is ApiResponse.Success) + fail = true + repository.fetchGames().join() + assertEquals(previous, repository.upcomingGamesFlow.value) + } + } + + @Test fun successfulEmptyRefreshClearsPreviousGames() = runBlocking { + withTimeout(10.seconds) { + repository.fetchGames().join() + empty = true + repository.fetchGames().join() + assertEquals(ApiResponse.Success(emptyList()), repository.upcomingGamesFlow.value) + } + } + + @Test fun refreshDuringHistoryFetchWaitsAndRuns() = runBlocking { + withTimeout(10.seconds) { + historyStarted = CountDownLatch(1) + releaseHistory = CountDownLatch(1) + val first = repository.fetchGames() + assertTrue(historyStarted!!.await(2, TimeUnit.SECONDS)) + val refresh = repository.fetchGames() + assertFalse(refresh.isCompleted) + releaseHistory!!.countDown() + first.join() + refresh.join() + assertEquals(4, requests.get()) + assertTrue(repository.upcomingGamesFlow.value is ApiResponse.Success) + } + } + + @Test fun firstLoadFailureShowsError() = runBlocking { + withTimeout(10.seconds) { + fail = true + repository.fetchGames().join() + assertEquals(ApiResponse.Error, repository.upcomingGamesFlow.value) + } + } +}