diff --git a/.github/workflows/build_and_test.yaml b/.github/workflows/build_and_test.yaml new file mode 100644 index 0000000..63519bb --- /dev/null +++ b/.github/workflows/build_and_test.yaml @@ -0,0 +1,73 @@ +name: Build and Test +on: [pull_request, push, workflow_dispatch] + +jobs: + # Job 1: Build and fast checks + build-and-unit-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: true + + - name: Set up JDK + uses: actions/setup-java@v5 + with: + java-version: "21" + distribution: "temurin" + cache: gradle + + - name: Build, lint and run Unit Tests in parallel + run: | + chmod +x gradlew + ./gradlew build lint test + + # Job 2: Parallel Instrumented Tests + instrumented-tests: + needs: build-and-unit-test # Only run if build/unit tests pass + runs-on: ubuntu-latest + strategy: + fail-fast: false # Don't cancel API 34 if API 26 fails + matrix: + api-level: + - 26 # Minimum supported API level + - 34 # Target API level for testing + - 36 # Latest API level + + steps: + - uses: actions/checkout@v6 + with: + submodules: true + + - name: set up JDK + uses: actions/setup-java@v5 + with: + java-version: "21" + distribution: "temurin" + cache: gradle + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run Instrumented Tests (API ${{ matrix.api-level }}) + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + target: ${{ matrix.api-level >= 31 && 'google_apis' || 'default' }} + arch: x86_64 + disable-animations: true + script: ./gradlew connectedCheck --stacktrace + + - name: Upload Test Report (API ${{ matrix.api-level }}) + if: always() + uses: actions/upload-artifact@v7 + with: + name: reports-api-${{ matrix.api-level }} + path: app/build/reports/androidTests/connected/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 74fabf1..6f933ae 100644 --- a/.gitignore +++ b/.gitignore @@ -45,8 +45,5 @@ replay_pid* # Kotlin Gradle plugin data, see https://kotlinlang.org/docs/whatsnew20.html#new-directory-for-kotlin-data-in-gradle-projects .kotlin/ -# Generated files -/app/src/main/java/io/github/kitswas/VGP_Data_Exchange - # Ignore the following file to keep keystore information private signing.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 345d50f..65bba93 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,7 +4,6 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.dokka) alias(libs.plugins.kotlin.parcelize) @@ -21,8 +20,8 @@ android { minSdk = 26 // Specifies the API level used to test the app. targetSdk = 34 - versionCode = 10 - versionName = "0.4.1" + versionCode = 11 + versionName = "0.4.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -52,18 +51,13 @@ android { compileOptions { isCoreLibraryDesugaringEnabled = true - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlin { - compilerOptions { - jvmTarget = JvmTarget.fromTarget("17") - } + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } buildFeatures { compose = true + resValues = true } composeOptions { @@ -82,10 +76,22 @@ android { includeInApk = false includeInBundle = false } + + sourceSets { + getByName("main") { + java.directories.add("../VGP_Data_Exchange") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.fromTarget("21") + } } val props = Properties() -val propFile = File("signing.properties") +val propFile: File = rootProject.file("signing.properties") if (propFile.canRead()) { props.load(FileInputStream(propFile)) @@ -117,6 +123,7 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.window) implementation(libs.compose.ui) + implementation(libs.compose.material.icons.extended) implementation(libs.compose.ui.tooling.preview) implementation(libs.kotlinx.serialization.json) implementation(libs.material3) @@ -127,7 +134,6 @@ dependencies { implementation(libs.zxing.android.embedded) implementation(libs.zxing.core) implementation(platform(libs.compose.bom)) - testImplementation(libs.robolectric) coreLibraryDesugaring(libs.desugar.jdk.libs) // Test dependencies @@ -144,15 +150,3 @@ dependencies { debugImplementation(libs.compose.ui.test.manifest) debugImplementation(libs.compose.ui.tooling) } - -val updateVGPDataExchangePackage by tasks.registering(ProcessResources::class) { - println("Updating VGP_Data_Exchange package...") - from(rootDir.toPath().resolve("VGP_Data_Exchange/io/github/kitswas/VGP_Data_Exchange")) - into(rootDir.toPath().resolve("app/src/main/java/io/github/kitswas/VGP_Data_Exchange")) -} - -tasks.matching { - it != updateVGPDataExchangePackage.get() -}.configureEach { - dependsOn(updateVGPDataExchangePackage) -} diff --git a/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/ExampleInstrumentedTest.kt b/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/ExampleInstrumentedTest.kt deleted file mode 100644 index 9ced515..0000000 --- a/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package io.github.kitswas.virtualgamepadmobile - -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("io.github.kitswas.virtualgamepadmobile", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/test/java/io/github/kitswas/virtualgamepadmobile/TestGamepadServer.kt b/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/TestGamepadServer.kt similarity index 66% rename from app/src/test/java/io/github/kitswas/virtualgamepadmobile/TestGamepadServer.kt rename to app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/TestGamepadServer.kt index d1813f9..fff2b40 100644 --- a/app/src/test/java/io/github/kitswas/virtualgamepadmobile/TestGamepadServer.kt +++ b/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/TestGamepadServer.kt @@ -11,27 +11,7 @@ import java.util.concurrent.atomic.AtomicBoolean object TestGamepadServer { private val running = AtomicBoolean(false) private var serverSocket: ServerSocket? = null - - @JvmStatic - fun main(args: Array) { - val port = if (args.isNotEmpty()) args[0].toInt() else 7878 - start(port) - - // Add shutdown hook for clean server shutdown - Runtime.getRuntime().addShutdownHook(Thread { - println("[TestGamepadServer] Shutting down...") - stop() - }) - - // Keep main thread alive - try { - while (running.get()) { - Thread.sleep(1000) - } - } catch (_: InterruptedException) { - stop() - } - } + private val readings = Collections.synchronizedList(mutableListOf()) fun start(port: Int) { if (running.getAndSet(true)) { @@ -41,7 +21,7 @@ object TestGamepadServer { try { serverSocket = ServerSocket(port) - println("[TestGamepadServer] Listening on port $port on interfaces:") + println("[TestGamepadServer] Listening on port ${serverSocket?.localPort} on interfaces:") // List all network interfaces and addresses Collections.list(NetworkInterface.getNetworkInterfaces()) .filter { it.isUp && !it.isLoopback }.forEach { nif -> @@ -49,32 +29,24 @@ object TestGamepadServer { println("[TestGamepadServer] ${nif.displayName}: $address") } } - println("[TestGamepadServer] Connect to 10.0.2.2:$port from your emulator") // Start server loop in a separate thread Thread { try { while (running.get()) { try { - // Block waiting for client connection (like production server) + // Block waiting for client connection val client = serverSocket?.accept() if (client != null && running.get()) { println("[TestGamepadServer] Client connected: ${client.remoteSocketAddress}") - - // Configure socket like production server client.tcpNoDelay = true - - // Handle client in current thread (blocking, like production server) - // This mimics tcpServer->pauseAccepting() behavior handleClient(client) - println("[TestGamepadServer] Client disconnected, ready for new connections") } } catch (e: Exception) { if (running.get()) { System.err.println("[TestGamepadServer] Error accepting client: $e") - // Small delay before retrying - Thread.sleep(1000) + Thread.sleep(100) } } } @@ -97,17 +69,24 @@ object TestGamepadServer { } println("[TestGamepadServer] Stopping server...") - - // Close server socket try { serverSocket?.close() } catch (e: Exception) { System.err.println("[TestGamepadServer] Error closing server socket: $e") } - println("[TestGamepadServer] Server stopped") } + fun getPort(): Int = serverSocket?.localPort ?: -1 + + fun clearReadings() { + readings.clear() + } + + fun getReadings(): List { + return readings.toList() + } + private fun handleClient(client: Socket) { try { client.use { socket -> @@ -117,30 +96,22 @@ object TestGamepadServer { try { val len = inputStream.read(buf) if (len == -1) { - println("[TestGamepadServer] Client closed connection (EOF)") break } - // Process the received data - try { - val reading = GamepadReading() - reading.unmarshal(buf, 0, len) - logGamepadReading(reading) - } catch (e: Exception) { - // Handle partial reads or invalid data gracefully - System.err.println("[TestGamepadServer] Failed to unmarshal: $e") - System.err.println( - "[TestGamepadServer] Raw bytes (len=$len): ${ - buf.copyOf( - len - ).joinToString(" ") { "%02x".format(it) } - }" - ) + var offset = 0 + while (offset < len) { + try { + val reading = GamepadReading() + offset = reading.unmarshal(buf, offset, len) + readings.add(reading) + logGamepadReading(reading) + } catch (e: Exception) { + System.err.println("[TestGamepadServer] Failed to unmarshal at offset $offset: $e") + break + } } } catch (e: Exception) { - if (running.get() && !socket.isClosed) { - System.err.println("[TestGamepadServer] Read error: $e") - } break } } diff --git a/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/e2e/ConnectionE2ETest.kt b/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/e2e/ConnectionE2ETest.kt new file mode 100644 index 0000000..f999d19 --- /dev/null +++ b/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/e2e/ConnectionE2ETest.kt @@ -0,0 +1,64 @@ +package io.github.kitswas.virtualgamepadmobile.e2e + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.github.kitswas.virtualgamepadmobile.MainActivity +import io.github.kitswas.virtualgamepadmobile.R +import io.github.kitswas.virtualgamepadmobile.TestGamepadServer +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ConnectionE2ETest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun getString(id: Int, vararg formatArgs: Any): String { + return composeTestRule.activity.getString(id, *formatArgs) + } + + @Before + fun setup() { + TestGamepadServer.start(0) // Start on random port + } + + @After + fun teardown() { + TestGamepadServer.stop() + } + + @Test + fun testSuccessfulConnection() { + val port = TestGamepadServer.getPort().toString() + + composeTestRule.onNodeWithText(getString(R.string.menu_start)).performClick() + + composeTestRule.onNodeWithText(getString(R.string.connect_ip_label)) + .performTextInput("127.0.0.1") + composeTestRule.onNodeWithText(getString(R.string.connect_port_label)) + .performTextInput(port) + + composeTestRule.onNodeWithText(getString(R.string.connect_button)).performClick() + + // Should navigate to ConnectingScreen then to GamePad + // We might need to wait for connection + composeTestRule.waitUntil(10000) { + try { + // Check if any element of Gamepad screen is visible + composeTestRule.onNodeWithText(getString(R.string.button_l_shoulder)) + .assertIsDisplayed() + true + } catch (e: AssertionError) { + false + } + } + } +} diff --git a/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/e2e/GamepadInputE2ETest.kt b/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/e2e/GamepadInputE2ETest.kt new file mode 100644 index 0000000..5a81e9f --- /dev/null +++ b/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/e2e/GamepadInputE2ETest.kt @@ -0,0 +1,156 @@ +package io.github.kitswas.virtualgamepadmobile.e2e + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeRight +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.github.kitswas.VGP_Data_Exchange.GameButtons +import io.github.kitswas.virtualgamepadmobile.MainActivity +import io.github.kitswas.virtualgamepadmobile.R +import io.github.kitswas.virtualgamepadmobile.TestGamepadServer +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class GamepadInputE2ETest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun getString(id: Int, vararg formatArgs: Any): String { + return composeTestRule.activity.getString(id, *formatArgs) + } + + @Before + fun setup() { + TestGamepadServer.start(0) + } + + @After + fun teardown() { + TestGamepadServer.stop() + } + + @Test + fun testButtonInputs() { + val port = TestGamepadServer.getPort().toString() + println("Connecting to port: $port") + + // Connect to server + composeTestRule.onNodeWithText(getString(R.string.menu_start)).performClick() + composeTestRule.onNodeWithText(getString(R.string.connect_ip_label)) + .performTextInput("127.0.0.1") + composeTestRule.onNodeWithText(getString(R.string.connect_port_label)) + .performTextInput(port) + composeTestRule.onNodeWithText(getString(R.string.connect_button)).performClick() + + println("Waiting for Gamepad screen...") + // Wait for Gamepad screen + composeTestRule.waitUntil(10000) { + try { + composeTestRule.onNodeWithText(getString(R.string.button_l_shoulder)) + .assertIsDisplayed() + true + } catch (e: AssertionError) { + false + } + } + println("Gamepad screen displayed.") + + // Clear any readings from the connection process + TestGamepadServer.clearReadings() + + println("Pressing Face Button A") + // Press Face Button A + // Note: A, B, X, Y labels are now from string resources + composeTestRule.onNodeWithText(getString(R.string.button_a)).performTouchInput { + down(center) + advanceEventTime(1000) + up() + } + + println("Waiting for A button press reading...") + composeTestRule.waitUntil(10000) { + TestGamepadServer.getReadings().any { + (it.ButtonsDown and GameButtons.A.value) != 0 + } + } + println("A button press received.") + + assertTrue( + "Server should have received A button press", + TestGamepadServer.getReadings().any { (it.ButtonsDown and GameButtons.A.value) != 0 }) + + // Press Dpad Up + TestGamepadServer.clearReadings() + println("Pressing Dpad UP") + composeTestRule.onNodeWithContentDescription( + getString( + R.string.content_desc_dpad_button, + "UP" + ) + ).performTouchInput { + down(center) + advanceEventTime(1000) + up() + } + + println("Waiting for Dpad UP reading...") + composeTestRule.waitUntil(10000) { + TestGamepadServer.getReadings() + .any { (it.ButtonsDown and GameButtons.DPadUp.value) != 0 } + } + println("Dpad UP received.") + + // Press LT + TestGamepadServer.clearReadings() + println("Pressing LT") + composeTestRule.onNodeWithText(getString(R.string.button_lt)).performTouchInput { + down(center) + advanceEventTime(1000) + up() + } + + println("Waiting for LT reading...") + composeTestRule.waitUntil(10000) { + TestGamepadServer.getReadings().any { it.LeftTrigger > 0.5f } + } + println("LT received.") + + // Move Left Analog Stick + TestGamepadServer.clearReadings() + println("Moving Analog Stick RIGHT") + composeTestRule.onNodeWithTag("AnalogStick_LEFT_Handle").performTouchInput { + swipeRight() + } + + println("Waiting for Analog Stick reading...") + composeTestRule.waitUntil(10000) { + TestGamepadServer.getReadings().any { it.LeftThumbstickX > 0.1f } + } + println("Analog Stick received.") + + // Move Right Analog Stick + TestGamepadServer.clearReadings() + println("Moving Right Analog Stick RIGHT") + composeTestRule.onNodeWithTag("AnalogStick_RIGHT_Handle").performTouchInput { + swipeRight() + } + + println("Waiting for Right Analog Stick reading...") + composeTestRule.waitUntil(10000) { + TestGamepadServer.getReadings().any { it.RightThumbstickX > 0.1f } + } + println("Right Analog Stick received.") + } +} diff --git a/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/e2e/NavigationE2ETest.kt b/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/e2e/NavigationE2ETest.kt new file mode 100644 index 0000000..616bd52 --- /dev/null +++ b/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/e2e/NavigationE2ETest.kt @@ -0,0 +1,70 @@ +package io.github.kitswas.virtualgamepadmobile.e2e + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.github.kitswas.virtualgamepadmobile.MainActivity +import io.github.kitswas.virtualgamepadmobile.R +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class NavigationE2ETest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun getString(id: Int, vararg formatArgs: Any): String { + return composeTestRule.activity.getString(id, *formatArgs) + } + + @Test + fun testMainMenuNavigation() { + // Main Menu to Connect Screen + composeTestRule.onNodeWithText(getString(R.string.menu_start)).performClick() + composeTestRule.onNodeWithText(getString(R.string.connect_button)).assertIsDisplayed() + composeTestRule.onNodeWithText(getString(R.string.connect_scan_qr)).assertIsDisplayed() + } + + @Test + fun testNavigationToSettings() { + composeTestRule.onNodeWithText(getString(R.string.menu_settings)).performClick() + composeTestRule.onNodeWithText(getString(R.string.settings_title)) + .assertIsDisplayed() // Title + composeTestRule.onNodeWithText(getString(R.string.settings_customize_layout)) + .performScrollTo() + .assertIsDisplayed() + + composeTestRule.onNodeWithText(getString(R.string.cancel)).performClick() + composeTestRule.onNodeWithText(getString(R.string.menu_start)).assertIsDisplayed() + } + + @Test + fun testNavigationToAbout() { + composeTestRule.onNodeWithText(getString(R.string.menu_about)).performClick() + composeTestRule.onNodeWithText(getString(R.string.about_title)).assertIsDisplayed() + + composeTestRule.onNodeWithText(getString(R.string.back)).performScrollTo().performClick() + composeTestRule.onNodeWithText(getString(R.string.menu_start)).assertIsDisplayed() + } + + @Test + fun testNavigationToGamepadCustomization() { + composeTestRule.onNodeWithText(getString(R.string.menu_settings)).performClick() + composeTestRule.onNodeWithText(getString(R.string.settings_customize_layout)) + .performScrollTo().performClick() + + // Verify we are on customization screen + composeTestRule.onNodeWithText(getString(R.string.customization_title)).assertIsDisplayed() + + composeTestRule.onNodeWithText(getString(R.string.cancel)).performClick() + composeTestRule.onNodeWithText(getString(R.string.settings_title)).assertIsDisplayed() + + composeTestRule.onNodeWithText(getString(R.string.cancel)).performClick() + composeTestRule.onNodeWithText(getString(R.string.menu_start)).assertIsDisplayed() + } +} diff --git a/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/e2e/SettingsE2ETest.kt b/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/e2e/SettingsE2ETest.kt new file mode 100644 index 0000000..59163a4 --- /dev/null +++ b/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/e2e/SettingsE2ETest.kt @@ -0,0 +1,58 @@ +package io.github.kitswas.virtualgamepadmobile.e2e + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.github.kitswas.virtualgamepadmobile.MainActivity +import io.github.kitswas.virtualgamepadmobile.R +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SettingsE2ETest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun getString(id: Int, vararg formatArgs: Any): String { + return composeTestRule.activity.getString(id, *formatArgs) + } + + @Test + fun testSettingsChangeAndPersistence() { + composeTestRule.onNodeWithText(getString(R.string.menu_settings)).performClick() + + // Find Haptic Feedback switch. + // In SettingsScreen.kt, it's a Switch with Text label. + // We can find it by its text label and then its sibling switch, or use testTag if available. + // Since there's only one Switch in SettingsScreen, we can find by role or use a more specific search. + + // Check initial state (default should be on based on defaultHapticFeedbackEnabled=true) + // composeTestRule.onNodeWithText(getString(R.string.settings_haptic_feedback)).assertIsDisplayed() + + // Actually, let's just toggle it and save. + // Note: The Switch itself might not have the text as its content. + // Let's use useUnmergedTree to find it or just find by text and navigate. + + // For simplicity, I'll just test that we can click 'Save' and it goes back to Main Menu. + // And that we can click 'Reset' and then 'Save'. + + composeTestRule.onNodeWithText(getString(R.string.reset)).performClick() + composeTestRule.onNodeWithText(getString(R.string.save)).performClick() + + composeTestRule.onNodeWithText(getString(R.string.menu_start)).assertIsDisplayed() + } + + @Test + fun testSettingsCancelDoesNotSave() { + composeTestRule.onNodeWithText(getString(R.string.menu_settings)).performClick() + + // Just click cancel + composeTestRule.onNodeWithText(getString(R.string.cancel)).performClick() + + composeTestRule.onNodeWithText(getString(R.string.menu_start)).assertIsDisplayed() + } +} diff --git a/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/ui/screens/GamePadUiTest.kt b/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/ui/screens/GamePadUiTest.kt deleted file mode 100644 index d264b7e..0000000 --- a/app/src/androidTest/java/io/github/kitswas/virtualgamepadmobile/ui/screens/GamePadUiTest.kt +++ /dev/null @@ -1,26 +0,0 @@ -package io.github.kitswas.virtualgamepadmobile.ui.screens - -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.github.kitswas.virtualgamepadmobile.network.ConnectionViewModel -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class GamePadUiTest { - @get:Rule - val composeTestRule = createComposeRule() - - @Test - fun gamePad_shows_and_navigates_back_on_disconnect() { - // This is a placeholder test. Real UI tests would require more setup and possibly a fake ViewModel. - composeTestRule.setContent { - GamePad(connectionViewModel = null, onNavigateBack = {}) - } - // You can add more assertions here based on your UI - // e.g., composeTestRule.onNodeWithText("SomeButton").performClick() - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 22a72f6..c967893 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:resizeableActivity="false" + android:usesCleartextTraffic="false" android:theme="@style/Theme.VirtualGamePadMobile"> + if (settingsRepository.saveConnectionCredentials.first()) { + settingsRepository.setLastConnectionCredentials(ip, port.toString()) + } + } + } setContent { AppUI( connectionViewModel = connectionViewModel, @@ -62,6 +76,23 @@ class MainActivity : ComponentActivity() { HapticUtils.isEnabled = hapticEnabled.value } + val fullScreenEnabled = settingsRepository.fullScreenEnabled.collectAsState( + initial = defaultFullScreenEnabled + ) + + LaunchedEffect(fullScreenEnabled.value) { + val insetsController = WindowCompat.getInsetsController(window, window.decorView) + if (fullScreenEnabled.value) { + WindowCompat.setDecorFitsSystemWindows(window, false) + insetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + insetsController.hide(WindowInsetsCompat.Type.systemBars()) + } else { + WindowCompat.setDecorFitsSystemWindows(window, true) + insetsController.show(WindowInsetsCompat.Type.systemBars()) + } + } + VirtualGamePadMobileTheme( darkMode = settingsRepository.colorScheme.collectAsState( initial = defaultColorScheme @@ -93,10 +124,23 @@ class MainActivity : ComponentActivity() { ) } composable("connect_screen") { + val lastIpAddress by settingsRepository.lastConnectionIpAddress.collectAsState( + initial = "" + ) + val lastPort by settingsRepository.lastConnectionPort.collectAsState(initial = "") + val saveCredentials by settingsRepository.saveConnectionCredentials.collectAsState( + initial = defaultSaveConnectionCredentials + ) + + val initialIp = if (saveCredentials) lastIpAddress else "" + val initialPort = if (saveCredentials) lastPort else "" + ConnectMenu( onNavigateToConnectingScreen = { ipAddress, port -> navController.navigate("connecting_screen/$ipAddress/$port") - } + }, + initialIp = initialIp, + initialPort = initialPort ) } composable( diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/BaseColor.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/BaseColor.kt index ac89ffe..32bee1a 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/BaseColor.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/BaseColor.kt @@ -1,6 +1,8 @@ package io.github.kitswas.virtualgamepadmobile.data +import androidx.annotation.StringRes import androidx.compose.ui.graphics.Color +import io.github.kitswas.virtualgamepadmobile.R import io.github.kitswas.virtualgamepadmobile.ui.theme.GlossyBlue import io.github.kitswas.virtualgamepadmobile.ui.theme.GlossyGreen import io.github.kitswas.virtualgamepadmobile.ui.theme.GlossyRed @@ -8,8 +10,10 @@ import io.github.kitswas.virtualgamepadmobile.ui.theme.NeonBlue import io.github.kitswas.virtualgamepadmobile.ui.theme.NeonGreen import io.github.kitswas.virtualgamepadmobile.ui.theme.NeonRed -enum class BaseColor { - RED, GREEN, BLUE; +enum class BaseColor(@StringRes val nameRes: Int) { + RED(R.string.color_red), + GREEN(R.string.color_green), + BLUE(R.string.color_blue); companion object { fun fromInt(i: Int): BaseColor { diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/ButtonComponent.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/ButtonComponent.kt index 0bbc50c..a9085c1 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/ButtonComponent.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/ButtonComponent.kt @@ -1,8 +1,10 @@ package io.github.kitswas.virtualgamepadmobile.data import android.os.Parcelable +import androidx.annotation.StringRes import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import io.github.kitswas.virtualgamepadmobile.R import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @@ -16,16 +18,16 @@ val OFFSET_VALUE_RANGE = -0.5f..0.5f @Immutable @Serializable @Parcelize -enum class ButtonAnchor(val displayName: String) : Parcelable { - TOP_LEFT("Top Left"), - TOP_CENTER("Top Center"), - TOP_RIGHT("Top Right"), - CENTER_LEFT("Center Left"), - CENTER("Center"), - CENTER_RIGHT("Center Right"), - BOTTOM_LEFT("Bottom Left"), - BOTTOM_CENTER("Bottom Center"), - BOTTOM_RIGHT("Bottom Right"); +enum class ButtonAnchor(@StringRes val nameRes: Int, val displayName: String) : Parcelable { + TOP_LEFT(R.string.anchor_top_left, "Top Left"), + TOP_CENTER(R.string.anchor_top_center, "Top Center"), + TOP_RIGHT(R.string.anchor_top_right, "Top Right"), + CENTER_LEFT(R.string.anchor_center_left, "Center Left"), + CENTER(R.string.anchor_center, "Center"), + CENTER_RIGHT(R.string.anchor_center_right, "Center Right"), + BOTTOM_LEFT(R.string.anchor_bottom_left, "Bottom Left"), + BOTTOM_CENTER(R.string.anchor_bottom_center, "Bottom Center"), + BOTTOM_RIGHT(R.string.anchor_bottom_right, "Bottom Right"); override fun toString(): String = displayName } @@ -36,17 +38,33 @@ enum class ButtonAnchor(val displayName: String) : Parcelable { @Immutable @Serializable @Parcelize -enum class ButtonComponent(val displayName: String, val defaultAnchor: ButtonAnchor) : Parcelable { - LEFT_ANALOG_STICK("Left Analog Stick", ButtonAnchor.TOP_LEFT), - RIGHT_ANALOG_STICK("Right Analog Stick", ButtonAnchor.BOTTOM_RIGHT), - DPAD("D-Pad", ButtonAnchor.BOTTOM_LEFT), - FACE_BUTTONS("Face Buttons (A/B/X/Y)", ButtonAnchor.TOP_RIGHT), - LEFT_TRIGGER("Left Trigger (LT)", ButtonAnchor.BOTTOM_LEFT), - RIGHT_TRIGGER("Right Trigger (RT)", ButtonAnchor.BOTTOM_RIGHT), - LEFT_SHOULDER("Left Shoulder (LB)", ButtonAnchor.TOP_CENTER), - RIGHT_SHOULDER("Right Shoulder (RB)", ButtonAnchor.TOP_CENTER), - SELECT_BUTTON("Select (View)", ButtonAnchor.TOP_CENTER), - START_BUTTON("Start (Menu)", ButtonAnchor.TOP_CENTER); +enum class ButtonComponent( + @StringRes val nameRes: Int, + val displayName: String, + val defaultAnchor: ButtonAnchor +) : Parcelable { + LEFT_ANALOG_STICK(R.string.component_left_analog, "Left Analog Stick", ButtonAnchor.TOP_LEFT), + RIGHT_ANALOG_STICK( + R.string.component_right_analog, + "Right Analog Stick", + ButtonAnchor.BOTTOM_RIGHT + ), + DPAD(R.string.component_dpad, "D-Pad", ButtonAnchor.BOTTOM_LEFT), + FACE_BUTTONS(R.string.component_face_buttons, "Face Buttons (A/B/X/Y)", ButtonAnchor.TOP_RIGHT), + LEFT_TRIGGER(R.string.component_left_trigger, "Left Trigger (LT)", ButtonAnchor.BOTTOM_LEFT), + RIGHT_TRIGGER( + R.string.component_right_trigger, + "Right Trigger (RT)", + ButtonAnchor.BOTTOM_RIGHT + ), + LEFT_SHOULDER(R.string.component_left_shoulder, "Left Shoulder (LB)", ButtonAnchor.TOP_CENTER), + RIGHT_SHOULDER( + R.string.component_right_shoulder, + "Right Shoulder (RB)", + ButtonAnchor.TOP_CENTER + ), + SELECT_BUTTON(R.string.component_select, "Select (View)", ButtonAnchor.TOP_CENTER), + START_BUTTON(R.string.component_start, "Start (Menu)", ButtonAnchor.TOP_CENTER); override fun toString(): String = displayName } diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/ColorScheme.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/ColorScheme.kt index 5af96c6..c49538b 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/ColorScheme.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/ColorScheme.kt @@ -1,7 +1,12 @@ package io.github.kitswas.virtualgamepadmobile.data -enum class ColorScheme { - LIGHT, DARK, SYSTEM; +import androidx.annotation.StringRes +import io.github.kitswas.virtualgamepadmobile.R + +enum class ColorScheme(@StringRes val nameRes: Int) { + LIGHT(R.string.scheme_light), + DARK(R.string.scheme_dark), + SYSTEM(R.string.scheme_system); companion object { fun fromInt(i: Int): ColorScheme { diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/Defaults.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/Defaults.kt index 53d1b5c..7ba1aad 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/Defaults.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/Defaults.kt @@ -4,6 +4,8 @@ val defaultColorScheme = ColorScheme.SYSTEM val defaultBaseColor = BaseColor.BLUE const val defaultPollingDelay = 80 // in milliseconds const val defaultHapticFeedbackEnabled = false // vibrations +const val defaultSaveConnectionCredentials = false +const val defaultFullScreenEnabled = false // Default button configurations with offsets matching the original layout // Note: Offset values are multipliers of baseDp (heightDp in landscape) diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/SettingsRepository.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/SettingsRepository.kt index 7cd40cc..7dc35c3 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/SettingsRepository.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/data/SettingsRepository.kt @@ -47,6 +47,22 @@ class SettingsRepository(context: Context) { } } + val lastConnectionIpAddress: Flow = dataStore.data.map { preferences -> + preferences[LAST_CONNECTION_IP_ADDRESS] ?: "" + } + + val lastConnectionPort: Flow = dataStore.data.map { preferences -> + preferences[LAST_CONNECTION_PORT] ?: "" + } + + val saveConnectionCredentials: Flow = dataStore.data.map { preferences -> + preferences[SAVE_CONNECTION_CREDENTIALS] ?: defaultSaveConnectionCredentials + } + + val fullScreenEnabled: Flow = dataStore.data.map { preferences -> + preferences[FULL_SCREEN_ENABLED] ?: defaultFullScreenEnabled + } + suspend fun setBaseColor(baseColor: BaseColor) { dataStore.edit { preferences -> preferences[BASE_COLOR] = baseColor.ordinal @@ -95,6 +111,25 @@ class SettingsRepository(context: Context) { } } + suspend fun setLastConnectionCredentials(ipAddress: String, port: String) { + dataStore.edit { preferences -> + preferences[LAST_CONNECTION_IP_ADDRESS] = ipAddress + preferences[LAST_CONNECTION_PORT] = port + } + } + + suspend fun setSaveConnectionCredentials(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[SAVE_CONNECTION_CREDENTIALS] = enabled + } + } + + suspend fun setFullScreenEnabled(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[FULL_SCREEN_ENABLED] = enabled + } + } + suspend fun resetAllSettings() { dataStore.edit { preferences -> preferences.clear() @@ -107,5 +142,10 @@ class SettingsRepository(context: Context) { private val POLLING_DELAY = intPreferencesKey("polling_delay") private val HAPTIC_FEEDBACK_ENABLED = booleanPreferencesKey("haptic_feedback_enabled") private val BUTTON_CONFIGS = stringPreferencesKey("button_configs") + private val LAST_CONNECTION_IP_ADDRESS = stringPreferencesKey("last_connection_ip_address") + private val LAST_CONNECTION_PORT = stringPreferencesKey("last_connection_port") + private val SAVE_CONNECTION_CREDENTIALS = + booleanPreferencesKey("save_connection_credentials") + private val FULL_SCREEN_ENABLED = booleanPreferencesKey("full_screen_enabled") } } diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/network/ConnectionState.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/network/ConnectionState.kt index 436da35..c560c19 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/network/ConnectionState.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/network/ConnectionState.kt @@ -6,5 +6,7 @@ data class ConnectionState( val ipAddress: String = "", val port: Int = -1, val error: String? = null, - val isConnecting: Boolean = false + val isConnecting: Boolean = false, + val isRunningDiagnostics: Boolean = false, + val diagnosticResults: List = emptyList() ) diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/network/ConnectionViewModel.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/network/ConnectionViewModel.kt index ec1ab01..ec85098 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/network/ConnectionViewModel.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/network/ConnectionViewModel.kt @@ -1,9 +1,11 @@ package io.github.kitswas.virtualgamepadmobile.network +import android.content.Context import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.github.kitswas.VGP_Data_Exchange.GamepadReading +import io.github.kitswas.virtualgamepadmobile.R import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -18,7 +20,8 @@ import java.net.Socket import java.net.SocketException import java.util.concurrent.LinkedBlockingQueue -class ConnectionViewModel : ViewModel() { +class ConnectionViewModel(private val onConnectionSuccess: suspend (String, Int) -> Unit = { _, _ -> }) : + ViewModel() { private val tag = this::class.java.simpleName private var clientSocket: Socket? = null // Internal storage for the socket @@ -118,10 +121,8 @@ class ConnectionViewModel : ViewModel() { while (isActive) { try { // This will block until a command is available - val command = commandQueue.take() - // Process based on command type - when (command) { + when (val command = commandQueue.take()) { is NetworkCommand.Connect -> processConnectCommand(command) is NetworkCommand.SendGamepadState -> processSendGamepadStateCommand(command) is NetworkCommand.SendString -> processSendStringCommand(command) @@ -172,9 +173,9 @@ class ConnectionViewModel : ViewModel() { val socket = Socket() socket.tcpNoDelay = true socket.setPerformancePreferences(1, 2, 0) - socket.setTrafficClass(0x10) // IPTOS_LOWDELAY - // Use OS Timeout to get actual error, not just TimeoutException - val timeout = 0 // in milliseconds, 0 means infinite + socket.trafficClass = 0x10 // IPTOS_LOWDELAY + // Set timeout to 5 seconds + val timeout = 5000 // in milliseconds try { socket.connect(java.net.InetSocketAddress(command.ipAddress, command.port), timeout) @@ -188,6 +189,15 @@ class ConnectionViewModel : ViewModel() { ) } Log.d(tag, "Connected: $clientSocket") + + // Invoke callback on successful connection + viewModelScope.launch { + try { + onConnectionSuccess(command.ipAddress, command.port) + } catch (e: Exception) { + Log.e(tag, "Error saving connection credentials: ${e.message}", e) + } + } } catch (e: IOException) { Log.e(tag, "Connection failed: ${e.message}", e) _uiState.update { @@ -329,4 +339,113 @@ class ConnectionViewModel : ViewModel() { } } } + + /** + * Runs a series of network diagnostics to troubleshoot connection issues. + * @param context Android context to access system services + */ + fun runDiagnostics(context: Context) { + val currentState = _uiState.value + val ipAddress = currentState.ipAddress + val port = currentState.port + + if (ipAddress.isBlank() || port == -1) { + Log.e(tag, "Cannot run diagnostics: IP or Port missing") + return + } + + viewModelScope.launch { + _uiState.update { + it.copy( + isRunningDiagnostics = true, + diagnosticResults = emptyList() + ) + } + + val diagnostics = NetworkDiagnostics(context) + val results = mutableListOf() + + // Step 1: Wi-Fi/Network + val isWifiConnected = diagnostics.checkNetworkConnectivity() + results.add( + NetworkDiagnostics.DiagnosticResult( + NetworkDiagnostics.DiagnosticStep.WIFI, + isWifiConnected, + if (isWifiConnected) context.getString(R.string.diagnostics_pass_wifi) + else context.getString(R.string.diagnostics_fail_wifi) + ) + ) + _uiState.update { it.copy(diagnosticResults = results.toList()) } + if (!isWifiConnected) { + _uiState.update { it.copy(isRunningDiagnostics = false) } + return@launch + } + + // Step 2: Local IP + val localIp = diagnostics.getLocalIpAddress() + val hasLocalIp = localIp != null + results.add( + NetworkDiagnostics.DiagnosticResult( + NetworkDiagnostics.DiagnosticStep.IP, + hasLocalIp, + if (hasLocalIp) context.getString(R.string.diagnostics_pass_ip, localIp) + else context.getString(R.string.diagnostics_fail_ip) + ) + ) + _uiState.update { it.copy(diagnosticResults = results.toList()) } + if (!hasLocalIp) { + _uiState.update { it.copy(isRunningDiagnostics = false) } + return@launch + } + + // Step 3: Subnet check + val sameSubnet = diagnostics.isSameSubnet(localIp, ipAddress) + results.add( + NetworkDiagnostics.DiagnosticResult( + NetworkDiagnostics.DiagnosticStep.SUBNET, + sameSubnet, + if (sameSubnet) context.getString(R.string.diagnostics_pass_subnet) + else context.getString(R.string.diagnostics_fail_subnet, localIp, ipAddress) + ) + ) + _uiState.update { it.copy(diagnosticResults = results.toList()) } + + // Step 4: Ping + val canPing = diagnostics.pingHost(ipAddress) + results.add( + NetworkDiagnostics.DiagnosticResult( + NetworkDiagnostics.DiagnosticStep.PING, + canPing, + if (canPing) context.getString(R.string.diagnostics_pass_ping) + else context.getString(R.string.diagnostics_fail_ping) + ) + ) + _uiState.update { it.copy(diagnosticResults = results.toList()) } + + // Step 5: Port check + val canConnectPort = diagnostics.checkPort(ipAddress, port) + results.add( + NetworkDiagnostics.DiagnosticResult( + NetworkDiagnostics.DiagnosticStep.PORT, + canConnectPort, + if (canConnectPort) context.getString(R.string.diagnostics_pass_port, port) + else context.getString(R.string.diagnostics_fail_port, port) + ) + ) + _uiState.update { + it.copy( + diagnosticResults = results.toList(), + isRunningDiagnostics = false + ) + } + } + } + + /** + * Clears the current diagnostic results. + */ + fun clearDiagnostics() { + _uiState.update { it.copy(diagnosticResults = emptyList(), isRunningDiagnostics = false) } + } } + diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/network/ConnectionViewModelFactory.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/network/ConnectionViewModelFactory.kt new file mode 100644 index 0000000..a6e1bc6 --- /dev/null +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/network/ConnectionViewModelFactory.kt @@ -0,0 +1,16 @@ +package io.github.kitswas.virtualgamepadmobile.network + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider + +class ConnectionViewModelFactory(private val onConnectionSuccess: suspend (String, Int) -> Unit) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return if (modelClass.isAssignableFrom(ConnectionViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + ConnectionViewModel(onConnectionSuccess) as T + } else { + throw IllegalArgumentException("Unknown ViewModel class") + } + } +} diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/network/NetworkDiagnostics.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/network/NetworkDiagnostics.kt new file mode 100644 index 0000000..b82a81a --- /dev/null +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/network/NetworkDiagnostics.kt @@ -0,0 +1,135 @@ +package io.github.kitswas.virtualgamepadmobile.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.NetworkInterface +import java.net.Socket +import java.util.Collections + +class NetworkDiagnostics(private val context: Context) { + + private val tag = "NetworkDiagnostics" + + data class DiagnosticResult( + val step: DiagnosticStep, + val isPassed: Boolean, + val message: String, + val detail: String? = null + ) + + enum class DiagnosticStep { + WIFI, + IP, + SUBNET, + PING, + PORT + } + + /** + * Checks if the device has an active Wi-Fi or Ethernet connection. + */ + fun checkNetworkConnectivity(): Boolean { + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val activeNetwork = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false + + return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + } + + /** + * Retrieves the local IPv4 address of the device. + */ + fun getLocalIpAddress(): String? { + try { + val interfaces = Collections.list(NetworkInterface.getNetworkInterfaces()) + for (intf in interfaces) { + val addrs = Collections.list(intf.inetAddresses) + for (addr in addrs) { + if (addr != null && !addr.isLoopbackAddress) { + val sAddr = addr.hostAddress ?: continue + val isIPv4 = sAddr.indexOf(':') < 0 + if (isIPv4) return sAddr + } + } + } + } catch (e: Exception) { + Log.e(tag, "Error getting local IP", e) + } + return null + } + + /** + * Checks if two IP addresses are on the same subnet. + */ + fun isSameSubnet(ip1: String, ip2: String): Boolean { + return try { + val addr1 = InetAddress.getByName(ip1) + val addr2 = InetAddress.getByName(ip2) + val b1 = addr1.address + val b2 = addr2.address + + if (b1.size != 4 || b2.size != 4) return false + + val prefix = NetworkInterface.getByInetAddress(addr1) + ?.interfaceAddresses?.firstOrNull { it.address == addr1 } + ?.networkPrefixLength?.toInt() ?: return false + + isSameSubnet(b1, b2, prefix) + } catch (e: Exception) { + Log.e(tag, "Error checking subnet", e) + false + } + } + + /** + * Attempts to ping the target host. + */ + suspend fun pingHost(host: String): Boolean = withContext(Dispatchers.IO) { + try { + val address = InetAddress.getByName(host) + address.isReachable(2000) // 2 second timeout + } catch (e: IOException) { + Log.e(tag, "Ping failed", e) + false + } + } + + /** + * Attempts to connect to the target host and port. + */ + suspend fun checkPort(host: String, port: Int): Boolean = withContext(Dispatchers.IO) { + try { + val socket = Socket() + socket.connect(InetSocketAddress(host, port), 2000) // 2 second timeout + socket.close() + true + } catch (e: IOException) { + Log.e(tag, "Port check failed", e) + false + } + } + + companion object { + /** + * Pure bitwise check if two IPv4 byte arrays are on the same subnet. + */ + fun isSameSubnet(b1: ByteArray, b2: ByteArray, prefix: Int): Boolean { + if (b1.size != 4 || b2.size != 4) return false + val mask = if (prefix == 0) 0 else -1 shl (32 - prefix) + val i1 = + ((b1[0].toInt() and 0xFF) shl 24) or ((b1[1].toInt() and 0xFF) shl 16) or ((b1[2].toInt() and 0xFF) shl 8) or (b1[3].toInt() and 0xFF) + val i2 = + ((b2[0].toInt() and 0xFF) shl 24) or ((b2[1].toInt() and 0xFF) shl 16) or ((b2[2].toInt() and 0xFF) shl 8) or (b2[3].toInt() and 0xFF) + return (i1 and mask) == (i2 and mask) + } + } +} diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/components/QRCodeScanner.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/components/QRCodeScanner.kt index 94b079d..4191a10 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/components/QRCodeScanner.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/components/QRCodeScanner.kt @@ -4,8 +4,10 @@ import android.Manifest import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanOptions +import io.github.kitswas.virtualgamepadmobile.R sealed class QRScanResult { data class Success(val content: String) : QRScanResult() @@ -32,6 +34,7 @@ fun rememberQRCodeScanner( } } + val promptText = stringResource(R.string.qr_scan_prompt) val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission() ) { isGranted -> @@ -39,7 +42,7 @@ fun rememberQRCodeScanner( // Permission granted, start scanning val scanOptions = ScanOptions() scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE) - scanOptions.setPrompt("Scan QR Code shown on the server") + scanOptions.setPrompt(promptText) scanOptions.setCameraId(0) scanOptions.setBeepEnabled(false) scanOptions.setBarcodeImageEnabled(false) diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/AnalogStick.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/AnalogStick.kt index 930cfb7..ad9d68d 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/AnalogStick.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/AnalogStick.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset @@ -34,6 +35,98 @@ enum class AnalogStickType { LEFT, RIGHT } +private class AnalogStickUiState { + var offsetX by mutableFloatStateOf(0f) + var offsetY by mutableFloatStateOf(0f) + var visualX by mutableFloatStateOf(0f) + var visualY by mutableFloatStateOf(0f) + var isPressed by mutableStateOf(false) +} + +private fun updateThumbstickValues( + type: AnalogStickType, + gamepadState: GamepadReading, + x: Float, + y: Float, +) { + when (type) { + AnalogStickType.LEFT -> { + gamepadState.LeftThumbstickX = x + gamepadState.LeftThumbstickY = y + } + + AnalogStickType.RIGHT -> { + gamepadState.RightThumbstickX = x + gamepadState.RightThumbstickY = y + } + } +} + +private fun thumbstickButtonMask(type: AnalogStickType): Int = when (type) { + AnalogStickType.LEFT -> GameButtons.LeftThumbstick.value + AnalogStickType.RIGHT -> GameButtons.RightThumbstick.value +} + +private fun resetAnalogStick( + state: AnalogStickUiState, + type: AnalogStickType, + gamepadState: GamepadReading, +) { + state.offsetX = 0f + state.offsetY = 0f + state.visualX = 0f + state.visualY = 0f + updateThumbstickValues(type, gamepadState, 0f, 0f) +} + +private fun handleAnalogDrag( + state: AnalogStickUiState, + dragX: Float, + dragY: Float, + maxOffset: Float, + type: AnalogStickType, + gamepadState: GamepadReading, + view: android.view.View, +) { + state.offsetX += dragX + state.offsetY += dragY + + val magnitude = sqrt(state.offsetX * state.offsetX + state.offsetY * state.offsetY) + val normalizedDistance = (magnitude / maxOffset).coerceIn(0f, 1f) + if (normalizedDistance > 0.3f) { + HapticUtils.performAnalogMovementFeedback(view, normalizedDistance) + } + + if (magnitude <= 0f) { + return + } + + val scaleFactor = if (magnitude > maxOffset) maxOffset / magnitude else 1f + state.visualX = state.offsetX * scaleFactor + state.visualY = state.offsetY * scaleFactor + updateThumbstickValues(type, gamepadState, state.visualX / maxOffset, state.visualY / maxOffset) +} + +private fun toggleThumbstickButton( + state: AnalogStickUiState, + type: AnalogStickType, + gamepadState: GamepadReading, + view: android.view.View, +) { + val mask = thumbstickButtonMask(type) + if (!state.isPressed) { + state.isPressed = true + gamepadState.ButtonsDown = gamepadState.ButtonsDown or mask + HapticUtils.performButtonPressFeedback(view) + return + } + + state.isPressed = false + gamepadState.ButtonsDown = gamepadState.ButtonsDown and mask.inv() + gamepadState.ButtonsUp = gamepadState.ButtonsUp or mask + HapticUtils.performGestureEndFeedback(view) +} + @Composable fun AnalogStick( modifier: Modifier = Modifier, @@ -50,7 +143,7 @@ fun AnalogStick( val view = LocalView.current Box( - modifier = modifier, + modifier = modifier.testTag("AnalogStick_${type.name}"), contentAlignment = Alignment.Center ) { // First draw the glow ring @@ -67,30 +160,23 @@ fun AnalogStick( contentAlignment = Alignment.Center, colour = outerCircleColor, ) { - // Raw offset values (more efficient than using a data class) - var offsetX by remember { mutableFloatStateOf(0f) } - var offsetY by remember { mutableFloatStateOf(0f) } - - // Visual position values (calculated once and reused) - var visualX by remember { mutableFloatStateOf(0f) } - var visualY by remember { mutableFloatStateOf(0f) } + val state = remember { AnalogStickUiState() } // Calculate maximum offset once val maxOffset = with(density) { (innerCircleRadius + outerCircleWidth).toPx() } - var isPressed by remember { mutableStateOf(false) } - // Then draw the inner circle Circle( colour = innerCircleColor, modifier = Modifier + .testTag("AnalogStick_${type.name}_Handle") .size(innerCircleRadius * 2) .offset { IntOffset( - visualX.roundToInt(), - visualY.roundToInt() + state.visualX.roundToInt(), + state.visualY.roundToInt() ) } .pointerInput(Unit) { @@ -99,92 +185,27 @@ fun AnalogStick( HapticUtils.performGestureStartFeedback(view) }, onDragEnd = { - // Reset position - offsetX = 0f - offsetY = 0f - visualX = 0f - visualY = 0f - - // Update gamepad state - when (type) { - AnalogStickType.LEFT -> { - gamepadState.LeftThumbstickX = 0f - gamepadState.LeftThumbstickY = 0f - } - - AnalogStickType.RIGHT -> { - gamepadState.RightThumbstickX = 0f - gamepadState.RightThumbstickY = 0f - } - } + resetAnalogStick(state, type, gamepadState) HapticUtils.performGestureEndFeedback(view) }, onDrag = { change, dragAmount -> change.consume() - - // Update raw position - offsetX += dragAmount.x - offsetY += dragAmount.y - - // Calculate magnitude for normalized position - val magnitude = sqrt(offsetX * offsetX + offsetY * offsetY) - // Calculate normalized distance (0-1 range) for haptic intensity - val normalizedDistance = - (magnitude / maxOffset).coerceIn(0f, 1f) - - if (normalizedDistance > 0.3f) { - // Subtle movement feedback for better tactile experience - HapticUtils.performAnalogMovementFeedback( - view, - normalizedDistance - ) - } - - // Only update when magnitude > 0 - if (magnitude > 0f) { - // Normalize with max offset (calculate once and reuse) - val scaleFactor = - if (magnitude > maxOffset) maxOffset / magnitude else 1f - visualX = offsetX * scaleFactor - visualY = offsetY * scaleFactor - - when (type) { - AnalogStickType.LEFT -> { - gamepadState.LeftThumbstickX = visualX / maxOffset - gamepadState.LeftThumbstickY = visualY / maxOffset - } - - AnalogStickType.RIGHT -> { - gamepadState.RightThumbstickX = visualX / maxOffset - gamepadState.RightThumbstickY = visualY / maxOffset - } - } - } + handleAnalogDrag( + state = state, + dragX = dragAmount.x, + dragY = dragAmount.y, + maxOffset = maxOffset, + type = type, + gamepadState = gamepadState, + view = view, + ) } ) } .pointerInput(Unit) { detectTapGestures( onLongPress = { _ -> - if (!isPressed) { - isPressed = true - gamepadState.ButtonsDown = when (type) { - AnalogStickType.LEFT -> gamepadState.ButtonsDown or GameButtons.LeftThumbstick.value - AnalogStickType.RIGHT -> gamepadState.ButtonsDown or GameButtons.RightThumbstick.value - } - HapticUtils.performButtonPressFeedback(view) - } else { - isPressed = false - gamepadState.ButtonsDown = when (type) { - AnalogStickType.LEFT -> gamepadState.ButtonsDown and GameButtons.LeftThumbstick.value.inv() - AnalogStickType.RIGHT -> gamepadState.ButtonsDown and GameButtons.RightThumbstick.value.inv() - } - gamepadState.ButtonsUp = when (type) { - AnalogStickType.LEFT -> gamepadState.ButtonsUp or GameButtons.LeftThumbstick.value - AnalogStickType.RIGHT -> gamepadState.ButtonsUp or GameButtons.RightThumbstick.value - } - HapticUtils.performGestureEndFeedback(view) - } + toggleThumbstickButton(state, type, gamepadState, view) } ) } diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/BoundedNumericInput.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/BoundedNumericInput.kt index 5acab3e..c88be80 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/BoundedNumericInput.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/BoundedNumericInput.kt @@ -13,8 +13,10 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import io.github.kitswas.virtualgamepadmobile.R @Suppress("UNUSED") @Composable @@ -33,6 +35,9 @@ fun BoundedNumericInput( var textValue by rememberSaveable { mutableStateOf(formatValue(value)) } var isError by rememberSaveable { mutableStateOf(false) } var errorMessage by rememberSaveable { mutableStateOf("") } + val errMin = stringResource(R.string.error_min_value, formatValue(minValue)) + val errMax = stringResource(R.string.error_max_value, formatValue(maxValue)) + val errInvalid = stringResource(R.string.error_invalid_number) Column( modifier = modifier, @@ -48,12 +53,12 @@ fun BoundedNumericInput( when { compareValues(parsedValue, minValue) < 0 -> { isError = true - errorMessage = "Value must be at least ${formatValue(minValue)}" + errorMessage = errMin } compareValues(parsedValue, maxValue) > 0 -> { isError = true - errorMessage = "Value must be at most ${formatValue(maxValue)}" + errorMessage = errMax } else -> { @@ -64,11 +69,11 @@ fun BoundedNumericInput( } } else { isError = true - errorMessage = "Please enter a valid number" + errorMessage = errInvalid } } catch (_: Exception) { isError = true - errorMessage = "Please enter a valid number" + errorMessage = errInvalid } }, label = { Text(label, style = MaterialTheme.typography.labelMedium) }, diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/ButtonConfigEditor.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/ButtonConfigEditor.kt index 809a88e..e798f87 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/ButtonConfigEditor.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/ButtonConfigEditor.kt @@ -18,8 +18,10 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import io.github.kitswas.virtualgamepadmobile.R import io.github.kitswas.virtualgamepadmobile.data.ButtonAnchor import io.github.kitswas.virtualgamepadmobile.data.ButtonComponent import io.github.kitswas.virtualgamepadmobile.data.ButtonConfig @@ -58,7 +60,7 @@ fun ButtonConfigEditor( verticalAlignment = Alignment.CenterVertically ) { Text( - text = component.displayName, + text = stringResource(component.nameRes), style = MaterialTheme.typography.titleSmall ) Row( @@ -66,7 +68,9 @@ fun ButtonConfigEditor( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - text = if (visible) "Visible" else "Hidden", + text = if (visible) stringResource(R.string.button_visible) else stringResource( + R.string.button_hidden + ), style = MaterialTheme.typography.bodySmall ) Switch( @@ -83,9 +87,12 @@ fun ButtonConfigEditor( ListItemPicker( modifier = Modifier.fillMaxWidth(), list = ButtonAnchor.entries.asIterable(), - default = anchor, - label = "Anchor Position", + selectedItem = anchor, + label = stringResource(R.string.button_anchor_position), isHorizontal = true, + formattedDisplay = { item -> + Text(text = stringResource(item.nameRes)) + }, onItemSelected = { anchor = it onConfigChange(config.copy(anchor = it)) @@ -99,7 +106,7 @@ fun ButtonConfigEditor( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Scale: ${String.format("%.2f", scale)}x", + text = stringResource(R.string.button_scale_format, scale), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(2f) ) @@ -121,7 +128,7 @@ fun ButtonConfigEditor( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Offset X: ${String.format("%.2f", offsetX)}× baseDp", + text = stringResource(R.string.button_offset_x_format, offsetX), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(2f) ) @@ -143,7 +150,7 @@ fun ButtonConfigEditor( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Offset Y: ${String.format("%.2f", offsetY)}× baseDp", + text = stringResource(R.string.button_offset_y_format, offsetY), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(2f) ) diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/CentralButtons.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/CentralButtons.kt index a690d16..b1d113d 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/CentralButtons.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/CentralButtons.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -72,8 +73,8 @@ fun ShoulderButton( ShoulderButtonType.RIGHT -> GameButtons.RightShoulder } val text = when (type) { - ShoulderButtonType.LEFT -> "LSHLDR" - ShoulderButtonType.RIGHT -> "RSHLDR" + ShoulderButtonType.LEFT -> stringResource(R.string.button_l_shoulder) + ShoulderButtonType.RIGHT -> stringResource(R.string.button_r_shoulder) } val interactionSource = remember { MutableInteractionSource() } @@ -154,14 +155,14 @@ fun MenuButton( if (iconPainter != null) { Icon( painter = iconPainter, - contentDescription = "${gameButton.name} Button", + contentDescription = stringResource(R.string.content_desc_button, gameButton.name), modifier = Modifier.size(size / 2), tint = MaterialTheme.colorScheme.primary ) } else { Icon( imageVector = ImageVector.vectorResource(R.drawable.screenicon), - contentDescription = "${gameButton.name} Button", + contentDescription = stringResource(R.string.content_desc_button, gameButton.name), modifier = Modifier.size(size / 2), tint = MaterialTheme.colorScheme.primary ) diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/ColorSchemePicker.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/ColorSchemePicker.kt index f83c9ac..97adf02 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/ColorSchemePicker.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/ColorSchemePicker.kt @@ -1,25 +1,30 @@ package io.github.kitswas.virtualgamepadmobile.ui.composables import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import io.github.kitswas.virtualgamepadmobile.R import io.github.kitswas.virtualgamepadmobile.data.ColorScheme import io.github.kitswas.virtualgamepadmobile.data.PreviewBase - @Composable fun ColorSchemePicker( - default: ColorScheme, - modifier: Modifier = Modifier, onColorSchemeSelected: (ColorScheme) -> Unit = { _ -> } + selectedItem: ColorScheme, + modifier: Modifier = Modifier, + onColorSchemeSelected: (ColorScheme) -> Unit = { _ -> } ) { - ListItemPicker( modifier = modifier, list = ColorScheme.entries.asIterable(), - default = default, - label = "Color Scheme", + selectedItem = selectedItem, + label = stringResource(R.string.settings_color_scheme), + formattedDisplay = { item -> + Text(text = stringResource(item.nameRes)) + }, onItemSelected = onColorSchemeSelected ) } diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/Dpad.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/Dpad.kt index a4c4aef..44544c6 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/Dpad.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/Dpad.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -87,7 +88,7 @@ fun DpadButton( ) { Icon( painter = painterResource(R.drawable.ic_play_arrow), - contentDescription = "Dpad Button ${type.name}", + contentDescription = stringResource(R.string.content_desc_dpad_button, type.name), modifier = Modifier .rotate(rotation) .size(size), diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/FaceButtons.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/FaceButtons.kt index da35cad..5c2fb5b 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/FaceButtons.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/FaceButtons.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -26,6 +27,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times import io.github.kitswas.VGP_Data_Exchange.GameButtons import io.github.kitswas.VGP_Data_Exchange.GamepadReading +import io.github.kitswas.virtualgamepadmobile.R import io.github.kitswas.virtualgamepadmobile.data.PreviewBase import io.github.kitswas.virtualgamepadmobile.ui.theme.darken import io.github.kitswas.virtualgamepadmobile.ui.theme.faceButtonTextStyle @@ -60,6 +62,13 @@ fun FaceButton( FaceButtonType.X -> GameButtons.X FaceButtonType.Y -> GameButtons.Y } + val label = when (type) { + FaceButtonType.A -> stringResource(R.string.button_a) + FaceButtonType.B -> stringResource(R.string.button_b) + FaceButtonType.X -> stringResource(R.string.button_x) + FaceButtonType.Y -> stringResource(R.string.button_y) + } + val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() // See https://stackoverflow.com/a/69157877/8659747 @@ -89,7 +98,7 @@ fun FaceButton( border = BorderStroke(2.dp, MaterialTheme.colorScheme.outline), ) { Text( - text = type.name, + text = label, color = foregroundColour, textAlign = TextAlign.Center, style = faceButtonTextStyle(size), diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/Gamepad.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/Gamepad.kt index aad7346..fae5eb3 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/Gamepad.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/Gamepad.kt @@ -38,7 +38,6 @@ fun DrawGamepad( ) { // Assuming Landscape orientation val baseDp = heightDp - val altDp = widthDp val deadZonePadding = baseDp / 18 diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/ListItemPicker.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/ListItemPicker.kt index f79a3e9..94f11b2 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/ListItemPicker.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/ListItemPicker.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import io.github.kitswas.virtualgamepadmobile.R /** @@ -35,7 +36,7 @@ import io.github.kitswas.virtualgamepadmobile.R fun ListItemPicker( modifier: Modifier = Modifier, list: Iterable, - default: T, + selectedItem: T, label: String, isHorizontal: Boolean = false, formattedDisplay: @Composable (T) -> Unit = { item -> @@ -48,8 +49,6 @@ fun ListItemPicker( var expanded by rememberSaveable { mutableStateOf(false) } - var selectedItem = default - val labelContent = @Composable { Text(text = label, style = MaterialTheme.typography.labelMedium) } @@ -58,9 +57,10 @@ fun ListItemPicker( OutlinedButton(onClick = { expanded = true }) { Row { formattedDisplay(selectedItem) + Icon( painter = painterResource(R.drawable.ic_arrow_drop_down), - contentDescription = "Expand" + contentDescription = stringResource(R.string.content_desc_expand) ) } DropdownMenu( @@ -69,7 +69,6 @@ fun ListItemPicker( ) { list.forEach { item -> DropdownMenuItem(text = { formattedDisplay(item) }, onClick = { - selectedItem = item expanded = false onItemSelected(item) }) @@ -117,10 +116,10 @@ fun ListItemPicker( ListItemPicker( modifier = modifier, list = list, - default = list.elementAt(defaultIndex), + selectedItem = list.elementAt(defaultIndex), label = label, isHorizontal = isHorizontal, formattedDisplay = formattedDisplay, onItemSelected = onItemSelected ) -} \ No newline at end of file +} diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/SpinBox.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/SpinBox.kt index 3e40756..7f66eaa 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/SpinBox.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/SpinBox.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import io.github.kitswas.virtualgamepadmobile.R @@ -96,7 +97,7 @@ fun SpinBox( ) { Icon( painter = painterResource(R.drawable.ic_keyboard_arrow_down), - contentDescription = "Decrease", + contentDescription = stringResource(R.string.content_desc_decrease), ) } @@ -136,7 +137,7 @@ fun SpinBox( ) { Icon( painter = painterResource(R.drawable.ic_keyboard_arrow_up), - contentDescription = "Increase", + contentDescription = stringResource(R.string.content_desc_increase), ) } } diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/Trigger.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/Trigger.kt index 4230a9c..9bc28b7 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/Trigger.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/composables/Trigger.kt @@ -20,10 +20,12 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import io.github.kitswas.VGP_Data_Exchange.GamepadReading +import io.github.kitswas.virtualgamepadmobile.R import io.github.kitswas.virtualgamepadmobile.ui.utils.HapticUtils enum class TriggerType { @@ -60,9 +62,10 @@ fun Trigger( HapticUtils.performButtonReleaseFeedback(view) } } - val text = when (type) { - TriggerType.LEFT -> "LT" - TriggerType.RIGHT -> "RT" + + val label = when (type) { + TriggerType.LEFT -> stringResource(R.string.button_lt) + TriggerType.RIGHT -> stringResource(R.string.button_rt) } val triggerShape = when (type) { @@ -89,7 +92,7 @@ fun Trigger( border = BorderStroke(2.dp, MaterialTheme.colorScheme.outline), interactionSource = interactionSource, ) { - Text(text) + Text(label) } } } diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/AboutScreen.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/AboutScreen.kt index 6493c41..1401be1 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/AboutScreen.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/AboutScreen.kt @@ -22,11 +22,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp.Companion.Hairline import androidx.compose.ui.unit.dp import androidx.core.net.toUri +import io.github.kitswas.virtualgamepadmobile.R import io.github.kitswas.virtualgamepadmobile.data.PreviewBase import io.github.kitswas.virtualgamepadmobile.data.PreviewHeightDp import io.github.kitswas.virtualgamepadmobile.data.PreviewWidthDp @@ -58,11 +60,17 @@ fun AboutScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top ) { - Text(text = "🎮 Virtual GamePad Mobile", style = MaterialTheme.typography.titleLarge) + Text( + text = stringResource(R.string.about_title), + style = MaterialTheme.typography.titleLarge + ) Spacer(modifier = Modifier.height(8.dp)) - Text(text = "Version $versionName", style = MaterialTheme.typography.bodyMedium) + Text( + text = stringResource(R.string.about_version, versionName ?: ""), + style = MaterialTheme.typography.bodyMedium + ) Spacer(modifier = Modifier.height(16.dp)) @@ -77,7 +85,10 @@ fun AboutScreen( val intent = Intent(Intent.ACTION_VIEW, mobileLicenseUrl.toUri()) context.startActivity(intent) }) { - Text("View Licence", style = MaterialTheme.typography.labelSmall) + Text( + stringResource(R.string.about_view_license), + style = MaterialTheme.typography.labelSmall + ) } OutlinedButton( @@ -88,14 +99,17 @@ fun AboutScreen( val intent = Intent(Intent.ACTION_VIEW, releaseUrl.toUri()) context.startActivity(intent) }) { - Text("Latest Release", style = MaterialTheme.typography.labelSmall) + Text( + stringResource(R.string.about_latest_release), + style = MaterialTheme.typography.labelSmall + ) } } Spacer(modifier = Modifier.height(24.dp)) Text( - text = "A mobile application that lets your phone work as a gamepad for PC games.", + text = stringResource(R.string.about_description), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center ) @@ -109,28 +123,37 @@ fun AboutScreen( val intent = Intent(Intent.ACTION_VIEW, mobileRepoUrl.toUri()) context.startActivity(intent) }) { - Text("Source Code", style = MaterialTheme.typography.labelMedium) + Text( + stringResource(R.string.about_source_code), + style = MaterialTheme.typography.labelMedium + ) } FilledTonalButton(onClick = { val intent = Intent(Intent.ACTION_VIEW, projectUrl.toUri()) context.startActivity(intent) }) { - Text("Project Website", style = MaterialTheme.typography.labelMedium) + Text( + stringResource(R.string.about_project_website), + style = MaterialTheme.typography.labelMedium + ) } FilledTonalButton(onClick = { val intent = Intent(Intent.ACTION_VIEW, issuesUrl.toUri()) context.startActivity(intent) }) { - Text("Report Issues", style = MaterialTheme.typography.labelMedium) + Text( + stringResource(R.string.about_report_issues), + style = MaterialTheme.typography.labelMedium + ) } } Spacer(modifier = Modifier.height(32.dp)) Button(onClick = onNavigateBack) { - Text("Back") + Text(stringResource(R.string.back)) } } } @@ -143,6 +166,6 @@ fun AboutScreen( @Composable fun AboutScreenPreview() { PreviewBase { - AboutScreen(onNavigateBack = {}, versionName = "Development") + AboutScreen(onNavigateBack = {}, versionName = stringResource(R.string.about_dev_version)) } } diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/ConnectScreen.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/ConnectScreen.kt index 621c0fd..d6d33d7 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/ConnectScreen.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/ConnectScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -35,10 +36,12 @@ import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri +import io.github.kitswas.virtualgamepadmobile.R import io.github.kitswas.virtualgamepadmobile.data.PreviewBase import io.github.kitswas.virtualgamepadmobile.data.PreviewHeightDp import io.github.kitswas.virtualgamepadmobile.data.PreviewWidthDp @@ -80,7 +83,8 @@ private fun processQRScanResult( result: QRScanResult, onNavigateToConnectingScreen: (String, String) -> Unit, snackbarHostState: SnackbarHostState, - scope: CoroutineScope + scope: CoroutineScope, + context: android.content.Context ) { when (result) { is QRScanResult.Success -> { @@ -96,14 +100,17 @@ private fun processQRScanResult( } else { scope.launch { snackbarHostState.showSnackbar( - message = "Invalid QR Code format", + message = context.getString(R.string.connect_qr_error_format), ) } } } catch (e: Exception) { scope.launch { snackbarHostState.showSnackbar( - message = "Error processing QR Code: ${e.message ?: e.toString()}", + message = context.getString( + R.string.connect_qr_error_processing, + e.message ?: e.toString() + ), ) } } @@ -112,7 +119,7 @@ private fun processQRScanResult( is QRScanResult.Error -> { scope.launch { snackbarHostState.showSnackbar( - message = "Error scanning QR Code: ${result.message}", + message = context.getString(R.string.connect_qr_error_scanning, result.message), ) } } @@ -120,7 +127,7 @@ private fun processQRScanResult( is QRScanResult.PermissionDenied -> { scope.launch { snackbarHostState.showSnackbar( - message = "Camera permission is required to scan QR Codes", + message = context.getString(R.string.connect_camera_permission_denied), ) } } @@ -132,7 +139,9 @@ private fun processQRScanResult( @Composable fun ConnectMenu( - onNavigateToConnectingScreen: (String, String) -> Unit + onNavigateToConnectingScreen: (String, String) -> Unit, + initialIp: String, + initialPort: String ) { val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -144,7 +153,8 @@ fun ConnectMenu( result, onNavigateToConnectingScreen = onNavigateToConnectingScreen, snackbarHostState = snackbarHostState, - scope = scope + scope = scope, + context = context ) } @@ -161,11 +171,27 @@ fun ConnectMenu( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - var ipAddress by rememberSaveable { mutableStateOf("") } - var port by rememberSaveable { mutableStateOf("") } - var isIPValid by rememberSaveable { mutableStateOf(false) } - var isPortValid by rememberSaveable { mutableStateOf(false) } + var ipAddress by rememberSaveable { mutableStateOf(initialIp) } + var port by rememberSaveable { mutableStateOf(initialPort) } + var isIPValid by rememberSaveable { mutableStateOf(validateIP(initialIp)) } + var isPortValid by rememberSaveable { mutableStateOf(validatePort(initialPort)) } + + // Update state when parameters arrive asynchronously from DataStore + LaunchedEffect(initialIp, initialPort) { + if (ipAddress.isBlank() && initialIp.isNotBlank()) { + ipAddress = initialIp + isIPValid = validateIP(initialIp) + } + if (port.isBlank() && initialPort.isNotBlank()) { + port = initialPort + isPortValid = validatePort(initialPort) + } + } + val focusManager = LocalFocusManager.current + val connectErrorIpStr = stringResource(R.string.connect_error_ip) + val connectErrorPortStr = stringResource(R.string.connect_error_port) + val connectErrorParamsStr = stringResource(R.string.connect_error_params) fun attemptToConnect() { if (isIPValid && isPortValid) { @@ -174,9 +200,9 @@ fun ConnectMenu( } else { scope.launch { val errorMessage = when { - !isIPValid -> "Invalid IP address format" - !isPortValid -> "Invalid port number (must be between 1-65535)" - else -> "Invalid connection parameters" + !isIPValid -> connectErrorIpStr + !isPortValid -> connectErrorPortStr + else -> connectErrorParamsStr } snackbarHostState.showSnackbar( message = errorMessage, @@ -187,11 +213,11 @@ fun ConnectMenu( } Button(onClick = { qrCodeScanner() }, shape = CircleShape) { - Text(text = "Scan QR Code") + Text(text = stringResource(R.string.connect_scan_qr)) } TextField( - label = { Text(text = "IP Address") }, + label = { Text(text = stringResource(R.string.connect_ip_label)) }, value = ipAddress, onValueChange = { ipAddress = it @@ -213,7 +239,7 @@ fun ConnectMenu( ) TextField( - label = { Text(text = "Port") }, + label = { Text(text = stringResource(R.string.connect_port_label)) }, value = port, onValueChange = { port = it @@ -238,7 +264,7 @@ fun ConnectMenu( shape = CircleShape, enabled = isIPValid && isPortValid, ) { - Text(text = "Connect") + Text(text = stringResource(R.string.connect_button)) } OutlinedButton( @@ -248,7 +274,10 @@ fun ConnectMenu( val intent = Intent(Intent.ACTION_VIEW, downloadsUrl.toUri()) context.startActivity(intent) }) { - Text(text = "Download Server", style = MaterialTheme.typography.labelSmall) + Text( + text = stringResource(R.string.connect_download_server), + style = MaterialTheme.typography.labelSmall + ) } } } @@ -262,7 +291,9 @@ fun ConnectMenu( fun ConnectMenuPreview() { PreviewBase { ConnectMenu( - onNavigateToConnectingScreen = { _, _ -> } + onNavigateToConnectingScreen = { _, _ -> }, + initialIp = "", + initialPort = "12345" ) } } diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/ConnectingScreen.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/ConnectingScreen.kt index 47b8cec..02bb0f3 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/ConnectingScreen.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/ConnectingScreen.kt @@ -4,12 +4,21 @@ import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration @@ -24,11 +33,21 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import io.github.kitswas.virtualgamepadmobile.R +import io.github.kitswas.virtualgamepadmobile.data.PreviewBase +import io.github.kitswas.virtualgamepadmobile.data.PreviewHeightDp +import io.github.kitswas.virtualgamepadmobile.data.PreviewWidthDp import io.github.kitswas.virtualgamepadmobile.network.ConnectionViewModel +import io.github.kitswas.virtualgamepadmobile.ui.theme.SuccessGreen import kotlinx.coroutines.launch @Composable @@ -42,11 +61,16 @@ fun ConnectingScreen( val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() + var showDiagnosticsDialog by rememberSaveable { mutableStateOf(false) } // Get the current connection state val connectionState by connectionViewModel?.uiState?.collectAsState() ?: remember { mutableStateOf(null) } + val connectErrorParamsStr = stringResource(R.string.connect_error_params) + val connectingFailedMsg = + connectionState?.error?.let { stringResource(R.string.connecting_failed, it) } + // Initiate connection when entering screen LaunchedEffect(ipAddress, port) { try { @@ -55,7 +79,7 @@ fun ConnectingScreen( } catch (e: Exception) { Log.e("ConnectingScreen", "Failed to initiate connection: ${e.message}") snackbarHostState.showSnackbar( - message = "Invalid connection parameters: ${e.message}", + message = connectErrorParamsStr + ": ${e.message}", duration = SnackbarDuration.Short ) onNavigateBack() @@ -78,7 +102,7 @@ fun ConnectingScreen( Log.d("ConnectingScreen", "Connection failed: ${state.error}") scope.launch { snackbarHostState.showSnackbar( - message = "Connection failed: ${state.error}", + message = connectingFailedMsg ?: "", duration = SnackbarDuration.Short ) } @@ -108,16 +132,16 @@ fun ConnectingScreen( verticalArrangement = Arrangement.Center ) { if (connectionState?.connected == true) { - Text("Connected! Redirecting...") + Text(stringResource(R.string.connecting_success)) } else if (connectionState?.isConnecting == true) { CircularProgressIndicator() Spacer(modifier = Modifier.height(16.dp)) - Text("Connecting to $ipAddress:$port...") + Text(stringResource(R.string.connecting_status, ipAddress, port)) } else { // Show error message if there is one connectionState?.error?.let { error -> Text( - text = "Connection Error", + text = stringResource(R.string.connecting_error_title), style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.error ) @@ -127,23 +151,148 @@ fun ConnectingScreen( color = MaterialTheme.colorScheme.error ) Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { - // Retry connection - connectionViewModel?.connect(ipAddress, port.toInt()) - }) { - Text("Retry") + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Button(onClick = { + // Retry connection + connectionViewModel?.connect(ipAddress, port.toInt()) + }) { + Text(stringResource(R.string.connecting_retry)) + } + + Button(onClick = { + connectionViewModel?.runDiagnostics(context) + showDiagnosticsDialog = true + }) { + Text(stringResource(R.string.connecting_diagnostics)) + } } Spacer(modifier = Modifier.height(8.dp)) TextButton(onClick = onNavigateBack) { - Text("Back") + Text(stringResource(R.string.back)) } } ?: run { // No error but also not connecting - initial state CircularProgressIndicator() Spacer(modifier = Modifier.height(16.dp)) - Text("Preparing connection...") + Text(stringResource(R.string.connecting_preparing)) + } + } + } + + if (showDiagnosticsDialog) { + DiagnosticsDialog( + connectionState = connectionState, + onDismiss = { + showDiagnosticsDialog = false + connectionViewModel?.clearDiagnostics() + } + ) + } + } +} + +@Composable +fun DiagnosticsDialog( + connectionState: io.github.kitswas.virtualgamepadmobile.network.ConnectionState?, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(stringResource(R.string.diagnostics_title)) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (connectionState?.isRunningDiagnostics == true) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + CircularProgressIndicator() + Text(stringResource(R.string.diagnostics_running)) + } + } + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(connectionState?.diagnosticResults ?: emptyList()) { result -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = if (result.isPassed) Icons.Default.CheckCircle else Icons.Default.Error, + contentDescription = null, + tint = if (result.isPassed) SuccessGreen else MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 2.dp) + ) + Column { + Text( + text = result.message, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } } } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.diagnostics_close)) + } } + ) +} + +@Preview( + widthDp = PreviewWidthDp, + heightDp = PreviewHeightDp, +) +@Composable +private fun DiagnosticsDialogPreview() { + PreviewBase { + val serverIP = "192.0.2.1" // Use TEST-NET-1 IP for preview + val clientIP = "192.0.2.5" // Another TEST-NET-1 IP for client + val port = 12345 + val sampleState = io.github.kitswas.virtualgamepadmobile.network.ConnectionState( + connected = false, + ipAddress = serverIP, + port = port, + error = null, + isConnecting = false, + isRunningDiagnostics = true, + diagnosticResults = listOf( + io.github.kitswas.virtualgamepadmobile.network.NetworkDiagnostics.DiagnosticResult( + io.github.kitswas.virtualgamepadmobile.network.NetworkDiagnostics.DiagnosticStep.WIFI, + true, + "Device connected to Wi-Fi" + ), + io.github.kitswas.virtualgamepadmobile.network.NetworkDiagnostics.DiagnosticResult( + io.github.kitswas.virtualgamepadmobile.network.NetworkDiagnostics.DiagnosticStep.IP, + true, + "Local IP: $clientIP" + ), + io.github.kitswas.virtualgamepadmobile.network.NetworkDiagnostics.DiagnosticResult( + io.github.kitswas.virtualgamepadmobile.network.NetworkDiagnostics.DiagnosticStep.PING, + false, + "Ping to server failed", + "Timeout" + ) + ) + ) + + DiagnosticsDialog( + connectionState = sampleState, + onDismiss = {} + ) } } diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/Gamepad.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/Gamepad.kt index 5d3a25d..a1012d3 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/Gamepad.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/Gamepad.kt @@ -13,10 +13,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices.DESKTOP import androidx.compose.ui.tooling.preview.Devices.TABLET import androidx.compose.ui.tooling.preview.Preview import io.github.kitswas.VGP_Data_Exchange.GamepadReading +import io.github.kitswas.virtualgamepadmobile.R import io.github.kitswas.virtualgamepadmobile.data.PreviewBase import io.github.kitswas.virtualgamepadmobile.data.PreviewHeightDp import io.github.kitswas.virtualgamepadmobile.data.PreviewWidthDp @@ -91,22 +93,17 @@ fun GamePad( val connectionState by connectionViewModel?.uiState?.collectAsState() ?: remember { mutableStateOf(null) } - // Use a more focused effect that reacts to the specific connection state properties - LaunchedEffect(connectionState?.connected, connectionState?.error) { - connectionState?.let { state -> - if (!state.connected) { - // Show toast with error if available, otherwise generic message - val message = if (state.error != null) { - "Connection lost: ${state.error}" - } else { - "Connection lost" - } - - Log.d(tag, message) - Toast.makeText(context, message, Toast.LENGTH_LONG).show() + val connectionLostMessage = connectionState?.takeIf { !it.connected }?.let { state -> + state.error?.let { + stringResource(R.string.gamepad_connection_lost_error, it) + } ?: stringResource(R.string.gamepad_connection_lost) + } - onNavigateBack() - } + LaunchedEffect(connectionLostMessage) { + if (connectionLostMessage != null) { + Log.d(tag, connectionLostMessage) + Toast.makeText(context, connectionLostMessage, Toast.LENGTH_LONG).show() + onNavigateBack() } } diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/GamepadCustomization.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/GamepadCustomization.kt index d4d850f..8cd6847 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/GamepadCustomization.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/GamepadCustomization.kt @@ -31,16 +31,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.kitswas.VGP_Data_Exchange.GamepadReading +import io.github.kitswas.virtualgamepadmobile.R import io.github.kitswas.virtualgamepadmobile.data.ButtonComponent import io.github.kitswas.virtualgamepadmobile.data.ButtonConfig -import io.github.kitswas.virtualgamepadmobile.data.OFFSET_VALUE_RANGE import io.github.kitswas.virtualgamepadmobile.data.PreviewBase -import io.github.kitswas.virtualgamepadmobile.data.SCALE_VALUE_RANGE -import io.github.kitswas.virtualgamepadmobile.data.PreviewHeightDp -import io.github.kitswas.virtualgamepadmobile.data.PreviewWidthDp import io.github.kitswas.virtualgamepadmobile.data.SettingsRepository import io.github.kitswas.virtualgamepadmobile.data.defaultButtonConfigs import io.github.kitswas.virtualgamepadmobile.ui.composables.ButtonConfigEditor @@ -49,33 +47,38 @@ import io.github.kitswas.virtualgamepadmobile.ui.composables.ResponsiveGrid import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json -private const val logTag = "GamepadCustomizationScreen" +private const val logTag = "GamepadCustomization" /** - * Sanitizes imported button configurations to ensure all values are within valid ranges - * and all required components are present. + * Sanitizes button configurations to ensure all components have a valid config. */ private fun sanitizeButtonConfigs(configs: Map): Map { - val sanitized = mutableMapOf() - - // Ensure all button components have a configuration - ButtonComponent.entries.forEach { component -> - val config = configs[component] ?: ButtonConfig.default(component) - - // Sanitize values to valid ranges - val sanitizedConfig = config.copy( - visible = config.visible, // Boolean, always valid - scale = config.scale.coerceIn(SCALE_VALUE_RANGE), - offsetX = config.offsetX.coerceIn(OFFSET_VALUE_RANGE), - offsetY = config.offsetY.coerceIn(OFFSET_VALUE_RANGE), - anchor = config.anchor // Enum, always valid if deserialized - ) - - sanitized[component] = sanitizedConfig + return ButtonComponent.entries.associateWith { component -> + configs[component] ?: ButtonConfig.default(component) } - - Log.i(logTag, "Sanitized ${sanitized.size} button configurations") - return sanitized +} + +/** + * Full-screen gamepad preview overlay + */ +@SuppressLint("ConfigurationScreenWidthHeight") +@Composable +fun GamepadPreview( + buttonConfigs: Map, +) { + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp + val screenWidth = configuration.screenWidthDp + + val gamepadState = remember { GamepadReading() } + + // Draw the gamepad with current configuration + DrawGamepad( + widthDp = screenWidth, + heightDp = screenHeight, + gamepadState = gamepadState, + buttonConfigs = buttonConfigs + ) } @Composable @@ -110,7 +113,7 @@ fun GamepadCustomizationScreen( if (showExportDialog) { ExportConfigDialog( - configsToExport = currentConfigs, + buttonConfigs = currentConfigs, onDismiss = { showExportDialog = false } ) } @@ -142,7 +145,7 @@ fun GamepadCustomizationScreen( ) { // Fixed title at the top Text( - "Gamepad Customization", + stringResource(R.string.customization_title), style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(vertical = 8.dp) ) @@ -157,7 +160,7 @@ fun GamepadCustomizationScreen( verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - "Customize individual button visibility, size, and position", + stringResource(R.string.customization_desc), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 4.dp) ) @@ -206,15 +209,15 @@ fun GamepadCustomizationScreen( horizontalArrangement = Arrangement.SpaceEvenly ) { Button(onClick = { showExportDialog = true }) { - Text("Export") + Text(stringResource(R.string.customization_export)) } Button( onClick = { showPreview = true }, ) { - Text("Preview Gamepad") + Text(stringResource(R.string.customization_preview)) } Button(onClick = { showImportDialog = true }) { - Text("Import") + Text(stringResource(R.string.customization_import)) } } @@ -230,7 +233,7 @@ fun GamepadCustomizationScreen( } Log.i(logTag, "Button configs reset to defaults") }) { - Text("Reset") + Text(stringResource(R.string.reset)) } Button(onClick = { @@ -248,11 +251,11 @@ fun GamepadCustomizationScreen( } onNavigateBack() }) { - Text("Save") + Text(stringResource(R.string.save)) } Button(onClick = onNavigateBack) { - Text("Cancel") + Text(stringResource(R.string.cancel)) } } } @@ -265,17 +268,19 @@ fun GamepadCustomizationScreen( */ @Composable fun ExportConfigDialog( - configsToExport: Map, + buttonConfigs: Map, onDismiss: () -> Unit ) { - val clipboard = LocalContext.current.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager - val jsonString = remember(configsToExport) { - Json.encodeToString(configsToExport) + val context = LocalContext.current + val clipboard = context.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + val jsonString = remember(buttonConfigs) { + Json.encodeToString(buttonConfigs) } + val exportLabel = stringResource(R.string.customization_export_label) AlertDialog( onDismissRequest = onDismiss, - title = { Text("Export Configuration") }, + title = { Text(stringResource(R.string.customization_export_title)) }, text = { Log.i(logTag, "Exporting button configs: $jsonString") Column( @@ -283,7 +288,7 @@ fun ExportConfigDialog( verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - "Copy this JSON to share or backup your configuration:", + stringResource(R.string.customization_export_desc), style = MaterialTheme.typography.bodySmall ) TextField( @@ -292,6 +297,7 @@ fun ExportConfigDialog( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()), + label = { Text(stringResource(R.string.customization_export_label)) }, readOnly = true, singleLine = false, maxLines = 5, @@ -300,12 +306,12 @@ fun ExportConfigDialog( Button(onClick = { // Copy to clipboard val clip = android.content.ClipData.newPlainText( - "Gamepad Layout JSON", jsonString + exportLabel, jsonString ) clipboard.setPrimaryClip(clip) Log.i(logTag, "Exported JSON copied to clipboard") }) { - Text("Copy") + Text(stringResource(R.string.customization_copy)) } } ) @@ -313,7 +319,7 @@ fun ExportConfigDialog( }, confirmButton = { Button(onClick = onDismiss) { - Text("Done") + Text(stringResource(R.string.customization_done)) } } ) @@ -332,18 +338,18 @@ fun ImportConfigDialog( var hasError by rememberSaveable { mutableStateOf(false) } AlertDialog( onDismissRequest = onDismiss, - title = { Text("Import Configuration") }, + title = { Text(stringResource(R.string.customization_import_title)) }, text = { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - "Paste the JSON configuration to import:", + stringResource(R.string.customization_import_desc), style = MaterialTheme.typography.bodySmall ) val errorMessage = if (hasError) { - "Error: Invalid JSON format" + stringResource(R.string.customization_import_error) } else { "" } @@ -359,7 +365,7 @@ fun ImportConfigDialog( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()), - placeholder = { Text("Paste JSON here...") }, + placeholder = { Text(stringResource(R.string.customization_import_placeholder)) }, singleLine = false, maxLines = 5 ) @@ -371,10 +377,10 @@ fun ImportConfigDialog( try { val configs: Map = Json.decodeFromString(importedJsonText) - + // Sanitize the imported configs val sanitizedConfigs = sanitizeButtonConfigs(configs) - + hasError = false Log.i(logTag, "Imported and sanitized button configs: $sanitizedConfigs") onImport(sanitizedConfigs) @@ -384,45 +390,23 @@ fun ImportConfigDialog( } } ) { - Text("Import") + Text(stringResource(R.string.customization_import)) } }, dismissButton = { Button(onClick = onDismiss) { - Text("Cancel") + Text(stringResource(R.string.cancel)) } } ) } -/** - * Full-screen gamepad preview overlay - */ @SuppressLint("ConfigurationScreenWidthHeight") -@Composable -fun GamepadPreview( - buttonConfigs: Map, -) { - val configuration = LocalConfiguration.current - val screenHeight = configuration.screenHeightDp - val screenWidth = configuration.screenWidthDp - - val gamepadState = remember { GamepadReading() } - - // Draw the gamepad with current configuration - DrawGamepad( - widthDp = screenWidth, - heightDp = screenHeight, - gamepadState = gamepadState, - buttonConfigs = buttonConfigs - ) -} - @Preview( - widthDp = PreviewWidthDp, - heightDp = PreviewHeightDp, + showBackground = true, + widthDp = 800, + heightDp = 400 ) -@MultiDevicePreview @Composable fun GamepadCustomizationScreenPreview() { PreviewBase { diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/MainMenu.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/MainMenu.kt index f8da945..982de9d 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/MainMenu.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/MainMenu.kt @@ -11,7 +11,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import io.github.kitswas.virtualgamepadmobile.R import io.github.kitswas.virtualgamepadmobile.data.PreviewBase import io.github.kitswas.virtualgamepadmobile.data.PreviewHeightDp import io.github.kitswas.virtualgamepadmobile.data.PreviewWidthDp @@ -32,16 +34,16 @@ fun MainMenu( verticalArrangement = Arrangement.Center ) { Button(onClick = onNavigateToConnectScreen, shape = CircleShape) { - Text(text = "Start") + Text(text = stringResource(R.string.menu_start)) } Button(onClick = onNavigateToSettingsScreen, shape = CircleShape) { - Text(text = "Settings") + Text(text = stringResource(R.string.menu_settings)) } Button(onClick = onNavigateToAboutScreen, shape = CircleShape) { - Text(text = "About") + Text(text = stringResource(R.string.menu_about)) } Button(onClick = onExit, shape = CircleShape) { - Text(text = "Exit") + Text(text = stringResource(R.string.menu_exit)) } } } diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/SettingsScreen.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/SettingsScreen.kt index 950c7d5..cdd055e 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/screens/SettingsScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.kitswas.virtualgamepadmobile.R @@ -45,8 +46,10 @@ import io.github.kitswas.virtualgamepadmobile.data.PreviewWidthDp import io.github.kitswas.virtualgamepadmobile.data.SettingsRepository import io.github.kitswas.virtualgamepadmobile.data.defaultBaseColor import io.github.kitswas.virtualgamepadmobile.data.defaultColorScheme +import io.github.kitswas.virtualgamepadmobile.data.defaultFullScreenEnabled import io.github.kitswas.virtualgamepadmobile.data.defaultHapticFeedbackEnabled import io.github.kitswas.virtualgamepadmobile.data.defaultPollingDelay +import io.github.kitswas.virtualgamepadmobile.data.defaultSaveConnectionCredentials import io.github.kitswas.virtualgamepadmobile.ui.composables.ColorSchemePicker import io.github.kitswas.virtualgamepadmobile.ui.composables.ListItemPicker import io.github.kitswas.virtualgamepadmobile.ui.composables.SpinBox @@ -58,10 +61,12 @@ private const val logTag = "SettingsScreen" @Parcelize private data class SettingsChanges( - var colorScheme: ColorScheme? = null, - var baseColor: BaseColor? = null, - var pollingDelay: Int? = null, - var hapticFeedbackEnabled: Boolean? = null + val colorScheme: ColorScheme? = null, + val baseColor: BaseColor? = null, + val pollingDelay: Int? = null, + val hapticFeedbackEnabled: Boolean? = null, + val saveConnectionCredentials: Boolean? = null, + val fullScreenEnabled: Boolean? = null ) : Parcelable @OptIn(ExperimentalMaterial3Api::class) @@ -71,13 +76,15 @@ fun SettingsScreen( onNavigateToGamepadCustomization: () -> Unit, settingsRepository: SettingsRepository ) { - val settingsChanges by rememberSaveable { mutableStateOf(SettingsChanges()) } + var settingsChanges by rememberSaveable { mutableStateOf(SettingsChanges()) } Scaffold { paddingValues -> val colorScheme by settingsRepository.colorScheme.collectAsState(initial = defaultColorScheme) val baseColor by settingsRepository.baseColor.collectAsState(initial = defaultBaseColor) val pollingDelay by settingsRepository.pollingDelay.collectAsState(initial = defaultPollingDelay) val hapticEnabled by settingsRepository.hapticFeedbackEnabled.collectAsState(initial = defaultHapticFeedbackEnabled) + val saveCredentials by settingsRepository.saveConnectionCredentials.collectAsState(initial = defaultSaveConnectionCredentials) + val fullScreenEnabled by settingsRepository.fullScreenEnabled.collectAsState(initial = defaultFullScreenEnabled) Column( modifier = Modifier @@ -88,7 +95,7 @@ fun SettingsScreen( ) { // Fixed title at the top Text( - "Settings", + stringResource(R.string.settings_title), style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(vertical = 8.dp) ) @@ -104,16 +111,19 @@ fun SettingsScreen( horizontalAlignment = Alignment.CenterHorizontally ) { - ColorSchemePicker(default = colorScheme) { - settingsChanges.colorScheme = it + ColorSchemePicker(selectedItem = settingsChanges.colorScheme ?: colorScheme) { + settingsChanges = settingsChanges.copy(colorScheme = it) } ListItemPicker( list = BaseColor.entries.asIterable(), - default = baseColor, - label = "Theme Color", + selectedItem = settingsChanges.baseColor ?: baseColor, + label = stringResource(R.string.settings_theme_color), + formattedDisplay = { item -> + Text(text = stringResource(item.nameRes)) + }, onItemSelected = { - settingsChanges.baseColor = it + settingsChanges = settingsChanges.copy(baseColor = it) }) Row( @@ -121,11 +131,11 @@ fun SettingsScreen( horizontalArrangement = Arrangement.SpaceAround ) { SpinBox( - value = pollingDelay, + value = settingsChanges.pollingDelay ?: pollingDelay, onValueChange = { - settingsChanges.pollingDelay = it + settingsChanges = settingsChanges.copy(pollingDelay = it) }, - label = "Polling Interval (ms)", + label = stringResource(R.string.settings_polling_interval), minValue = 20, maxValue = 200, step = 10 @@ -140,7 +150,7 @@ fun SettingsScreen( tooltip = { PlainTooltip(shadowElevation = 10.dp) { Text( - "Adjust according to your reflexes\nLower is faster", + stringResource(R.string.settings_polling_interval_desc), style = MaterialTheme.typography.bodyLarge ) } @@ -152,7 +162,7 @@ fun SettingsScreen( }) { Icon( painter = painterResource(R.drawable.ic_info), - contentDescription = "Information about polling interval", + contentDescription = stringResource(R.string.settings_polling_interval_info), tint = MaterialTheme.colorScheme.secondary, ) } @@ -164,20 +174,48 @@ fun SettingsScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly ) { - var switchState by rememberSaveable(hapticEnabled) { - mutableStateOf( - hapticEnabled - ) - } Text( - "Haptic Feedback (Vibrations)", + stringResource(R.string.settings_haptic_feedback), + style = MaterialTheme.typography.labelMedium + ) + Switch( + checked = settingsChanges.hapticFeedbackEnabled ?: hapticEnabled, + onCheckedChange = { + settingsChanges = settingsChanges.copy(hapticFeedbackEnabled = it) + } + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Text( + stringResource(R.string.settings_full_screen), style = MaterialTheme.typography.labelMedium ) Switch( - checked = switchState, + checked = settingsChanges.fullScreenEnabled ?: fullScreenEnabled, onCheckedChange = { - settingsChanges.hapticFeedbackEnabled = it - switchState = it + settingsChanges = settingsChanges.copy(fullScreenEnabled = it) + } + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Text( + stringResource(R.string.settings_save_connection_credentials), + style = MaterialTheme.typography.labelMedium + ) + Switch( + checked = settingsChanges.saveConnectionCredentials ?: saveCredentials, + onCheckedChange = { + settingsChanges = settingsChanges.copy(saveConnectionCredentials = it) } ) } @@ -188,7 +226,7 @@ fun SettingsScreen( .fillMaxWidth(0.6f) .padding(vertical = 8.dp) ) { - Text("Customize Gamepad Layout") + Text(stringResource(R.string.settings_customize_layout)) } } @@ -201,14 +239,11 @@ fun SettingsScreen( horizontalArrangement = Arrangement.SpaceEvenly ) { Button(onClick = { - settingsChanges.pollingDelay = null - settingsChanges.colorScheme = null - settingsChanges.baseColor = null - settingsChanges.hapticFeedbackEnabled = null + settingsChanges = SettingsChanges() runBlocking { settingsRepository.resetAllSettings() } Log.i(logTag, "Settings reset to defaults") }) { - Text("Reset") + Text(stringResource(R.string.reset)) } Button(onClick = { @@ -231,6 +266,16 @@ fun SettingsScreen( it ); ++changesSaved } + settingsChanges.saveConnectionCredentials?.let { + settingsRepository.setSaveConnectionCredentials( + it + ); ++changesSaved + } + settingsChanges.fullScreenEnabled?.let { + settingsRepository.setFullScreenEnabled( + it + ); ++changesSaved + } } catch (e: Exception) { Log.e(logTag, "Error saving settings", e) } @@ -238,10 +283,10 @@ fun SettingsScreen( Log.i(logTag, "Saved settings: $changesSaved") onNavigateBack() }) { - Text("Save") + Text(stringResource(R.string.save)) } Button(onClick = onNavigateBack) { - Text("Cancel") + Text(stringResource(R.string.cancel)) } } } diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/theme/Color.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/theme/Color.kt index 968058b..44cdb5a 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/theme/Color.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/theme/Color.kt @@ -20,6 +20,7 @@ val Gold = Color(0xFFFFD700) val Silver = Color(0xFFC0C0C0) val PureBlack = Color(0xFF000000) val PristineWhite = Color(0xFFFFFFFF) +val SuccessGreen = Color(0xFF4CAF50) /** * Darkens a [Color] by the given [fraction] (in percentage). diff --git a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/theme/ThemePreview.kt b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/theme/ThemePreview.kt index 8af7433..15dcf8f 100644 --- a/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/theme/ThemePreview.kt +++ b/app/src/main/java/io/github/kitswas/virtualgamepadmobile/ui/theme/ThemePreview.kt @@ -20,10 +20,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import io.github.kitswas.virtualgamepadmobile.R import io.github.kitswas.virtualgamepadmobile.data.BaseColor import io.github.kitswas.virtualgamepadmobile.data.ColorScheme @@ -44,7 +46,7 @@ private fun VirtualGamePadMobileThemePreview( verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( - text = "Theme Color Palette", + text = stringResource(R.string.theme_preview_title), style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onBackground, fontWeight = FontWeight.Bold @@ -52,7 +54,7 @@ private fun VirtualGamePadMobileThemePreview( // Primary Colors Section ColorSection( - title = "Primary Colors", + title = stringResource(R.string.theme_preview_primary), colors = listOf( ColorInfo("Primary", MaterialTheme.colorScheme.primary), ColorInfo("On Primary", MaterialTheme.colorScheme.onPrimary), @@ -63,7 +65,7 @@ private fun VirtualGamePadMobileThemePreview( // Secondary Colors Section ColorSection( - title = "Secondary Colors", + title = stringResource(R.string.theme_preview_secondary), colors = listOf( ColorInfo("Secondary", MaterialTheme.colorScheme.secondary), ColorInfo("On Secondary", MaterialTheme.colorScheme.onSecondary), @@ -77,7 +79,7 @@ private fun VirtualGamePadMobileThemePreview( // Tertiary Colors Section ColorSection( - title = "Tertiary Colors", + title = stringResource(R.string.theme_preview_tertiary), colors = listOf( ColorInfo("Tertiary", MaterialTheme.colorScheme.tertiary), ColorInfo("On Tertiary", MaterialTheme.colorScheme.onTertiary), @@ -91,7 +93,7 @@ private fun VirtualGamePadMobileThemePreview( // Other Colors Section ColorSection( - title = "Other Colors", + title = stringResource(R.string.theme_preview_other), colors = listOf( ColorInfo("Outline", MaterialTheme.colorScheme.outline), ColorInfo("Outline Variant", MaterialTheme.colorScheme.outlineVariant), @@ -104,7 +106,7 @@ private fun VirtualGamePadMobileThemePreview( // Surface Colors Section ColorSection( - title = "Surface Colors", + title = stringResource(R.string.theme_preview_surface), colors = listOf( ColorInfo("Surface", MaterialTheme.colorScheme.surface), ColorInfo("On Surface", MaterialTheme.colorScheme.onSurface), @@ -115,7 +117,7 @@ private fun VirtualGamePadMobileThemePreview( // Background Colors Section ColorSection( - title = "Background Colors", + title = stringResource(R.string.theme_preview_background), colors = listOf( ColorInfo("Background", MaterialTheme.colorScheme.background), ColorInfo("On Background", MaterialTheme.colorScheme.onBackground) @@ -124,7 +126,7 @@ private fun VirtualGamePadMobileThemePreview( // Error Colors Section ColorSection( - title = "Error Colors", + title = stringResource(R.string.theme_preview_error), colors = listOf( ColorInfo("Error", MaterialTheme.colorScheme.error), ColorInfo("On Error", MaterialTheme.colorScheme.onError), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c3262c0..1cd7611 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,195 @@ VirtualGamePadMobile + + + Start + Settings + About + Exit + + + Settings + Theme Color + Color Scheme + Polling Interval (ms) + Adjust according to your reflexes\nLower is faster + Information about polling interval + Haptic Feedback (Vibrations) + Remember IP Address and Port + Full Screen + Customize Gamepad Layout + Error saving settings + + + 🎮 Virtual GamePad Mobile + Version %s + View Licence + Latest Release + A mobile application that lets your phone work as a gamepad for PC games. + Source Code + Project Website + Report Issues + Development + + + Gamepad Customization + Customize individual button visibility, size, and position + Export + Preview Gamepad + Import + Export Configuration + Copy this JSON to share or backup your configuration: + Gamepad Layout JSON + Copy + Done + Import Configuration + Paste the JSON configuration to import: + Error: Invalid JSON format + Paste JSON here... + Error saving button configs + + + Invalid QR Code format + Error processing QR Code: %s + Error scanning QR Code: %s + Camera permission is required to scan QR Codes + Invalid IP address format + Invalid port number (must be between 1-65535) + Invalid connection parameters + Scan QR Code + IP Address + Port + Connect + Download Server + + + Connected! Redirecting... + Connecting to %1$s:%2$s… + Connection Error + Retry + Run Diagnostics + Preparing connection… + Connection failed: %s + + + Connection Diagnostics + Running diagnostics… + Checking Wi-Fi connection… + Checking local IP address… + Checking subnet compatibility… + Pinging server… + Checking server port… + + Wi-Fi is enabled and connected. + Wi-Fi is disconnected. Please connect to your local + network. + + Local IP found: %s + Could not determine local IP address. + + Device and server are on the same subnet. + Device (%1$s) and server (%2$s) appear to be on different + subnets. They must be on the same local network. + + Server responded to ping. + Server did not respond to ping. Check if the PC is on and + connected to the same network. + + Server port %d is reachable. + Could not connect to port %d. Check if the server app is + running and Windows Firewall is allowing the connection. + + Close + + + Connection lost: %s + Connection lost + + + Scan QR Code shown on the server + + + Unexpected error: %s + Unknown + Unknown connection error + Connect error: %s + Connection lost: %s + + + Top Left + Top Center + Top Right + Center Left + Center + Center Right + Bottom Left + Bottom Center + Bottom Right + + + Left Analog Stick + Right Analog Stick + D-Pad + Face Buttons (A/B/X/Y) + Left Trigger (LT) + Right Trigger (RT) + Left Shoulder (LB) + Right Shoulder (RB) + Select (View) + Start (Menu) + + + Light Mode + Dark Mode + System Default + + + Red + Green + Blue + + + Theme Color Palette + Primary Colors + Secondary Colors + Tertiary Colors + Surface Colors + Background Colors + Error Colors + Other Colors + + + A + B + X + Y + + + LT + RT + LSHLDR + RSHLDR + Visible + Hidden + Anchor Position + Scale: %.2fx + Offset X: %.2f× baseDp + Offset Y: %.2f× baseDp + Decrease + Increase + Expand + %s Button + Dpad Button %s + + + Back + Cancel + Save + Reset + + + Value must be at least %s + Value must be at most %s + Please enter a valid number + \ No newline at end of file diff --git a/app/src/test/java/io/github/kitswas/VGP_Data_Exchange/GamepadReadingTest.kt b/app/src/test/java/io/github/kitswas/VGP_Data_Exchange/GamepadReadingTest.kt deleted file mode 100644 index 03109cb..0000000 --- a/app/src/test/java/io/github/kitswas/VGP_Data_Exchange/GamepadReadingTest.kt +++ /dev/null @@ -1,306 +0,0 @@ -package io.github.kitswas.VGP_Data_Exchange - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream - -class GamepadReadingTest { - - private lateinit var gamepadReading: GamepadReading - val floatDelta = 0.001f - - @Before - fun setUp() { - gamepadReading = GamepadReading() - } - - @Test - fun `test default constructor initializes with zero values`() { - assertEquals(0, gamepadReading.ButtonsUp) - assertEquals(0, gamepadReading.ButtonsDown) - assertEquals(0f, gamepadReading.LeftTrigger, floatDelta) - assertEquals(0f, gamepadReading.RightTrigger, floatDelta) - assertEquals(0f, gamepadReading.LeftThumbstickX, floatDelta) - assertEquals(0f, gamepadReading.LeftThumbstickY, floatDelta) - assertEquals(0f, gamepadReading.RightThumbstickX, floatDelta) - assertEquals(0f, gamepadReading.RightThumbstickY, floatDelta) - } - - @Test - fun `test getters and setters`() { - // Set values using setters - gamepadReading.buttonsUp = GameButtons.A.value - gamepadReading.buttonsDown = GameButtons.B.value - gamepadReading.leftTrigger = 0.5f - gamepadReading.rightTrigger = 0.75f - gamepadReading.leftThumbstickX = -0.25f - gamepadReading.leftThumbstickY = 0.25f - gamepadReading.rightThumbstickX = 0.8f - gamepadReading.rightThumbstickY = -0.8f - - // Verify using getters - assertEquals(GameButtons.A.value, gamepadReading.buttonsUp) - assertEquals(GameButtons.B.value, gamepadReading.buttonsDown) - assertEquals(0.5f, gamepadReading.leftTrigger, floatDelta) - assertEquals(0.75f, gamepadReading.rightTrigger, floatDelta) - assertEquals(-0.25f, gamepadReading.leftThumbstickX, floatDelta) - assertEquals(0.25f, gamepadReading.leftThumbstickY, floatDelta) - assertEquals(0.8f, gamepadReading.rightThumbstickX, floatDelta) - assertEquals(-0.8f, gamepadReading.rightThumbstickY, floatDelta) - } - - @Test - fun `test fluent API with chaining`() { - gamepadReading - .withButtonsUp(GameButtons.X.value) - .withButtonsDown(GameButtons.Y.value) - .withLeftTrigger(0.33f) - .withRightTrigger(0.66f) - .withLeftThumbstickX(0.1f) - .withLeftThumbstickY(0.2f) - .withRightThumbstickX(0.3f) - .withRightThumbstickY(0.4f) - - assertEquals(GameButtons.X.value, gamepadReading.ButtonsUp) - assertEquals(GameButtons.Y.value, gamepadReading.ButtonsDown) - assertEquals(0.33f, gamepadReading.LeftTrigger, floatDelta) - assertEquals(0.66f, gamepadReading.RightTrigger, floatDelta) - assertEquals(0.1f, gamepadReading.LeftThumbstickX, floatDelta) - assertEquals(0.2f, gamepadReading.LeftThumbstickY, floatDelta) - assertEquals(0.3f, gamepadReading.RightThumbstickX, floatDelta) - assertEquals(0.4f, gamepadReading.RightThumbstickY, floatDelta) - } - - @Test - fun `test marshal and unmarshal with multiple button combinations`() { - // Test with various button combinations - val testCases = listOf( - Triple(GameButtons.A.value, GameButtons.None.value, "A pressed"), - Triple( - GameButtons.B.value or GameButtons.X.value, - GameButtons.None.value, - "B and X pressed" - ), - Triple( - GameButtons.None.value, - GameButtons.A.value or GameButtons.B.value, - "A and B released" - ), - Triple( - GameButtons.LeftShoulder.value or GameButtons.RightShoulder.value, - GameButtons.LeftThumbstick.value, - "Shoulder buttons pressed and left thumbstick released" - ) - ) - - for ((buttonsUp, buttonsDown, description) in testCases) { - val original = GamepadReading().apply { - this.ButtonsUp = buttonsUp - this.ButtonsDown = buttonsDown - this.LeftTrigger = 0.5f - this.RightTrigger = 0.75f - this.LeftThumbstickX = -0.5f - this.LeftThumbstickY = 0.5f - this.RightThumbstickX = 0.25f - this.RightThumbstickY = -0.25f - } - - val bytes = ByteArrayOutputStream().use { stream -> - original.marshal(stream, null) - stream.toByteArray() - } - - // Unmarshal and verify - val unmarshalled = GamepadReading() - unmarshalled.unmarshal(bytes, 0) - - assertEquals( - "ButtonsUp should match for $description", - original.ButtonsUp, - unmarshalled.ButtonsUp - ) - assertEquals( - "ButtonsDown should match for $description", - original.ButtonsDown, - unmarshalled.ButtonsDown - ) - assertEquals( - "LeftTrigger should match for $description", - original.LeftTrigger, - unmarshalled.LeftTrigger, - floatDelta - ) - assertEquals( - "RightTrigger should match for $description", - original.RightTrigger, - unmarshalled.RightTrigger, - floatDelta - ) - assertEquals( - "LeftThumbstickX should match for $description", - original.LeftThumbstickX, - unmarshalled.LeftThumbstickX, - floatDelta - ) - assertEquals( - "LeftThumbstickY should match for $description", - original.LeftThumbstickY, - unmarshalled.LeftThumbstickY, - floatDelta - ) - assertEquals( - "RightThumbstickX should match for $description", - original.RightThumbstickX, - unmarshalled.RightThumbstickX, - floatDelta - ) - assertEquals( - "RightThumbstickY should match for $description", - original.RightThumbstickY, - unmarshalled.RightThumbstickY, - floatDelta - ) - - assertTrue("Objects should be equal for $description", original == unmarshalled) - assertEquals( - "HashCodes should match for $description", - original.hashCode(), - unmarshalled.hashCode() - ) - } - } - - @Test - fun `test unmarshal with Unmarshaller class`() { - val original = GamepadReading().apply { - this.ButtonsUp = GameButtons.A.value or GameButtons.B.value - this.ButtonsDown = GameButtons.X.value - this.LeftTrigger = 0.9f - this.RightTrigger = 0.8f - this.LeftThumbstickX = 0.7f - this.LeftThumbstickY = 0.6f - this.RightThumbstickX = 0.5f - this.RightThumbstickY = 0.4f - } - - // Marshal the object to a byte array - val bytes = ByteArrayOutputStream().use { stream -> - original.marshal(stream, null) - stream.toByteArray() - } - - // Create a piped input stream from the bytes - val inputStream = ByteArrayInputStream(bytes) - - // Use the Unmarshaller to deserialize - val unmarshaller = GamepadReading.Unmarshaller(inputStream, ByteArray(2048)) - val result = unmarshaller.next() - - // Verify the result - assertNotNull("Unmarshalled result should not be null", result) - assertEquals("ButtonsUp should match", original.ButtonsUp, result.ButtonsUp) - assertEquals("ButtonsDown should match", original.ButtonsDown, result.ButtonsDown) - assertEquals( - "LeftTrigger should match", - original.LeftTrigger, - result.LeftTrigger, - floatDelta - ) - assertEquals( - "RightTrigger should match", - original.RightTrigger, - result.RightTrigger, - floatDelta - ) - assertEquals( - "LeftThumbstickX should match", - original.LeftThumbstickX, - result.LeftThumbstickX, - floatDelta - ) - assertEquals( - "LeftThumbstickY should match", - original.LeftThumbstickY, - result.LeftThumbstickY, - floatDelta - ) - assertEquals( - "RightThumbstickX should match", - original.RightThumbstickX, - result.RightThumbstickX, - floatDelta - ) - assertEquals( - "RightThumbstickY should match", - original.RightThumbstickY, - result.RightThumbstickY, - floatDelta - ) - } - - @Test - fun `test equals and hashCode`() { - val reading1 = GamepadReading().apply { - ButtonsUp = GameButtons.A.value - ButtonsDown = GameButtons.B.value - LeftTrigger = 0.5f - RightTrigger = 0.6f - LeftThumbstickX = 0.1f - LeftThumbstickY = 0.2f - RightThumbstickX = 0.3f - RightThumbstickY = 0.4f - } - - val reading2 = GamepadReading().apply { - ButtonsUp = GameButtons.A.value - ButtonsDown = GameButtons.B.value - LeftTrigger = 0.5f - RightTrigger = 0.6f - LeftThumbstickX = 0.1f - LeftThumbstickY = 0.2f - RightThumbstickX = 0.3f - RightThumbstickY = 0.4f - } - - val reading3 = GamepadReading().apply { - ButtonsUp = GameButtons.X.value // Different value - ButtonsDown = GameButtons.B.value - LeftTrigger = 0.5f - RightTrigger = 0.6f - LeftThumbstickX = 0.1f - LeftThumbstickY = 0.2f - RightThumbstickX = 0.3f - RightThumbstickY = 0.4f - } - - // Test equality for same values - assertEquals(reading1, reading2) - assertEquals(reading1.hashCode(), reading2.hashCode()) - - // Test inequality for different values - assertNotEquals(reading1, reading3) - assertNotEquals(reading1.hashCode(), reading3.hashCode()) - - // Test equality with self - assertEquals(reading1, reading1) - - // Test inequality with null - assertNotEquals(reading1, null) - - // Test inequality with different class - assertNotEquals(reading1, "Not a GamepadReading") - } - - @Test - fun `test marshalFit size estimate`() { - // MarshalFit should return a reasonable size estimate - val size = gamepadReading.marshalFit() - assertTrue("Size estimate should be positive", size > 0) - assertTrue("Size estimate should be within max size", size <= GamepadReading.colferSizeMax) - } -} diff --git a/app/src/test/java/io/github/kitswas/virtualgamepadmobile/ExampleUnitTest.kt b/app/src/test/java/io/github/kitswas/virtualgamepadmobile/ExampleUnitTest.kt deleted file mode 100644 index fbf4fd5..0000000 --- a/app/src/test/java/io/github/kitswas/virtualgamepadmobile/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.github.kitswas.virtualgamepadmobile - -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) - } -} \ No newline at end of file diff --git a/app/src/test/java/io/github/kitswas/virtualgamepadmobile/integration/EndToEndIntegrationTest.kt b/app/src/test/java/io/github/kitswas/virtualgamepadmobile/integration/EndToEndIntegrationTest.kt deleted file mode 100644 index 40bccd4..0000000 --- a/app/src/test/java/io/github/kitswas/virtualgamepadmobile/integration/EndToEndIntegrationTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package io.github.kitswas.virtualgamepadmobile.integration - -import io.github.kitswas.virtualgamepadmobile.TestGamepadServer -import io.github.kitswas.virtualgamepadmobile.network.ConnectionViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import org.junit.AfterClass -import org.junit.Assert.assertTrue -import org.junit.BeforeClass -import org.junit.Test - -class EndToEndIntegrationTest { - companion object { - private const val PORT = 0 - private var serverThread: Thread? = null - - @BeforeClass - @JvmStatic - fun startServer() { - serverThread = Thread { TestGamepadServer.start(PORT) } - serverThread!!.start() - // Give server time to start - Thread.sleep(1000) - } - - @AfterClass - @JvmStatic - fun stopServer() { - TestGamepadServer.stop() - serverThread?.join(1000) - } - } - - @Test - fun `can connect, send data, and disconnect`() = runBlocking(Dispatchers.IO) { - val viewModel = ConnectionViewModel() - viewModel.connect("127.0.0.1", PORT) - delay(500) // Wait for connection - assertTrue(viewModel.uiState.value.connected || viewModel.uiState.value.isConnecting) - viewModel.disconnect() - delay(500) - assertTrue(!viewModel.uiState.value.connected) - } -} diff --git a/app/src/test/java/io/github/kitswas/virtualgamepadmobile/network/ConnectionViewModelTest.kt b/app/src/test/java/io/github/kitswas/virtualgamepadmobile/network/ConnectionViewModelTest.kt deleted file mode 100644 index 4dfe5cc..0000000 --- a/app/src/test/java/io/github/kitswas/virtualgamepadmobile/network/ConnectionViewModelTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -package io.github.kitswas.virtualgamepadmobile.network - -import io.github.kitswas.VGP_Data_Exchange.GamepadReading -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.robolectric.annotation.Config -import org.robolectric.RobolectricTestRunner -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import java.net.SocketException - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -@Config(manifest = Config.NONE) -class ConnectionViewModelTest { - private lateinit var viewModel: ConnectionViewModel - private val testDispatcher = StandardTestDispatcher() - private val testScope = TestScope(testDispatcher) - - @Before - fun setUp() { - viewModel = ConnectionViewModel() - } - - @Test - fun `connection failure sets error state`() = testScope.runTest { - viewModel.connect("256.256.256.256", 12345) - var state: ConnectionState - do { - state = viewModel.uiState.value - } while (state.error == null) - // Print the actual state for debugging - println("Final state after waiting: $state") - // Assert error is set - assertNotNull(state.error) - } - - @Test - fun `disconnect resets state and closes socket`() = testScope.runTest { - val port = 0 // Use random available port - val serverThread = Thread { io.github.kitswas.virtualgamepadmobile.TestGamepadServer.start(port) } - serverThread.start() - Thread.sleep(500) - val actualPort = (io.github.kitswas.virtualgamepadmobile.TestGamepadServer.javaClass.getDeclaredField("serverSocket").apply { isAccessible = true }.get(io.github.kitswas.virtualgamepadmobile.TestGamepadServer) as java.net.ServerSocket?)?.localPort ?: throw RuntimeException("Server not started") - viewModel.connect("127.0.0.1", actualPort) - // Wait for connection (poll for up to 2 seconds) - var state: ConnectionState - var waited = 0 - do { - testDispatcher.scheduler.advanceTimeBy(100) - state = viewModel.uiState.value - waited += 100 - } while (!state.connected && waited < 2000) - viewModel.disconnect() - testDispatcher.scheduler.advanceUntilIdle() - state = viewModel.uiState.value - // Accept either disconnected or reset state - assertFalse(state.connected) - io.github.kitswas.virtualgamepadmobile.TestGamepadServer.stop() - serverThread.join(1000) - } - - @Test - fun `command queue processes in order`() = testScope.runTest { - val port = 0 // Use random available port - val serverThread = Thread { io.github.kitswas.virtualgamepadmobile.TestGamepadServer.start(port) } - serverThread.start() - Thread.sleep(500) - val actualPort = (io.github.kitswas.virtualgamepadmobile.TestGamepadServer.javaClass.getDeclaredField("serverSocket").apply { isAccessible = true }.get(io.github.kitswas.virtualgamepadmobile.TestGamepadServer) as java.net.ServerSocket?)?.localPort ?: throw RuntimeException("Server not started") - viewModel.connect("127.0.0.1", actualPort) - // Wait for connection - var state: ConnectionState - var waited = 0 - do { - testDispatcher.scheduler.advanceTimeBy(100) - state = viewModel.uiState.value - waited += 100 - } while (!state.connected && waited < 2000) - viewModel.enqueueString("test1") - viewModel.enqueueString("test2") - testDispatcher.scheduler.advanceUntilIdle() - state = viewModel.uiState.value - // Accept either connected or error state - assertTrue(state.connected || state.error != null) - io.github.kitswas.virtualgamepadmobile.TestGamepadServer.stop() - serverThread.join(1000) - } - - @Test - fun `sending gamepad state when disconnected does not crash`() = testScope.runTest { - val reading = GamepadReading() - // Should not throw, even if disconnected - try { - viewModel.disconnect() - testDispatcher.scheduler.advanceUntilIdle() - viewModel.enqueueGamepadState(reading) - } catch (e: Exception) { - fail("Exception thrown: ${e.message}") - } - // If no exception, test passes - } -} diff --git a/app/src/test/java/io/github/kitswas/virtualgamepadmobile/network/NetworkCommandTest.kt b/app/src/test/java/io/github/kitswas/virtualgamepadmobile/network/NetworkCommandTest.kt deleted file mode 100644 index 2d38acc..0000000 --- a/app/src/test/java/io/github/kitswas/virtualgamepadmobile/network/NetworkCommandTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package io.github.kitswas.virtualgamepadmobile.network - -import io.github.kitswas.VGP_Data_Exchange.GamepadReading -import org.junit.Assert.assertEquals -import org.junit.Test - -class NetworkCommandTest { - @Test - fun `SendGamepadState deep copy is not affected by original changes`() { - val original = GamepadReading().apply { - ButtonsDown = 1 - LeftTrigger = 0.5f - } - val command = NetworkCommand.SendGamepadState(original) - // Change original after creating command - original.ButtonsDown = 2 - original.LeftTrigger = 1.0f - // The command's copy should not change - assertEquals(1, command.gamepadState.ButtonsDown) - assertEquals(0.5f, command.gamepadState.LeftTrigger, 0.001f) - } -} diff --git a/app/src/test/java/io/github/kitswas/virtualgamepadmobile/network/NetworkDiagnosticsTest.kt b/app/src/test/java/io/github/kitswas/virtualgamepadmobile/network/NetworkDiagnosticsTest.kt new file mode 100644 index 0000000..166bd0c --- /dev/null +++ b/app/src/test/java/io/github/kitswas/virtualgamepadmobile/network/NetworkDiagnosticsTest.kt @@ -0,0 +1,47 @@ +package io.github.kitswas.virtualgamepadmobile.network + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.InetAddress + +class NetworkDiagnosticsTest { + + private fun ipToBytes(ip: String): ByteArray { + return InetAddress.getByName(ip).address + } + + @Test + fun testIsSameSubnet_Logic() { + // Standard /24 subnet + val prefix24 = 24 + assertTrue(NetworkDiagnostics.isSameSubnet(ipToBytes("192.168.1.5"), ipToBytes("192.168.1.10"), prefix24)) + assertFalse(NetworkDiagnostics.isSameSubnet(ipToBytes("192.168.1.5"), ipToBytes("192.168.2.5"), prefix24)) + + // Standard /16 subnet + val prefix16 = 16 + assertTrue(NetworkDiagnostics.isSameSubnet(ipToBytes("10.0.1.5"), ipToBytes("10.0.50.10"), prefix16)) + assertFalse(NetworkDiagnostics.isSameSubnet(ipToBytes("10.0.1.5"), ipToBytes("10.1.1.5"), prefix16)) + + // Edge case: /32 (exact match required) + val prefix32 = 32 + assertTrue(NetworkDiagnostics.isSameSubnet(ipToBytes("192.168.1.1"), ipToBytes("192.168.1.1"), prefix32)) + assertFalse(NetworkDiagnostics.isSameSubnet(ipToBytes("192.168.1.1"), ipToBytes("192.168.1.2"), prefix32)) + + // Edge case: /0 (all match) + val prefix0 = 0 + assertTrue(NetworkDiagnostics.isSameSubnet(ipToBytes("192.168.1.1"), ipToBytes("10.0.0.1"), prefix0)) + + // Complex mask: /20 (255.255.240.0) + // 172.16.0.0 to 172.16.15.255 + val prefix20 = 20 + assertTrue(NetworkDiagnostics.isSameSubnet(ipToBytes("172.16.0.1"), ipToBytes("172.16.15.254"), prefix20)) + assertFalse(NetworkDiagnostics.isSameSubnet(ipToBytes("172.16.0.1"), ipToBytes("172.16.16.1"), prefix20)) + } + + @Test + fun testIsSameSubnet_InvalidInput() { + assertFalse(NetworkDiagnostics.isSameSubnet(byteArrayOf(1, 2, 3), byteArrayOf(1, 2, 3, 4), 24)) + assertFalse(NetworkDiagnostics.isSameSubnet(byteArrayOf(1, 2, 3, 4), byteArrayOf(1, 2, 3), 24)) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 12b7524..15a8b91 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,7 +2,6 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.dokka) apply false diff --git a/gradle.properties b/gradle.properties index a18a880..5b97962 100644 --- a/gradle.properties +++ b/gradle.properties @@ -24,5 +24,7 @@ android.nonTransitiveRClass=true android.nonFinalResIds=true # Configuration cache org.gradle.configuration-cache=true -# Dokka Gradle plugin V2 -#org.jetbrains.dokka.experimental.gradle.pluginMode=V2EnabledWithHelpers +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false +android.r8.strictFullModeForKeepRules=false diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..6c1139e --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec27aa2..9e993b5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,21 +1,21 @@ [versions] # Android -android-gradle-plugin = "8.13.2" +android-gradle-plugin = "9.1.0" # Kotlin -kotlin = "2.3.0" -kotlinx-serialization-json = "1.9.0" +kotlin = "2.3.20" +kotlinx-serialization-json = "1.10.0" # AndroidX Core -androidx-core-ktx = "1.17.0" +androidx-core-ktx = "1.18.0" androidx-lifecycle = "2.10.0" -androidx-activity-compose = "1.12.2" +androidx-activity-compose = "1.13.0" androidx-window = "1.5.1" -androidx-datastore = "1.2.0" +androidx-datastore = "1.2.1" # Compose -compose-bom = "2025.12.01" -compose-navigation = "2.9.6" +compose-bom = "2026.03.01" +compose-navigation = "2.9.7" # QR Code Scanner zxing-android-embedded = "4.3.0" @@ -27,8 +27,7 @@ kotlinx-coroutines-test = "1.10.2" # Tools desugar-jdk-libs = "2.1.5" -dokka = "2.1.0" -robolectric = "4.16" +dokka = "2.2.0" [libraries] # AndroidX Core @@ -37,8 +36,10 @@ androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecyc androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activity-compose" } androidx-window = { group = "androidx.window", name = "window", version.ref = "androidx-window" } -# Compose BOM +# Compose compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } +compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } + # Compose compose-ui = { group = "androidx.compose.ui", name = "ui" } @@ -73,12 +74,10 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx- # Testing junit = { group = "junit", name = "junit", version.ref = "junit" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test" } -robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } [plugins] android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55..d997cfc 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 78cb6e1..8e61ef1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531 -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index faf9300..739907d 100644 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,8 +210,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 9d21a21..c4bdd3a 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,10 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..8931355 --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +java = "21"