diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt index e781d8356c..f94c4b75f1 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/NetplaySession.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext +import org.dolphinemu.dolphinemu.features.netplay.model.GameDigestProgress import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage import org.dolphinemu.dolphinemu.features.netplay.model.Player import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress @@ -104,6 +105,9 @@ class NetplaySession( private val _saveTransferProgress = MutableStateFlow(null) val saveTransferProgress = _saveTransferProgress.asStateFlow() + private val _gameDigestProgress = MutableStateFlow(null) + val gameDigestProgress = _gameDigestProgress.asStateFlow() + suspend fun join(): Boolean = withContext(Dispatchers.IO) { mergeMessages() .runningFold(emptyList()) { acc, msg -> listOf(msg) + acc } @@ -280,6 +284,60 @@ class NetplaySession( fun onHideChunkedProgressDialog() { _saveTransferProgress.value = null } + + @Keep + fun onShowGameDigestDialog(title: String) { + val players = _players.replayCache.firstOrNull() + _gameDigestProgress.value = GameDigestProgress( + title = title, + playerProgresses = players?.map { player -> + GameDigestProgress.PlayerProgress( + playerId = player.pid, + name = player.name, + progress = 0, + result = null, + ) + } ?: emptyList(), + matches = null, + ) + } + + @Keep + fun onSetGameDigestProgress(playerId: Int, progress: Int) { + val current = _gameDigestProgress.value ?: return + _gameDigestProgress.value = current.copy( + playerProgresses = current.playerProgresses.map { + if (it.playerId == playerId) it.copy(progress = progress) else it + } + ) + } + + @Keep + fun onSetGameDigestResult(playerId: Int, result: String) { + val current = _gameDigestProgress.value ?: return + val updated = current.copy( + playerProgresses = current.playerProgresses.map { + if (it.playerId == playerId) it.copy(result = result) else it + } + ) + val finished = updated.playerProgresses.all { it.result != null } + _gameDigestProgress.value = if (finished) { + val results = updated.playerProgresses.map { it.result } + updated.copy(matches = results.distinct().size == 1) + } else { + updated + } + } + + /** + * Hosts send this when they dismiss their dialog even in a successful scenario. Ensuring + * that the value is cleared before a new game digest is started. Without this, StateFlow + * would not be a good choice. + */ + @Keep + fun onAbortGameDigest() { + _gameDigestProgress.value = null + } } private fun Channel.flush() { diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt new file mode 100644 index 0000000000..57a9cae78b --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt @@ -0,0 +1,14 @@ +package org.dolphinemu.dolphinemu.features.netplay.model + +data class GameDigestProgress( + val title: String, + val playerProgresses: List, + val matches: Boolean?, +) { + data class PlayerProgress( + val playerId: Int, + val name: String, + val progress: Int, + val result: String?, + ) +} diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt index 96f9d7328b..d0f86ed3f6 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/NetplayViewModel.kt @@ -41,6 +41,8 @@ class NetplayViewModel( val saveTransferProgress = netplaySession.saveTransferProgress + val gameDigestProgress = netplaySession.gameDigestProgress + fun sendMessage(message: String) { val trimmedMessage = message.trim() if (trimmedMessage.isEmpty()) { diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt index f2acf50107..d99c4da57a 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayActivity.kt @@ -56,6 +56,7 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { maxBuffer = viewModel.maxBuffer.collectAsState().value, onMaxBufferChanged = viewModel::setMaxBuffer, saveTransferProgress = viewModel.saveTransferProgress.collectAsState().value, + gameDigestProgress = viewModel.gameDigestProgress.collectAsState().value, ) } } diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt index 5608775bdc..ce6b62ecae 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/ui/NetplayScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -69,6 +70,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import org.dolphinemu.dolphinemu.R +import org.dolphinemu.dolphinemu.features.netplay.model.GameDigestProgress import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage import org.dolphinemu.dolphinemu.features.netplay.model.Player import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress @@ -91,6 +93,7 @@ fun NetplayScreen( onMaxBufferChanged: (Int) -> Unit, players: List, saveTransferProgress: SaveTransferProgress?, + gameDigestProgress: GameDigestProgress?, ) { Scaffold( topBar = { @@ -136,16 +139,21 @@ fun NetplayScreen( ) } - var showConnectionLostDialog by remember { mutableStateOf(false) } + var showConnectionLostDialog by rememberSaveable { mutableStateOf(false) } LaunchedEffect(Unit) { connectionLost.collect { showConnectionLostDialog = true } } - var dismissSaveTransferProgressDialog by remember { mutableStateOf(false) } + var dismissSaveTransferProgressDialog by rememberSaveable { mutableStateOf(false) } if (saveTransferProgress == null) { dismissSaveTransferProgressDialog = false } + var dismissGameDigestDialog by rememberSaveable { mutableStateOf(false) } + if (gameDigestProgress == null) { + dismissGameDigestDialog = false + } + when { showConnectionLostDialog -> { AlertDialog( @@ -165,6 +173,13 @@ fun NetplayScreen( onDismiss = { dismissSaveTransferProgressDialog = true }, ) } + + gameDigestProgress != null && !dismissGameDigestDialog -> { + GameDigestProgressDialog( + gameDigestProgress = gameDigestProgress, + onDismiss = { dismissGameDigestDialog = true }, + ) + } } } } @@ -548,6 +563,7 @@ private fun SaveTransferProgressDialog( text = { Column( verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.verticalScroll(rememberScrollState()), ) { saveTransferProgress.playerProgresses.forEachIndexed { index, playerProgress -> SaveTransferProgressRow( @@ -600,6 +616,82 @@ private fun SaveTransferProgressRow( } } +@Composable +private fun GameDigestProgressDialog( + gameDigestProgress: GameDigestProgress, + onDismiss: () -> Unit, +) { + AlertDialog( + title = { Text(gameDigestProgress.title) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + gameDigestProgress.playerProgresses.forEachIndexed { index, playerProgress -> + GameDigestPlayerRow(playerProgress) + if (index < gameDigestProgress.playerProgresses.lastIndex) { + HorizontalDivider() + } + } + if (gameDigestProgress.matches != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource( + if (gameDigestProgress.matches) { + R.string.netplay_game_digest_match + } else { + R.string.netplay_game_digest_mismatch + } + ), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + } + } + }, + confirmButton = { + if (gameDigestProgress.matches != null) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.netplay_game_digest_close)) + } + } + }, + onDismissRequest = { onDismiss() }, + ) +} + +@Composable +private fun GameDigestPlayerRow( + playerProgress: GameDigestProgress.PlayerProgress, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + LinearProgressIndicator( + progress = { playerProgress.progress / 100f }, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + ) { + if (playerProgress.result == null) { + Text( + text = playerProgress.name, + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "${playerProgress.progress}%", + ) + } else { + Text( + text = "${playerProgress.name}:\u00A0${playerProgress.result}", + ) + } + } + } +} + @Preview @Composable private fun NetplayScreenPreview() { @@ -671,6 +763,7 @@ private fun PreviewNetplayScreen() { maxBuffer = 10, onMaxBufferChanged = {}, saveTransferProgress = null, + gameDigestProgress = null, // saveTransferProgress = SaveTransferProgress( // title = "Title", // totalSize = 1024L, diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 4e9c977faa..ef103432aa 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1011,4 +1011,7 @@ It can efficiently compress both junk data and encrypted Wii data. Max buffer Netplay connection lost Close + The hashes match + The hashes do not match + Close diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index caf305fc9b..91f7599078 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -44,6 +44,10 @@ static jmethodID s_netplay_on_show_chunked_progress_dialog; static jmethodID s_netplay_on_set_chunked_progress; static jmethodID s_netplay_on_hide_chunked_progress_dialog; static jmethodID s_netplay_on_desync; +static jmethodID s_netplay_on_show_game_digest_dialog; +static jmethodID s_netplay_on_set_game_digest_progress; +static jmethodID s_netplay_on_set_game_digest_result; +static jmethodID s_netplay_on_abort_game_digest; static jclass s_netplay_player_class; static jmethodID s_netplay_player_constructor; @@ -333,6 +337,26 @@ jmethodID GetNetplayOnDesync() return s_netplay_on_desync; } +jmethodID GetNetplayOnShowGameDigestDialog() +{ + return s_netplay_on_show_game_digest_dialog; +} + +jmethodID GetNetplayOnSetGameDigestProgress() +{ + return s_netplay_on_set_game_digest_progress; +} + +jmethodID GetNetplayOnSetGameDigestResult() +{ + return s_netplay_on_set_game_digest_result; +} + +jmethodID GetNetplayOnAbortGameDigest() +{ + return s_netplay_on_abort_game_digest; +} + jclass GetNetplayPlayerClass() { return s_netplay_player_class; @@ -774,6 +798,14 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) env->GetMethodID(netplay_class, "onHideChunkedProgressDialog", "()V"); s_netplay_on_desync = env->GetMethodID(netplay_class, "onDesync", "(ILjava/lang/String;)V"); + s_netplay_on_show_game_digest_dialog = + env->GetMethodID(netplay_class, "onShowGameDigestDialog", "(Ljava/lang/String;)V"); + s_netplay_on_set_game_digest_progress = + env->GetMethodID(netplay_class, "onSetGameDigestProgress", "(II)V"); + s_netplay_on_set_game_digest_result = + env->GetMethodID(netplay_class, "onSetGameDigestResult", "(ILjava/lang/String;)V"); + s_netplay_on_abort_game_digest = + env->GetMethodID(netplay_class, "onAbortGameDigest", "()V"); env->DeleteLocalRef(netplay_class); const jclass netplay_player_class = diff --git a/Source/Android/jni/AndroidCommon/IDCache.h b/Source/Android/jni/AndroidCommon/IDCache.h index ae86740f09..f610380908 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -47,6 +47,10 @@ jmethodID GetNetplayOnShowChunkedProgressDialog(); jmethodID GetNetplayOnSetChunkedProgress(); jmethodID GetNetplayOnHideChunkedProgressDialog(); jmethodID GetNetplayOnDesync(); +jmethodID GetNetplayOnShowGameDigestDialog(); +jmethodID GetNetplayOnSetGameDigestProgress(); +jmethodID GetNetplayOnSetGameDigestResult(); +jmethodID GetNetplayOnAbortGameDigest(); jclass GetNetplayPlayerClass(); jmethodID GetNetplayPlayerConstructor(); diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index c411119ab9..018d5556dd 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -265,10 +265,53 @@ NetPlayUICallbacks::FindGameFile(const NetPlay::SyncIdentifier& sync_identifier, } std::string NetPlayUICallbacks::FindGBARomPath(const std::array&, std::string_view, int) { return {}; } -void NetPlayUICallbacks::ShowGameDigestDialog(const std::string&) {} -void NetPlayUICallbacks::SetGameDigestProgress(int, int) {} -void NetPlayUICallbacks::SetGameDigestResult(int, const std::string&) {} -void NetPlayUICallbacks::AbortGameDigest() {} + +void NetPlayUICallbacks::ShowGameDigestDialog(const std::string& title) +{ + JNIEnv* env = IDCache::GetEnvForThread(); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnShowGameDigestDialog(), + ToJString(env, title)); + env->DeleteLocalRef(netplay_session); +} + +void NetPlayUICallbacks::SetGameDigestProgress(int pid, int progress) +{ + JNIEnv* env = IDCache::GetEnvForThread(); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnSetGameDigestProgress(), + static_cast(pid), static_cast(progress)); + env->DeleteLocalRef(netplay_session); +} + +void NetPlayUICallbacks::SetGameDigestResult(int pid, const std::string& result) +{ + JNIEnv* env = IDCache::GetEnvForThread(); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnSetGameDigestResult(), + static_cast(pid), ToJString(env, result)); + env->DeleteLocalRef(netplay_session); +} + +void NetPlayUICallbacks::AbortGameDigest() +{ + JNIEnv* env = IDCache::GetEnvForThread(); + jobject netplay_session = GetNetplaySessionLocalRef(env); + if (!netplay_session) + return; + + env->CallVoidMethod(netplay_session, IDCache::GetNetplayOnAbortGameDigest()); + env->DeleteLocalRef(netplay_session); +} void NetPlayUICallbacks::ShowChunkedProgressDialog(const std::string& title, u64 data_size, std::span players)