Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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<ReconnectState?>(null)
val reconnectState: StateFlow<ReconnectState?> = _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<String>()
val connectionFailedEvent: SharedFlow<String> = _connectionFailedEvent.asSharedFlow()

/**
* Starts listening for inbound TLS connections and routing them.
* Idempotent — safe to call multiple times.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading