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.
This commit is contained in:
Tom Pratt 2026-04-30 10:16:20 +02:00
parent 8792a4b924
commit 2c82e5188e
9 changed files with 256 additions and 6 deletions

View File

@ -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<SaveTransferProgress?>(null)
val saveTransferProgress = _saveTransferProgress.asStateFlow()
private val _gameDigestProgress = MutableStateFlow<GameDigestProgress?>(null)
val gameDigestProgress = _gameDigestProgress.asStateFlow()
suspend fun join(): Boolean = withContext(Dispatchers.IO) {
mergeMessages()
.runningFold(emptyList<NetplayMessage>()) { 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 <T> Channel<T>.flush() {

View File

@ -0,0 +1,14 @@
package org.dolphinemu.dolphinemu.features.netplay.model
data class GameDigestProgress(
val title: String,
val playerProgresses: List<PlayerProgress>,
val matches: Boolean?,
) {
data class PlayerProgress(
val playerId: Int,
val name: String,
val progress: Int,
val result: String?,
)
}

View File

@ -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()) {

View File

@ -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,
)
}
}

View File

@ -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<Player>,
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,

View File

@ -1011,4 +1011,7 @@ It can efficiently compress both junk data and encrypted Wii data.
<string name="netplay_max_buffer">Max buffer</string>
<string name="netplay_connection_lost">Netplay connection lost</string>
<string name="netplay_save_transfer_progress_close">Close</string>
<string name="netplay_game_digest_match">The hashes match</string>
<string name="netplay_game_digest_mismatch">The hashes do not match</string>
<string name="netplay_game_digest_close">Close</string>
</resources>

View File

@ -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 =

View File

@ -47,6 +47,10 @@ jmethodID GetNetplayOnShowChunkedProgressDialog();
jmethodID GetNetplayOnSetChunkedProgress();
jmethodID GetNetplayOnHideChunkedProgressDialog();
jmethodID GetNetplayOnDesync();
jmethodID GetNetplayOnShowGameDigestDialog();
jmethodID GetNetplayOnSetGameDigestProgress();
jmethodID GetNetplayOnSetGameDigestResult();
jmethodID GetNetplayOnAbortGameDigest();
jclass GetNetplayPlayerClass();
jmethodID GetNetplayPlayerConstructor();

View File

@ -265,10 +265,53 @@ NetPlayUICallbacks::FindGameFile(const NetPlay::SyncIdentifier& sync_identifier,
}
std::string NetPlayUICallbacks::FindGBARomPath(const std::array<u8, 20>&, 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<jint>(pid), static_cast<jint>(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<jint>(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<const int> players)