Baseline Compose Multiplatform Iroh migration

This commit is contained in:
2026-07-02 23:53:47 +02:00
commit 90463d7210
64 changed files with 8439 additions and 0 deletions

View File

@@ -0,0 +1,206 @@
package com.vnidrop.app
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.CoreRepository
import kotlinx.coroutines.launch
@Composable
@Preview
fun App() {
val platform = remember { getPlatform() }
val repository = remember { CoreRepository() }
val state by repository.state.collectAsState()
val scope = rememberCoroutineScope()
var appDataDir by remember { mutableStateOf(platform.defaultCoreDataDir) }
var sourcePath by remember { mutableStateOf("") }
var transferName by remember { mutableStateOf("VniDrop transfer") }
var senderName by remember { mutableStateOf("") }
var ticket by remember { mutableStateOf("") }
var receiveDir by remember { mutableStateOf(platform.defaultReceiveDir) }
var receiverName by remember { mutableStateOf("") }
MaterialTheme {
Surface(
modifier = Modifier
.safeContentPadding()
.fillMaxSize()
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Text("VniDrop Core", style = MaterialTheme.typography.headlineMedium)
Text(platform.name, style = MaterialTheme.typography.bodySmall)
Text(state.status, style = MaterialTheme.typography.bodyMedium)
}
item {
CoreCard(title = "Initialize") {
OutlinedTextField(
value = appDataDir,
onValueChange = { appDataDir = it },
label = { Text("Core data directory") },
modifier = Modifier.fillMaxWidth(),
)
Button(onClick = {
scope.launch { repository.initialize(appDataDir) }
}) {
Text("Initialize core")
}
}
}
item {
CoreCard(title = "Share path") {
OutlinedTextField(
value = sourcePath,
onValueChange = { sourcePath = it },
label = { Text("Source file path") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = transferName,
onValueChange = { transferName = it },
label = { Text("Transfer name") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = senderName,
onValueChange = { senderName = it },
label = { Text("Sender name") },
modifier = Modifier.fillMaxWidth(),
)
Button(
enabled = state.isInitialized && sourcePath.isNotBlank(),
onClick = {
scope.launch {
repository.sharePath(sourcePath, transferName, senderName)
}
},
) {
Text("Create share ticket")
}
state.lastShare?.let { share ->
Text("Ticket")
Text(share.ticket, style = MaterialTheme.typography.bodySmall)
}
}
}
item {
CoreCard(title = "Receive") {
OutlinedTextField(
value = ticket,
onValueChange = { ticket = it },
label = { Text("Ticket") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
)
OutlinedTextField(
value = receiveDir,
onValueChange = { receiveDir = it },
label = { Text("Output directory") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = receiverName,
onValueChange = { receiverName = it },
label = { Text("Receiver name") },
modifier = Modifier.fillMaxWidth(),
)
Button(
enabled = state.isInitialized && ticket.isNotBlank(),
onClick = { scope.launch { repository.inspectTicket(ticket) } },
) {
Text("Inspect ticket")
}
Button(
enabled = state.isInitialized && ticket.isNotBlank() && receiveDir.isNotBlank(),
onClick = {
scope.launch { repository.receive(ticket, receiveDir, receiverName) }
},
) {
Text("Receive")
}
state.lastInspection?.let { inspection ->
Text("Kind: ${inspection.kind}")
Text("Blob ticket: ${inspection.blobTicket.take(96)}")
inspection.metadata?.let { metadata ->
Text("${metadata.transferName} | ${metadata.fileCount} files | ${metadata.totalSize} bytes")
}
}
}
}
state.error?.let { error ->
item {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Text(
text = error,
modifier = Modifier.padding(12.dp),
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}
item {
Text("Events", style = MaterialTheme.typography.titleMedium)
}
items(state.events, key = { it.id }) { event ->
Card {
Text(
text = "${event.scope}/${event.direction ?: "-"} ${event.phase}:${event.kind}",
modifier = Modifier.padding(start = 12.dp, top = 10.dp, end = 12.dp),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = event.dataJson,
modifier = Modifier.padding(start = 12.dp, bottom = 10.dp, end = 12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}
}
@Composable
private fun CoreCard(
title: String,
content: @Composable () -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(title, style = MaterialTheme.typography.titleMedium)
HorizontalDivider()
content()
}
}
}

View File

@@ -0,0 +1,9 @@
package com.vnidrop.app
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return sayHello(platform.name)
}
}

View File

@@ -0,0 +1,4 @@
package com.vnidrop.app
fun sayHello(to: String): String =
"Hello, $to!"

View File

@@ -0,0 +1,9 @@
package com.vnidrop.app
interface Platform {
val name: String
val defaultCoreDataDir: String
val defaultReceiveDir: String
}
expect fun getPlatform(): Platform

View File

@@ -0,0 +1,114 @@
package com.vnidrop.app.core
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import kotlin.random.Random
import uniffi.vnidrop_core.CoreEvent
import uniffi.vnidrop_core.CoreEventSink
import uniffi.vnidrop_core.ShareMetadataInput
import uniffi.vnidrop_core.ShareResult
import uniffi.vnidrop_core.ShareSource
import uniffi.vnidrop_core.SourceKind
import uniffi.vnidrop_core.TicketInspection
import uniffi.vnidrop_core.VnidropCore
data class CoreUiState(
val isInitialized: Boolean = false,
val status: String = "Not initialized",
val events: List<CoreEvent> = emptyList(),
val lastShare: ShareResult? = null,
val lastInspection: TicketInspection? = null,
val error: String? = null,
)
class CoreRepository(
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
private val _state = MutableStateFlow(CoreUiState())
val state: StateFlow<CoreUiState> = _state
private var core: VnidropCore? = null
private val sink = object : CoreEventSink {
override fun onEvent(event: CoreEvent) {
_state.update { current ->
current.copy(events = (listOf(event) + current.events).take(200))
}
}
}
suspend fun initialize(appDataDir: String) = runCore {
core?.shutdown()
core = VnidropCore.initialize(appDataDir, sink)
refreshStatus()
_state.update { it.copy(isInitialized = true, error = null) }
}
suspend fun sharePath(path: String, transferName: String, senderName: String) = runCore {
val active = requireCore()
val result = active.shareFiles(
sources = listOf(
ShareSource(
kind = SourceKind.PATH,
value = path,
displayName = path.substringAfterLast('/').ifBlank { null },
isDirectory = false,
),
),
metadata = ShareMetadataInput(
transferId = nextTransferId(),
transferName = transferName.ifBlank { null },
senderName = senderName.ifBlank { null },
),
)
_state.update { it.copy(lastShare = result, error = null) }
refreshStatus()
}
suspend fun inspectTicket(ticket: String) = runCore {
val inspection = requireCore().inspectTicket(ticket)
_state.update { it.copy(lastInspection = inspection, error = null) }
}
suspend fun receive(ticket: String, outputDir: String, receiverName: String) = runCore {
requireCore().receive(ticket, outputDir, receiverName.ifBlank { null })
refreshStatus()
}
suspend fun cancel(transferId: ULong) = runCore {
requireCore().cancelTransfer(transferId)
refreshStatus()
}
private suspend fun runCore(block: suspend () -> Unit) {
withContext(dispatcher) {
try {
block()
} catch (error: Throwable) {
_state.update { it.copy(error = error.message ?: error.toString()) }
}
}
}
private fun requireCore(): VnidropCore =
core ?: error("Initialize the core first.")
private fun refreshStatus() {
val status = core?.status()
_state.update {
it.copy(
status = status?.let { value ->
"Endpoint ${value.endpointId.take(12)}... | active=${value.activeTransfers} shares=${value.activeShares}"
} ?: "Not initialized",
)
}
}
private fun nextTransferId(): ULong =
Random.nextLong(1, Long.MAX_VALUE).toULong()
}