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,12 @@
package com.vnidrop.app
import kotlin.test.Test
import kotlin.test.assertEquals
class SharedLogicAndroidHostTest {
@Test
fun example() {
assertEquals(3, 1 + 2)
}
}

View File

@@ -0,0 +1,13 @@
package com.vnidrop.app
import android.os.Build
class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}"
override val defaultCoreDataDir: String =
System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop"
override val defaultReceiveDir: String =
System.getProperty("java.io.tmpdir") ?: "/data/local/tmp/vnidrop-receive"
}
actual fun getPlatform(): Platform = AndroidPlatform()

View File

@@ -0,0 +1,44 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="450dp"
android:height="450dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M56.25,18V46L32,60 7.75,46V18L32,4Z"
android:fillColor="#6075f2"/>
<path
android:pathData="m41.5,26.5v11L32,43V60L56.25,46V18Z"
android:fillColor="#6b57ff"/>
<path
android:pathData="m32,43 l-9.5,-5.5v-11L7.75,18V46L32,60Z">
<aapt:attr name="android:fillColor">
<gradient
android:centerX="23.131"
android:centerY="18.441"
android:gradientRadius="42.132"
android:type="radial">
<item android:offset="0" android:color="#FF5383EC"/>
<item android:offset="0.867" android:color="#FF7F52FF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M22.5,26.5 L32,21 41.5,26.5 56.25,18 32,4 7.75,18Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="44.172"
android:startY="4.377"
android:endX="17.973"
android:endY="34.035"
android:type="linear">
<item android:offset="0" android:color="#FF33C3FF"/>
<item android:offset="0.878" android:color="#FF5383EC"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="m32,21 l9.526,5.5v11L32,43 22.474,37.5v-11z"
android:fillColor="#000000"/>
</vector>

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()
}

View File

@@ -0,0 +1,12 @@
package com.vnidrop.app
import kotlin.test.Test
import kotlin.test.assertEquals
class SharedCommonTest {
@Test
fun example() {
assertEquals(3, 1 + 2)
}
}

View File

@@ -0,0 +1,5 @@
package com.vnidrop.app
import androidx.compose.ui.window.ComposeUIViewController
fun MainViewController() = ComposeUIViewController { App() }

View File

@@ -0,0 +1,12 @@
package com.vnidrop.app
import platform.UIKit.UIDevice
import platform.Foundation.NSTemporaryDirectory
class IOSPlatform : Platform {
override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
override val defaultCoreDataDir: String = NSTemporaryDirectory() + "vnidrop"
override val defaultReceiveDir: String = NSTemporaryDirectory() + "vnidrop-receive"
}
actual fun getPlatform(): Platform = IOSPlatform()

View File

@@ -0,0 +1,27 @@
package com.vnidrop.app
import platform.Foundation.NSTemporaryDirectory
import kotlin.test.Test
import kotlin.test.assertTrue
import uniffi.vnidrop_core.CoreEvent
import uniffi.vnidrop_core.CoreEventSink
import uniffi.vnidrop_core.VnidropCore
class SharedLogicIOSTest {
@Test
fun generatedBindingsCanInitializeRustCore() {
val core = VnidropCore.initialize(
appDataDir = NSTemporaryDirectory() + "vnidrop-ios-test",
eventSink = object : CoreEventSink {
override fun onEvent(event: CoreEvent) = Unit
},
)
try {
assertTrue(core.status().endpointId.isNotBlank())
} finally {
core.shutdown()
}
}
}

View File

@@ -0,0 +1,11 @@
package com.vnidrop.app
class JVMPlatform : Platform {
override val name: String = "Java ${System.getProperty("java.version")}"
override val defaultCoreDataDir: String =
System.getProperty("user.home") + "/.vnidrop"
override val defaultReceiveDir: String =
System.getProperty("user.home") + "/Downloads"
}
actual fun getPlatform(): Platform = JVMPlatform()

View File

@@ -0,0 +1,28 @@
package com.vnidrop.app
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertTrue
import uniffi.vnidrop_core.CoreEvent
import uniffi.vnidrop_core.CoreEventSink
import uniffi.vnidrop_core.VnidropCore
class CoreNativeLoadTest {
@Test
fun generatedBindingsCanInitializeRustCore() {
val coreDir = Files.createTempDirectory("vnidrop-core-jvm-test")
val core = VnidropCore.initialize(
appDataDir = coreDir.toString(),
eventSink = object : CoreEventSink {
override fun onEvent(event: CoreEvent) = Unit
},
)
try {
assertTrue(core.status().endpointId.isNotBlank())
} finally {
core.shutdown()
coreDir.toFile().deleteRecursively()
}
}
}

View File

@@ -0,0 +1,12 @@
package com.vnidrop.app
import kotlin.test.Test
import kotlin.test.assertEquals
class SharedLogicDesktopTest {
@Test
fun example() {
assertEquals(3, 1 + 2)
}
}