From 76f11e4ffce37a0c6d2c6bcba7c19115b4513ace Mon Sep 17 00:00:00 2001 From: NimrodNetzer Date: Sun, 29 Mar 2026 11:31:08 +0300 Subject: [PATCH] feat(android-ui): reconnect banner, transfer speed/ETA/retry, error states --- .../app/core/DeviceConnectionService.kt | 47 +++- .../airbridge/app/ui/screens/DevicesScreen.kt | 87 ++++++- .../app/ui/screens/TransferScreen.kt | 234 +++++++++++++++--- .../app/ui/viewmodels/DevicesViewModel.kt | 28 +++ .../app/ui/viewmodels/TransferViewModel.kt | 106 +++++++- 5 files changed, 460 insertions(+), 42 deletions(-) diff --git a/android/app/src/main/java/com/airbridge/app/core/DeviceConnectionService.kt b/android/app/src/main/java/com/airbridge/app/core/DeviceConnectionService.kt index 57c75de..ab36fd7 100644 --- a/android/app/src/main/java/com/airbridge/app/core/DeviceConnectionService.kt +++ b/android/app/src/main/java/com/airbridge/app/core/DeviceConnectionService.kt @@ -30,6 +30,19 @@ import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject import javax.inject.Singleton +/** + * Represents the reconnect state for a device that is currently being re-connected to. + * + * @property deviceId The device being reconnected. + * @property attempt The current attempt number (1-based). + * @property maxAttempts Total number of attempts allowed. + */ +data class ReconnectState( + val deviceId: String, + val attempt: Int, + val maxAttempts: Int, +) + /** * Represents an inbound pairing request received from a remote (typically Windows) device. * @@ -103,6 +116,20 @@ class DeviceConnectionService @Inject constructor( // ── Lifecycle ───────────────────────────────────────────────────────── + /** + * Non-null while an outbound reconnect is in progress; null when connected or idle. + * The UI can observe this to show a "Reconnecting…" indicator. + */ + private val _reconnectState = MutableStateFlow(null) + val reconnectState: StateFlow = _reconnectState.asStateFlow() + + /** + * Emits the device ID of a connection that failed all reconnect attempts. + * The UI can observe this to show a "Connection failed" error with a Retry button. + */ + private val _connectionFailedEvent = MutableSharedFlow() + val connectionFailedEvent: SharedFlow = _connectionFailedEvent.asSharedFlow() + /** * Starts listening for inbound TLS connections and routing them. * Idempotent — safe to call multiple times. @@ -132,12 +159,21 @@ class DeviceConnectionService @Inject constructor( var attempt = 1 while (attempt <= maxAttempts) { + // Emit reconnect state so the UI can show a "Reconnecting…" banner. + // On the very first attempt (attempt == 1) this signals the initial connect; + // on subsequent attempts it signals a true reconnect after a drop. + _reconnectState.value = ReconnectState(device.deviceId, attempt, maxAttempts) + val channel = try { withTimeout(connectTimeoutMs) { connectionManager.connect(device) } } catch (e: TimeoutCancellationException) { - if (attempt >= maxAttempts) throw e + if (attempt >= maxAttempts) { + _reconnectState.value = null + _connectionFailedEvent.emit(device.deviceId) + throw e + } val delayMs = minOf( (Math.pow(backoffBase, (attempt - 1).toDouble()) * 1000).toLong(), backoffMaxMs @@ -146,7 +182,11 @@ class DeviceConnectionService @Inject constructor( attempt++ continue } catch (e: Exception) { - if (attempt >= maxAttempts) throw e + if (attempt >= maxAttempts) { + _reconnectState.value = null + _connectionFailedEvent.emit(device.deviceId) + throw e + } val delayMs = minOf( (Math.pow(backoffBase, (attempt - 1).toDouble()) * 1000).toLong(), backoffMaxMs @@ -156,7 +196,8 @@ class DeviceConnectionService @Inject constructor( continue } - // Connected — register session and wait for disconnect before retrying. + // Connected — clear reconnect indicator and register session. + _reconnectState.value = null registerSession(device.deviceId, channel) _establishedChannels.emit(channel) diff --git a/android/app/src/main/java/com/airbridge/app/ui/screens/DevicesScreen.kt b/android/app/src/main/java/com/airbridge/app/ui/screens/DevicesScreen.kt index 571bd7b..949995f 100644 --- a/android/app/src/main/java/com/airbridge/app/ui/screens/DevicesScreen.kt +++ b/android/app/src/main/java/com/airbridge/app/ui/screens/DevicesScreen.kt @@ -1,8 +1,10 @@ package com.airbridge.app.ui.screens import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -19,11 +21,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cast import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Tablet import androidx.compose.material3.AssistChip import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -33,6 +37,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.TextButton @@ -65,9 +70,11 @@ fun DevicesScreen( navController: NavController, viewModel: DevicesViewModel = hiltViewModel(), ) { - val devices by viewModel.devices.collectAsStateWithLifecycle() - val isScanning by viewModel.isScanning.collectAsStateWithLifecycle() - val statusMessage by viewModel.statusMessage.collectAsStateWithLifecycle() + val devices by viewModel.devices.collectAsStateWithLifecycle() + val isScanning by viewModel.isScanning.collectAsStateWithLifecycle() + val statusMessage by viewModel.statusMessage.collectAsStateWithLifecycle() + val reconnectState by viewModel.reconnectState.collectAsStateWithLifecycle() + val connectionError by viewModel.connectionErrorMessage.collectAsStateWithLifecycle() var manualIp by remember { mutableStateOf("") } Scaffold( @@ -102,6 +109,80 @@ fun DevicesScreen( }, ) { padding -> Column(modifier = Modifier.padding(padding)) { + // ── Reconnecting banner ────────────────────────────────────────── + AnimatedVisibility( + visible = reconnectState != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + reconnectState?.let { state -> + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Text( + text = "Reconnecting to ${state.deviceId}… (${state.attempt}/${state.maxAttempts})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + } + + // ── Connection-failed error banner ─────────────────────────────── + AnimatedVisibility( + visible = connectionError != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + connectionError?.let { message -> + Surface( + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.Default.ErrorOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(18.dp), + ) + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f), + ) + FilledTonalButton( + onClick = { viewModel.dismissConnectionError() }, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text("Dismiss", style = MaterialTheme.typography.labelSmall) + } + } + } + } + } + + // ── mDNS scan status ───────────────────────────────────────────── AnimatedVisibility(visible = isScanning, enter = fadeIn(), exit = fadeOut()) { Text( text = statusMessage, diff --git a/android/app/src/main/java/com/airbridge/app/ui/screens/TransferScreen.kt b/android/app/src/main/java/com/airbridge/app/ui/screens/TransferScreen.kt index 235e3ff..626c82e 100644 --- a/android/app/src/main/java/com/airbridge/app/ui/screens/TransferScreen.kt +++ b/android/app/src/main/java/com/airbridge/app/ui/screens/TransferScreen.kt @@ -4,6 +4,11 @@ import android.Manifest import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -14,22 +19,31 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material.icons.filled.Wifi import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -50,8 +64,12 @@ import com.airbridge.app.ui.viewmodels.TransferViewModel /** * File Transfer screen. * - * Shows the list of active and completed sessions with real-time progress bars. - * A FAB opens the system file-picker to initiate a new send. + * Shows: + * - A "Reconnecting…" banner while the transport layer is attempting to reconnect. + * - A "Connection failed" banner when all reconnect attempts are exhausted. + * - The list of active and completed sessions with real-time progress bars, + * transfer speed, ETA, and a Retry button for failed transfers. + * - A FAB that opens the system file-picker to initiate a new send. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -59,8 +77,10 @@ fun TransferScreen( navController: NavController, viewModel: TransferViewModel = hiltViewModel(), ) { - val sessions by viewModel.activeSessions.collectAsStateWithLifecycle() - val connectedDevice by viewModel.connectedDeviceId.collectAsStateWithLifecycle() + val sessions by viewModel.activeSessions.collectAsStateWithLifecycle() + val connectedDevice by viewModel.connectedDeviceId.collectAsStateWithLifecycle() + val reconnectState by viewModel.reconnectState.collectAsStateWithLifecycle() + val connectionError by viewModel.connectionErrorMessage.collectAsStateWithLifecycle() val filePicker = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent(), @@ -110,24 +130,125 @@ fun TransferScreen( } }, ) { padding -> - if (sessions.isEmpty()) { - EmptyTransferPlaceholder(modifier = Modifier.padding(padding)) - } else { - LazyColumn( - modifier = Modifier.padding(padding), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + Column(modifier = Modifier.padding(padding)) { + // ── Reconnecting banner ────────────────────────────────────────── + AnimatedVisibility( + visible = reconnectState != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), ) { - items(sessions, key = { it.sessionId }) { session -> - TransferSessionCard(session = session) + reconnectState?.let { state -> + ReconnectingBanner( + attempt = state.attempt, + maxAttempts = state.maxAttempts, + ) } } + + // ── Connection-failed error banner ─────────────────────────────── + AnimatedVisibility( + visible = connectionError != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + connectionError?.let { message -> + ConnectionErrorBanner( + message = message, + onDismiss = { viewModel.dismissConnectionError() }, + ) + } + } + + if (sessions.isEmpty()) { + EmptyTransferPlaceholder(modifier = Modifier.weight(1f)) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(sessions, key = { it.sessionId }) { session -> + TransferSessionCard( + session = session, + onRetry = { viewModel.retryTransfer(session.sessionId) }, + ) + } + } + } + } + } +} + +// ── Status banners ───────────────────────────────────────────────────────────── + +@Composable +private fun ReconnectingBanner(attempt: Int, maxAttempts: Int) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Text( + text = "Reconnecting… (attempt $attempt of $maxAttempts)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun ConnectionErrorBanner(message: String, onDismiss: () -> Unit) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.Default.ErrorOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(18.dp), + ) + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f), + ) + FilledTonalButton( + onClick = onDismiss, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text("Dismiss", style = MaterialTheme.typography.labelSmall) + } } } } +// ── Session card ─────────────────────────────────────────────────────────────── + @Composable -private fun TransferSessionCard(session: TransferSessionUiState) { +private fun TransferSessionCard(session: TransferSessionUiState, onRetry: () -> Unit) { Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), @@ -157,7 +278,9 @@ private fun TransferSessionCard(session: TransferSessionUiState) { ), ) } + Spacer(Modifier.height(8.dp)) + LinearProgressIndicator( progress = { if (session.totalBytes > 0) @@ -167,12 +290,61 @@ private fun TransferSessionCard(session: TransferSessionUiState) { modifier = Modifier.fillMaxWidth(), trackColor = MaterialTheme.colorScheme.surfaceVariant, ) + Spacer(Modifier.height(4.dp)) - Text( - text = "${session.transferredBytes.toHumanReadable()} / ${session.totalBytes.toHumanReadable()}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + + // Bytes transferred / total + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "${session.transferredBytes.toHumanReadable()} / ${session.totalBytes.toHumanReadable()}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Speed and ETA — only shown while the transfer is active + if (session.state == TransferState.ACTIVE) { + val speedText = session.speedBytesPerSec?.let { "${it.toHumanReadable()}/s" } + val etaText = session.etaSeconds?.let { formatEta(it) } + val detail = listOfNotNull(speedText, etaText?.let { "ETA $it" }) + .joinToString(" · ") + if (detail.isNotEmpty()) { + Text( + text = detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Error message + Retry button for failed transfers + if (session.state == TransferState.FAILED) { + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = session.errorMessage ?: "Transfer failed.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + Button( + onClick = onRetry, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Retry", style = MaterialTheme.typography.labelMedium) + } + } + } } } } @@ -202,20 +374,20 @@ private fun EmptyTransferPlaceholder(modifier: Modifier = Modifier) { } } -// ── Helpers ─────────────────────────────────────────────────────────────────── +// ── Helpers ──────────────────────────────────────────────────────────────────── private fun TransferState.label(): String = when (this) { - TransferState.PENDING -> "Pending" - TransferState.ACTIVE -> "Active" - TransferState.PAUSED -> "Paused" + TransferState.PENDING -> "Pending" + TransferState.ACTIVE -> "Active" + TransferState.PAUSED -> "Paused" TransferState.COMPLETED -> "Complete" - TransferState.FAILED -> "Failed" + TransferState.FAILED -> "Failed" TransferState.CANCELLED -> "Cancelled" } @Composable private fun TransferState.chipColor(): Color = when (this) { - TransferState.ACTIVE -> MaterialTheme.colorScheme.primaryContainer + TransferState.ACTIVE -> MaterialTheme.colorScheme.primaryContainer TransferState.COMPLETED -> Color(0xFF2E7D32) TransferState.FAILED, TransferState.CANCELLED -> MaterialTheme.colorScheme.errorContainer else -> MaterialTheme.colorScheme.surfaceVariant @@ -223,7 +395,13 @@ private fun TransferState.chipColor(): Color = when (this) { private fun Long.toHumanReadable(): String = when { this >= 1_073_741_824L -> "%.1f GB".format(this / 1_073_741_824.0) - this >= 1_048_576L -> "%.1f MB".format(this / 1_048_576.0) - this >= 1_024L -> "%.1f KB".format(this / 1_024.0) - else -> "$this B" + this >= 1_048_576L -> "%.1f MB".format(this / 1_048_576.0) + this >= 1_024L -> "%.1f KB".format(this / 1_024.0) + else -> "$this B" +} + +private fun formatEta(seconds: Long): String = when { + seconds < 60 -> "${seconds}s" + seconds < 3600 -> "${seconds / 60}m ${seconds % 60}s" + else -> "${seconds / 3600}h ${(seconds % 3600) / 60}m" } diff --git a/android/app/src/main/java/com/airbridge/app/ui/viewmodels/DevicesViewModel.kt b/android/app/src/main/java/com/airbridge/app/ui/viewmodels/DevicesViewModel.kt index a4a83a8..851beb9 100644 --- a/android/app/src/main/java/com/airbridge/app/ui/viewmodels/DevicesViewModel.kt +++ b/android/app/src/main/java/com/airbridge/app/ui/viewmodels/DevicesViewModel.kt @@ -2,6 +2,8 @@ package com.airbridge.app.ui.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.airbridge.app.core.DeviceConnectionService +import com.airbridge.app.core.ReconnectState import com.airbridge.app.core.interfaces.IDeviceRegistry import com.airbridge.app.core.models.DeviceInfo import com.airbridge.app.core.models.DeviceType @@ -25,6 +27,7 @@ import javax.inject.Inject class DevicesViewModel @Inject constructor( private val discoveryService: IDiscoveryService, private val deviceRegistry: IDeviceRegistry, + private val deviceConnectionService: DeviceConnectionService, ) : ViewModel() { private val _devices = MutableStateFlow>(emptyList()) @@ -36,6 +39,19 @@ class DevicesViewModel @Inject constructor( private val _statusMessage = MutableStateFlow("Tap scan to find devices") val statusMessage: StateFlow = _statusMessage.asStateFlow() + /** + * Mirrors [DeviceConnectionService.reconnectState]. + * Non-null while an outbound reconnect to a paired device is in progress. + */ + val reconnectState = deviceConnectionService.reconnectState + + /** + * Emits a non-null error message when all reconnect attempts to a device are exhausted. + * Observed by the UI to show a dismissible error banner with a Retry button. + */ + private val _connectionErrorMessage = MutableStateFlow(null) + val connectionErrorMessage: StateFlow = _connectionErrorMessage.asStateFlow() + private var scanJob: Job? = null private var registryJob: Job? = null @@ -46,10 +62,22 @@ class DevicesViewModel @Inject constructor( _devices.value = list } } + // Surface permanent connection failures as an error message. + viewModelScope.launch { + deviceConnectionService.connectionFailedEvent.collect { deviceId -> + _connectionErrorMessage.value = + "Could not connect to "$deviceId". Check that the PC is reachable." + } + } // Auto-start scanning so the user sees devices without tapping the search icon. startScan() } + /** Dismisses the current connection error banner. */ + fun dismissConnectionError() { + _connectionErrorMessage.value = null + } + /** Starts mDNS discovery and merges discovered devices into the registry. */ fun startScan() { if (_isScanning.value) return diff --git a/android/app/src/main/java/com/airbridge/app/ui/viewmodels/TransferViewModel.kt b/android/app/src/main/java/com/airbridge/app/ui/viewmodels/TransferViewModel.kt index ddd5a4e..94ffce1 100644 --- a/android/app/src/main/java/com/airbridge/app/ui/viewmodels/TransferViewModel.kt +++ b/android/app/src/main/java/com/airbridge/app/ui/viewmodels/TransferViewModel.kt @@ -26,6 +26,10 @@ import javax.inject.Inject * @property totalBytes Total file size in bytes. * @property transferredBytes Bytes transferred so far. * @property state Current [TransferState]. + * @property speedBytesPerSec Current transfer speed in bytes per second, or null if unknown. + * @property etaSeconds Estimated seconds remaining, or null if unknown. + * @property errorMessage Human-readable error description when [state] is [TransferState.FAILED]. + * @property sourceUri The original [Uri] for re-sending on retry; null for received files. */ data class TransferSessionUiState( val sessionId: String, @@ -33,6 +37,10 @@ data class TransferSessionUiState( val totalBytes: Long, val transferredBytes: Long, val state: TransferState, + val speedBytesPerSec: Long? = null, + val etaSeconds: Long? = null, + val errorMessage: String? = null, + val sourceUri: Uri? = null, ) /** @@ -40,6 +48,8 @@ data class TransferSessionUiState( * * Bridges the file-picker [Uri] to the [IFileTransferService] and maintains * the [activeSessions] list for UI observation. + * Also exposes [reconnectState] from [DeviceConnectionService] so the Transfer screen + * can show a "Reconnecting…" banner when the connection drops. */ @HiltViewModel class TransferViewModel @Inject constructor( @@ -63,9 +73,25 @@ class TransferViewModel @Inject constructor( private val _connectedDeviceId = MutableStateFlow(null) val connectedDeviceId: StateFlow = _connectedDeviceId.asStateFlow() + /** + * Mirrors [DeviceConnectionService.reconnectState] for UI consumption. + * Non-null while the transport layer is trying to reconnect. + */ + val reconnectState = deviceConnectionService.reconnectState + + /** + * Emits a non-null error message when a connection attempt fails permanently. + * The UI should show this as a dismissible error banner with a Retry button. + */ + private val _connectionErrorMessage = MutableStateFlow(null) + val connectionErrorMessage: StateFlow = _connectionErrorMessage.asStateFlow() + /** Tracks which device IDs we have already registered a receive handler for. */ private val _handlerRegisteredFor = mutableSetOf() + /** Tracks the last URI sent per session so that failed transfers can be retried. */ + private val _pendingRetryUris = mutableMapOf() + init { // Observe connected device IDs from the session manager and surface the first one. // Also register the inbound file-transfer handler when a new device connects. @@ -81,6 +107,19 @@ class TransferViewModel @Inject constructor( } } } + + // Surface permanent connection failures as a dismissible error message. + viewModelScope.launch { + deviceConnectionService.connectionFailedEvent.collect { deviceId -> + _connectionErrorMessage.value = + "Could not connect to device "$deviceId". Check that the PC is reachable." + } + } + } + + /** Dismisses the current connection error banner. */ + fun dismissConnectionError() { + _connectionErrorMessage.value = null } /** @@ -103,22 +142,46 @@ class TransferViewModel @Inject constructor( transferService.sendFile(filePath, device) } catch (e: Exception) { android.util.Log.e("AirBridge/Transfer", "sendFile failed: ${e.message}", e) - _errorMessage.value = "Send failed: ${e.message}" + _connectionErrorMessage.value = "Send failed: ${e.message}" return@launch } - // Observe progress and state val uiState = TransferSessionUiState( sessionId = session.sessionId, fileName = session.fileName, totalBytes = session.totalBytes, transferredBytes = 0L, state = TransferState.PENDING, + sourceUri = uri, ) addOrUpdateSession(uiState) + _pendingRetryUris[session.sessionId] = uri + + // Track speed and ETA using timestamps between progress updates. + var lastProgressBytes = 0L + var lastProgressTimeMs = System.currentTimeMillis() launch { session.progressFlow.collect { transferred -> - updateSessionProgress(session.sessionId, transferred) + val nowMs = System.currentTimeMillis() + val elapsedMs = nowMs - lastProgressTimeMs + val bytesDelta = transferred - lastProgressBytes + + val speed: Long? + val eta: Long? + if (elapsedMs > 0 && bytesDelta >= 0) { + speed = (bytesDelta * 1000L) / elapsedMs + val remaining = session.totalBytes - transferred + eta = if (speed > 0) remaining / speed else null + } else { + speed = null + eta = null + } + + lastProgressBytes = transferred + lastProgressTimeMs = nowMs + + updateSessionProgress(session.sessionId, transferred, speed, eta) + val percent = if (session.totalBytes > 0) ((transferred * 100) / session.totalBytes).toInt() else 0 @@ -127,7 +190,10 @@ class TransferViewModel @Inject constructor( } session.stateFlow.collect { state -> - updateSessionState(session.sessionId, state) + val errMsg = if (state == TransferState.FAILED) + "Transfer of "${session.fileName}" failed. Tap Retry to try again." + else null + updateSessionState(session.sessionId, state, errMsg) when (state) { TransferState.COMPLETED -> notificationManager.showComplete(session.fileName) TransferState.FAILED, TransferState.CANCELLED -> notificationManager.cancel() @@ -137,6 +203,21 @@ class TransferViewModel @Inject constructor( } } + /** + * Retries a previously failed transfer. + * Looks up the original [Uri] stored during [sendFile] and re-submits it. + * No-ops if the session is not in [TransferState.FAILED] state or the URI is unavailable. + */ + fun retryTransfer(sessionId: String) { + val session = _activeSessions.value.find { it.sessionId == sessionId } ?: return + if (session.state != TransferState.FAILED) return + val uri = session.sourceUri ?: _pendingRetryUris[sessionId] ?: return + + // Remove the failed session from the list before re-sending so it gets a fresh entry. + _activeSessions.value = _activeSessions.value.filter { it.sessionId != sessionId } + sendFile(uri) + } + private fun addOrUpdateSession(uiState: TransferSessionUiState) { val current = _activeSessions.value.toMutableList() val index = current.indexOfFirst { it.sessionId == uiState.sessionId } @@ -144,20 +225,29 @@ class TransferViewModel @Inject constructor( _activeSessions.value = current } - private fun updateSessionProgress(sessionId: String, transferred: Long) { + private fun updateSessionProgress( + sessionId: String, + transferred: Long, + speed: Long?, + eta: Long?, + ) { val current = _activeSessions.value.toMutableList() val index = current.indexOfFirst { it.sessionId == sessionId } if (index >= 0) { - current[index] = current[index].copy(transferredBytes = transferred) + current[index] = current[index].copy( + transferredBytes = transferred, + speedBytesPerSec = speed, + etaSeconds = eta, + ) _activeSessions.value = current } } - private fun updateSessionState(sessionId: String, state: TransferState) { + private fun updateSessionState(sessionId: String, state: TransferState, errorMessage: String?) { val current = _activeSessions.value.toMutableList() val index = current.indexOfFirst { it.sessionId == sessionId } if (index >= 0) { - current[index] = current[index].copy(state = state) + current[index] = current[index].copy(state = state, errorMessage = errorMessage) _activeSessions.value = current } }