diff --git a/FloconDesktop/composeApp/build.gradle.kts b/FloconDesktop/composeApp/build.gradle.kts
index 383507e30..6d25823fc 100644
--- a/FloconDesktop/composeApp/build.gradle.kts
+++ b/FloconDesktop/composeApp/build.gradle.kts
@@ -43,6 +43,7 @@ kotlin {
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.zxing.core)
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
diff --git a/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml
index ab2cfc712..8b3f6e6ba 100644
--- a/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml
+++ b/FloconDesktop/composeApp/src/commonMain/composeResources/values-fr/strings.xml
@@ -327,6 +327,14 @@
Fermer les onglets à gauche
Fermer les autres onglets
Fermer les onglets à droite
+ Connecter un appareil
+ Connecter
+ Scanner pour connecter
+ Copier l\'adresse
+ Choisissez le réseau sur lequel est votre téléphone.
+ Même Wi-Fi que cet ordinateur, puis scannez, ou appelez Flocon.connect("%1$s")
+ Aucune adresse du réseau local trouvée. Vérifiez que cet ordinateur est connecté à un réseau.
+ port %1$d
Ouvrir le dossier Flocon
Enregistrer l’écran
Redémarrer l’app
diff --git a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml
index efbee6d8c..f70afe164 100644
--- a/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/FloconDesktop/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -400,6 +400,14 @@
Close Tabs to the Left
Close Other Tabs
Close Tabs to the Right
+ connect a device
+ Connect
+ Scan to connect
+ Copy address
+ Pick the network your phone is on.
+ Same Wi-Fi as this computer, then scan, or call Flocon.connect("%1$s")
+ No local network address found. Check that this computer is connected to a network.
+ port %1$d
open flocon folder
Record
restart
diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/MainScreenTopBar.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/MainScreenTopBar.kt
index 062c86317..59ab2c989 100644
--- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/MainScreenTopBar.kt
+++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/MainScreenTopBar.kt
@@ -19,6 +19,10 @@ import androidx.compose.material.icons.outlined.ArrowCircleUp
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -43,7 +47,9 @@ import io.github.openflocon.flocondesktop.app.ui.model.DeviceItemUiModel
import io.github.openflocon.flocondesktop.app.ui.model.DevicesStateUiModel
import io.github.openflocon.flocondesktop.app.ui.model.RecordVideoStateUiModel
import io.github.openflocon.flocondesktop.app.ui.view.topbar.actions.TopBarActions
+import io.github.openflocon.flocondesktop.app.ui.view.topbar.actions.TopBarConnectButton
import io.github.openflocon.flocondesktop.app.version.VersionCheckerViewModel
+import io.github.openflocon.flocondesktop.features.connect.view.ConnectDialog
import io.github.openflocon.library.designsystem.FloconTheme
import io.github.openflocon.library.designsystem.components.floconIsland
import org.jetbrains.compose.resources.painterResource
@@ -66,6 +72,11 @@ fun MainScreenTopBar(
updateChip: VersionCheckerViewModel.UpdateChipUiModel? = null,
onUpdateChipClicked: (VersionCheckerViewModel.UpdateChipUiModel) -> Unit = {},
) {
+ var showConnectDialog by remember { mutableStateOf(false) }
+ if (showConnectDialog) {
+ ConnectDialog(onDismissRequest = { showConnectDialog = false })
+ }
+
Row(
modifier = modifier
.floconIsland(
@@ -100,6 +111,7 @@ fun MainScreenTopBar(
onOpenFolderClicked = onOpenFolderClicked,
devicesState = devicesState,
)
+ TopBarConnectButton(onClicked = { showConnectDialog = true })
}
}
diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/actions/TopBarConnectButton.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/actions/TopBarConnectButton.kt
new file mode 100644
index 000000000..c61b32da6
--- /dev/null
+++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/app/ui/view/topbar/actions/TopBarConnectButton.kt
@@ -0,0 +1,39 @@
+package io.github.openflocon.flocondesktop.app.ui.view.topbar.actions
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Laptop
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import flocondesktop.composeapp.generated.resources.Res
+import flocondesktop.composeapp.generated.resources.topbar_connect
+import io.github.openflocon.library.designsystem.FloconTheme
+import org.jetbrains.compose.resources.stringResource
+
+/** Standalone button, on purpose not part of the [TopBarActions] group. */
+@Composable
+internal fun TopBarConnectButton(
+ onClicked: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = FloconTheme.colors
+
+ Box(
+ modifier = modifier
+ .background(colors.bg100, FloconTheme.shapes.medium)
+ .border(1.dp, colors.line, FloconTheme.shapes.medium)
+ .padding(FloconTheme.spacing.extraSmall),
+ ) {
+ TopBarButton(
+ active = false,
+ imageVector = Icons.Outlined.Laptop,
+ contentDescription = stringResource(Res.string.topbar_connect),
+ onClicked = onClicked,
+ isEnabled = true,
+ )
+ }
+}
diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/FeaturesModule.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/FeaturesModule.kt
index c4005054c..4b0f3addb 100644
--- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/FeaturesModule.kt
+++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/FeaturesModule.kt
@@ -5,6 +5,7 @@ import io.github.openflocon.flocondesktop.app.ui.settings.settingsModule
import io.github.openflocon.flocondesktop.features.accessibility.accessibilityModule
import io.github.openflocon.flocondesktop.features.adbcommander.adbCommanderModule
import io.github.openflocon.flocondesktop.features.analytics.analyticsModule
+import io.github.openflocon.flocondesktop.features.connect.connectModule
import io.github.openflocon.flocondesktop.features.crashreporter.crashReporterModule
import io.github.openflocon.flocondesktop.features.dashboard.dashboardModule
import io.github.openflocon.flocondesktop.features.database.databaseModule
@@ -22,6 +23,7 @@ import org.koin.dsl.module
val featuresModule = module {
includes(
analyticsModule,
+ connectModule,
featureFlagsModule,
accessibilityModule,
databaseModule,
diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/ConnectViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/ConnectViewModel.kt
new file mode 100644
index 000000000..a31d10607
--- /dev/null
+++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/ConnectViewModel.kt
@@ -0,0 +1,56 @@
+package io.github.openflocon.flocondesktop.features.connect
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.github.openflocon.domain.Constant
+import io.github.openflocon.domain.common.DispatcherProvider
+import io.github.openflocon.domain.connect.usecase.GetLocalAddressesUseCase
+import io.github.openflocon.domain.settings.usecase.ObserveMascotEnabledUseCase
+import io.github.openflocon.flocondesktop.common.utils.stateInWhileSubscribed
+import io.github.openflocon.flocondesktop.features.connect.model.ConnectUiState
+import io.github.openflocon.flocondesktop.features.connect.model.LocalAddressUiModel
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class ConnectViewModel(
+ private val dispatcherProvider: DispatcherProvider,
+ private val getLocalAddressesUseCase: GetLocalAddressesUseCase,
+ observeMascotEnabledUseCase: ObserveMascotEnabledUseCase,
+) : ViewModel() {
+
+ private val addresses = MutableStateFlow>(persistentListOf())
+
+ val uiState: StateFlow = combine(
+ addresses,
+ observeMascotEnabledUseCase(),
+ ) { addresses, mascotEnabled ->
+ ConnectUiState(
+ addresses = addresses,
+ port = Constant.SERVER_WEBSOCKET_PORT,
+ mascotEnabled = mascotEnabled,
+ )
+ }.stateInWhileSubscribed(
+ ConnectUiState(
+ addresses = persistentListOf(),
+ port = Constant.SERVER_WEBSOCKET_PORT,
+ mascotEnabled = false,
+ ),
+ )
+
+ /** The network can change while the app is open, so addresses are read again each time the dialog opens. */
+ fun refresh() {
+ viewModelScope.launch {
+ addresses.value = withContext(dispatcherProvider.viewModel) {
+ getLocalAddressesUseCase()
+ .map { LocalAddressUiModel(interfaceName = it.interfaceName, ip = it.ip) }
+ .toImmutableList()
+ }
+ }
+ }
+}
diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/DI.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/DI.kt
new file mode 100644
index 000000000..fadb1a300
--- /dev/null
+++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/DI.kt
@@ -0,0 +1,8 @@
+package io.github.openflocon.flocondesktop.features.connect
+
+import org.koin.core.module.dsl.viewModelOf
+import org.koin.dsl.module
+
+val connectModule = module {
+ viewModelOf(::ConnectViewModel)
+}
diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/model/ConnectUiModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/model/ConnectUiModel.kt
new file mode 100644
index 000000000..c9c5cc1c8
--- /dev/null
+++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/model/ConnectUiModel.kt
@@ -0,0 +1,31 @@
+package io.github.openflocon.flocondesktop.features.connect.model
+
+import androidx.compose.runtime.Immutable
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+
+@Immutable
+data class LocalAddressUiModel(
+ val interfaceName: String,
+ val ip: String,
+)
+
+@Immutable
+data class ConnectUiState(
+ val addresses: ImmutableList,
+ val port: Int,
+ val mascotEnabled: Boolean,
+)
+
+fun previewConnectUiState() = ConnectUiState(
+ addresses = persistentListOf(
+ LocalAddressUiModel(interfaceName = "en0", ip = "192.168.1.20"),
+ LocalAddressUiModel(interfaceName = "en5", ip = "10.0.0.12"),
+ LocalAddressUiModel(interfaceName = "bridge0", ip = "172.20.10.4"),
+ ),
+ port = 9023,
+ mascotEnabled = true,
+)
+
+fun previewSingleConnectUiState() = previewConnectUiState().let { it.copy(addresses = it.addresses.take(1).toImmutableList()) }
diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt
new file mode 100644
index 000000000..eb924bf84
--- /dev/null
+++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectDialog.kt
@@ -0,0 +1,439 @@
+package io.github.openflocon.flocondesktop.features.connect.view
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+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.layout.wrapContentHeight
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material.icons.outlined.ContentCopy
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.PointerIcon
+import androidx.compose.ui.input.pointer.pointerHoverIcon
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import flocondesktop.composeapp.generated.resources.Res
+import flocondesktop.composeapp.generated.resources.connect_copy
+import flocondesktop.composeapp.generated.resources.connect_empty
+import flocondesktop.composeapp.generated.resources.connect_hint
+import flocondesktop.composeapp.generated.resources.connect_pick_network
+import flocondesktop.composeapp.generated.resources.connect_port
+import flocondesktop.composeapp.generated.resources.connect_scan
+import flocondesktop.composeapp.generated.resources.connect_title
+import io.github.openflocon.flocondesktop.features.connect.ConnectViewModel
+import io.github.openflocon.flocondesktop.features.connect.model.ConnectUiState
+import io.github.openflocon.flocondesktop.features.connect.model.LocalAddressUiModel
+import io.github.openflocon.flocondesktop.features.connect.model.previewConnectUiState
+import io.github.openflocon.flocondesktop.features.connect.model.previewSingleConnectUiState
+import io.github.openflocon.library.designsystem.FloconTheme
+import io.github.openflocon.library.designsystem.components.FloconDialog
+import io.github.openflocon.library.designsystem.components.FloconIcon
+import io.github.openflocon.library.designsystem.components.FloconIconButton
+import io.github.openflocon.library.designsystem.components.FloconSurface
+import io.github.openflocon.library.designsystem.components.escape.EscapeHandler
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.viewmodel.koinViewModel
+
+private val SingleWidth = 420.dp
+private val MultiWidth = 680.dp
+private val QrSize = 216.dp
+
+/** The paws rest on the top edge of the window: the surface starts a bit above their bottom. */
+private val MascotPawsOverlap = 8.dp
+private val MascotEndOffset = 56.dp
+
+@Composable
+fun ConnectDialog(
+ onDismissRequest: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val viewModel: ConnectViewModel = koinViewModel()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(viewModel) { viewModel.refresh() }
+
+ ConnectDialog(
+ uiState = uiState,
+ onDismissRequest = onDismissRequest,
+ modifier = modifier,
+ )
+}
+
+@Composable
+private fun ConnectDialog(
+ uiState: ConnectUiState,
+ onDismissRequest: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val multiple = uiState.addresses.size > 1
+
+ ConnectDialogFrame(
+ onDismissRequest = onDismissRequest,
+ width = if (multiple) MultiWidth else SingleWidth,
+ mascotEnabled = uiState.mascotEnabled,
+ modifier = modifier,
+ ) {
+ // FloconDialog has no border, drawn here over the content so that the window stands out
+ Box(modifier = Modifier.border(1.dp, FloconTheme.colors.lineStrong, RoundedCornerShape(10.dp))) {
+ if (multiple) {
+ MultipleAddresses(
+ uiState = uiState,
+ onClose = onDismissRequest,
+ )
+ } else {
+ SingleAddress(
+ address = uiState.addresses.firstOrNull(),
+ port = uiState.port,
+ onClose = onDismissRequest,
+ )
+ }
+ }
+ }
+}
+
+/**
+ * The window of the dialog. With the mascot, it looks at the window from above: it has to be
+ * drawn outside of the surface, which clips its content, so the dialog is built here.
+ */
+@Composable
+private fun ConnectDialogFrame(
+ onDismissRequest: () -> Unit,
+ width: Dp,
+ mascotEnabled: Boolean,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit,
+) {
+ val properties = DialogProperties(usePlatformDefaultWidth = false)
+ if (!mascotEnabled) {
+ FloconDialog(
+ onDismissRequest = onDismissRequest,
+ properties = properties,
+ modifier = modifier.width(width),
+ ) {
+ content()
+ }
+ return
+ }
+ Dialog(onDismissRequest = onDismissRequest, properties = properties) {
+ EscapeHandler {
+ onDismissRequest()
+ true
+ }
+ Box(modifier = modifier.width(width)) {
+ FloconSurface(
+ color = FloconTheme.colors.floating,
+ shape = RoundedCornerShape(10.dp),
+ modifier = Modifier
+ .padding(top = ConnectMascotPeekPawsY + MascotPawsOverlap)
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ ) {
+ content()
+ }
+ ConnectMascotPeek(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(end = MascotEndOffset),
+ )
+ }
+ }
+}
+
+@Composable
+private fun SingleAddress(
+ address: LocalAddressUiModel?,
+ port: Int,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(FloconTheme.colors.bg000)
+ .padding(start = FloconTheme.spacing.extraLarge, end = FloconTheme.spacing.small),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(Res.string.connect_title),
+ style = FloconTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = FloconTheme.colors.ink,
+ modifier = Modifier.weight(1f),
+ )
+ FloconIconButton(onClick = onClose, modifier = Modifier.padding(vertical = FloconTheme.spacing.small)) {
+ FloconIcon(Icons.Outlined.Close)
+ }
+ }
+ Box(Modifier.fillMaxWidth().height(1.dp).background(FloconTheme.colors.line))
+ if (address == null) {
+ Text(
+ text = stringResource(Res.string.connect_empty),
+ style = FloconTheme.typography.bodyMedium,
+ color = FloconTheme.colors.inkMuted,
+ modifier = Modifier.padding(FloconTheme.spacing.extraLarge),
+ )
+ } else {
+ AddressPreview(
+ address = address,
+ port = port,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(FloconTheme.spacing.extraLarge),
+ )
+ }
+ }
+}
+
+@Composable
+private fun MultipleAddresses(
+ uiState: ConnectUiState,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var selectedIp by remember { mutableStateOf(null) }
+ val selected = uiState.addresses.firstOrNull { it.ip == selectedIp } ?: uiState.addresses.first()
+
+ Row(modifier = modifier.height(IntrinsicSize.Min)) {
+ Column(
+ modifier = Modifier
+ .width(260.dp)
+ .background(FloconTheme.colors.bg000)
+ .verticalScroll(rememberScrollState())
+ .padding(FloconTheme.spacing.large),
+ verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.extraSmall),
+ ) {
+ Text(
+ text = stringResource(Res.string.connect_title),
+ style = FloconTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = FloconTheme.colors.ink,
+ modifier = Modifier.padding(horizontal = FloconTheme.spacing.small),
+ )
+ Text(
+ text = stringResource(Res.string.connect_pick_network),
+ style = FloconTheme.typography.bodySmall,
+ color = FloconTheme.colors.inkMuted,
+ modifier = Modifier.padding(horizontal = FloconTheme.spacing.small).padding(bottom = FloconTheme.spacing.small),
+ )
+ uiState.addresses.forEach { address ->
+ AddressRow(
+ address = address,
+ selected = address.ip == selected.ip,
+ onClick = { selectedIp = address.ip },
+ )
+ }
+ }
+ Box(Modifier.fillMaxHeight().width(1.dp).background(FloconTheme.colors.line))
+ Box(modifier = Modifier.weight(1f)) {
+ Column {
+ AddressPreview(
+ address = selected,
+ port = uiState.port,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(FloconTheme.spacing.extraLarge),
+ )
+ }
+ FloconIconButton(
+ onClick = onClose,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(FloconTheme.spacing.small),
+ ) {
+ FloconIcon(Icons.Outlined.Close)
+ }
+ }
+ }
+}
+
+@Composable
+private fun AddressRow(
+ address: LocalAddressUiModel,
+ selected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val colors = FloconTheme.colors
+ val shape = FloconTheme.shapes.medium
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(shape)
+ .background(if (selected) colors.accentSoft else Color.Transparent)
+ .border(1.dp, if (selected) colors.accent.copy(alpha = 0.4f) else Color.Transparent, shape)
+ .clickable(onClick = onClick)
+ .pointerHoverIcon(PointerIcon.Hand)
+ .padding(horizontal = FloconTheme.spacing.medium, vertical = FloconTheme.spacing.small),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.medium),
+ ) {
+ Box(
+ Modifier
+ .size(8.dp)
+ .clip(CircleShape)
+ .background(if (selected) colors.accent else colors.lineStrong),
+ )
+ Column {
+ Text(
+ text = address.ip,
+ style = FloconTheme.typography.monoMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = colors.ink,
+ )
+ Text(
+ text = address.interfaceName,
+ style = FloconTheme.typography.bodySmall,
+ color = colors.inkMuted,
+ )
+ }
+ }
+}
+
+@Composable
+private fun AddressPreview(
+ address: LocalAddressUiModel,
+ port: Int,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(FloconTheme.spacing.large),
+ ) {
+ Text(
+ text = stringResource(Res.string.connect_scan).uppercase(),
+ style = FloconTheme.typography.labelMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = FloconTheme.colors.accent,
+ )
+ // the SDK connects with the bare ip, see Flocon.connect()
+ QrCodeImage(
+ content = address.ip,
+ modifier = Modifier
+ .size(QrSize)
+ .clip(FloconTheme.shapes.large),
+ )
+ Text(
+ text = address.ip,
+ style = FloconTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = FloconTheme.colors.ink,
+ )
+ Text(
+ text = "${address.interfaceName} · ${stringResource(Res.string.connect_port, port)}",
+ style = FloconTheme.typography.bodySmall,
+ color = FloconTheme.colors.inkMuted,
+ )
+ CopyButton(ip = address.ip)
+ Text(
+ text = stringResource(Res.string.connect_hint, address.ip),
+ style = FloconTheme.typography.bodySmall,
+ color = FloconTheme.colors.inkMuted,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+@Composable
+private fun CopyButton(
+ ip: String,
+ modifier: Modifier = Modifier,
+) {
+ val clipboardManager = LocalClipboardManager.current
+ val scope = rememberCoroutineScope()
+ val scale = remember { Animatable(1f) }
+
+ Row(
+ modifier = modifier
+ .scale(scale.value)
+ .clip(FloconTheme.shapes.medium)
+ .background(FloconTheme.colors.accent)
+ .clickable(interactionSource = null, indication = null) {
+ clipboardManager.setText(AnnotatedString(ip))
+ scope.launch {
+ scale.animateTo(0.88f, tween(durationMillis = 70))
+ scale.animateTo(1f, spring(dampingRatio = Spring.DampingRatioHighBouncy, stiffness = Spring.StiffnessMedium))
+ }
+ }
+ .pointerHoverIcon(PointerIcon.Hand)
+ .padding(horizontal = FloconTheme.spacing.large, vertical = FloconTheme.spacing.medium),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(FloconTheme.spacing.small),
+ ) {
+ FloconIcon(
+ imageVector = Icons.Outlined.ContentCopy,
+ tint = FloconTheme.colors.accentInk,
+ modifier = Modifier.size(16.dp),
+ )
+ Text(
+ text = stringResource(Res.string.connect_copy),
+ style = FloconTheme.typography.labelLarge,
+ fontWeight = FontWeight.Bold,
+ color = FloconTheme.colors.accentInk,
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun ConnectDialogMultiplePreview() {
+ FloconTheme {
+ ConnectDialog(
+ uiState = previewConnectUiState(),
+ onDismissRequest = {},
+ )
+ }
+}
+
+@Preview
+@Composable
+private fun ConnectDialogSinglePreview() {
+ FloconTheme {
+ ConnectDialog(
+ uiState = previewSingleConnectUiState(),
+ onDismissRequest = {},
+ )
+ }
+}
diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectMascotPeek.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectMascotPeek.kt
new file mode 100644
index 000000000..5409e79bf
--- /dev/null
+++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/ConnectMascotPeek.kt
@@ -0,0 +1,112 @@
+package io.github.openflocon.flocondesktop.features.connect.view
+
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import io.github.openflocon.library.designsystem.components.FloconPixelPalette
+import io.github.openflocon.library.designsystem.components.FloconPixelScene
+import io.github.openflocon.library.designsystem.components.FloconPixelScope
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+private const val GridWidth = 19
+private const val GridHeight = 22
+private const val FrameMs = 100L
+
+/** One pixel of the sprite is 6 dp. */
+private val PixelSize = 6.dp
+internal val ConnectMascotPeekWidth: Dp = PixelSize * GridWidth
+internal val ConnectMascotPeekHeight: Dp = PixelSize * GridHeight
+
+/** From the top of the sprite to the top of its paws, the window edge is just below. */
+internal val ConnectMascotPeekPawsY: Dp = PixelSize * 20
+
+private const val LoopFrames = 36
+private val Frames: ImmutableList = (0 until LoopFrames).toList().toImmutableList()
+private val GlanceAside = 11..21
+private val EarUp = setOf(16, 17, 29, 30)
+private const val BlinkFrame = 33
+
+/**
+ * The upper body of the mascot looking at the window from above, facing left: it looks at the
+ * window, glances aside (a "?" shows up), blinks and twitches an ear. 36 frames of [FrameMs].
+ */
+@Composable
+internal fun ConnectMascotPeek(modifier: Modifier = Modifier) {
+ FloconPixelScene(
+ frames = Frames,
+ frameMs = FrameMs,
+ gridWidth = GridWidth,
+ gridHeight = GridHeight,
+ modifier = modifier.size(ConnectMascotPeekWidth, ConnectMascotPeekHeight),
+ ) { frame ->
+ val aside = frame in GlanceAside
+ drawBody()
+ drawHead(
+ lean = if (aside) 1 else 0,
+ glance = aside,
+ blink = frame == BlinkFrame,
+ earUp = frame in EarUp,
+ )
+ drawPaws()
+ if (aside) drawQuestionMark()
+ }
+}
+
+/** The sprite is drawn facing right on this grid (x from 13, y from -8), then flipped. */
+private fun FloconPixelScope.mirrored(x: Int, y: Int, width: Int, height: Int, color: Color) {
+ pixels(32 - x - width, y + 8, width, height, color)
+}
+
+private fun FloconPixelScope.drawBody() {
+ mirrored(16, 7, 12, 6, FloconPixelPalette.GoldenBody)
+ mirrored(19, 8, 6, 5, FloconPixelPalette.GoldenLight)
+ mirrored(17, 11, 3, 3, FloconPixelPalette.GoldenDark)
+ mirrored(24, 11, 3, 3, FloconPixelPalette.GoldenDark)
+}
+
+private fun FloconPixelScope.drawHead(lean: Int, glance: Boolean, blink: Boolean, earUp: Boolean) {
+ val x = lean
+ val y = -3
+ mirrored(17 + x, 2 + y, 12, 11, FloconPixelPalette.Golden)
+ mirrored(25 + x, 8 + y, 6, 5, FloconPixelPalette.GoldenLight)
+ mirrored(29 + x, 8 + y, 2, 2, FloconPixelPalette.Ink)
+ mirrored(21 + x, 10 + y, 2, 1, FloconPixelPalette.Blush)
+ when {
+ blink -> mirrored(21 + x, 7 + y, 3, 1, FloconPixelPalette.Ink)
+
+ glance -> {
+ mirrored(23 + x, 6 + y, 2, 3, FloconPixelPalette.Ink)
+ mirrored(23 + x, 6 + y, 1, 1, FloconPixelPalette.White)
+ }
+
+ else -> {
+ mirrored(22 + x, 5 + y, 2, 3, FloconPixelPalette.Ink)
+ mirrored(22 + x, 5 + y, 1, 1, FloconPixelPalette.White)
+ }
+ }
+ if (earUp) {
+ mirrored(15 + x, 3 + y, 3, 7, FloconPixelPalette.Ear)
+ mirrored(15 + x, 8 + y, 3, 2, FloconPixelPalette.Pink)
+ } else {
+ mirrored(15 + x, 4 + y, 3, 7, FloconPixelPalette.Ear)
+ mirrored(15 + x, 9 + y, 3, 2, FloconPixelPalette.Pink)
+ }
+}
+
+private fun FloconPixelScope.drawPaws() {
+ mirrored(18, 12, 4, 2, FloconPixelPalette.GoldenBody)
+ mirrored(24, 12, 3, 2, FloconPixelPalette.GoldenBody)
+}
+
+private fun FloconPixelScope.drawQuestionMark() {
+ val color = FloconPixelPalette.Spark
+ // not flipped: a "?" has to stay readable, it sits next to the muzzle
+ pixels(2, 1, 3, 1, color)
+ pixels(4, 2, 1, 1, color)
+ pixels(3, 3, 1, 1, color)
+ pixels(3, 5, 1, 1, color)
+}
diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/QrCodeImage.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/QrCodeImage.kt
new file mode 100644
index 000000000..dad93181e
--- /dev/null
+++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/connect/view/QrCodeImage.kt
@@ -0,0 +1,54 @@
+package io.github.openflocon.flocondesktop.features.connect.view
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.EncodeHintType
+import com.google.zxing.qrcode.QRCodeWriter
+import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
+
+/** Always drawn black on white, even in dark mode, so that scanners can read it. */
+@Composable
+internal fun QrCodeImage(
+ content: String,
+ modifier: Modifier = Modifier,
+) {
+ val matrix = remember(content) {
+ QRCodeWriter().encode(
+ content,
+ BarcodeFormat.QR_CODE,
+ 0,
+ 0,
+ mapOf(EncodeHintType.MARGIN to 0, EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M),
+ )
+ }
+ Canvas(
+ modifier = modifier
+ .aspectRatio(1f)
+ .background(Color.White)
+ .padding(12.dp),
+ ) {
+ val cell = size.width / matrix.width
+ for (x in 0 until matrix.width) {
+ for (y in 0 until matrix.height) {
+ if (matrix[x, y]) {
+ drawRect(
+ color = Color.Black,
+ topLeft = Offset(x * cell, y * cell),
+ // slight overlap avoids hairlines between cells
+ size = Size(cell + 0.5f, cell + 0.5f),
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/DI.kt b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/DI.kt
index 39b887a50..4db4f545f 100644
--- a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/DI.kt
+++ b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/DI.kt
@@ -2,6 +2,7 @@ package com.flocon.data.remote
import com.flocon.data.remote.accessibility.accessibilityModule
import com.flocon.data.remote.analytics.analyticsModule
+import com.flocon.data.remote.connect.connectModule
import com.flocon.data.remote.crashreporter.crashReportModule
import com.flocon.data.remote.dashboard.dashboardModule
import com.flocon.data.remote.database.databaseModule
@@ -21,6 +22,7 @@ val dataRemoteModule = module {
includes(
accessibilityModule,
analyticsModule,
+ connectModule,
featureFlagsModule,
dashboardModule,
databaseModule,
diff --git a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/connect/DI.kt b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/connect/DI.kt
new file mode 100644
index 000000000..93c1876cc
--- /dev/null
+++ b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/connect/DI.kt
@@ -0,0 +1,8 @@
+package com.flocon.data.remote.connect
+
+import io.github.openflocon.domain.connect.repository.LocalNetworkRepository
+import org.koin.dsl.module
+
+internal val connectModule = module {
+ single { getLocalNetworkRepository() }
+}
diff --git a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryFactory.kt b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryFactory.kt
new file mode 100644
index 000000000..6fb9283de
--- /dev/null
+++ b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryFactory.kt
@@ -0,0 +1,5 @@
+package com.flocon.data.remote.connect
+
+import io.github.openflocon.domain.connect.repository.LocalNetworkRepository
+
+expect fun getLocalNetworkRepository(): LocalNetworkRepository
diff --git a/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryFactory.desktop.kt b/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryFactory.desktop.kt
new file mode 100644
index 000000000..c7685f430
--- /dev/null
+++ b/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryFactory.desktop.kt
@@ -0,0 +1,5 @@
+package com.flocon.data.remote.connect
+
+import io.github.openflocon.domain.connect.repository.LocalNetworkRepository
+
+actual fun getLocalNetworkRepository(): LocalNetworkRepository = LocalNetworkRepositoryImpl()
diff --git a/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryImpl.kt b/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryImpl.kt
new file mode 100644
index 000000000..7f5f905c1
--- /dev/null
+++ b/FloconDesktop/data/remote/src/desktopMain/kotlin/com/flocon/data/remote/connect/LocalNetworkRepositoryImpl.kt
@@ -0,0 +1,30 @@
+package com.flocon.data.remote.connect
+
+import io.github.openflocon.domain.connect.models.LocalAddressDomainModel
+import io.github.openflocon.domain.connect.repository.LocalNetworkRepository
+import java.net.Inet4Address
+import java.net.NetworkInterface
+
+internal class LocalNetworkRepositoryImpl : LocalNetworkRepository {
+
+ override fun getLocalAddresses(): List = try {
+ NetworkInterface.getNetworkInterfaces()
+ ?.toList()
+ .orEmpty()
+ .filter { it.isUp && !it.isLoopback && !it.isVirtual }
+ .flatMap { networkInterface ->
+ networkInterface.inetAddresses.toList()
+ .filterIsInstance()
+ .mapNotNull { address ->
+ address.hostAddress?.let {
+ LocalAddressDomainModel(
+ interfaceName = networkInterface.displayName ?: networkInterface.name,
+ ip = it,
+ )
+ }
+ }
+ }
+ } catch (_: Exception) {
+ emptyList()
+ }
+}
diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/DI.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/DI.kt
index ec858f6d9..d7cd2fb33 100644
--- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/DI.kt
+++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/DI.kt
@@ -4,6 +4,7 @@ import io.github.openflocon.domain.accessibility.accessibilityModule
import io.github.openflocon.domain.adb.adbModule
import io.github.openflocon.domain.adbcommander.adbCommanderModule
import io.github.openflocon.domain.analytics.analyticsModule
+import io.github.openflocon.domain.connect.connectModule
import io.github.openflocon.domain.crashreporter.crashReporterDomainModule
import io.github.openflocon.domain.dashboard.dashboardModule
import io.github.openflocon.domain.database.databaseModule
@@ -29,6 +30,7 @@ val domainModule = module {
adbModule,
adbCommanderModule,
analyticsModule,
+ connectModule,
featureFlagsModule,
dashboardModule,
databaseModule,
diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/DI.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/DI.kt
new file mode 100644
index 000000000..4851d392b
--- /dev/null
+++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/DI.kt
@@ -0,0 +1,9 @@
+package io.github.openflocon.domain.connect
+
+import io.github.openflocon.domain.connect.usecase.GetLocalAddressesUseCase
+import org.koin.core.module.dsl.factoryOf
+import org.koin.dsl.module
+
+internal val connectModule = module {
+ factoryOf(::GetLocalAddressesUseCase)
+}
diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/models/LocalAddressDomainModel.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/models/LocalAddressDomainModel.kt
new file mode 100644
index 000000000..16abae734
--- /dev/null
+++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/models/LocalAddressDomainModel.kt
@@ -0,0 +1,6 @@
+package io.github.openflocon.domain.connect.models
+
+data class LocalAddressDomainModel(
+ val interfaceName: String,
+ val ip: String,
+)
diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/repository/LocalNetworkRepository.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/repository/LocalNetworkRepository.kt
new file mode 100644
index 000000000..62d7c5e1a
--- /dev/null
+++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/repository/LocalNetworkRepository.kt
@@ -0,0 +1,7 @@
+package io.github.openflocon.domain.connect.repository
+
+import io.github.openflocon.domain.connect.models.LocalAddressDomainModel
+
+interface LocalNetworkRepository {
+ fun getLocalAddresses(): List
+}
diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/usecase/GetLocalAddressesUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/usecase/GetLocalAddressesUseCase.kt
new file mode 100644
index 000000000..62c0a78f6
--- /dev/null
+++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/connect/usecase/GetLocalAddressesUseCase.kt
@@ -0,0 +1,35 @@
+package io.github.openflocon.domain.connect.usecase
+
+import io.github.openflocon.domain.connect.models.LocalAddressDomainModel
+import io.github.openflocon.domain.connect.repository.LocalNetworkRepository
+
+class GetLocalAddressesUseCase(
+ private val localNetworkRepository: LocalNetworkRepository,
+) {
+ operator fun invoke(): List = cleanLocalAddresses(localNetworkRepository.getLocalAddresses())
+}
+
+private val ipv4Regex = Regex("""^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$""")
+
+/**
+ * Keeps the IPv4 addresses a device can reach from the local network:
+ * no loopback, no link-local (169.254.x.x), no duplicates. Private ranges come first.
+ */
+internal fun cleanLocalAddresses(addresses: List): List = addresses
+ .filter { address ->
+ val match = ipv4Regex.matchEntire(address.ip) ?: return@filter false
+ val parts = match.groupValues.drop(1).map { it.toInt() }
+ parts.all { it in 0..255 } &&
+ parts[0] != 127 &&
+ parts[0] != 0 &&
+ !(parts[0] == 169 && parts[1] == 254)
+ }
+ .distinctBy { it.ip }
+ .sortedBy { if (it.ip.isPrivateIpv4()) 0 else 1 }
+
+private fun String.isPrivateIpv4(): Boolean {
+ val parts = split(".").map { it.toInt() }
+ return parts[0] == 10 ||
+ (parts[0] == 172 && parts[1] in 16..31) ||
+ (parts[0] == 192 && parts[1] == 168)
+}
diff --git a/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/connect/usecase/GetLocalAddressesUseCaseTest.kt b/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/connect/usecase/GetLocalAddressesUseCaseTest.kt
new file mode 100644
index 000000000..b7372e343
--- /dev/null
+++ b/FloconDesktop/domain/src/commonTest/kotlin/io/github/openflocon/domain/connect/usecase/GetLocalAddressesUseCaseTest.kt
@@ -0,0 +1,45 @@
+package io.github.openflocon.domain.connect.usecase
+
+import io.github.openflocon.domain.connect.models.LocalAddressDomainModel
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class GetLocalAddressesUseCaseTest {
+
+ private fun address(ip: String, name: String = "en0") = LocalAddressDomainModel(name, ip)
+
+ @Test
+ fun `empty list stays empty`() {
+ assertTrue(cleanLocalAddresses(emptyList()).isEmpty())
+ }
+
+ @Test
+ fun `loopback, link-local and non ipv4 addresses are removed`() {
+ val result = cleanLocalAddresses(
+ listOf(
+ address("127.0.0.1", "lo0"),
+ address("169.254.10.2"),
+ address("fe80::1"),
+ address("not an ip"),
+ address("300.1.1.1"),
+ address("192.168.1.20"),
+ ),
+ )
+ assertEquals(listOf("192.168.1.20"), result.map { it.ip })
+ }
+
+ @Test
+ fun `duplicates are removed`() {
+ val result = cleanLocalAddresses(listOf(address("192.168.1.20", "en0"), address("192.168.1.20", "en1")))
+ assertEquals(1, result.size)
+ }
+
+ @Test
+ fun `private addresses come first`() {
+ val result = cleanLocalAddresses(
+ listOf(address("8.8.4.4"), address("172.20.0.5"), address("10.0.0.2"), address("172.40.0.1")),
+ )
+ assertEquals(listOf("172.20.0.5", "10.0.0.2", "8.8.4.4", "172.40.0.1"), result.map { it.ip })
+ }
+}
diff --git a/FloconDesktop/gradle/libs.versions.toml b/FloconDesktop/gradle/libs.versions.toml
index 98ecd98ea..138c9be10 100644
--- a/FloconDesktop/gradle/libs.versions.toml
+++ b/FloconDesktop/gradle/libs.versions.toml
@@ -1,4 +1,5 @@
[versions]
+zxing = "3.5.3"
about-libraries = "15.0.2"
androidx-lifecycle = "2.11.0"
buildconfig = "6.0.10"
@@ -91,6 +92,7 @@ kotlinx-schema-generator-json = { module = "org.jetbrains.kotlinx:kotlinx-schema
kotlinx-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-immutable" }
# Ktor
+zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core-jvm", version.ref = "ktor" }
diff --git a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconPixelScene.kt b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconPixelScene.kt
index eef0e8c13..9ab3b1796 100644
--- a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconPixelScene.kt
+++ b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconPixelScene.kt
@@ -30,14 +30,16 @@ class FloconPixelScope internal constructor(
private val unit: Float,
private val left: Float,
private val top: Float,
+ private val gridWidth: Int = FloconPixelSceneWidth,
+ private val gridHeight: Int = FloconPixelSceneHeight,
private val originX: Int = 0,
private val originY: Int = 0,
) {
fun pixels(x: Int, y: Int, width: Int, height: Int, color: Color) {
val gridX = (originX + x).coerceAtLeast(0)
val gridY = (originY + y).coerceAtLeast(0)
- val right = (originX + x + width).coerceAtMost(FloconPixelSceneWidth)
- val bottom = (originY + y + height).coerceAtMost(FloconPixelSceneHeight)
+ val right = (originX + x + width).coerceAtMost(gridWidth)
+ val bottom = (originY + y + height).coerceAtMost(gridHeight)
if (right <= gridX || bottom <= gridY) return
val l = floor(left + gridX * unit)
val t = floor(top + gridY * unit)
@@ -49,18 +51,21 @@ class FloconPixelScope internal constructor(
}
/** Same grid, with its origin moved by ([dx], [dy]). */
- fun offset(dx: Int, dy: Int): FloconPixelScope = FloconPixelScope(drawScope, unit, left, top, originX + dx, originY + dy)
+ fun offset(dx: Int, dy: Int): FloconPixelScope = FloconPixelScope(drawScope, unit, left, top, gridWidth, gridHeight, originX + dx, originY + dy)
}
/**
* Small looping 8-bit animation on a [FloconPixelSceneWidth] x [FloconPixelSceneHeight] grid,
* scaled to fit and centered. [draw] gets the current frame: [frames] are played in a loop, [frameMs] each.
+ * The grid is [gridWidth] x [gridHeight] pixels, a wider one is for a banner.
*/
@Composable
fun FloconPixelScene(
frames: ImmutableList,
frameMs: Long,
modifier: Modifier = Modifier,
+ gridWidth: Int = FloconPixelSceneWidth,
+ gridHeight: Int = FloconPixelSceneHeight,
draw: FloconPixelScope.(frame: Int) -> Unit,
) {
var step by remember(frames) { mutableIntStateOf(0) }
@@ -71,9 +76,9 @@ fun FloconPixelScene(
}
}
Canvas(modifier = modifier) {
- val unit = min(size.width / FloconPixelSceneWidth, size.height / FloconPixelSceneHeight)
- val left = (size.width - unit * FloconPixelSceneWidth) / 2f
- val top = (size.height - unit * FloconPixelSceneHeight) / 2f
- FloconPixelScope(this, unit, left, top).draw(frames[step])
+ val unit = min(size.width / gridWidth, size.height / gridHeight)
+ val left = (size.width - unit * gridWidth) / 2f
+ val top = (size.height - unit * gridHeight) / 2f
+ FloconPixelScope(this, unit, left, top, gridWidth, gridHeight).draw(frames[step])
}
}
diff --git a/docs/setup.md b/docs/setup.md
index 17f9184e5..10735c8ab 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -125,6 +125,10 @@ Flocon.connect("192.168.1.20")
Until a desktop is reachable, everything the plugins log is kept in memory. `Flocon.connect` is a `suspend` function: it opens the connection, introduces the app to the desktop, sends what was kept in the order it was logged, then sends the next messages right away. It returns a `FloconConnectResult` (`Connected`, `NotStarted`, `InvalidAddress` or `Failed`) instead of throwing; the messages are kept in every case, so you can call it again. Calling it with a new address replaces the previous one. The desktop shows a wifi icon next to a device connected this way. These devices have no adb link, so the features that rely on adb (screenshot, screen recording, deeplinks on Android, ADB Commander and their MCP tools) are disabled for them. Give only the IP address (or host) of the computer, without scheme nor port: the port is always `9023`, anything else returns `InvalidAddress`.
+### Connecting with a QR code
+
+The desktop app has a **Connect** button in the top bar (QR code icon). It opens a dialog listing the IP addresses of the computer on the local network (one per network interface) and, for each one, a button that shows the address as a QR code. The QR code contains only the IP address, the same value as `Flocon.connect("192.168.1.20")`. Scanning it from the SDK is not available yet.
+
Up to 5000 messages are kept: above that, the oldest are dropped. `connect { bufferCapacity = ... }` changes this size.
`connect { ... }` has two more options, all set here with a computer that has a fixed IP: