From 2c82e5188e8d85ecdb6955e4c569fd4d8f9ce669 Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Thu, 30 Apr 2026 10:16:20 +0200 Subject: [PATCH] Game digest progress dialog We just about get away with using a StateFlow in NetplaySession since the host sends AbortGameDigest when closing their own dialog. Without that it would be harder for the UI to distinguish between subsequent dialogs. If that wasn't the case then NetplaySession might need to expose the individual progress and result updates and have the view model assemble it into the overall GameDigestProgress. --- .../features/netplay/NetplaySession.kt | 58 +++++++++++ .../netplay/model/GameDigestProgress.kt | 14 +++ .../netplay/model/NetplayViewModel.kt | 2 + .../features/netplay/ui/NetplayActivity.kt | 1 + .../features/netplay/ui/NetplayScreen.kt | 97 ++++++++++++++++++- .../app/src/main/res/values/strings.xml | 3 + Source/Android/jni/AndroidCommon/IDCache.cpp | 32 ++++++ Source/Android/jni/AndroidCommon/IDCache.h | 4 + .../jni/NetPlay/NetPlayUICallbacks.cpp | 51 +++++++++- 9 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/GameDigestProgress.kt 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)