commit 9d17dd8f9a6489b04f1f91d2b11f7d16706d3d01 Author: Tom Hempel Date: Sun Jul 5 20:19:45 2026 +0200 initial upload diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a72b2b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Built output +build/ +.gradle/ + +# Local config (machine-specific) +local.properties +*.iml + +# IDE +.idea/ +*.DS_Store + +# Kotlin / Java +*.class + +# Proguard +proguard/ + +# Android Studio navigation files +.navigation/ + +# Test results +test-results/ diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..7563ed5 --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/app/src/androidTest/java/com/tomhempel/unityudp/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/tomhempel/unityudp/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..061f213 --- /dev/null +++ b/app/src/androidTest/java/com/tomhempel/unityudp/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7659e8c --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/tomhempel/unityudp/MainActivity.kt b/app/src/main/java/com/tomhempel/unityudp/MainActivity.kt new file mode 100644 index 0000000..c44ec49 --- /dev/null +++ b/app/src/main/java/com/tomhempel/unityudp/MainActivity.kt @@ -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 + ) + } + } + } + } +} diff --git a/app/src/main/java/com/tomhempel/unityudp/data/Models.kt b/app/src/main/java/com/tomhempel/unityudp/data/Models.kt new file mode 100644 index 0000000..f3a689b --- /dev/null +++ b/app/src/main/java/com/tomhempel/unityudp/data/Models.kt @@ -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 = listOf("127.0.0.1"), + val isBroadcast: Boolean = false +) { + fun copyWith( + id: String? = null, + name: String? = null, + data: String? = null, + ipAddresses: List? = 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 = listOf("127.0.0.1"), + val isBroadcast: Boolean = false, + val port: Int = 8888, + val packages: List = emptyList() +) { + fun copyWith( + id: String? = null, + name: String? = null, + ipAddresses: List? = null, + isBroadcast: Boolean? = null, + port: Int? = null, + packages: List? = 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 + ) +} diff --git a/app/src/main/java/com/tomhempel/unityudp/data/StorageService.kt b/app/src/main/java/com/tomhempel/unityudp/data/StorageService.kt new file mode 100644 index 0000000..d26a595 --- /dev/null +++ b/app/src/main/java/com/tomhempel/unityudp/data/StorageService.kt @@ -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 { + val json = prefs.getString("projects", null) ?: return emptyList() + return try { + val type = object : TypeToken>() {}.type + gson.fromJson(json, type) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } + + fun saveProjects(projects: List) { + prefs.edit().putString("projects", gson.toJson(projects)).apply() + } +} diff --git a/app/src/main/java/com/tomhempel/unityudp/data/UdpService.kt b/app/src/main/java/com/tomhempel/unityudp/data/UdpService.kt new file mode 100644 index 0000000..17bedc7 --- /dev/null +++ b/app/src/main/java/com/tomhempel/unityudp/data/UdpService.kt @@ -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 + ): Map = 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 +} diff --git a/app/src/main/java/com/tomhempel/unityudp/ui/HomeScreen.kt b/app/src/main/java/com/tomhempel/unityudp/ui/HomeScreen.kt new file mode 100644 index 0000000..7e07b90 --- /dev/null +++ b/app/src/main/java/com/tomhempel/unityudp/ui/HomeScreen.kt @@ -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(null) } + var deleteTarget by remember { mutableStateOf(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, + 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(null) } + var portError by remember { mutableStateOf(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 + } +} diff --git a/app/src/main/java/com/tomhempel/unityudp/ui/ListenerScreen.kt b/app/src/main/java/com/tomhempel/unityudp/ui/ListenerScreen.kt new file mode 100644 index 0000000..4454e37 --- /dev/null +++ b/app/src/main/java/com/tomhempel/unityudp/ui/ListenerScreen.kt @@ -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> { + 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(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 + ) + } + } + } +} diff --git a/app/src/main/java/com/tomhempel/unityudp/ui/MainViewModel.kt b/app/src/main/java/com/tomhempel/unityudp/ui/MainViewModel.kt new file mode 100644 index 0000000..4526e0d --- /dev/null +++ b/app/src/main/java/com/tomhempel/unityudp/ui/MainViewModel.kt @@ -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>(emptyList()) + val projects: StateFlow> = _projects.asStateFlow() + + private val _screen = MutableStateFlow(Screen.Home) + val screen: StateFlow = _screen.asStateFlow() + + private val _isSending = MutableStateFlow(false) + val isSending: StateFlow = _isSending.asStateFlow() + + private val _lastSentPackageId = MutableStateFlow(null) + val lastSentPackageId: StateFlow = _lastSentPackageId.asStateFlow() + + private val _sendResult = MutableStateFlow(null) + val sendResult: StateFlow = _sendResult.asStateFlow() + + // Listener state + private val _isListening = MutableStateFlow(false) + val isListening: StateFlow = _isListening.asStateFlow() + + private val _listenError = MutableStateFlow(null) + val listenError: StateFlow = _listenError.asStateFlow() + + private val _receivedMessages = MutableStateFlow>(emptyList()) + val receivedMessages: StateFlow> = _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 + } +} diff --git a/app/src/main/java/com/tomhempel/unityudp/ui/ProjectScreen.kt b/app/src/main/java/com/tomhempel/unityudp/ui/ProjectScreen.kt new file mode 100644 index 0000000..226f0de --- /dev/null +++ b/app/src/main/java/com/tomhempel/unityudp/ui/ProjectScreen.kt @@ -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(null) } + var deletePackageTarget by remember { mutableStateOf(null) } + var showApplyDialog by remember { mutableStateOf?>(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(null) } + + // Derived list view — recomputed whenever the string changes + val projectIps: List = remember(projectIpsStr) { + projectIpsStr.split("|").filter { it.isNotEmpty() } + } + fun setProjectIps(list: List) { 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, + 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, 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, + 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(null) } + var dataError by remember { mutableStateOf(null) } + var ipError by remember { mutableStateOf(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) +} diff --git a/app/src/main/java/com/tomhempel/unityudp/ui/theme/Color.kt b/app/src/main/java/com/tomhempel/unityudp/ui/theme/Color.kt new file mode 100644 index 0000000..7308fe1 --- /dev/null +++ b/app/src/main/java/com/tomhempel/unityudp/ui/theme/Color.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/com/tomhempel/unityudp/ui/theme/Theme.kt b/app/src/main/java/com/tomhempel/unityudp/ui/theme/Theme.kt new file mode 100644 index 0000000..3395e26 --- /dev/null +++ b/app/src/main/java/com/tomhempel/unityudp/ui/theme/Theme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/tomhempel/unityudp/ui/theme/Type.kt b/app/src/main/java/com/tomhempel/unityudp/ui/theme/Type.kt new file mode 100644 index 0000000..7fbd4d2 --- /dev/null +++ b/app/src/main/java/com/tomhempel/unityudp/ui/theme/Type.kt @@ -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 + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..16f2274 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..80793e0 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..de6ba6d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + UnityUDP + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..42f33f2 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +