diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 69a5f61..8b31e8a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,6 +43,11 @@ android { "BASE_URL", "\"${secrets.getProperty("API_URL_DEV")}\"" ) + buildConfigField( + "String", + "SOCKET_URL", + "\"${secrets.getProperty("SOCKET_URL_DEV")}\"" + ) } release { buildConfigField( @@ -50,6 +55,11 @@ android { "BASE_URL", "\"${secrets.getProperty("API_URL_PROD")}\"" ) + buildConfigField( + "String", + "SOCKET_URL", + "\"${secrets.getProperty("SOCKET_URL_PROD")}\"" + ) isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), @@ -97,6 +107,9 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") + implementation("io.socket:socket.io-client:2.1.1") { + exclude(group = "org.json", module = "json") + } implementation(libs.apollo.runtime) implementation("io.coil-kt.coil3:coil-compose:3.1.0") implementation("io.coil-kt.coil3:coil-network-okhttp:3.1.0") diff --git a/app/src/main/java/com/cornellappdev/score/model/Game.kt b/app/src/main/java/com/cornellappdev/score/model/Game.kt index d6c93d7..66dd3f2 100644 --- a/app/src/main/java/com/cornellappdev/score/model/Game.kt +++ b/app/src/main/java/com/cornellappdev/score/model/Game.kt @@ -302,6 +302,38 @@ fun GameDetailsGame.toGameCardData(): DetailsCardData { ) } +/** + * Merges a live socket update into the current DetailsCardData. + * Null fields in [update] leave existing values unchanged. + */ +fun DetailsCardData.applySocketUpdate(update: SocketGameUpdateData): DetailsCardData { + val newScoreBreakdown = update.scoreBreakdown ?: scoreBreakdown + val newGameData = if (update.scoreBreakdown != null) { + toGameData( + scoreBreakdown = newScoreBreakdown, + team1 = TeamBoxScore("Cornell"), + team2 = TeamBoxScore(opponent), + sport = sport + ) + } else gameData + + val newBoxScore: List = update.boxScore + ?.map { it?.toGameDetailsBoxScore() } + ?: boxScore + val newScoreEvents = if (update.boxScore != null) { + newBoxScore.filterNotNull().toScoreEvents(opponentLogo) + } else scoreEvent + + return copy( + homeScore = update.homeScore ?: homeScore, + oppScore = update.oppScore ?: oppScore, + scoreBreakdown = newScoreBreakdown, + gameData = newGameData, + boxScore = newBoxScore, + scoreEvent = newScoreEvents + ) +} + fun List.toScoreEvents(teamLogo: String): List { return this.mapIndexed { index, boxScore -> val teamName = boxScore.team ?: "" diff --git a/app/src/main/java/com/cornellappdev/score/model/SocketManager.kt b/app/src/main/java/com/cornellappdev/score/model/SocketManager.kt new file mode 100644 index 0000000..ac6064d --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/model/SocketManager.kt @@ -0,0 +1,70 @@ +package com.cornellappdev.score.model + +import android.util.Log +import com.cornellappdev.score.BuildConfig +import io.socket.client.IO +import io.socket.client.Socket +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import org.json.JSONObject +import java.util.Collections +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "SocketManager" + +@Singleton +class SocketManager @Inject constructor(private val appScope: CoroutineScope) { + + private val _gameUpdateFlow = + MutableSharedFlow(extraBufferCapacity = 16) + val gameUpdateFlow: SharedFlow = _gameUpdateFlow.asSharedFlow() + + private val activeSubscriptions: MutableSet = + Collections.synchronizedSet(mutableSetOf()) + + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + private val socket: Socket by lazy { + val opts = IO.Options.builder() + .setTransports(arrayOf("websocket")) + .build() + IO.socket(BuildConfig.SOCKET_URL, opts).also { s -> + // "on" is a listener + s.on(Socket.EVENT_CONNECT) { + Log.d(TAG, "Connected") + activeSubscriptions.forEach { id -> + s.emit("subscribe", JSONObject().put("gameId", id)) + } + } + s.on(Socket.EVENT_DISCONNECT) { args -> + Log.d( + TAG, + "Disconnected: ${args.firstOrNull()}" + ) + } + s.on(Socket.EVENT_CONNECT_ERROR) { args -> Log.e(TAG, "Error: ${args.firstOrNull()}") } + s.on("game_update") { args -> + val raw = args.firstOrNull() as? JSONObject ?: return@on + runCatching { json.decodeFromString(raw.toString()) } + .onSuccess { appScope.launch { _gameUpdateFlow.emit(it) } } + .onFailure { Log.e(TAG, "Parse error: $it") } + } + s.connect() + } + } + + fun subscribe(gameId: String) { + activeSubscriptions.add(gameId) + socket.emit("subscribe", JSONObject().put("gameId", gameId)) + } + + fun unsubscribe(gameId: String) { + activeSubscriptions.remove(gameId) + socket.emit("unsubscribe", JSONObject().put("gameId", gameId)) + } +} diff --git a/app/src/main/java/com/cornellappdev/score/model/SocketModels.kt b/app/src/main/java/com/cornellappdev/score/model/SocketModels.kt new file mode 100644 index 0000000..b59f3e0 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/score/model/SocketModels.kt @@ -0,0 +1,37 @@ +package com.cornellappdev.score.model + +import kotlinx.serialization.Serializable + +@Serializable +data class SocketGameUpdateEnvelope( + val type: String, + val gameId: String, + val timestamp: String, + val data: SocketGameUpdateData +) + +@Serializable +data class SocketGameUpdateData( + val homeScore: Int? = null, + val oppScore: Int? = null, + val scoreBreakdown: List?>? = null, + val boxScore: List? = null +) + +@Serializable +data class SocketBoxScoreEntry( + val team: String? = null, + val period: String? = null, + val time: String? = null, + val description: String? = null, + val scorer: String? = null, + val assist: String? = null, + val scoreBy: String? = null, + val corScore: Int? = null, + val oppScore: Int? = null +) + +fun SocketBoxScoreEntry.toGameDetailsBoxScore() = GameDetailsBoxScore( + team = team, period = period, time = time, description = description, + scorer = scorer, assist = assist, scoreBy = scoreBy, corScore = corScore, oppScore = oppScore +) diff --git a/app/src/main/java/com/cornellappdev/score/viewmodel/GameDetailsViewModel.kt b/app/src/main/java/com/cornellappdev/score/viewmodel/GameDetailsViewModel.kt index 1b69f30..455e760 100644 --- a/app/src/main/java/com/cornellappdev/score/viewmodel/GameDetailsViewModel.kt +++ b/app/src/main/java/com/cornellappdev/score/viewmodel/GameDetailsViewModel.kt @@ -1,14 +1,16 @@ package com.cornellappdev.score.viewmodel import androidx.lifecycle.SavedStateHandle -import androidx.navigation.toRoute +import androidx.lifecycle.viewModelScope import com.cornellappdev.score.model.ApiResponse import com.cornellappdev.score.model.DetailsCardData import com.cornellappdev.score.model.ScoreRepository +import com.cornellappdev.score.model.SocketManager +import com.cornellappdev.score.model.applySocketUpdate import com.cornellappdev.score.model.map import com.cornellappdev.score.model.toGameCardData -import com.cornellappdev.score.nav.root.ScoreScreens import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch import javax.inject.Inject data class GameDetailsUiState( @@ -18,6 +20,7 @@ data class GameDetailsUiState( @HiltViewModel class GameDetailsViewModel @Inject constructor( private val scoreRepository: ScoreRepository, + private val socketManager: SocketManager, savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialUiState = GameDetailsUiState( @@ -37,10 +40,28 @@ class GameDetailsViewModel @Inject constructor( } } onRefresh() + + socketManager.subscribe(gameId) + + viewModelScope.launch { + socketManager.gameUpdateFlow.collect { envelope -> + if (envelope.gameId != gameId) return@collect + applyMutation { + val current = loadedState + if (current !is ApiResponse.Success) return@applyMutation this + copy(loadedState = ApiResponse.Success(current.data.applySocketUpdate(envelope.data))) + } + } + } } fun onRefresh() { applyMutation { copy(loadedState = ApiResponse.Loading) } scoreRepository.getGameById(gameId) } -} \ No newline at end of file + + override fun onCleared() { + super.onCleared() + socketManager.unsubscribe(gameId) + } +}