initial upload

This commit is contained in:
2026-07-05 20:19:45 +02:00
commit 9d17dd8f9a
46 changed files with 2840 additions and 0 deletions

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

57
app/build.gradle.kts Normal file
View File

@ -0,0 +1,57 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.tomhempel.unityudp"
compileSdk = 37
defaultConfig {
applicationId = "com.tomhempel.unityudp"
minSdk = 24
targetSdk = 37
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.gson)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
}

21
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,24 @@
package com.tomhempel.unityudp
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.tomhempel.unityudp", appContext.packageName)
}
}

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.UnityUDP">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.UnityUDP"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,35 @@
package com.tomhempel.unityudp
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tomhempel.unityudp.ui.HomeScreen
import com.tomhempel.unityudp.ui.MainViewModel
import com.tomhempel.unityudp.ui.ProjectScreen
import com.tomhempel.unityudp.ui.Screen
import com.tomhempel.unityudp.ui.theme.UnityUDPTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
UnityUDPTheme {
val viewModel: MainViewModel = viewModel()
val screen by viewModel.screen.collectAsState()
when (val currentScreen = screen) {
is Screen.Home -> HomeScreen(viewModel = viewModel)
is Screen.Project -> ProjectScreen(
projectId = currentScreen.projectId,
viewModel = viewModel
)
}
}
}
}
}

View File

@ -0,0 +1,56 @@
package com.tomhempel.unityudp.data
data class ReceivedMessage(
val id: String,
val data: String,
val senderIp: String,
val port: Int,
val timestamp: Long = System.currentTimeMillis()
)
data class UdpPackageData(
val id: String = "",
val name: String = "",
val data: String = "",
val ipAddresses: List<String> = listOf("127.0.0.1"),
val isBroadcast: Boolean = false
) {
fun copyWith(
id: String? = null,
name: String? = null,
data: String? = null,
ipAddresses: List<String>? = null,
isBroadcast: Boolean? = null
) = UdpPackageData(
id = id ?: this.id,
name = name ?: this.name,
data = data ?: this.data,
ipAddresses = ipAddresses ?: this.ipAddresses,
isBroadcast = isBroadcast ?: this.isBroadcast
)
}
data class ProjectData(
val id: String = "",
val name: String = "",
val ipAddresses: List<String> = listOf("127.0.0.1"),
val isBroadcast: Boolean = false,
val port: Int = 8888,
val packages: List<UdpPackageData> = emptyList()
) {
fun copyWith(
id: String? = null,
name: String? = null,
ipAddresses: List<String>? = null,
isBroadcast: Boolean? = null,
port: Int? = null,
packages: List<UdpPackageData>? = null
) = ProjectData(
id = id ?: this.id,
name = name ?: this.name,
ipAddresses = ipAddresses ?: this.ipAddresses,
isBroadcast = isBroadcast ?: this.isBroadcast,
port = port ?: this.port,
packages = packages ?: this.packages
)
}

View File

@ -0,0 +1,25 @@
package com.tomhempel.unityudp.data
import android.content.Context
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
class StorageService(context: Context) {
private val gson = Gson()
private val prefs = context.getSharedPreferences("unity_udp_prefs", Context.MODE_PRIVATE)
fun loadProjects(): List<ProjectData> {
val json = prefs.getString("projects", null) ?: return emptyList()
return try {
val type = object : TypeToken<List<ProjectData>>() {}.type
gson.fromJson(json, type) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
fun saveProjects(projects: List<ProjectData>) {
prefs.edit().putString("projects", gson.toJson(projects)).apply()
}
}

View File

@ -0,0 +1,101 @@
package com.tomhempel.unityudp.data
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
class UdpService {
private var listenerSocket: DatagramSocket? = null
@Volatile private var listening = false
// ── Send ──────────────────────────────────────────────────────────────────
suspend fun sendPackage(data: String, ipAddress: String, port: Int): Boolean =
withContext(Dispatchers.IO) {
try {
val socket = DatagramSocket()
val bytes = data.toByteArray(Charsets.UTF_8)
val address = InetAddress.getByName(ipAddress)
val packet = DatagramPacket(bytes, bytes.size, address, port)
socket.send(packet)
socket.close()
true
} catch (e: Exception) {
false
}
}
suspend fun sendBroadcast(data: String, port: Int): Boolean =
withContext(Dispatchers.IO) {
try {
val socket = DatagramSocket()
socket.broadcast = true
val bytes = data.toByteArray(Charsets.UTF_8)
val address = InetAddress.getByName("255.255.255.255")
val packet = DatagramPacket(bytes, bytes.size, address, port)
socket.send(packet)
socket.close()
true
} catch (e: Exception) {
false
}
}
suspend fun sendAdvanced(
data: String,
port: Int,
isBroadcast: Boolean,
ipAddresses: List<String>
): Map<String, Boolean> = withContext(Dispatchers.IO) {
when {
isBroadcast -> mapOf("broadcast" to sendBroadcast(data, port))
ipAddresses.isNotEmpty() -> ipAddresses.associateWith { ip ->
sendPackage(data, ip, port)
}
else -> mapOf("127.0.0.1" to sendPackage(data, "127.0.0.1", port))
}
}
// ── Listen ────────────────────────────────────────────────────────────────
suspend fun startListening(
port: Int,
onReceive: (data: String, senderIp: String, senderPort: Int) -> Unit
) = withContext(Dispatchers.IO) {
stopListening()
try {
val socket = DatagramSocket(port)
socket.soTimeout = 0
listenerSocket = socket
listening = true
val buffer = ByteArray(65535)
while (listening) {
try {
val packet = DatagramPacket(buffer, buffer.size)
socket.receive(packet)
val received = String(packet.data, 0, packet.length, Charsets.UTF_8)
val senderIp = packet.address.hostAddress ?: "unknown"
val senderPort = packet.port
onReceive(received, senderIp, senderPort)
} catch (e: Exception) {
if (!listening) break
}
}
} catch (e: Exception) {
// socket failed to bind or was closed
} finally {
listening = false
}
}
fun stopListening() {
listening = false
listenerSocket?.close()
listenerSocket = null
}
val isListening get() = listening
}

View File

@ -0,0 +1,383 @@
package com.tomhempel.unityudp.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.tomhempel.unityudp.data.ProjectData
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(viewModel: MainViewModel) {
val projects by viewModel.projects.collectAsState()
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
var showAddDialog by remember { mutableStateOf(false) }
var editingProject by remember { mutableStateOf<ProjectData?>(null) }
var deleteTarget by remember { mutableStateOf<ProjectData?>(null) }
var showAbout by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text("UnityUDP")
Text(
"by Tom Hempel",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
actions = {
IconButton(onClick = { showAbout = true }) {
Icon(Icons.Default.Info, contentDescription = "About")
}
}
)
},
floatingActionButton = {
if (selectedTab == 0) {
ExtendedFloatingActionButton(
onClick = { showAddDialog = true },
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text("New Project") }
)
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
TabRow(selectedTabIndex = selectedTab) {
Tab(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
text = { Text("Projects") },
icon = { Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(18.dp)) }
)
Tab(
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
text = { Text("Listener") },
icon = { Icon(Icons.Default.Hearing, contentDescription = null, modifier = Modifier.size(18.dp)) }
)
}
when (selectedTab) {
0 -> ProjectsTab(
projects = projects,
onTap = { viewModel.navigateToProject(it.id) },
onEdit = { editingProject = it },
onDelete = { deleteTarget = it }
)
1 -> ListenerScreen(viewModel = viewModel)
}
}
}
if (showAddDialog) {
ProjectDialog(
project = null,
onSave = { viewModel.addProject(it) },
onDismiss = { showAddDialog = false }
)
}
editingProject?.let { proj ->
ProjectDialog(
project = proj,
onSave = { viewModel.updateProject(it) },
onDismiss = { editingProject = null }
)
}
deleteTarget?.let { proj ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text("Delete Project") },
text = { Text("Are you sure you want to delete \"${proj.name}\"?") },
confirmButton = {
TextButton(
onClick = {
viewModel.deleteProject(proj.id)
deleteTarget = null
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) { Text("Cancel") }
}
)
}
if (showAbout) {
AlertDialog(
onDismissRequest = { showAbout = false },
icon = { Icon(Icons.Default.Router, contentDescription = null) },
title = { Text("UnityUDP") },
text = {
Text(
"A simple app for sending UDP commands to specified IP addresses.\n\n" +
"Create projects with multiple target IPs and configure UDP packages " +
"to send with a single tap.\n\n" +
"The Listener tab lets you receive and inspect UDP packets on any port.\n\n" +
"by Tom Hempel"
)
},
confirmButton = {
TextButton(onClick = { showAbout = false }) { Text("Close") }
}
)
}
}
@Composable
private fun ProjectsTab(
projects: List<ProjectData>,
onTap: (ProjectData) -> Unit,
onEdit: (ProjectData) -> Unit,
onDelete: (ProjectData) -> Unit
) {
if (projects.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 80.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.FolderOpen,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(16.dp))
Text("No projects yet", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Create your first project to get started",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(projects, key = { it.id }) { project ->
ProjectCard(
project = project,
onTap = { onTap(project) },
onEdit = { onEdit(project) },
onDelete = { onDelete(project) }
)
}
item { Spacer(modifier = Modifier.height(72.dp)) }
}
}
}
@Composable
private fun ProjectCard(
project: ProjectData,
onTap: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit
) {
Card(
onClick = onTap,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(48.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Default.Folder,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
project.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
val subtitle = when {
project.isBroadcast ->
"Broadcast • Port: ${project.port}${project.packages.size} package(s)"
project.ipAddresses.size > 1 ->
"${project.ipAddresses.size} IPs • Port: ${project.port}${project.packages.size} package(s)"
project.ipAddresses.isNotEmpty() ->
"${project.ipAddresses.first()}:${project.port}${project.packages.size} package(s)"
else ->
"No IPs configured • Port: ${project.port}${project.packages.size} package(s)"
}
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
var menuExpanded by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { menuExpanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "Options")
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
) {
DropdownMenuItem(
text = { Text("Edit") },
leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) },
onClick = { menuExpanded = false; onEdit() }
)
DropdownMenuItem(
text = { Text("Delete", color = MaterialTheme.colorScheme.error) },
leadingIcon = {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
onClick = { menuExpanded = false; onDelete() }
)
}
}
}
}
}
@Composable
fun ProjectDialog(
project: ProjectData?,
onSave: (ProjectData) -> Unit,
onDismiss: () -> Unit
) {
var name by remember { mutableStateOf(project?.name ?: "") }
var port by remember { mutableStateOf(project?.port?.toString() ?: "8888") }
var nameError by remember { mutableStateOf<String?>(null) }
var portError by remember { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (project == null) "New Project" else "Edit Project") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it; nameError = null },
label = { Text("Project Name") },
leadingIcon = { Icon(Icons.Default.Folder, null) },
isError = nameError != null,
supportingText = nameError?.let { { Text(it) } },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
OutlinedTextField(
value = port,
onValueChange = { port = it; portError = null },
label = { Text("UDP Port") },
leadingIcon = { Icon(Icons.Default.Router, null) },
isError = portError != null,
supportingText = portError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Info,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(modifier = Modifier.width(8.dp))
Text(
"Target IPs can be configured after creation",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
},
confirmButton = {
Button(onClick = {
var valid = true
if (name.isBlank()) { nameError = "Please enter a project name"; valid = false }
val portNum = port.toIntOrNull()
if (portNum == null || portNum < 1 || portNum > 65535) {
portError = "Port must be between 1 and 65535"; valid = false
}
if (valid) {
onSave(
ProjectData(
id = project?.id ?: System.currentTimeMillis().toString(),
name = name.trim(),
ipAddresses = project?.ipAddresses ?: emptyList(),
isBroadcast = project?.isBroadcast ?: false,
port = portNum!!,
packages = project?.packages ?: emptyList()
)
)
onDismiss()
}
}) { Text("Save") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
fun isValidIp(ip: String): Boolean {
val parts = ip.trim().split(".")
if (parts.size != 4) return false
return parts.all { part ->
val n = part.toIntOrNull()
n != null && n in 0..255
}
}

View File

@ -0,0 +1,386 @@
package com.tomhempel.unityudp.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tomhempel.unityudp.data.ReceivedMessage
import java.net.NetworkInterface
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** Returns all non-loopback IPv4 addresses on the device (e.g. Wi-Fi, hotspot). */
private fun getLocalIpAddresses(): List<Pair<String, String>> {
return try {
NetworkInterface.getNetworkInterfaces()
?.asSequence()
?.filter { iface -> iface.isUp && !iface.isLoopback }
?.flatMap { iface ->
iface.inetAddresses.asSequence()
.filter { addr ->
!addr.isLoopbackAddress &&
addr.hostAddress?.contains(':') == false
}
.map { addr -> Pair(iface.displayName, addr.hostAddress ?: "") }
}
?.filter { (_, ip) -> ip.isNotEmpty() }
?.toList() ?: emptyList()
} catch (e: Exception) {
emptyList()
}
}
@Composable
fun ListenerScreen(viewModel: MainViewModel) {
val isListening by viewModel.isListening.collectAsState()
val receivedMessages by viewModel.receivedMessages.collectAsState()
val listenError by viewModel.listenError.collectAsState()
// rememberSaveable so port survives rotation
var portInput by rememberSaveable { mutableStateOf("8888") }
var portError by remember { mutableStateOf<String?>(null) }
val localAddresses = remember { getLocalIpAddresses() }
val clipboardManager = LocalClipboardManager.current
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(listenError) {
listenError?.let {
snackbarHostState.showSnackbar(message = it, duration = SnackbarDuration.Long)
viewModel.clearListenError()
}
}
Box(modifier = Modifier.fillMaxSize()) {
// Single LazyColumn for the whole screen so everything scrolls in any orientation
LazyColumn(
modifier = Modifier
.fillMaxSize()
.imePadding(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// ── Control card ───────────────────────────────────────────────
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
// Header
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Hearing,
contentDescription = null,
tint = if (isListening) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
"UDP Listener",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.weight(1f))
AnimatedVisibility(visible = isListening) {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.primaryContainer
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.FiberManualRecord,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(10.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
"Listening",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
// This device's IP addresses
if (localAddresses.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.PhoneAndroid,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
"This device",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(8.dp))
localAddresses.forEach { (ifaceName, ip) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp)
) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.weight(1f)
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
Text(
ip,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
ifaceName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)
)
}
}
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = { clipboardManager.setText(AnnotatedString(ip)) },
modifier = Modifier.size(36.dp)
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy IP",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
// Port + start/stop
Row(verticalAlignment = Alignment.Top) {
OutlinedTextField(
value = portInput,
onValueChange = { portInput = it; portError = null },
label = { Text("Listen on Port") },
leadingIcon = { Icon(Icons.Default.Router, null) },
isError = portError != null,
supportingText = portError?.let { { Text(it) } },
enabled = !isListening,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = {
if (!isListening) tryStartListening(portInput, viewModel) { portError = it }
}),
modifier = Modifier.weight(1f),
singleLine = true
)
Spacer(modifier = Modifier.width(8.dp))
if (!isListening) {
Button(
onClick = { tryStartListening(portInput, viewModel) { portError = it } },
modifier = Modifier.padding(top = 4.dp)
) {
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Start")
}
} else {
Button(
onClick = { viewModel.stopListening() },
modifier = Modifier.padding(top = 4.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Icon(Icons.Default.Stop, null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Stop")
}
}
}
}
}
}
// ── Messages header ────────────────────────────────────────────
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Received Messages",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
AnimatedVisibility(visible = receivedMessages.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"${receivedMessages.size}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(4.dp))
IconButton(
onClick = { viewModel.clearReceivedMessages() },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.DeleteSweep,
contentDescription = "Clear messages",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
// ── Empty state or message list ────────────────────────────────
if (receivedMessages.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Inbox,
contentDescription = null,
modifier = Modifier.size(56.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
if (isListening) "Waiting for packets…"
else "Start listening to receive packets",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
} else {
items(receivedMessages, key = { it.id }) { msg ->
ReceivedMessageCard(msg)
}
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
private fun tryStartListening(
portInput: String,
viewModel: MainViewModel,
onError: (String) -> Unit
) {
val port = portInput.toIntOrNull()
if (port == null || port < 1 || port > 65535) {
onError("Port must be between 1 and 65535")
} else {
viewModel.startListening(port)
}
}
@Composable
private fun ReceivedMessageCard(msg: ReceivedMessage) {
val timeFormat = remember { SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) }
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.ArrowDownward,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
msg.senderIp,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
"port ${msg.port}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.weight(1f))
Text(
timeFormat.format(Date(msg.timestamp)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceContainerHighest,
modifier = Modifier.fillMaxWidth()
) {
Text(
msg.data,
modifier = Modifier.padding(10.dp),
fontFamily = FontFamily.Monospace,
fontSize = 13.sp
)
}
}
}
}

View File

@ -0,0 +1,219 @@
package com.tomhempel.unityudp.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.tomhempel.unityudp.data.ProjectData
import com.tomhempel.unityudp.data.ReceivedMessage
import com.tomhempel.unityudp.data.StorageService
import com.tomhempel.unityudp.data.UdpPackageData
import com.tomhempel.unityudp.data.UdpService
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
sealed class Screen {
data object Home : Screen()
data class Project(val projectId: String) : Screen()
}
data class SendResult(
val message: String,
val isSuccess: Boolean,
val isPartial: Boolean = false
)
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val storageService = StorageService(application)
private val udpService = UdpService()
private val _projects = MutableStateFlow<List<ProjectData>>(emptyList())
val projects: StateFlow<List<ProjectData>> = _projects.asStateFlow()
private val _screen = MutableStateFlow<Screen>(Screen.Home)
val screen: StateFlow<Screen> = _screen.asStateFlow()
private val _isSending = MutableStateFlow(false)
val isSending: StateFlow<Boolean> = _isSending.asStateFlow()
private val _lastSentPackageId = MutableStateFlow<String?>(null)
val lastSentPackageId: StateFlow<String?> = _lastSentPackageId.asStateFlow()
private val _sendResult = MutableStateFlow<SendResult?>(null)
val sendResult: StateFlow<SendResult?> = _sendResult.asStateFlow()
// Listener state
private val _isListening = MutableStateFlow(false)
val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
private val _listenError = MutableStateFlow<String?>(null)
val listenError: StateFlow<String?> = _listenError.asStateFlow()
private val _receivedMessages = MutableStateFlow<List<ReceivedMessage>>(emptyList())
val receivedMessages: StateFlow<List<ReceivedMessage>> = _receivedMessages.asStateFlow()
private var listenJob: Job? = null
init {
loadProjects()
}
override fun onCleared() {
super.onCleared()
udpService.stopListening()
}
private fun loadProjects() {
_projects.value = storageService.loadProjects()
}
private fun saveProjects() {
storageService.saveProjects(_projects.value)
}
fun navigateToProject(projectId: String) {
_screen.value = Screen.Project(projectId)
}
fun navigateHome() {
_screen.value = Screen.Home
}
fun getProject(projectId: String): ProjectData? =
_projects.value.find { it.id == projectId }
fun addProject(project: ProjectData) {
_projects.value = _projects.value + project
saveProjects()
}
fun updateProject(project: ProjectData) {
_projects.value = _projects.value.map {
if (it.id == project.id) project else it
}
saveProjects()
}
fun deleteProject(projectId: String) {
_projects.value = _projects.value.filter { it.id != projectId }
saveProjects()
}
fun addPackage(projectId: String, pkg: UdpPackageData) {
val project = getProject(projectId) ?: return
updateProject(project.copyWith(packages = project.packages + pkg))
}
fun updatePackage(projectId: String, pkg: UdpPackageData) {
val project = getProject(projectId) ?: return
updateProject(project.copyWith(
packages = project.packages.map { if (it.id == pkg.id) pkg else it }
))
}
fun deletePackage(projectId: String, packageId: String) {
val project = getProject(projectId) ?: return
updateProject(project.copyWith(
packages = project.packages.filter { it.id != packageId }
))
}
fun copyPackage(projectId: String, pkg: UdpPackageData) {
val copy = pkg.copyWith(
id = System.currentTimeMillis().toString(),
name = "${pkg.name} (Copy)"
)
addPackage(projectId, copy)
}
fun sendPackage(project: ProjectData, pkg: UdpPackageData) {
viewModelScope.launch {
_isSending.value = true
_sendResult.value = null
val isBroadcast = pkg.isBroadcast || (project.isBroadcast && pkg.ipAddresses.isEmpty())
val ipAddresses = if (pkg.ipAddresses.isNotEmpty()) pkg.ipAddresses else project.ipAddresses
val results = udpService.sendAdvanced(
data = pkg.data,
port = project.port,
isBroadcast = isBroadcast,
ipAddresses = if (isBroadcast) emptyList() else ipAddresses
)
val successCount = results.values.count { it }
val totalCount = results.size
val allSuccess = successCount == totalCount
_isSending.value = false
val result = when {
allSuccess -> {
_lastSentPackageId.value = pkg.id
kotlinx.coroutines.delay(2000)
_lastSentPackageId.value = null
val msg = when {
isBroadcast -> "\"${pkg.name}\" broadcast successfully!"
totalCount > 1 -> "\"${pkg.name}\" sent to $totalCount addresses!"
else -> "\"${pkg.name}\" sent successfully!"
}
SendResult(msg, isSuccess = true)
}
successCount > 0 -> SendResult(
"\"${pkg.name}\" sent to $successCount of $totalCount addresses",
isSuccess = true,
isPartial = true
)
else -> SendResult("Failed to send \"${pkg.name}\"", isSuccess = false)
}
_sendResult.value = result
}
}
fun clearSendResult() {
_sendResult.value = null
}
// ── Listener ──────────────────────────────────────────────────────────────
fun startListening(port: Int) {
if (_isListening.value) return
_listenError.value = null
listenJob = viewModelScope.launch {
_isListening.value = true
try {
udpService.startListening(port) { data, senderIp, senderPort ->
val msg = ReceivedMessage(
id = "${System.currentTimeMillis()}_${senderIp}_${senderPort}",
data = data,
senderIp = senderIp,
port = senderPort
)
_receivedMessages.value = listOf(msg) + _receivedMessages.value
}
} catch (e: Exception) {
_listenError.value = e.message ?: "Failed to start listener"
}
_isListening.value = false
}
}
fun stopListening() {
udpService.stopListening()
listenJob?.cancel()
listenJob = null
_isListening.value = false
}
fun clearReceivedMessages() {
_receivedMessages.value = emptyList()
}
fun clearListenError() {
_listenError.value = null
}
}

View File

@ -0,0 +1,793 @@
package com.tomhempel.unityudp.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tomhempel.unityudp.data.ProjectData
import com.tomhempel.unityudp.data.UdpPackageData
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProjectScreen(projectId: String, viewModel: MainViewModel) {
val projects by viewModel.projects.collectAsState()
val project = projects.find { it.id == projectId } ?: return
val isSending by viewModel.isSending.collectAsState()
val lastSentPackageId by viewModel.lastSentPackageId.collectAsState()
val sendResult by viewModel.sendResult.collectAsState()
var showAddPackageDialog by remember { mutableStateOf(false) }
var editingPackage by remember { mutableStateOf<UdpPackageData?>(null) }
var deletePackageTarget by remember { mutableStateOf<UdpPackageData?>(null) }
var showApplyDialog by remember { mutableStateOf<Pair<Int, Boolean>?>(null) }
// Project settings state — rememberSaveable so rotation doesn't lose unsaved edits.
// IPs stored as a pipe-separated string to keep rememberSaveable simple.
var ipInput by rememberSaveable(key = "ipInput_${project.id}") { mutableStateOf("") }
var portInput by rememberSaveable(key = "port_${project.id}") { mutableStateOf(project.port.toString()) }
var projectIpsStr by rememberSaveable(key = "ips_${project.id}") {
mutableStateOf(project.ipAddresses.joinToString("|"))
}
var isBroadcast by rememberSaveable(key = "broadcast_${project.id}") { mutableStateOf(project.isBroadcast) }
var ipError by remember { mutableStateOf<String?>(null) }
// Derived list view — recomputed whenever the string changes
val projectIps: List<String> = remember(projectIpsStr) {
projectIpsStr.split("|").filter { it.isNotEmpty() }
}
fun setProjectIps(list: List<String>) { projectIpsStr = list.joinToString("|") }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(sendResult) {
sendResult?.let { result ->
snackbarHostState.showSnackbar(
message = result.message,
duration = SnackbarDuration.Short
)
viewModel.clearSendResult()
}
}
BackHandler { viewModel.navigateHome() }
Scaffold(
topBar = {
TopAppBar(
title = { Text(project.name) },
navigationIcon = {
IconButton(onClick = { viewModel.navigateHome() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = { showAddPackageDialog = true },
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text("New Package") }
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 340.dp),
modifier = Modifier
.fillMaxSize()
.padding(padding)
.imePadding(),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Settings card - spans full width
item(span = { GridItemSpan(maxLineSpan) }) {
ProjectSettingsCard(
project = project,
ipInput = ipInput,
onIpInputChange = { ipInput = it; ipError = null },
portInput = portInput,
onPortInputChange = { portInput = it },
projectIps = projectIps,
isBroadcast = isBroadcast,
onBroadcastChange = { isBroadcast = it },
onAddIp = {
val ip = ipInput.trim()
when {
!isValidIp(ip) -> ipError = "Invalid IP address"
projectIps.contains(ip) -> ipError = "IP already added"
else -> {
setProjectIps(projectIps + ip)
ipInput = ""
}
}
},
onRemoveIp = { ip ->
setProjectIps(projectIps.filter { it != ip })
},
onApply = {
val port = portInput.toIntOrNull()
if (port == null || port < 1 || port > 65535) {
portInput = project.port.toString()
} else {
showApplyDialog = Pair(port, true)
}
},
ipError = ipError
)
}
if (project.packages.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Inventory2,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(16.dp))
Text("No packages yet", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Create your first UDP package",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
} else {
items(project.packages, key = { it.id }) { pkg ->
PackageCard(
pkg = pkg,
project = project,
isSending = isSending,
wasSent = lastSentPackageId == pkg.id,
onSend = { viewModel.sendPackage(project, pkg) },
onEdit = { editingPackage = pkg },
onCopy = { viewModel.copyPackage(projectId, pkg) },
onDelete = { deletePackageTarget = pkg }
)
}
}
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(modifier = Modifier.height(72.dp))
}
}
}
// Apply settings dialog
showApplyDialog?.let { (port, _) ->
AlertDialog(
onDismissRequest = { showApplyDialog = null },
title = { Text("Apply Settings") },
text = {
Text(
"Do you want to apply these settings to all packages?\n\n" +
"• Yes - All packages will use these settings\n" +
"• No - Only project defaults will be updated"
)
},
confirmButton = {
Button(onClick = {
val updatedIps = if (isBroadcast) emptyList() else projectIps.toList()
val updatedPackages = project.packages.map { pkg ->
pkg.copyWith(ipAddresses = updatedIps, isBroadcast = isBroadcast)
}
viewModel.updateProject(
project.copyWith(
ipAddresses = updatedIps,
isBroadcast = isBroadcast,
port = port,
packages = updatedPackages
)
)
showApplyDialog = null
}) { Text("Yes") }
},
dismissButton = {
TextButton(onClick = {
val updatedIps = if (isBroadcast) emptyList() else projectIps.toList()
viewModel.updateProject(
project.copyWith(
ipAddresses = updatedIps,
isBroadcast = isBroadcast,
port = port
)
)
showApplyDialog = null
}) { Text("No") }
}
)
}
if (showAddPackageDialog) {
PackageDialog(
pkg = null,
projectIps = project.ipAddresses,
onSave = { viewModel.addPackage(projectId, it) },
onDismiss = { showAddPackageDialog = false }
)
}
editingPackage?.let { pkg ->
PackageDialog(
pkg = pkg,
projectIps = project.ipAddresses,
onSave = { viewModel.updatePackage(projectId, it) },
onDismiss = { editingPackage = null }
)
}
deletePackageTarget?.let { pkg ->
AlertDialog(
onDismissRequest = { deletePackageTarget = null },
title = { Text("Delete Package") },
text = { Text("Are you sure you want to delete \"${pkg.name}\"?") },
confirmButton = {
TextButton(
onClick = {
viewModel.deletePackage(projectId, pkg.id)
deletePackageTarget = null
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { deletePackageTarget = null }) { Text("Cancel") }
}
)
}
}
@Composable
private fun ProjectSettingsCard(
project: ProjectData,
ipInput: String,
onIpInputChange: (String) -> Unit,
portInput: String,
onPortInputChange: (String) -> Unit,
projectIps: List<String>,
isBroadcast: Boolean,
onBroadcastChange: (Boolean) -> Unit,
onAddIp: () -> Unit,
onRemoveIp: (String) -> Unit,
onApply: () -> Unit,
ipError: String?
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Settings,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
"Project Settings",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(16.dp))
// Broadcast toggle
SwitchListItem(
title = "Broadcast Mode",
subtitle = "Send to all devices on network",
icon = Icons.Default.Sensors,
checked = isBroadcast,
onCheckedChange = onBroadcastChange
)
Spacer(modifier = Modifier.height(8.dp))
if (!isBroadcast) {
Text(
"Target IP Addresses",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.Top) {
OutlinedTextField(
value = ipInput,
onValueChange = onIpInputChange,
label = { Text("IP Address") },
placeholder = { Text("192.168.1.100") },
leadingIcon = { Icon(Icons.Default.Computer, null) },
isError = ipError != null,
supportingText = ipError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { onAddIp() }),
modifier = Modifier.weight(1f),
singleLine = true
)
Spacer(modifier = Modifier.width(8.dp))
FilledIconButton(
onClick = onAddIp,
modifier = Modifier.padding(top = 4.dp)
) {
Icon(Icons.Default.Add, contentDescription = "Add IP")
}
}
Spacer(modifier = Modifier.height(8.dp))
if (projectIps.isNotEmpty()) {
IpChipRow(
ips = projectIps,
onRemove = { ip -> onRemoveIp(ip) },
canRemove = true
)
} else {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.WarningAmber,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
"No target IPs — add one above before sending",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
} else {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth()
) {
Row(modifier = Modifier.padding(12.dp)) {
Icon(
Icons.Default.Info,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.width(8.dp))
Text(
"Packages will be broadcast to all devices on your local network",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
Row(verticalAlignment = Alignment.Top) {
OutlinedTextField(
value = portInput,
onValueChange = onPortInputChange,
label = { Text("UDP Port") },
leadingIcon = { Icon(Icons.Default.Router, null) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { onApply() }),
modifier = Modifier.weight(1f),
singleLine = true
)
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = onApply,
modifier = Modifier.padding(top = 4.dp)
) {
Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Apply")
}
}
}
}
}
@Composable
private fun SwitchListItem(
title: String,
subtitle: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun IpChipRow(ips: List<String>, onRemove: (String) -> Unit, canRemove: Boolean) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ips.forEach { ip ->
InputChip(
selected = false,
onClick = {},
label = { Text(ip) },
trailingIcon = if (canRemove) {
{
IconButton(
onClick = { onRemove(ip) },
modifier = Modifier.size(18.dp)
) {
Icon(Icons.Default.Close, contentDescription = "Remove", modifier = Modifier.size(14.dp))
}
}
} else null
)
}
}
}
@Composable
private fun PackageCard(
pkg: UdpPackageData,
project: ProjectData,
isSending: Boolean,
wasSent: Boolean,
onSend: () -> Unit,
onEdit: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = if (wasSent) CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
) else CardDefaults.cardColors()
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.Top) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
pkg.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
if (wasSent) {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = Color(0xFF4CAF50)
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = Color.White
)
Spacer(modifier = Modifier.width(4.dp))
Text("Sent", color = Color.White, fontSize = 12.sp)
}
}
}
}
Spacer(modifier = Modifier.height(4.dp))
if (pkg.isBroadcast) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Sensors,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
"Broadcast • Port: ${project.port}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
}
} else {
val ipText = if (pkg.ipAddresses.size > 1)
"To: ${pkg.ipAddresses.joinToString(", ")} • Port: ${project.port}"
else
"To: ${pkg.ipAddresses.firstOrNull() ?: ""}:${project.port}"
Text(
ipText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
}
var menuExpanded by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { menuExpanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "Options")
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
) {
DropdownMenuItem(
text = { Text("Edit") },
leadingIcon = { Icon(Icons.Default.Edit, null) },
onClick = { menuExpanded = false; onEdit() }
)
DropdownMenuItem(
text = { Text("Copy") },
leadingIcon = { Icon(Icons.Default.ContentCopy, null) },
onClick = { menuExpanded = false; onCopy() }
)
DropdownMenuItem(
text = { Text("Delete", color = MaterialTheme.colorScheme.error) },
leadingIcon = {
Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error)
},
onClick = { menuExpanded = false; onDelete() }
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceContainerHighest,
modifier = Modifier.fillMaxWidth()
) {
Text(
pkg.data,
modifier = Modifier.padding(12.dp),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = onSend,
enabled = !isSending,
modifier = Modifier.fillMaxWidth()
) {
if (isSending) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(modifier = Modifier.width(8.dp))
Text("Sending...")
} else {
Icon(Icons.Default.Send, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Send")
}
}
}
}
}
@Composable
fun PackageDialog(
pkg: UdpPackageData?,
projectIps: List<String>,
onSave: (UdpPackageData) -> Unit,
onDismiss: () -> Unit
) {
var name by remember { mutableStateOf(pkg?.name ?: "") }
var dataText by remember { mutableStateOf(pkg?.data ?: "") }
var isBroadcast by remember { mutableStateOf(pkg?.isBroadcast ?: false) }
var ipAddresses by remember {
mutableStateOf((pkg?.ipAddresses?.takeIf { it.isNotEmpty() } ?: projectIps).toMutableList())
}
var ipInput by remember { mutableStateOf("") }
var nameError by remember { mutableStateOf<String?>(null) }
var dataError by remember { mutableStateOf<String?>(null) }
var ipError by remember { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (pkg == null) "New Package" else "Edit Package") },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedTextField(
value = name,
onValueChange = { name = it; nameError = null },
label = { Text("Package Name") },
leadingIcon = { Icon(Icons.Default.Label, null) },
isError = nameError != null,
supportingText = nameError?.let { { Text(it) } },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
SwitchListItem(
title = "Broadcast Mode",
subtitle = "Send to all devices",
icon = Icons.Default.Sensors,
checked = isBroadcast,
onCheckedChange = { isBroadcast = it }
)
if (!isBroadcast) {
Text(
"Target IP Addresses",
style = MaterialTheme.typography.titleSmall
)
Row(verticalAlignment = Alignment.Top) {
OutlinedTextField(
value = ipInput,
onValueChange = { ipInput = it; ipError = null },
label = { Text("IP Address") },
placeholder = { Text("192.168.1.100") },
leadingIcon = { Icon(Icons.Default.Computer, null) },
isError = ipError != null,
supportingText = ipError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = {
val ip = ipInput.trim()
when {
!isValidIp(ip) -> ipError = "Invalid IP address"
ipAddresses.contains(ip) -> ipError = "Already added"
else -> { ipAddresses = (ipAddresses + ip).toMutableList(); ipInput = "" }
}
}),
modifier = Modifier.weight(1f),
singleLine = true
)
Spacer(modifier = Modifier.width(8.dp))
FilledIconButton(
onClick = {
val ip = ipInput.trim()
when {
!isValidIp(ip) -> ipError = "Invalid IP address"
ipAddresses.contains(ip) -> ipError = "Already added"
else -> { ipAddresses = (ipAddresses + ip).toMutableList(); ipInput = "" }
}
},
modifier = Modifier.padding(top = 4.dp)
) {
Icon(Icons.Default.Add, contentDescription = "Add IP")
}
}
if (ipAddresses.isNotEmpty()) {
IpChipRow(
ips = ipAddresses,
onRemove = { ip ->
if (ipAddresses.size > 1) {
ipAddresses = ipAddresses.filter { it != ip }.toMutableList()
}
},
canRemove = ipAddresses.size > 1
)
}
} else {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth()
) {
Row(modifier = Modifier.padding(12.dp)) {
Icon(
Icons.Default.Info,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.width(8.dp))
Text(
"This package will be broadcast to all devices on your local network",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
OutlinedTextField(
value = dataText,
onValueChange = { dataText = it; dataError = null },
label = { Text("Data") },
leadingIcon = { Icon(Icons.Default.DataObject, null) },
placeholder = { Text("Enter the data to send") },
isError = dataError != null,
supportingText = dataError?.let { { Text(it) } },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 6
)
}
},
confirmButton = {
Button(onClick = {
var valid = true
if (name.isBlank()) { nameError = "Please enter a package name"; valid = false }
if (dataText.isEmpty()) { dataError = "Please enter data to send"; valid = false }
if (valid) {
onSave(
UdpPackageData(
id = pkg?.id ?: System.currentTimeMillis().toString(),
name = name.trim(),
data = dataText,
ipAddresses = if (isBroadcast) emptyList() else ipAddresses.toList(),
isBroadcast = isBroadcast
)
)
onDismiss()
}
}) { Text("Save") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@Composable
fun BackHandler(onBack: () -> Unit) {
androidx.activity.compose.BackHandler(onBack = onBack)
}

View File

@ -0,0 +1,11 @@
package com.tomhempel.unityudp.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View File

@ -0,0 +1,58 @@
package com.tomhempel.unityudp.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun UnityUDPTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@ -0,0 +1,34 @@
package com.tomhempel.unityudp.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#0D2E6E" />
</shape>

View File

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!--
Safe area: 72x72 centered at (18,18).
Paper plane (send arrow) on the left, signal arcs on the right.
All shapes in white.
-->
<!-- Paper plane body -->
<path
android:fillColor="#FFFFFF"
android:pathData="
M24,54
L56,36
L56,48
L72,54
L56,60
L56,72
Z" />
<!-- Signal arc 1 (small) -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="4"
android:strokeLineCap="round"
android:fillColor="@android:color/transparent"
android:pathData="M 74,46 A 11,11 0 0 1 74,62" />
<!-- Signal arc 2 (medium) -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="4"
android:strokeLineCap="round"
android:fillColor="@android:color/transparent"
android:pathData="M 79,40 A 20,20 0 0 1 79,68" />
<!-- Signal arc 3 (large) -->
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="4"
android:strokeLineCap="round"
android:fillColor="@android:color/transparent"
android:pathData="M 84,34 A 29,29 0 0 1 84,74" />
</vector>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">UnityUDP</string>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.UnityUDP" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@ -0,0 +1,17 @@
package com.tomhempel.unityudp
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}