From 3572afcbbf241cb0534ea869800cb96faeac409a Mon Sep 17 00:00:00 2001 From: Tom Pratt Date: Sat, 25 Apr 2026 17:39:41 +0200 Subject: [PATCH] Show save transfer progress When transferring saves from the host. Equivalent of ChunkedProgressDialog in QT. --- .../dolphinemu/features/netplay/Netplay.kt | 45 +++++++ .../netplay/model/NetplayViewModel.kt | 4 +- .../netplay/model/SaveTransferProgress.kt | 13 ++ .../features/netplay/ui/NetplayActivity.kt | 1 + .../features/netplay/ui/NetplayScreen.kt | 118 ++++++++++++++++-- .../app/src/main/res/values/strings.xml | 1 + Source/Android/jni/AndroidCommon/IDCache.cpp | 24 ++++ Source/Android/jni/AndroidCommon/IDCache.h | 3 + .../jni/NetPlay/NetPlayUICallbacks.cpp | 33 ++++- 9 files changed, 227 insertions(+), 15 deletions(-) create mode 100644 Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/SaveTransferProgress.kt diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt index f4eb178cb4..882016dfa9 100644 --- a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/Netplay.kt @@ -13,7 +13,9 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -26,6 +28,7 @@ import kotlinx.coroutines.withContext import org.dolphinemu.dolphinemu.features.netplay.model.ConnectionType import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage import org.dolphinemu.dolphinemu.features.netplay.model.Player +import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress object Netplay { @Keep @@ -87,6 +90,9 @@ object Netplay { ) val padBuffer = _padBuffer.asSharedFlow() + private val _saveTransferProgress = MutableStateFlow(null) + val saveTransferProgress = _saveTransferProgress.asStateFlow() + suspend fun join(): Boolean = withContext(Dispatchers.IO) { val scope = createSessionScope() @@ -140,6 +146,7 @@ object Netplay { _game.resetReplayCache() _hostInputAuthorityEnabled.resetReplayCache() _padBuffer.resetReplayCache() + _saveTransferProgress.value = null } private fun createSessionScope(): CoroutineScope { @@ -234,6 +241,44 @@ object Netplay { _padBuffer.tryEmit(buffer) } + @Keep + @JvmStatic + fun onShowChunkedProgressDialog(title: String, dataSize: Long, playerIds: IntArray) { + val players = _players.replayCache.firstOrNull() + _saveTransferProgress.value = SaveTransferProgress( + title = title, + totalSize = dataSize, + playerProgresses = playerIds.map { playerId -> + SaveTransferProgress.PlayerProgress( + playerId = playerId, + name = players?.find { it.pid == playerId }?.name ?: "Invalid Player ID", + progress = 0, + ) + }, + ) + } + + @Keep + @JvmStatic + fun onSetChunkedProgress(playerId: Int, progress: Long) { + val current = _saveTransferProgress.value + _saveTransferProgress.value = current?.copy( + playerProgresses = current.playerProgresses.map { + if (it.playerId == playerId) { + it.copy(progress = progress) + } else { + it + } + } + ) + } + + @Keep + @JvmStatic + fun onHideChunkedProgressDialog() { + _saveTransferProgress.value = null + } + // Settings object Settings { @JvmStatic 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 955fb876dd..620a66ff5a 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 @@ -2,8 +2,6 @@ package org.dolphinemu.dolphinemu.features.netplay.model -import android.app.Application -import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -42,6 +40,8 @@ class NetplayViewModel : ViewModel() { private val _maxBuffer = MutableStateFlow(Netplay.Settings.getClientBufferSize()) val maxBuffer = _maxBuffer.asStateFlow() + val saveTransferProgress = Netplay.saveTransferProgress + init { if (!Netplay.isClientConnected()) { _goBack.trySend(Unit) diff --git a/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/SaveTransferProgress.kt b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/SaveTransferProgress.kt new file mode 100644 index 0000000000..e16cbab8ca --- /dev/null +++ b/Source/Android/app/src/main/java/org/dolphinemu/dolphinemu/features/netplay/model/SaveTransferProgress.kt @@ -0,0 +1,13 @@ +package org.dolphinemu.dolphinemu.features.netplay.model + +data class SaveTransferProgress( + val title: String, + val totalSize: Long, + val playerProgresses: List +) { + data class PlayerProgress( + val playerId: Int, + val name: String, + val progress: Long, + ) +} 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 463ad31506..8a8f860825 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 @@ -52,6 +52,7 @@ class NetplayActivity : AppCompatActivity(), ThemeProvider { hostInputAuthorityEnabled = viewModel.hostInputAuthority.collectAsState().value, maxBuffer = viewModel.maxBuffer.collectAsState().value, onMaxBufferChanged = viewModel::setMaxBuffer, + saveTransferProgress = viewModel.saveTransferProgress.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 c47c8b8e3e..5608775bdc 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 @@ -34,6 +34,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar @@ -70,10 +71,12 @@ import kotlinx.coroutines.flow.emptyFlow import org.dolphinemu.dolphinemu.R import org.dolphinemu.dolphinemu.features.netplay.model.NetplayMessage import org.dolphinemu.dolphinemu.features.netplay.model.Player +import org.dolphinemu.dolphinemu.features.netplay.model.SaveTransferProgress import org.dolphinemu.dolphinemu.ui.theme.DolphinTheme import org.dolphinemu.dolphinemu.ui.theme.MenuSpacer import org.dolphinemu.dolphinemu.ui.theme.OutlinedBox import org.dolphinemu.dolphinemu.ui.theme.PreviewTheme +import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -87,6 +90,7 @@ fun NetplayScreen( maxBuffer: Int, onMaxBufferChanged: (Int) -> Unit, players: List, + saveTransferProgress: SaveTransferProgress?, ) { Scaffold( topBar = { @@ -136,16 +140,31 @@ fun NetplayScreen( LaunchedEffect(Unit) { connectionLost.collect { showConnectionLostDialog = true } } - if (showConnectionLostDialog) { - AlertDialog( - text = { Text(stringResource(R.string.netplay_connection_lost)) }, - confirmButton = { - TextButton(onClick = onBackClicked) { - Text(stringResource(R.string.ok)) - } - }, - onDismissRequest = onBackClicked, - ) + + var dismissSaveTransferProgressDialog by remember { mutableStateOf(false) } + if (saveTransferProgress == null) { + dismissSaveTransferProgressDialog = false + } + + when { + showConnectionLostDialog -> { + AlertDialog( + text = { Text(stringResource(R.string.netplay_connection_lost)) }, + confirmButton = { + TextButton(onClick = onBackClicked) { + Text(stringResource(R.string.ok)) + } + }, + onDismissRequest = onBackClicked, + ) + } + + saveTransferProgress != null && !dismissSaveTransferProgressDialog -> { + SaveTransferProgressDialog( + saveTransferProgress = saveTransferProgress, + onDismiss = { dismissSaveTransferProgressDialog = true }, + ) + } } } } @@ -519,6 +538,68 @@ private fun BufferInput( } } +@Composable +private fun SaveTransferProgressDialog( + saveTransferProgress: SaveTransferProgress, + onDismiss: () -> Unit, +) { + AlertDialog( + title = { Text(saveTransferProgress.title) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + saveTransferProgress.playerProgresses.forEachIndexed { index, playerProgress -> + SaveTransferProgressRow( + playerProgress = playerProgress, + totalSize = saveTransferProgress.totalSize, + ) + + if (index < saveTransferProgress.playerProgresses.lastIndex) { + HorizontalDivider() + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.netplay_save_transfer_progress_close)) + } + }, + onDismissRequest = onDismiss, + ) +} + +@Composable +private fun SaveTransferProgressRow( + playerProgress: SaveTransferProgress.PlayerProgress, + totalSize: Long, +) { + fun formatMib(bytes: Long) = String.format(Locale.US, "%.2f", bytes / 1024f / 1024f) + val progressFraction = (playerProgress.progress.toFloat() / totalSize).coerceIn(0f, 1f) + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + LinearProgressIndicator( + progress = { progressFraction }, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = playerProgress.name, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = "${formatMib(playerProgress.progress)}/${formatMib(totalSize)} MiB", + ) + } + } +} + @Preview @Composable private fun NetplayScreenPreview() { @@ -589,5 +670,22 @@ private fun PreviewNetplayScreen() { hostInputAuthorityEnabled = true, maxBuffer = 10, onMaxBufferChanged = {}, + saveTransferProgress = null, +// saveTransferProgress = SaveTransferProgress( +// title = "Title", +// totalSize = 1024L, +// playerProgresses = listOf( +// SaveTransferProgress.PlayerProgress( +// playerId = 1, +// name = "Player 1", +// progress = 256, +// ), +// SaveTransferProgress.PlayerProgress( +// playerId = 2, +// name = "Player 2", +// progress = 512, +// ), +// ), +// ), ) } diff --git a/Source/Android/app/src/main/res/values/strings.xml b/Source/Android/app/src/main/res/values/strings.xml index 7b4311c996..cc1866d473 100644 --- a/Source/Android/app/src/main/res/values/strings.xml +++ b/Source/Android/app/src/main/res/values/strings.xml @@ -1009,4 +1009,5 @@ It can efficiently compress both junk data and encrypted Wii data. Mapping Max buffer Netplay connection lost + Close diff --git a/Source/Android/jni/AndroidCommon/IDCache.cpp b/Source/Android/jni/AndroidCommon/IDCache.cpp index 461576f217..150cb8162e 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.cpp +++ b/Source/Android/jni/AndroidCommon/IDCache.cpp @@ -40,6 +40,9 @@ static jmethodID s_netplay_on_host_input_authority_changed; static jmethodID s_netplay_on_pad_buffer_changed; static jmethodID s_netplay_on_chat_message_received; static jmethodID s_netplay_update; +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 jclass s_netplay_player_class; static jmethodID s_netplay_player_constructor; @@ -309,6 +312,21 @@ jmethodID GetNetplayUpdate() return s_netplay_update; } +jmethodID GetNetplayOnShowChunkedProgressDialog() +{ + return s_netplay_on_show_chunked_progress_dialog; +} + +jmethodID GetNetplayOnSetChunkedProgress() +{ + return s_netplay_on_set_chunked_progress; +} + +jmethodID GetNetplayOnHideChunkedProgressDialog() +{ + return s_netplay_on_hide_chunked_progress_dialog; +} + jclass GetNetplayPlayerClass() { return s_netplay_player_class; @@ -741,6 +759,12 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) s_netplay_on_chat_message_received = env->GetStaticMethodID(netplay_class, "onChatMessageReceived", "(Ljava/lang/String;)V"); s_netplay_update = env->GetStaticMethodID(netplay_class, "onUpdate", "([Lorg/dolphinemu/dolphinemu/features/netplay/model/Player;)V"); + s_netplay_on_show_chunked_progress_dialog = + env->GetStaticMethodID(netplay_class, "onShowChunkedProgressDialog", "(Ljava/lang/String;J[I)V"); + s_netplay_on_set_chunked_progress = + env->GetStaticMethodID(netplay_class, "onSetChunkedProgress", "(IJ)V"); + s_netplay_on_hide_chunked_progress_dialog = + env->GetStaticMethodID(netplay_class, "onHideChunkedProgressDialog", "()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 c535fcb9ae..4041b5060e 100644 --- a/Source/Android/jni/AndroidCommon/IDCache.h +++ b/Source/Android/jni/AndroidCommon/IDCache.h @@ -43,6 +43,9 @@ jmethodID GetNetplayOnHostInputAuthorityChanged(); jmethodID GetNetplayOnPadBufferChanged(); jmethodID GetNetplayOnChatMessageReceived(); jmethodID GetNetplayUpdate(); +jmethodID GetNetplayOnShowChunkedProgressDialog(); +jmethodID GetNetplayOnSetChunkedProgress(); +jmethodID GetNetplayOnHideChunkedProgressDialog(); jclass GetNetplayPlayerClass(); jmethodID GetNetplayPlayerConstructor(); diff --git a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp index 21dd77d019..fa013017dc 100644 --- a/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp +++ b/Source/Android/jni/NetPlay/NetPlayUICallbacks.cpp @@ -186,9 +186,36 @@ void NetPlayUICallbacks::ShowGameDigestDialog(const std::string&) {} void NetPlayUICallbacks::SetGameDigestProgress(int, int) {} void NetPlayUICallbacks::SetGameDigestResult(int, const std::string&) {} void NetPlayUICallbacks::AbortGameDigest() {} -void NetPlayUICallbacks::ShowChunkedProgressDialog(const std::string&, u64, std::span) {} -void NetPlayUICallbacks::HideChunkedProgressDialog() {} -void NetPlayUICallbacks::SetChunkedProgress(int, u64) {} + +void NetPlayUICallbacks::ShowChunkedProgressDialog(const std::string& title, u64 data_size, + std::span players) +{ + JNIEnv* env = IDCache::GetEnvForThread(); + + jintArray j_players = env->NewIntArray(static_cast(players.size())); + env->SetIntArrayRegion(j_players, 0, static_cast(players.size()), players.data()); + + env->CallStaticVoidMethod(IDCache::GetNetplayClass(), + IDCache::GetNetplayOnShowChunkedProgressDialog(), + ToJString(env, title), static_cast(data_size), j_players); + env->DeleteLocalRef(j_players); +} + +void NetPlayUICallbacks::HideChunkedProgressDialog() +{ + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(IDCache::GetNetplayClass(), + IDCache::GetNetplayOnHideChunkedProgressDialog()); +} + +void NetPlayUICallbacks::SetChunkedProgress(int pid, u64 progress) +{ + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(IDCache::GetNetplayClass(), + IDCache::GetNetplayOnSetChunkedProgress(), + static_cast(pid), static_cast(progress)); +} + void NetPlayUICallbacks::SetHostWiiSyncData(std::vector, std::string) {} } // namespace NetPlay