diff --git a/.gitignore b/.gitignore index 8e7acd132..8d26e7839 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ native/opennow-streamer/bin/*/* .serena/ *.xcuserstate xcuserdata/ - +.logs/ # Xcode build outputs DerivedData/ @@ -44,6 +44,8 @@ gfn_tokens.json # Test files test/ +!android/app/src/test/ +!android/app/src/test/** package-lock.json opennow-stable/package-lock.json @@ -56,3 +58,6 @@ release-notes.md result .deriveddata/ .deriveddata-device/ + +/android/app/.cxx +/android/app/release diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 000000000..025d1a5df --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +.idea/ +build/ +local.properties +captures/ +*.iml +app/build/ +app/release/ \ No newline at end of file diff --git a/android/README.md b/android/README.md new file mode 100644 index 000000000..5219585e4 --- /dev/null +++ b/android/README.md @@ -0,0 +1,37 @@ +# OpenNOW Native Android + +This folder is a standalone Android Studio project for the native Kotlin / Jetpack Compose OpenNOW target. + +Open it from Android Studio with **File > Open > `OpenNOW/android`**. Android Studio will install/use the required Gradle, Android SDK, CMake, and NDK components from the project configuration. + +## Build Targets + +- `:app:assembleDebug` builds a debug APK. +- `:app:assembleRelease` builds the direct-distribution release APK with APK update support. +- `:app:bundleRelease` builds the Google Play Android App Bundle. This task removes `REQUEST_INSTALL_PACKAGES` and disables APK self-updates so Play installs use Google Play's update mechanism. + +Release and debug builds include `arm64-v8a`, `armeabi-v7a`, and `x86_64`. The `armeabi-v7a` slice supports 32-bit ARM phones and 32-bit Android TV firmware. OpenNOW recommends 720p/30 FPS/12 Mbps for 32-bit processes and memory-constrained TVs, and warns when a custom profile exceeds that recommendation without overriding the user's selection. + +## APK Update Manifest + +APK and debug builds check `https://api.printedwaste.com/releases/opennow/latest` and can download the returned APK. App Bundle builds installed from Google Play detect `com.android.vending` as the install source and do not check, download, or install APK updates. The manifest should look like this: + +```json +{ + "versionCode": 7, + "versionName": "0.5.2", + "apkUrl": "https://api.printedwaste.com/release-files/opennow/app-release.apk", + "artifactUrl": "https://api.printedwaste.com/release-files/opennow/app-release.apk", + "sha256": "optional lowercase apk checksum", + "releaseNotes": "Short notes shown in Settings\nSecond line" +} +``` + +`apkUrl`, `artifactUrl`, or `url` may point at the APK. `releaseNotes` may use real newlines or literal `\n` separators. The app compares `versionCode` against its installed build and asks Android's package installer to confirm the downloaded APK. + +## Runtime Notes + +- UI is native Compose. +- GFN auth, catalog, subscription, CloudMatch session creation/polling/claim/stop, signaling, and input packet behavior are implemented in Kotlin from the Electron project contracts. +- Streaming uses Android WebRTC plus hardware MediaCodec probing. The bundled `opennow_native` JNI library exposes native runtime diagnostics and keeps the NDK/CMake path wired for media-sensitive code. +- Queue ad metadata is preserved in `SessionInfo`; ad playback can use the included Media3 dependency when the server returns an ad media URL. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 000000000..c35be8e1d --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,165 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") +} + +val localProperties = Properties().apply { + rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use { load(it) } +} +fun Sequence.firstNonBlankOrNull(): String? = + mapNotNull { value -> value?.trim()?.takeIf { it.isNotEmpty() } }.firstOrNull() + +fun gradlePropertyValue(vararg names: String): String? = + names.asSequence().map { name -> providers.gradleProperty(name).orNull }.firstNonBlankOrNull() + +fun localPropertyValue(vararg names: String): String? = + names.asSequence().map { name -> localProperties.getProperty(name) }.firstNonBlankOrNull() + +fun environmentValue(vararg names: String): String? = + names.asSequence().map { name -> providers.environmentVariable(name).orNull }.firstNonBlankOrNull() + +fun firstNonBlankValue(vararg values: String?): String = + values.asSequence().firstNonBlankOrNull().orEmpty() + +fun buildConfigString(value: String): String = + "\"" + value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + "\"" + +val defaultPostHogProjectToken = "phc_pdob6BhBvbayfd7BA6zXBkty8o6EkxKYY7sF3ZwLymk3" +val defaultPostHogHost = "https://aa.printedwaste.com" + +val postHogProjectToken = firstNonBlankValue( + gradlePropertyValue("posthog.apiKey", "posthog.projectToken"), + environmentValue("POSTHOG_API_KEY", "POSTHOG_PROJECT_TOKEN"), + localPropertyValue("posthog.apiKey", "posthog.projectToken"), + defaultPostHogProjectToken, +) +val postHogHost = firstNonBlankValue( + gradlePropertyValue("posthog.host"), + environmentValue("POSTHOG_HOST"), + localPropertyValue("posthog.host"), + defaultPostHogHost, +) +val buildingPlayReleaseBundle = gradle.startParameter.taskNames.any { taskName -> + taskName.substringAfterLast(":").equals("bundleRelease", ignoreCase = true) +} + +android { + namespace = "com.opencloudgaming.opennow" + compileSdk = 37 + + defaultConfig { + applicationId = "com.opencloudgaming.opennow" + minSdk = 23 + targetSdk = 36 + versionCode = 52 + versionName = "0.9.7" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField("String", "POSTHOG_PROJECT_TOKEN", buildConfigString(postHogProjectToken)) + buildConfigField("String", "POSTHOG_HOST", buildConfigString(postHogHost)) + buildConfigField("boolean", "APK_UPDATES_SUPPORTED", "true") + + ndk { + abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") + } + + } + + buildTypes { + debug { + isMinifyEnabled = false + } + release { + isMinifyEnabled = true + isShrinkResources = true + buildConfigField("boolean", "APK_UPDATES_SUPPORTED", (!buildingPlayReleaseBundle).toString()) + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + sourceSets { + if (buildingPlayReleaseBundle) { + getByName("release").manifest.srcFile("src/playBundle/AndroidManifest.xml") + } + } + + buildFeatures { + compose = true + buildConfig = true + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } + + packaging { + jniLibs { + useLegacyPackaging = false + } + resources { + excludes += setOf( + "META-INF/AL2.0", + "META-INF/LGPL2.1", + "META-INF/LICENSE*", + "META-INF/NOTICE*", + ) + } + } +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation(platform("androidx.compose:compose-bom:2026.06.00")) + androidTestImplementation(platform("androidx.compose:compose-bom:2026.06.00")) + + implementation("androidx.activity:activity-compose:1.13.0") + implementation("androidx.browser:browser:1.10.0") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.core:core:1.19.0") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.11.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0") + implementation("androidx.media3:media3-common:1.10.1") + implementation("androidx.media3:media3-exoplayer:1.10.1") + implementation("androidx.media3:media3-ui:1.10.1") + implementation("androidx.work:work-runtime:2.11.2") + + implementation("io.coil-kt.coil3:coil-compose:3.4.0") + implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0") + + implementation("com.squareup.okhttp3:logging-interceptor:5.4.0") + implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:5.4.0") + implementation("com.squareup.okhttp3:okhttp:5.4.0") + implementation("io.github.webrtc-sdk:android:144.7559.09") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") + implementation("com.posthog:posthog-android:3.51.2") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.3.0") + androidTestImplementation("androidx.test:runner:1.7.0") + androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 000000000..4c235b42d --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,6 @@ +-keep class org.webrtc.** { *; } +-keep class org.jni_zero.** { *; } +-keep class kotlinx.serialization.** { *; } +-keepclassmembers class com.opencloudgaming.opennow.** { + @kotlinx.serialization.Serializable *; +} diff --git a/android/app/src/androidTest/java/com/opencloudgaming/opennow/LocalTvConnectorInstrumentedTest.kt b/android/app/src/androidTest/java/com/opencloudgaming/opennow/LocalTvConnectorInstrumentedTest.kt new file mode 100644 index 000000000..2f10531bc --- /dev/null +++ b/android/app/src/androidTest/java/com/opencloudgaming/opennow/LocalTvConnectorInstrumentedTest.kt @@ -0,0 +1,84 @@ +package com.opencloudgaming.opennow + +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LocalTvConnectorInstrumentedTest { + @Test + fun encryptedLocalPairingTransfersLaunchAndSignIn() = runBlocking { + val tv = LocalTvConnector() + val phone = LocalTvConnector() + try { + tv.startHosting() + val pairingState = awaitState(tv) { it.hosting && it.pairUri != null } + assertTrue(pairingState.pairingCode.orEmpty().matches(Regex("[0-9]{4}"))) + val pairUri = pairingState.pairUri!! + + phone.pairPhone(Uri.parse(pairUri)) + val phoneState = awaitState(phone) { it.phoneConnected || it.error != null } + assertTrue(phoneState.error.orEmpty(), phoneState.phoneConnected) + val pairedState = awaitState(tv) { it.pairedDeviceName != null } + assertTrue(pairedState.trustRequestedByDevice) + + val launchResult = async { withTimeout(5_000L) { tv.launchRequests.first() } } + phone.sendLaunch("test-game-42", "Connector test game") + assertEquals("test-game-42", launchResult.await().gameId) + + phone.sendRemoteAction("open_stream_menu") + val untrustedState = awaitState(phone) { it.error?.contains("Trust this phone") == true } + assertTrue(untrustedState.error.orEmpty(), untrustedState.error?.contains("Trust this phone") == true) + + tv.setPairedDeviceTrusted(true) + val remoteResult = async { withTimeout(5_000L) { tv.remoteRequests.first() } } + phone.sendRemoteAction("open_stream_menu") + assertEquals("open_stream_menu", remoteResult.await().action) + + val transferred = AuthSession( + provider = LoginProvider( + idpId = "test", + code = "TEST", + displayName = "Test provider", + streamingServiceUrl = "https://example.invalid", + ), + tokens = AuthTokens( + accessToken = "instrumentation-access-token", + refreshToken = "instrumentation-refresh-token", + expiresAt = System.currentTimeMillis() + 60_000L, + ), + user = AuthUser( + userId = "instrumentation-user", + displayName = "Instrumentation user", + membershipTier = "FREE", + ), + ) + val signInResult = async { withTimeout(5_000L) { tv.signInRequests.first() } } + phone.sendSignIn(transferred) + assertEquals(transferred, signInResult.await()) + } finally { + phone.close() + tv.close() + } + } + + private suspend fun awaitState( + connector: LocalTvConnector, + predicate: (LocalTvConnectorState) -> Boolean, + ): LocalTvConnectorState = withTimeout(8_000L) { + while (true) { + connector.state.value.takeIf(predicate)?.let { return@withTimeout it } + delay(25L) + } + @Suppress("UNREACHABLE_CODE") + connector.state.value + } +} diff --git a/android/app/src/androidTest/java/com/opencloudgaming/opennow/StreamingProfileInstrumentedTest.kt b/android/app/src/androidTest/java/com/opencloudgaming/opennow/StreamingProfileInstrumentedTest.kt new file mode 100644 index 000000000..0b93a3b34 --- /dev/null +++ b/android/app/src/androidTest/java/com/opencloudgaming/opennow/StreamingProfileInstrumentedTest.kt @@ -0,0 +1,66 @@ +package com.opencloudgaming.opennow + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class StreamingProfileInstrumentedTest { + @Test + fun commonResolutionAspectAndCodecMatrixResolvesToUsableHardwareProfile() { + val report = CodecProbe.report(ApplicationProvider.getApplicationContext()) + val h264 = report.capabilities.firstOrNull { it.codec == VideoCodec.H264 } + assertNotNull("Every supported Android target must provide H264 decoding", h264) + assertTrue("H264 must be usable by the WebRTC launch path", h264?.streamingDecoderUsableForLaunch() == true) + + val modes = listOf( + "1280x720" to "16:9", + "1366x768" to "16:9", + "1600x900" to "16:9", + "1920x1080" to "16:9", + "2560x1440" to "16:9", + "3840x2160" to "16:9", + "1280x800" to "16:10", + "1920x1200" to "16:10", + "2560x1600" to "16:10", + "1024x768" to "4:3", + "1680x720" to "21:9", + "2560x1080" to "21:9", + "3440x1440" to "21:9", + ) + for ((resolution, aspectRatio) in modes) { + for (codec in VideoCodec.entries) { + val requested = StreamSettings( + resolution = resolution, + aspectRatio = aspectRatio, + fps = 60, + codec = codec, + colorQuality = if (codec == VideoCodec.H264) ColorQuality.EightBit420 else ColorQuality.TenBit420, + ) + val adjusted = requested.adjustedForDevice(report) + val effectiveCapability = report.capabilities.first { it.codec == adjusted.codec } + + assertTrue("$resolution $codec did not resolve to a usable decoder", effectiveCapability.streamingDecoderUsableForLaunch()) + assertEquals( + "$resolution $codec resolved with inconsistent aspect metadata", + streamAspectRatioForResolution(adjusted.resolution), + adjusted.aspectRatio, + ) + assertEquals( + "$resolution $codec was unnecessarily reduced despite explicit decoder support", + resolution, + adjusted.resolution, + ) + println( + "STREAM_MATRIX requested=$resolution/$aspectRatio/$codec effective=${adjusted.resolution}/${adjusted.aspectRatio}/${adjusted.codec} " + + "fps=${adjusted.fps} decoder=${effectiveCapability.webRtcDecoderName ?: effectiveCapability.decoderName} " + + "max=${effectiveCapability.maxSupportedWidth}x${effectiveCapability.maxSupportedHeight}", + ) + } + } + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..6976bcb51 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 000000000..9fa05aa75 --- /dev/null +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.22.1) + +project(opennow_native) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +add_library(opennow_native SHARED opennow_native.cpp) + +target_compile_options(opennow_native PRIVATE -fexceptions -frtti) + +find_library(log-lib log) +find_library(android-lib android) +find_library(mediandk-lib mediandk) + +target_link_libraries( + opennow_native + ${android-lib} + ${log-lib} + ${mediandk-lib} +) diff --git a/android/app/src/main/cpp/opennow_native.cpp b/android/app/src/main/cpp/opennow_native.cpp new file mode 100644 index 000000000..2b3041670 --- /dev/null +++ b/android/app/src/main/cpp/opennow_native.cpp @@ -0,0 +1,39 @@ +#include +#include +#include +#include +#include + +extern "C" JNIEXPORT jstring JNICALL +Java_com_opencloudgaming_opennow_NativeCodecProbe_nativeRuntimeSummary(JNIEnv *env, jobject) { + std::ostringstream out; + out << "{"; + out << "\"nativeLibrary\":\"opennow_native\","; + out << "\"mediaNdk\":true,"; + out << "\"rtpPacketSize\":1140,"; + out << "\"inputProtocolVersion\":3"; + out << "}"; + const std::string value = out.str(); + return env->NewStringUTF(value.c_str()); +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_opencloudgaming_opennow_NativeCodecProbe_nativeDecoderAvailable(JNIEnv *env, jobject, jstring mimeType) { + if (mimeType == nullptr) { + return JNI_FALSE; + } + + const char *rawMimeType = env->GetStringUTFChars(mimeType, nullptr); + if (rawMimeType == nullptr) { + return JNI_FALSE; + } + + AMediaCodec *codec = AMediaCodec_createDecoderByType(rawMimeType); + env->ReleaseStringUTFChars(mimeType, rawMimeType); + if (codec == nullptr) { + return JNI_FALSE; + } + + AMediaCodec_delete(codec); + return JNI_TRUE; +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidAppDataReset.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidAppDataReset.kt new file mode 100644 index 000000000..dbb9c1dff --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidAppDataReset.kt @@ -0,0 +1,76 @@ +package com.opencloudgaming.opennow + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Process +import android.os.SystemClock +import android.util.Log +import java.io.File +import kotlin.system.exitProcess + +private const val APP_DATA_RESET_LOG_TAG = "OpenNOW.AppDataReset" +private const val APP_DATA_RESET_RELAUNCH_DELAY_MS = 500L +private const val APP_DATA_RESET_RELAUNCH_REQUEST_CODE = 1007 + +internal fun wipeAppDataAndRelaunch(context: Context): Nothing { + val appContext = context.applicationContext + val relaunchIntent = buildAppRelaunchIntent(appContext) + val relaunchPendingIntent = PendingIntent.getActivity( + appContext, + APP_DATA_RESET_RELAUNCH_REQUEST_CODE, + relaunchIntent, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val alarmManager = appContext.getSystemService(AlarmManager::class.java) + alarmManager?.set( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + APP_DATA_RESET_RELAUNCH_DELAY_MS, + relaunchPendingIntent, + ) + clearAppDataDirectories(appContext) + Process.killProcess(Process.myPid()) + exitProcess(0) +} + +private fun buildAppRelaunchIntent(context: Context): Intent = + (context.packageManager.getLaunchIntentForPackage(context.packageName) ?: Intent(context, MainActivity::class.java)).apply { + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + +private fun clearAppDataDirectories(context: Context) { + val dataDir = context.dataDir + val children = dataDir.listFiles().orEmpty() + if (children.isEmpty()) { + deleteKnownAppDataDirectories(context) + return + } + children.forEach { child -> + if (child.name == "lib") return@forEach + deleteAppDataPath(child) + } +} + +private fun deleteKnownAppDataDirectories(context: Context) { + listOf( + context.filesDir, + context.cacheDir, + context.codeCacheDir, + context.noBackupFilesDir, + File(context.dataDir, "shared_prefs"), + File(context.dataDir, "databases"), + ).forEach(::deleteAppDataPath) +} + +private fun deleteAppDataPath(path: File?) { + if (path == null || !path.exists()) return + val deleted = runCatching { path.deleteRecursively() } + .onFailure { error -> Log.w(APP_DATA_RESET_LOG_TAG, "Failed to delete ${path.name}", error) } + .getOrDefault(false) + if (!deleted && path.exists()) { + Log.w(APP_DATA_RESET_LOG_TAG, "Failed to delete ${path.name}") + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidAuthRefresh.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidAuthRefresh.kt new file mode 100644 index 000000000..4346deabf --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidAuthRefresh.kt @@ -0,0 +1,93 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import java.util.concurrent.TimeUnit + +private const val AUTH_REFRESH_WORK_NAME = "opennow-auth-token-refresh" +private const val AUTH_REFRESH_LOG_TAG = "OpenNOWAuthRefresh" +private const val AUTH_REFRESH_INTERVAL_MINUTES = 15L +private const val AUTH_REFRESH_FLEX_MINUTES = 5L +private const val AUTH_REFRESH_BACKOFF_MINUTES = 5L + +internal fun AuthTokens.needsBackgroundRefresh(nowMs: Long = System.currentTimeMillis()): Boolean = + expiresAt - nowMs < TOKEN_REFRESH_WINDOW_MS || + clientToken.isNullOrBlank() || + clientTokenExpiresAt == null || + clientTokenExpiresAt - nowMs < CLIENT_TOKEN_REFRESH_WINDOW_MS + +internal fun authenticationRefreshClientIds( + savedClientId: String?, + browserClientId: String, + deviceClientId: String, +): List = + listOfNotNull( + savedClientId?.takeIf(String::isNotBlank), + browserClientId, + deviceClientId, + ).distinct() + +internal object AndroidAuthRefreshScheduler { + fun schedule(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val request = PeriodicWorkRequest.Builder( + AndroidAuthRefreshWorker::class.java, + AUTH_REFRESH_INTERVAL_MINUTES, + TimeUnit.MINUTES, + AUTH_REFRESH_FLEX_MINUTES, + TimeUnit.MINUTES, + ) + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + AUTH_REFRESH_BACKOFF_MINUTES, + TimeUnit.MINUTES, + ) + .build() + WorkManager.getInstance(context.applicationContext).enqueueUniquePeriodicWork( + AUTH_REFRESH_WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + request, + ) + } +} + +internal class AndroidAuthRefreshWorker( + appContext: Context, + workerParams: WorkerParameters, +) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + val application = applicationContext as OpenNowApplication + val authStore = application.authStore + val activeSession = authStore.reload().let { state -> + state.sessions.firstOrNull { it.user.userId == state.activeUserId } + ?: state.sessions.firstOrNull() + } ?: return Result.success() + if (!activeSession.tokens.needsBackgroundRefresh()) return Result.success() + + return runCatching { + val refreshed = application.authRepository.restore( + throwOnRefreshFailure = true, + removeExpiredSessionOnFailure = false, + ) + if (refreshed?.tokens?.needsBackgroundRefresh() == true) { + Result.retry() + } else { + Result.success() + } + }.getOrElse { error -> + Log.w(AUTH_REFRESH_LOG_TAG, "Background token refresh failed; retrying", error) + Result.retry() + } + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidDisplayResolution.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidDisplayResolution.kt new file mode 100644 index 000000000..39759cdf3 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidDisplayResolution.kt @@ -0,0 +1,20 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import android.hardware.display.DisplayManager +import android.view.Display + +/** + * Returns the real display-mode geometry in the landscape orientation used for streaming. + * This is intentionally separate from the requested logical stream resolution. + */ +internal fun Context.physicalStreamDisplayResolution(): Pair? { + val display = getSystemService(DisplayManager::class.java) + ?.getDisplay(Display.DEFAULT_DISPLAY) + ?: return null + val mode = display.mode + val width = mode.physicalWidth + val height = mode.physicalHeight + if (width <= 0 || height <= 0) return null + return maxOf(width, height) to minOf(width, height) +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidNerdAudio.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidNerdAudio.kt new file mode 100644 index 000000000..ed4f4e05c --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidNerdAudio.kt @@ -0,0 +1,165 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.MediaPlayer +import android.media.ToneGenerator +import android.os.Handler +import android.os.Looper +import android.os.SystemClock + +internal class AndroidNerdAudioController(context: Context) { + private val appContext = context.applicationContext + private val mainHandler = Handler(Looper.getMainLooper()) + private var cuePlayer: MediaPlayer? = null + private var cuePurpose: MusicCuePurpose? = null + private var cuePlayingChanged: ((Boolean) -> Unit)? = null + private var toneGenerator: ToneGenerator? = null + private var lastToneAtMs = 0L + private val stopCueRunnable = Runnable { + stopMusicCue(cuePlayingChanged ?: {}) + } + + fun startIntro(enabled: Boolean, onPlayingChanged: (Boolean) -> Unit) { + startMusicCue(MusicCuePurpose.Intro, enabled, INTRO_MUSIC_MAX_DURATION_MS, onPlayingChanged) + } + + fun startQueueReadyReminder(enabled: Boolean, onPlayingChanged: (Boolean) -> Unit = {}) { + startMusicCue(MusicCuePurpose.QueueReady, enabled, QUEUE_READY_CUE_DURATION_MS, onPlayingChanged) + } + + private fun startMusicCue( + purpose: MusicCuePurpose, + enabled: Boolean, + maxDurationMs: Long, + onPlayingChanged: (Boolean) -> Unit, + ) { + stopMusicCue(onPlayingChanged) + if (!enabled) return + + val descriptor = runCatching { + appContext.resources.openRawResourceFd(musicCueResource(purpose)) + }.getOrNull() ?: return + + val player = MediaPlayer() + runCatching { + descriptor.use { + player.setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(), + ) + player.setDataSource(it.fileDescriptor, it.startOffset, it.length) + } + player.setVolume(MUSIC_CUE_VOLUME, MUSIC_CUE_VOLUME) + player.isLooping = false + player.setOnCompletionListener { completed -> + if (cuePlayer === completed) { + cuePlayer = null + cuePurpose = null + cuePlayingChanged = null + mainHandler.removeCallbacks(stopCueRunnable) + } + completed.release() + onPlayingChanged(false) + } + player.setOnErrorListener { failed, _, _ -> + if (cuePlayer === failed) { + cuePlayer = null + cuePurpose = null + cuePlayingChanged = null + mainHandler.removeCallbacks(stopCueRunnable) + } + failed.release() + onPlayingChanged(false) + true + } + player.prepare() + cuePlayer = player + cuePurpose = purpose + cuePlayingChanged = onPlayingChanged + player.start() + mainHandler.postDelayed(stopCueRunnable, maxDurationMs) + onPlayingChanged(true) + }.onFailure { + player.release() + if (cuePlayer === player) { + cuePlayer = null + cuePurpose = null + cuePlayingChanged = null + mainHandler.removeCallbacks(stopCueRunnable) + } + onPlayingChanged(false) + } + } + + fun stopIntro(onPlayingChanged: (Boolean) -> Unit = {}) { + if (cuePurpose == MusicCuePurpose.Intro) { + stopMusicCue(onPlayingChanged) + } + } + + fun stopQueueReadyReminder(onPlayingChanged: (Boolean) -> Unit = {}) { + if (cuePurpose == MusicCuePurpose.QueueReady) { + stopMusicCue(onPlayingChanged) + } + } + + fun stopAll(onPlayingChanged: (Boolean) -> Unit = {}) { + stopMusicCue(onPlayingChanged) + } + + private fun stopMusicCue(onPlayingChanged: (Boolean) -> Unit = {}) { + val player = cuePlayer ?: return + cuePlayer = null + cuePurpose = null + cuePlayingChanged = null + mainHandler.removeCallbacks(stopCueRunnable) + runCatching { player.stop() } + player.release() + onPlayingChanged(false) + } + + fun playButtonTone(enabled: Boolean) { + if (!enabled) return + val now = SystemClock.uptimeMillis() + if (now - lastToneAtMs < MIN_BUTTON_TONE_INTERVAL_MS) return + lastToneAtMs = now + val generator = toneGenerator ?: runCatching { + ToneGenerator(AudioManager.STREAM_MUSIC, BUTTON_TONE_VOLUME) + }.getOrNull()?.also { + toneGenerator = it + } ?: return + runCatching { + generator.startTone(ToneGenerator.TONE_PROP_ACK, BUTTON_TONE_DURATION_MS) + } + } + + fun release() { + stopAll() + toneGenerator?.release() + toneGenerator = null + } + + private enum class MusicCuePurpose { + Intro, + QueueReady, + } + + private fun musicCueResource(purpose: MusicCuePurpose): Int = + when (purpose) { + MusicCuePurpose.Intro -> R.raw.nerd_stream_intro + MusicCuePurpose.QueueReady -> R.raw.nerd_queue_ready + } + + private companion object { + private const val MUSIC_CUE_VOLUME = 0.20f + private const val INTRO_MUSIC_MAX_DURATION_MS = 150_000L + private const val QUEUE_READY_CUE_DURATION_MS = 7_000L + private const val BUTTON_TONE_VOLUME = 34 + private const val BUTTON_TONE_DURATION_MS = 48 + private const val MIN_BUTTON_TONE_INTERVAL_MS = 55L + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidQueueAds.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidQueueAds.kt new file mode 100644 index 000000000..21caf04a8 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidQueueAds.kt @@ -0,0 +1,63 @@ +package com.opencloudgaming.opennow + +internal fun isSessionAdsRequired(adState: SessionAdState?): Boolean = + adState?.sessionAdsRequired ?: (adState?.isAdsRequired == true) + +internal fun sessionAdItems(adState: SessionAdState?): List = + adState?.sessionAds?.takeIf { it.isNotEmpty() } ?: adState?.ads.orEmpty() + +internal fun shouldWaitForQueueAdPlayback(adState: SessionAdState?): Boolean = + isSessionAdsRequired(adState) && sessionAdItems(adState).isNotEmpty() + +internal fun mergeQueueAdState( + previous: SessionAdState?, + next: SessionAdState?, + preserveMissingAdState: Boolean = true, +): SessionAdState? { + if (next == null) return if (preserveMissingAdState) previous else null + val shouldRestorePreviousAds = + preserveMissingAdState && + isSessionAdsRequired(next) && + next.serverSentEmptyAds && + sessionAdItems(next).isEmpty() && + sessionAdItems(previous).isNotEmpty() + + return if (shouldRestorePreviousAds) { + next.copy( + sessionAds = sessionAdItems(previous), + ads = previous?.ads?.takeIf { it.isNotEmpty() } ?: sessionAdItems(previous), + ) + } else { + next + } +} + +internal fun mergeQueueSessionState( + previous: SessionInfo, + next: SessionInfo, + preserveMissingAdState: Boolean = true, +): SessionInfo { + if (next.isReadyForStream()) return next + return next.copy( + adState = mergeQueueAdState(previous.adState, next.adState, preserveMissingAdState), + mediaConnectionInfo = next.mediaConnectionInfo ?: previous.mediaConnectionInfo, + ) +} + +internal fun removeSessionAdItem(adState: SessionAdState?, adId: String): SessionAdState? { + if (adState == null) return null + return adState.copy( + sessionAds = adState.sessionAds.filterNot { it.adId == adId }, + ads = adState.ads.filterNot { it.adId == adId }, + serverSentEmptyAds = false, + ) +} + +internal fun removeSessionAdItem(session: SessionInfo, adId: String): SessionInfo = + session.copy(adState = removeSessionAdItem(session.adState, adId)) + +internal fun nextSessionAdId(adState: SessionAdState?, completedAdId: String): String? { + val ads = sessionAdItems(adState) + val completedIndex = ads.indexOfFirst { it.adId == completedAdId } + return ads.getOrNull(completedIndex + 1)?.adId +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidQueueStatusNotifier.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidQueueStatusNotifier.kt new file mode 100644 index 000000000..45303bd67 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidQueueStatusNotifier.kt @@ -0,0 +1,272 @@ +package com.opencloudgaming.opennow + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.util.Log + +internal const val QUEUE_CHANNEL_ID = "opennow_queue_status" +internal const val QUEUE_NOTIFICATION_ID = 4210 +private const val QUEUE_ALERT_CHANNEL_ID = "opennow_queue_ready" +private const val QUEUE_ALERT_NOTIFICATION_ID = 4212 + +private const val QUEUE_SERVICE_ACTION_UPDATE = "com.opencloudgaming.opennow.queue.UPDATE" +private const val QUEUE_SERVICE_ACTION_STOP = "com.opencloudgaming.opennow.queue.STOP" +private const val QUEUE_SERVICE_EXTRA_TITLE = "title" +private const val QUEUE_SERVICE_EXTRA_TEXT = "text" +private const val QUEUE_SERVICE_TAG = "OpenNOWQueueService" +private val QUEUE_NOTIFICATION_SMALL_ICON = R.drawable.ic_tab_stream + +/** Returns true if the queue wait is over and the game is now launching/loading. */ +private fun isQueueComplete(state: OpenNowUiState): Boolean { + val queuePosition = queueDisplayPosition(state) + if (queuePosition != null) return false // Still in queue + val phase = state.launchPhase + if (phase.isBlank()) return false + return phase.equals("Connecting stream", ignoreCase = true) || + phase.equals("Setting up rig", ignoreCase = true) || + phase.equals("Resuming session", ignoreCase = true) || + phase.contains("Starting", ignoreCase = true) +} + +class AndroidQueueStatusNotifier(private val context: Context) { + private val appContext = context.applicationContext + private val notificationManager = appContext.getSystemService(NotificationManager::class.java) + private var serviceStartRequested = false + private var queueReadyAlertSent = false + + fun update(state: OpenNowUiState) { + if (!shouldShowQueueLaunchStatus(state)) { + cancel() + return + } + + // Reset alert tracker when user is actively queuing so it fires again next time queue completes. + if (queueDisplayPosition(state) != null) { + queueReadyAlertSent = false + } + + // Send a one-shot high-priority heads-up alert when queue finishes and game is loading. + if (!queueReadyAlertSent && isQueueComplete(state)) { + queueReadyAlertSent = true + if (canPostNotifications()) { + runCatching { + ensureQueueAlertChannel(appContext) + notificationManager.notify( + QUEUE_ALERT_NOTIFICATION_ID, + buildQueueReadyNotification(appContext, state.streamGame?.title ?: "OpenNOW"), + ) + }.onFailure { error -> + Log.w(QUEUE_SERVICE_TAG, "Unable to post queue-ready alert notification", error) + } + } + } + + ensureChannel() + val intent = Intent(appContext, AndroidQueueStatusService::class.java).apply { + action = QUEUE_SERVICE_ACTION_UPDATE + putExtra(QUEUE_SERVICE_EXTRA_TITLE, state.streamGame?.title ?: "OpenNOW") + putExtra(QUEUE_SERVICE_EXTRA_TEXT, queueLaunchStatusText(state)) + } + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + appContext.startForegroundService(intent) + } else { + appContext.startService(intent) + } + serviceStartRequested = true + }.onFailure { error -> + Log.w(QUEUE_SERVICE_TAG, "Unable to start queue foreground service", error) + if (canPostNotifications()) { + runCatching { + notificationManager.notify( + QUEUE_NOTIFICATION_ID, + buildQueueNotification(appContext, state.streamGame?.title ?: "OpenNOW", queueLaunchStatusText(state)), + ) + }.onFailure { notifyError -> + Log.w(QUEUE_SERVICE_TAG, "Unable to post fallback queue notification", notifyError) + } + } + } + } + + fun cancel() { + val intent = Intent(appContext, AndroidQueueStatusService::class.java).apply { + action = QUEUE_SERVICE_ACTION_STOP + } + runCatching { + if (serviceStartRequested) { + appContext.startService(intent) + } else { + appContext.stopService(intent) + } + }.onFailure { error -> + Log.w(QUEUE_SERVICE_TAG, "Unable to stop queue foreground service", error) + } + serviceStartRequested = false + queueReadyAlertSent = false + notificationManager.cancel(QUEUE_NOTIFICATION_ID) + notificationManager.cancel(QUEUE_ALERT_NOTIFICATION_ID) + } + + private fun canPostNotifications(): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + appContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } + + private fun ensureChannel() { + ensureQueueNotificationChannel(appContext) + } + + private fun ensureAlertChannel() { + ensureQueueAlertChannel(appContext) + } +} + +class AndroidQueueStatusService : Service() { + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + runCatching { + when (intent?.action) { + QUEUE_SERVICE_ACTION_STOP -> { + startQueueForeground("OpenNOW", "Queue status") + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf(startId) + } + QUEUE_SERVICE_ACTION_UPDATE, null -> { + val title = intent?.getStringExtra(QUEUE_SERVICE_EXTRA_TITLE) ?: "OpenNOW" + val text = intent?.getStringExtra(QUEUE_SERVICE_EXTRA_TEXT) ?: "Queue status" + startQueueForeground(title, text) + } + else -> { + startQueueForeground("OpenNOW", "Queue status") + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf(startId) + } + } + }.onFailure { error -> + Log.e(QUEUE_SERVICE_TAG, "Queue foreground service failed to start", error) + stopSelf(startId) + } + return START_NOT_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun startQueueForeground(title: String, text: String) { + ensureQueueNotificationChannel(this) + val notification = buildQueueNotification(this, title, text) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(QUEUE_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + startForeground(QUEUE_NOTIFICATION_ID, notification) + } + } +} + +private fun ensureQueueNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val notificationManager = context.applicationContext.getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + QUEUE_CHANNEL_ID, + "Queue status", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Shows OpenNOW queue and session startup progress." + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + setShowBadge(false) + } + notificationManager.createNotificationChannel(channel) +} + +private fun buildQueueNotification(context: Context, title: String, text: String): Notification { + val appContext = context.applicationContext + val openIntent = Intent(appContext, MainActivity::class.java).apply { + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + appContext, + 0, + openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(appContext, QUEUE_CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(appContext) + } + return builder + .setSmallIcon(QUEUE_NOTIFICATION_SMALL_ICON) + .setContentTitle(title) + .setContentText(text) + .setSubText("OpenNOW") + .setCategory(Notification.CATEGORY_PROGRESS) + .setProgress(0, 0, true) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setContentIntent(pendingIntent) + .build() +} + +private fun ensureQueueAlertChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val notificationManager = context.applicationContext.getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + QUEUE_ALERT_CHANNEL_ID, + "Queue ready alert", + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "Alerts when a GFN queue finishes and the game is about to launch." + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + setShowBadge(true) + enableVibration(true) + } + notificationManager.createNotificationChannel(channel) +} + +private fun buildQueueReadyNotification(context: Context, gameTitle: String): Notification { + val appContext = context.applicationContext + val openIntent = Intent(appContext, MainActivity::class.java).apply { + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + appContext, + 2, + openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(appContext, QUEUE_ALERT_CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(appContext) + } + return builder + .setSmallIcon(QUEUE_NOTIFICATION_SMALL_ICON) + .setContentTitle("$gameTitle is ready to play!") + .setContentText("Your GFN queue is done. Tap to return to the app.") + .setSubText("OpenNOW") + .setCategory(Notification.CATEGORY_ALARM) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setOngoing(false) + .setAutoCancel(true) + .setShowWhen(true) + .setContentIntent(pendingIntent) + .build() +} + diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidRecommendedProfile.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidRecommendedProfile.kt new file mode 100644 index 000000000..21caa9323 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidRecommendedProfile.kt @@ -0,0 +1,92 @@ +package com.opencloudgaming.opennow + +import android.app.ActivityManager +import android.content.Context +import kotlin.math.abs + +internal data class AndroidDeviceRecommendation( + val stream: StreamSettings, + val displayWidth: Int, + val displayHeight: Int, + val processorCount: Int, + val totalMemoryMiB: Long?, + val androidTvProfile: Boolean, + val lowPowerProfile: Boolean, +) { + fun debugSummary(): String = + "display=${displayWidth}x$displayHeight processors=$processorCount memoryMiB=${totalMemoryMiB ?: "unknown"} " + + "tv=$androidTvProfile lowPower=$lowPowerProfile recommended=${stream.resolution}@${stream.fps} " + + "codec=${stream.codec} bitrate=${stream.maxBitrateMbps}" +} + +internal fun recommendedAndroidStreamProfile( + context: Context, + report: RuntimeCodecReport?, +): AndroidDeviceRecommendation { + val metrics = context.resources.displayMetrics + val displayWidth = maxOf(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1) + val displayHeight = minOf(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1) + val processorCount = Runtime.getRuntime().availableProcessors().coerceAtLeast(1) + val totalMemoryMiB = runCatching { + val info = ActivityManager.MemoryInfo() + (context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager)?.getMemoryInfo(info) + info.totalMem.takeIf { it > 0L }?.div(1024L * 1024L) + }.getOrNull() + val tvProfile = report?.androidTvProfile ?: isAndroidTvProfile(context) + val lowPower = report?.lowPowerGpuProfile == true || + totalMemoryMiB?.let { it < 3_000L } == true || + processorCount <= 4 + + val maxHeight = when { + lowPower -> 720 + tvProfile -> 1080 + totalMemoryMiB?.let { it >= 6_000L } == true && processorCount >= 8 -> 1440 + else -> 1080 + } + val deviceAspect = displayWidth.toDouble() / displayHeight.toDouble() + val aspectRatio = streamAspectRatioOptions().minByOrNull { option -> + val parts = option.split(':') + val optionAspect = parts.getOrNull(0)?.toDoubleOrNull() + ?.div(parts.getOrNull(1)?.toDoubleOrNull() ?: 1.0) + ?: (16.0 / 9.0) + abs(deviceAspect - optionAspect) + } ?: "16:9" + val choices = streamResolutionChoicesForAspect(aspectRatio) + val selected = choices + .filter { it.width <= displayWidth && it.height <= displayHeight && it.height <= maxHeight } + .maxByOrNull { it.width * it.height } + ?: choices + .filter { it.height <= maxHeight } + .maxByOrNull { it.width * it.height } + ?: streamResolutionChoicesForAspect("16:9").first() + + val h265Ready = report?.capabilities + ?.firstOrNull { it.codec == VideoCodec.H265 } + ?.streamingDecoderUsableForLaunch() == true + val preferH265 = !lowPower && !tvProfile && maxHeight >= 1440 && h265Ready + val fps = if (report?.constrainedRuntimeProfile == true || (lowPower && processorCount <= 4)) 30 else 60 + val bitrate = when { + lowPower -> if (fps <= 30) 12 else 18 + selected.height >= 1440 -> 45 + tvProfile -> 30 + else -> 35 + } + val stream = StreamSettings( + resolution = selected.value, + aspectRatio = selected.aspectRatio, + fps = fps, + maxBitrateMbps = bitrate, + codec = if (preferH265) VideoCodec.H265 else VideoCodec.H264, + colorQuality = ColorQuality.EightBit420, + ).adjustedForDevice(report) + + return AndroidDeviceRecommendation( + stream = stream, + displayWidth = displayWidth, + displayHeight = displayHeight, + processorCount = processorCount, + totalMemoryMiB = totalMemoryMiB, + androidTvProfile = tvProfile, + lowPowerProfile = lowPower, + ) +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidRuntimeDiagnostics.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidRuntimeDiagnostics.kt new file mode 100644 index 000000000..734c8f1d9 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidRuntimeDiagnostics.kt @@ -0,0 +1,220 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager +import android.os.BatteryManager +import android.os.Build +import android.os.PowerManager +import kotlin.math.roundToInt + +internal data class AndroidRuntimeDiagnosticsSnapshot( + val batteryPercent: Int? = null, + val batteryCharging: Boolean = false, + val batteryTemperatureC: Float? = null, + val thermalStatus: AndroidThermalStatus = AndroidThermalStatus.Unknown, + val networkKind: AndroidNetworkKind = AndroidNetworkKind.Unknown, + val networkSignalBars: Int? = null, + val cellularGeneration: String? = null, + val networkDownstreamKbps: Int? = null, + val wifiFrequencyMhz: Int? = null, + val wifiBand: AndroidWifiBand = AndroidWifiBand.Unknown, +) { + fun debugSummary(): String { + val temperature = batteryTemperatureC?.let { "%.1f".format(java.util.Locale.US, it) } ?: "unknown" + return "battery=${batteryPercent?.toString() ?: "unknown"} charging=$batteryCharging batteryTempC=$temperature thermal=${thermalStatus.logValue} network=${networkKind.logValue} generation=${cellularGeneration ?: "unknown"} bars=${networkSignalBars?.toString() ?: "unknown"} downKbps=${networkDownstreamKbps ?: 0} wifiMhz=${wifiFrequencyMhz ?: 0} wifiBand=${wifiBand.logValue}" + } +} + +enum class AndroidNetworkKind(val label: String, val logValue: String) { + Wifi("WiFi", "wifi"), + Cellular("Cell", "cellular"), + Ethernet("LAN", "ethernet"), + Other("Net", "other"), + None("Off", "none"), + Unknown("Net", "unknown"), +} + +enum class AndroidWifiBand(val label: String, val logValue: String) { + TwoPointFourGhz("2.4 GHz", "2.4ghz"), + FiveGhz("5 GHz", "5ghz"), + SixGhz("6 GHz", "6ghz"), + Unknown("Wi-Fi", "unknown"), +} + +internal fun androidWifiBandForFrequency(frequencyMhz: Int?): AndroidWifiBand = when (frequencyMhz) { + in 2_400..2_500 -> AndroidWifiBand.TwoPointFourGhz + in 4_900..5_900 -> AndroidWifiBand.FiveGhz + in 5_925..7_125 -> AndroidWifiBand.SixGhz + else -> AndroidWifiBand.Unknown +} + +internal enum class AndroidThermalStatus(val logValue: String) { + Unknown("unknown"), + None("none"), + Light("light"), + Moderate("moderate"), + Severe("severe"), + Critical("critical"), + Emergency("emergency"), + Shutdown("shutdown"), +} + +internal object AndroidRuntimeDiagnostics { + fun snapshot(context: Context): AndroidRuntimeDiagnosticsSnapshot { + val appContext = context.applicationContext + val battery = readBattery(appContext) + val network = readNetwork(appContext) + return AndroidRuntimeDiagnosticsSnapshot( + batteryPercent = battery.percent, + batteryCharging = battery.charging, + batteryTemperatureC = battery.temperatureC, + thermalStatus = readThermalStatus(appContext), + networkKind = network.kind, + networkSignalBars = network.signalBars, + cellularGeneration = network.cellularGeneration, + networkDownstreamKbps = network.downstreamKbps, + wifiFrequencyMhz = network.wifiFrequencyMhz, + wifiBand = androidWifiBandForFrequency(network.wifiFrequencyMhz), + ) + } + + fun networkSnapshot(context: Context): AndroidRuntimeDiagnosticsSnapshot { + val network = readNetwork(context.applicationContext) + return AndroidRuntimeDiagnosticsSnapshot( + networkKind = network.kind, + networkSignalBars = network.signalBars, + cellularGeneration = network.cellularGeneration, + networkDownstreamKbps = network.downstreamKbps, + wifiFrequencyMhz = network.wifiFrequencyMhz, + wifiBand = androidWifiBandForFrequency(network.wifiFrequencyMhz), + ) + } + + private fun readBattery(context: Context): BatteryDiagnostics { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED), Context.RECEIVER_NOT_EXPORTED) + } else { + @Suppress("DEPRECATION") + context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + } + val level = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 + val scale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1 + val percent = if (level >= 0 && scale > 0) { + ((level / scale.toFloat()) * 100f).roundToInt().coerceIn(0, 100) + } else { + null + } + val batteryStatus = intent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN) + ?: BatteryManager.BATTERY_STATUS_UNKNOWN + val plugged = intent?.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) ?: 0 + val temperatureTenths = intent?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, Int.MIN_VALUE) ?: Int.MIN_VALUE + return BatteryDiagnostics( + percent = percent, + charging = batteryStatus == BatteryManager.BATTERY_STATUS_CHARGING || + batteryStatus == BatteryManager.BATTERY_STATUS_FULL || + plugged != 0, + temperatureC = temperatureTenths.takeIf { it != Int.MIN_VALUE }?.let { it / 10f }, + ) + } + + private fun readThermalStatus(context: Context): AndroidThermalStatus { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return AndroidThermalStatus.Unknown + val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return AndroidThermalStatus.Unknown + return when (powerManager.currentThermalStatus) { + PowerManager.THERMAL_STATUS_NONE -> AndroidThermalStatus.None + PowerManager.THERMAL_STATUS_LIGHT -> AndroidThermalStatus.Light + PowerManager.THERMAL_STATUS_MODERATE -> AndroidThermalStatus.Moderate + PowerManager.THERMAL_STATUS_SEVERE -> AndroidThermalStatus.Severe + PowerManager.THERMAL_STATUS_CRITICAL -> AndroidThermalStatus.Critical + PowerManager.THERMAL_STATUS_EMERGENCY -> AndroidThermalStatus.Emergency + PowerManager.THERMAL_STATUS_SHUTDOWN -> AndroidThermalStatus.Shutdown + else -> AndroidThermalStatus.Unknown + } + } + + private fun readNetwork(context: Context): NetworkDiagnostics { + val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + val capabilities = connectivity?.getNetworkCapabilities(connectivity.activeNetwork) + val kind = when { + capabilities == null -> AndroidNetworkKind.None + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> AndroidNetworkKind.Wifi + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> AndroidNetworkKind.Cellular + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> AndroidNetworkKind.Ethernet + else -> AndroidNetworkKind.Other + } + return NetworkDiagnostics( + kind = kind, + signalBars = if (kind == AndroidNetworkKind.Cellular) { + CellularNetworkStatus.signalBars(context) ?: networkBars(capabilities) + } else { + networkBars(capabilities) + }, + cellularGeneration = if (kind == AndroidNetworkKind.Cellular) CellularNetworkStatus.displayLabel(context) else null, + downstreamKbps = capabilities?.linkDownstreamBandwidthKbps?.takeIf { it > 0 }, + wifiFrequencyMhz = if (kind == AndroidNetworkKind.Wifi) { + wifiFrequencyMhz(context, capabilities) + } else { + null + }, + ) + } + + @Suppress("DEPRECATION") + private fun wifiFrequencyMhz(context: Context, capabilities: NetworkCapabilities?): Int? { + val networkInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + capabilities?.transportInfo as? WifiInfo + } else { + null + } + val wifiInfo = networkInfo ?: (context.getSystemService(Context.WIFI_SERVICE) as? WifiManager)?.connectionInfo + return wifiInfo?.frequency?.takeIf { it > 0 } + } + + private fun networkBars(capabilities: NetworkCapabilities?): Int? { + if (capabilities == null) return 0 + val signalBars = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + networkBarsFromSignal(capabilities.signalStrength) + } else { + null + } + return signalBars ?: networkBarsFromBandwidth(capabilities.linkDownstreamBandwidthKbps) + } + + private fun networkBarsFromSignal(signalStrength: Int): Int? { + if (signalStrength == Int.MIN_VALUE) return null + return when { + signalStrength in 0..4 -> signalStrength + signalStrength >= -55 -> 4 + signalStrength >= -67 -> 3 + signalStrength >= -80 -> 2 + else -> 1 + } + } + + private fun networkBarsFromBandwidth(downstreamKbps: Int): Int? = when { + downstreamKbps >= 25_000 -> 4 + downstreamKbps >= 10_000 -> 3 + downstreamKbps >= 3_000 -> 2 + downstreamKbps > 0 -> 1 + else -> null + } + + private data class BatteryDiagnostics( + val percent: Int?, + val charging: Boolean, + val temperatureC: Float?, + ) + + private data class NetworkDiagnostics( + val kind: AndroidNetworkKind, + val signalBars: Int?, + val cellularGeneration: String?, + val downstreamKbps: Int?, + val wifiFrequencyMhz: Int?, + ) +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidStreamKeepAliveNotifier.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidStreamKeepAliveNotifier.kt new file mode 100644 index 000000000..a3528ad6a --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidStreamKeepAliveNotifier.kt @@ -0,0 +1,284 @@ +package com.opencloudgaming.opennow + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString + +private const val STREAM_CHANNEL_ID = "opennow_active_stream" +private const val STREAM_NOTIFICATION_ID = 4211 +private const val STREAM_SERVICE_ACTION_START = "com.opencloudgaming.opennow.stream.START" +private const val STREAM_SERVICE_ACTION_STOP = "com.opencloudgaming.opennow.stream.STOP" +private const val STREAM_SERVICE_EXTRA_TITLE = "title" +private const val STREAM_SERVICE_EXTRA_SHUTDOWN_REQUEST = "shutdown_request" +private const val STREAM_SERVICE_TAG = "OpenNOWStreamService" +private const val STREAM_TASK_REMOVAL_TIMEOUT_MS = 15_000L + +internal fun shouldKeepAndroidStreamAlive(state: OpenNowUiState): Boolean = + state.page == AppPage.Stream && + state.streamStatus != "idle" && + state.streamSession?.isReadyForStream() == true + +@Serializable +internal data class ActiveStreamShutdownRequest( + val session: SessionInfo, + val settings: StreamSettings, +) + +internal fun activeStreamShutdownRequest(state: OpenNowUiState): ActiveStreamShutdownRequest? { + if (!shouldKeepAndroidStreamAlive(state)) return null + val session = state.streamSession ?: return null + return ActiveStreamShutdownRequest( + session = session, + settings = state.activeStreamSettings ?: state.settings.stream, + ) +} + +class AndroidStreamKeepAliveNotifier(context: Context) { + private val appContext = context.applicationContext + private var serviceStartRequested = false + private var activeTitle: String? = null + private var activeShutdownRequestJson: String? = null + private var cancellationApplied = false + + fun update(state: OpenNowUiState) { + if (!shouldKeepAndroidStreamAlive(state)) { + cancel() + return + } + cancellationApplied = false + + val title = state.streamGame?.title ?: "OpenNOW" + val shutdownRequestJson = activeStreamShutdownRequest(state)?.let { request -> + OpenNowJson.encodeToString(request) + } + if ( + serviceStartRequested && + activeTitle == title && + activeShutdownRequestJson == shutdownRequestJson + ) return + val intent = Intent(appContext, AndroidStreamKeepAliveService::class.java).apply { + action = STREAM_SERVICE_ACTION_START + putExtra(STREAM_SERVICE_EXTRA_TITLE, title) + putExtra(STREAM_SERVICE_EXTRA_SHUTDOWN_REQUEST, shutdownRequestJson) + } + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + appContext.startForegroundService(intent) + } else { + appContext.startService(intent) + } + serviceStartRequested = true + activeTitle = title + activeShutdownRequestJson = shutdownRequestJson + }.onFailure { error -> + Log.w(STREAM_SERVICE_TAG, "Unable to start stream foreground service", error) + } + } + + fun cancel() { + if (!serviceStartRequested && cancellationApplied) return + val intent = Intent(appContext, AndroidStreamKeepAliveService::class.java).apply { + action = STREAM_SERVICE_ACTION_STOP + } + runCatching { + if (serviceStartRequested) { + appContext.startService(intent) + } else { + appContext.stopService(intent) + } + }.onFailure { error -> + Log.w(STREAM_SERVICE_TAG, "Unable to stop stream foreground service", error) + } + serviceStartRequested = false + activeTitle = null + activeShutdownRequestJson = null + cancellationApplied = true + appContext.getSystemService(NotificationManager::class.java).cancel(STREAM_NOTIFICATION_ID) + } +} + +class AndroidStreamKeepAliveService : Service() { + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var streamWakeLock: PowerManager.WakeLock? = null + private var activeShutdownRequest: ActiveStreamShutdownRequest? = null + private var taskRemovalCleanupJob: Job? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + runCatching { + when (intent?.action) { + STREAM_SERVICE_ACTION_STOP -> { + releaseStreamWakeLock() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf(startId) + } + STREAM_SERVICE_ACTION_START, null -> { + intent?.getStringExtra(STREAM_SERVICE_EXTRA_SHUTDOWN_REQUEST) + ?.let { encoded -> + runCatching { OpenNowJson.decodeFromString(encoded) } + .onSuccess { activeShutdownRequest = it } + .onFailure { error -> + Log.w(STREAM_SERVICE_TAG, "Unable to read active stream shutdown request", error) + } + } + startStreamForeground(intent?.getStringExtra(STREAM_SERVICE_EXTRA_TITLE) ?: "OpenNOW") + } + else -> { + releaseStreamWakeLock() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf(startId) + } + } + }.onFailure { error -> + Log.e(STREAM_SERVICE_TAG, "Stream foreground service failed", error) + stopSelf(startId) + } + return START_NOT_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onTaskRemoved(rootIntent: Intent?) { + val shutdownRequest = activeShutdownRequest + if (shutdownRequest != null && taskRemovalCleanupJob?.isActive != true) { + taskRemovalCleanupJob = serviceScope.launch { + terminateCloudSessionAfterTaskRemoval(shutdownRequest) + withContext(Dispatchers.Main.immediate) { + releaseStreamWakeLock() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + } else if (shutdownRequest == null) { + releaseStreamWakeLock() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + super.onTaskRemoved(rootIntent) + } + + override fun onDestroy() { + releaseStreamWakeLock() + serviceScope.cancel() + super.onDestroy() + } + + private suspend fun terminateCloudSessionAfterTaskRemoval(request: ActiveStreamShutdownRequest) { + runCatching { + withTimeout(STREAM_TASK_REMOVAL_TIMEOUT_MS) { + val openNowApplication = application as OpenNowApplication + val auth = openNowApplication.authRepository.restore( + forceRefresh = false, + throwOnRefreshFailure = false, + removeExpiredSessionOnFailure = false, + ) ?: error("No signed-in account is available to stop the stream") + GfnSessionRepository( + authStore = openNowApplication.authStore, + http = openNowApplication.httpClient, + ).stopSession( + token = auth.tokens.idToken ?: auth.tokens.accessToken, + input = request.session, + settings = request.settings, + ) + } + }.onSuccess { + SessionTimerAnchorStore(this).clear(request.session.sessionId) + Log.i(STREAM_SERVICE_TAG, "Stopped cloud session after app task removal") + }.onFailure { error -> + Log.w(STREAM_SERVICE_TAG, "Unable to stop cloud session after app task removal", error) + } + } + + private fun startStreamForeground(title: String) { + ensureStreamNotificationChannel(this) + acquireStreamWakeLock() + val notification = buildStreamNotification(this, title) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(STREAM_NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + } else { + startForeground(STREAM_NOTIFICATION_ID, notification) + } + } + + private fun acquireStreamWakeLock() { + if (streamWakeLock?.isHeld == true) return + streamWakeLock = getSystemService(PowerManager::class.java) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "OpenNOW:ActiveStream") + .apply { + setReferenceCounted(false) + acquire() + } + } + + private fun releaseStreamWakeLock() { + streamWakeLock?.takeIf { it.isHeld }?.release() + streamWakeLock = null + } +} + +private fun ensureStreamNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val notificationManager = context.applicationContext.getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + STREAM_CHANNEL_ID, + "Active stream", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Keeps an active OpenNOW stream connected while the screen is off." + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + setShowBadge(false) + } + notificationManager.createNotificationChannel(channel) +} + +private fun buildStreamNotification(context: Context, title: String): Notification { + val appContext = context.applicationContext + val openIntent = Intent(appContext, MainActivity::class.java).apply { + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + appContext, + 1, + openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(appContext, STREAM_CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(appContext) + } + return builder + .setSmallIcon(R.drawable.ic_tab_stream) + .setContentTitle(title) + .setContentText("Streaming continues while the screen is off") + .setSubText("OpenNOW") + .setCategory(Notification.CATEGORY_TRANSPORT) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setContentIntent(pendingIntent) + .build() +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AndroidStreamOrientation.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidStreamOrientation.kt new file mode 100644 index 000000000..b02ada9a5 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AndroidStreamOrientation.kt @@ -0,0 +1,18 @@ +package com.opencloudgaming.opennow + +internal const val PHONE_STREAM_LANDSCAPE_MAX_SMALLEST_WIDTH_DP = 600 + +internal fun shouldLockPhoneStreamLandscape( + state: OpenNowUiState, + smallestScreenWidthDp: Int, +): Boolean = + state.page == AppPage.Stream && + state.streamStatus in phoneStreamLandscapeStatuses && + state.streamSession?.isReadyForStream() == true && + !(state.androidTvProfile || state.codecReport?.androidTvProfile == true) && + isPhoneSizedAndroidDevice(smallestScreenWidthDp) + +private val phoneStreamLandscapeStatuses = setOf("connecting", "streaming") + +private fun isPhoneSizedAndroidDevice(smallestScreenWidthDp: Int): Boolean = + smallestScreenWidthDp in 1 until PHONE_STREAM_LANDSCAPE_MAX_SMALLEST_WIDTH_DP diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/AppUpdate.kt b/android/app/src/main/java/com/opencloudgaming/opennow/AppUpdate.kt new file mode 100644 index 000000000..b7e12920f --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/AppUpdate.kt @@ -0,0 +1,655 @@ +package com.opencloudgaming.opennow + +import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.content.FileProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.security.MessageDigest +import java.util.Locale + +private const val APK_MIME_TYPE = "application/vnd.android.package-archive" +internal const val ANDROID_UPDATE_SOURCE_URL = "https://api.printedwaste.com/releases/opennow/latest" +internal const val GOOGLE_PLAY_STORE_PACKAGE = "com.android.vending" +private const val UPDATE_FILE_PROVIDER_AUTHORITY_SUFFIX = ".updates" +private val UPDATE_USER_AGENT = "OpenNOW-AndroidUpdater/${BuildConfig.VERSION_NAME}" +private val KNOWN_PACKAGE_INSTALLER_GRANT_TARGETS = setOf( + "com.android.packageinstaller", + "com.google.android.packageinstaller", + "com.android.vending", +) + +enum class AndroidUpdateStatus { + Idle, + Checking, + Available, + NotAvailable, + Downloading, + Downloaded, + Error, +} + +data class AndroidAppInstallSource( + val installerPackageNames: Set = emptySet(), + val apkUpdatesSupportedByBuild: Boolean = BuildConfig.APK_UPDATES_SUPPORTED, +) { + val isGooglePlay: Boolean + get() = installerPackageNames.any(::isGooglePlayInstallerPackage) + + val distributionKind: String + get() = if (isGooglePlay) "play-store" else "apk" + + val buildDistributionKind: String + get() = if (apkUpdatesSupportedByBuild) "apk" else "play-release" + + val allowsApkUpdates: Boolean + get() = apkUpdatesSupportedByBuild && !isGooglePlay + + val displayName: String + get() = when { + isGooglePlay -> "Google Play" + installerPackageNames.isEmpty() -> "Sideloaded" + else -> installerPackageNames.sorted().joinToString(", ") + } +} + +internal fun AndroidUpdateState.debugHeaderLine(debugBuild: Boolean = BuildConfig.DEBUG): String { + val variant = if (debugBuild) "debug" else "release" + return listOf( + "app.version=$currentVersionName", + "build=$currentVersionCode", + "variant=$variant", + "distribution=${installSource.distributionKind}", + "installSource=${installSource.displayName}", + "buildDistribution=${installSource.buildDistributionKind}", + "apkUpdatesAllowed=$apkUpdatesAllowed", + ).joinToString(" ") +} + +data class AndroidUpdateProgress( + val percent: Int?, + val transferredBytes: Long, + val totalBytes: Long?, +) + +data class AndroidUpdateState( + val status: AndroidUpdateStatus = AndroidUpdateStatus.Idle, + val currentVersionName: String = BuildConfig.VERSION_NAME, + val currentVersionCode: Long = BuildConfig.VERSION_CODE.toLong(), + val sourceUrl: String = ANDROID_UPDATE_SOURCE_URL, + val installSource: AndroidAppInstallSource = AndroidAppInstallSource(), + val availableVersionName: String? = null, + val availableVersionCode: Long? = null, + val releaseNotes: String? = null, + val downloadedFileName: String? = null, + val progress: AndroidUpdateProgress? = null, + val message: String = "Ready to check for updates.", + val lastCheckedAt: Long? = null, +) { + val apkUpdatesAllowed: Boolean + get() = installSource.allowsApkUpdates + + val canCheck: Boolean + get() = apkUpdatesAllowed && status != AndroidUpdateStatus.Checking && status != AndroidUpdateStatus.Downloading + + val canDownload: Boolean + get() = apkUpdatesAllowed && status == AndroidUpdateStatus.Available + + val canInstall: Boolean + get() = apkUpdatesAllowed && status == AndroidUpdateStatus.Downloaded +} + +internal fun AndroidUpdateState.shouldRunAutomaticCheck(): Boolean { + if (!apkUpdatesAllowed) return false + return when (status) { + AndroidUpdateStatus.Checking, + AndroidUpdateStatus.Available, + AndroidUpdateStatus.Downloading, + AndroidUpdateStatus.Downloaded -> false + else -> true + } +} + +internal fun androidUpdateNoticeKey(update: AndroidUpdateState): String? = + if (!update.apkUpdatesAllowed) { + null + } else when (update.status) { + AndroidUpdateStatus.Available, + AndroidUpdateStatus.Downloading, + AndroidUpdateStatus.Downloaded, + -> listOfNotNull( + update.availableVersionCode?.let { "code:$it" }, + update.availableVersionName?.takeIf { it.isNotBlank() }?.let { "name:$it" }, + ).takeIf { it.isNotEmpty() }?.joinToString("|") + ?: update.sourceUrl.takeIf { it.isNotBlank() }?.let { "source:$it" } + else -> null + } + +internal fun androidUpdateUnavailableMessage(installSource: AndroidAppInstallSource): String = + when { + installSource.isGooglePlay -> "Installed from Google Play. Updates are handled by Google Play." + !installSource.apkUpdatesSupportedByBuild -> "APK self-updates are disabled in this Play release." + else -> "Ready to check for sideload APK updates." + } + +internal fun AndroidUpdateState.visibleNoticeKey(dismissedKey: String?): String? = + androidUpdateNoticeKey(this)?.takeUnless { it == dismissedKey } + +internal data class AndroidUpdateCandidate( + val sourceUrl: String, + val apkUrl: String, + val versionName: String?, + val versionCode: Long?, + val sha256: String?, + val releaseNotes: String?, + val fileName: String?, +) { + val displayVersion: String + get() = versionName ?: versionCode?.toString() ?: "APK update" +} + +class AndroidAppUpdater( + private val context: Context, + private val http: OkHttpClient, +) { + private val appContext = context.applicationContext + private val installSource = detectAndroidAppInstallSource(appContext) + private val _state = MutableStateFlow( + AndroidUpdateState( + installSource = installSource, + message = androidUpdateUnavailableMessage(installSource), + ), + ) + val state: StateFlow = _state + + private var latestCandidate: AndroidUpdateCandidate? = null + private var downloadedApk: File? = null + + suspend fun checkForUpdate(sourceUrl: String = ANDROID_UPDATE_SOURCE_URL) { + if (!_state.value.apkUpdatesAllowed) { + publishApkUpdatesUnavailable() + return + } + val normalizedSourceUrl = runCatching { normalizeAndroidUpdateSourceUrl(sourceUrl) }.getOrElse { error -> + publishError(sourceUrl, error.message ?: "Update source URL is invalid.") + return + } + withContext(Dispatchers.IO) { + publish( + status = AndroidUpdateStatus.Checking, + sourceUrl = normalizedSourceUrl, + message = "Checking update source...", + progress = null, + clearCandidate = true, + ) + try { + val candidate = fetchCandidate(normalizedSourceUrl) + ensureActive() + latestCandidate = candidate + downloadedApk = null + val checkedAt = System.currentTimeMillis() + if (candidate.versionCode != null && candidate.versionCode <= BuildConfig.VERSION_CODE.toLong()) { + publish( + status = AndroidUpdateStatus.NotAvailable, + sourceUrl = normalizedSourceUrl, + message = "OpenNOW Android is up to date.", + availableVersionName = candidate.versionName, + availableVersionCode = candidate.versionCode, + releaseNotes = candidate.releaseNotes, + lastCheckedAt = checkedAt, + ) + } else { + val compareHint = if (candidate.versionCode == null) { + " Version could not be compared, so only download this source if you trust it." + } else { + "" + } + publish( + status = AndroidUpdateStatus.Available, + sourceUrl = normalizedSourceUrl, + message = "OpenNOW ${candidate.displayVersion} is available to download.$compareHint", + availableVersionName = candidate.versionName, + availableVersionCode = candidate.versionCode, + releaseNotes = candidate.releaseNotes, + lastCheckedAt = checkedAt, + ) + } + } catch (error: Throwable) { + if (error is CancellationException) throw error + ensureActive() + publishError(normalizedSourceUrl, error.message ?: "Update check failed.") + } + } + } + + fun markCheckDeferredForStreaming() { + if (!_state.value.apkUpdatesAllowed) { + publishApkUpdatesUnavailable() + return + } + val current = _state.value + when (current.status) { + AndroidUpdateStatus.Available, + AndroidUpdateStatus.Downloading, + AndroidUpdateStatus.Downloaded -> return + else -> Unit + } + _state.value = current.copy( + status = if (current.status == AndroidUpdateStatus.Checking) AndroidUpdateStatus.Idle else current.status, + message = "Update checks pause while streaming.", + progress = null, + ) + } + + suspend fun downloadUpdate(sourceUrl: String = ANDROID_UPDATE_SOURCE_URL) { + if (!_state.value.apkUpdatesAllowed) { + publishApkUpdatesUnavailable() + return + } + val normalizedSourceUrl = runCatching { normalizeAndroidUpdateSourceUrl(sourceUrl) }.getOrElse { error -> + publishError(sourceUrl, error.message ?: "Update source URL is invalid.") + return + } + withContext(Dispatchers.IO) { + val candidate = latestCandidate + ?.takeIf { it.sourceUrl == normalizedSourceUrl } + ?: runCatching { fetchCandidate(normalizedSourceUrl) }.getOrElse { error -> + publishError(normalizedSourceUrl, error.message ?: "Update check failed.") + return@withContext + } + latestCandidate = candidate + publish( + status = AndroidUpdateStatus.Downloading, + sourceUrl = normalizedSourceUrl, + message = "Downloading OpenNOW ${candidate.displayVersion}...", + availableVersionName = candidate.versionName, + availableVersionCode = candidate.versionCode, + releaseNotes = candidate.releaseNotes, + progress = AndroidUpdateProgress(percent = 0, transferredBytes = 0, totalBytes = null), + ) + + runCatching { + downloadCandidate(candidate) + }.onSuccess { apk -> + downloadedApk = apk + publish( + status = AndroidUpdateStatus.Downloaded, + sourceUrl = normalizedSourceUrl, + message = "Downloaded ${apk.name}. Android will ask you to confirm the install.", + availableVersionName = candidate.versionName, + availableVersionCode = candidate.versionCode, + releaseNotes = candidate.releaseNotes, + downloadedFileName = apk.name, + progress = null, + ) + }.onFailure { error -> + publishError(normalizedSourceUrl, error.message ?: "Update download failed.") + } + } + } + + @Suppress("DEPRECATION") + fun installDownloadedUpdate() { + if (!_state.value.apkUpdatesAllowed) { + publishApkUpdatesUnavailable() + return + } + val apk = downloadedApk?.takeIf { it.exists() && it.isFile } ?: run { + publishError(_state.value.sourceUrl, "Downloaded APK is no longer available.") + return + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !appContext.packageManager.canRequestPackageInstalls()) { + val settingsIntent = Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${BuildConfig.APPLICATION_ID}"), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { appContext.startActivity(settingsIntent) } + _state.value = _state.value.copy( + status = AndroidUpdateStatus.Downloaded, + message = "Allow OpenNOW to install unknown apps, then tap Install again.", + ) + return + } + + val uri = FileProvider.getUriForFile(appContext, updateFileProviderAuthority(), apk) + val installIntent = Intent(Intent.ACTION_INSTALL_PACKAGE) + .setDataAndType(uri, APK_MIME_TYPE) + .putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + installIntent.clipData = ClipData.newRawUri("OpenNOW update", uri) + try { + grantInstallUriPermissions(uri, installIntent) + appContext.startActivity(installIntent) + _state.value = _state.value.copy( + status = AndroidUpdateStatus.Downloaded, + message = "Android package installer opened.", + ) + } catch (error: ActivityNotFoundException) { + publishError(_state.value.sourceUrl, error.message ?: "No package installer is available.") + } catch (error: SecurityException) { + publishError(_state.value.sourceUrl, error.message ?: "Android blocked package install access.") + } + } + + private fun fetchCandidate(sourceUrl: String): AndroidUpdateCandidate { + val request = Request.Builder() + .url(sourceUrl) + .header("User-Agent", UPDATE_USER_AGENT) + .build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + error("Update source returned HTTP ${response.code}.") + } + val contentType = response.header("Content-Type").orEmpty() + if (looksLikeApk(sourceUrl, contentType)) { + return directApkCandidate(sourceUrl, response.header("X-OpenNOW-Version-Name"), response.header("X-OpenNOW-Version-Code")?.toLongOrNull(), response.header("X-OpenNOW-SHA256")) + } + val body = response.body?.string()?.takeIf { it.isNotBlank() } ?: error("Update source returned an empty manifest.") + return parseAndroidUpdateCandidate(sourceUrl, body) + ?: error("Update manifest must provide versionCode/versionName and an apkUrl.") + } + } + + private fun downloadCandidate(candidate: AndroidUpdateCandidate): File { + val request = Request.Builder() + .url(candidate.apkUrl) + .header("User-Agent", UPDATE_USER_AGENT) + .build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + error("APK download returned HTTP ${response.code}.") + } + val body = response.body ?: error("APK download response was empty.") + val totalBytes = body.contentLength().takeIf { it > 0 } + val updatesDir = androidUpdateStorageDir(appContext).apply { + mkdirs() + listFiles()?.forEach { it.delete() } + } + val tmp = File(updatesDir, "opennow-update.tmp") + val outputName = candidate.safeFileName() + val outputFile = File(updatesDir, outputName) + var transferred = 0L + var lastPercent: Int? = null + var lastProgressAt = 0L + body.byteStream().use { input -> + tmp.outputStream().use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read == -1) break + output.write(buffer, 0, read) + transferred += read + val percent = totalBytes?.let { ((transferred * 100) / it).toInt().coerceIn(0, 100) } + val now = System.currentTimeMillis() + if (percent != lastPercent || now - lastProgressAt > 300L) { + lastPercent = percent + lastProgressAt = now + _state.value = _state.value.copy( + progress = AndroidUpdateProgress(percent = percent, transferredBytes = transferred, totalBytes = totalBytes), + ) + } + } + } + } + candidate.sha256?.takeIf { it.isNotBlank() }?.let { expected -> + val actual = tmp.sha256() + if (!actual.equals(expected.cleanHex(), ignoreCase = true)) { + tmp.delete() + error("Downloaded APK failed SHA-256 verification.") + } + } + if (outputFile.exists()) outputFile.delete() + if (!tmp.renameTo(outputFile)) { + tmp.copyTo(outputFile, overwrite = true) + tmp.delete() + } + return outputFile + } + } + + private fun publish( + status: AndroidUpdateStatus, + sourceUrl: String, + message: String, + availableVersionName: String? = null, + availableVersionCode: Long? = null, + releaseNotes: String? = null, + downloadedFileName: String? = null, + progress: AndroidUpdateProgress? = null, + lastCheckedAt: Long? = _state.value.lastCheckedAt, + clearCandidate: Boolean = false, + ) { + if (clearCandidate) { + latestCandidate = null + downloadedApk = null + } + _state.value = AndroidUpdateState( + status = status, + sourceUrl = sourceUrl, + installSource = _state.value.installSource, + availableVersionName = availableVersionName, + availableVersionCode = availableVersionCode, + releaseNotes = releaseNotes, + downloadedFileName = downloadedFileName, + progress = progress, + message = message, + lastCheckedAt = lastCheckedAt, + ) + } + + private fun publishApkUpdatesUnavailable() { + latestCandidate = null + downloadedApk = null + val current = _state.value + _state.value = current.copy( + status = AndroidUpdateStatus.Idle, + availableVersionName = null, + availableVersionCode = null, + releaseNotes = null, + downloadedFileName = null, + progress = null, + message = androidUpdateUnavailableMessage(current.installSource), + ) + } + + private fun publishError(sourceUrl: String, message: String) { + _state.value = _state.value.copy( + status = AndroidUpdateStatus.Error, + sourceUrl = sourceUrl, + message = message, + progress = null, + ) + } + + private fun updateFileProviderAuthority(): String = + "${BuildConfig.APPLICATION_ID}$UPDATE_FILE_PROVIDER_AUTHORITY_SUFFIX" + + private fun grantInstallUriPermissions(uri: Uri, installIntent: Intent) { + val packageManager = appContext.packageManager + val grantTargets = KNOWN_PACKAGE_INSTALLER_GRANT_TARGETS.toMutableSet() + packageManager.resolveActivity(installIntent, 0)?.activityInfo?.packageName?.let(grantTargets::add) + packageManager.queryIntentActivities(installIntent, 0) + .mapNotNullTo(grantTargets) { it.activityInfo?.packageName } + grantTargets.forEach { packageName -> + runCatching { + appContext.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + } +} + +internal fun androidUpdateStorageDir(context: Context): File = + File(context.applicationContext.filesDir, "updates") + +@Suppress("DEPRECATION") +internal fun detectAndroidAppInstallSource(context: Context): AndroidAppInstallSource { + val appContext = context.applicationContext + val packageManager = appContext.packageManager + val packageNames = linkedSetOf() + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val sourceInfo = packageManager.getInstallSourceInfo(appContext.packageName) + packageNames.addIfNotBlank(sourceInfo.initiatingPackageName) + packageNames.addIfNotBlank(sourceInfo.installingPackageName) + packageNames.addIfNotBlank(sourceInfo.originatingPackageName) + } else { + packageNames.addIfNotBlank(packageManager.getInstallerPackageName(appContext.packageName)) + } + } catch (_: PackageManager.NameNotFoundException) { + // PackageManager should know this app, but treat lookup failure like a sideload/unknown source. + } + return AndroidAppInstallSource(packageNames) +} + +internal fun isGooglePlayInstallerPackage(packageName: String?): Boolean = + packageName?.trim()?.equals(GOOGLE_PLAY_STORE_PACKAGE, ignoreCase = true) == true + +private fun MutableSet.addIfNotBlank(value: String?) { + value?.trim()?.takeIf { it.isNotBlank() }?.let(::add) +} + +internal fun normalizeAndroidUpdateSourceUrl(raw: String): String { + val trimmed = raw.trim() + require(trimmed.isNotBlank()) { "Add an update source URL first." } + val withScheme = if (Regex("^[a-z][a-z0-9+.-]*://", RegexOption.IGNORE_CASE).containsMatchIn(trimmed)) { + trimmed + } else { + "https://$trimmed" + } + val url = withScheme.toHttpUrlOrNull() ?: error("Update source URL is invalid.") + if (url.scheme != "https" && !url.isLoopbackHttp()) { + error("Use HTTPS for update sources. HTTP is only allowed for localhost.") + } + return url.toString() +} + +internal fun parseAndroidUpdateCandidate(sourceUrl: String, body: String): AndroidUpdateCandidate? { + val root = runCatching { OpenNowJson.parseToJsonElement(body).jsonObject }.getOrNull() ?: return null + parseGithubReleaseCandidate(sourceUrl, root)?.let { return it } + + val manifest = root.obj("android") ?: root.obj("androidUpdate") ?: root + val rawApkUrl = manifest.string("apkUrl", "apk_url", "artifactUrl", "artifact_url", "downloadUrl", "download_url", "url") ?: return null + val apkUrl = resolveUpdateUrl(sourceUrl, rawApkUrl) ?: return null + return AndroidUpdateCandidate( + sourceUrl = sourceUrl, + apkUrl = apkUrl, + versionName = manifest.string("versionName", "version_name", "name"), + versionCode = manifest.long("versionCode", "version_code", "androidVersionCode"), + sha256 = manifest.string("sha256", "sha256sum", "checksumSha256")?.cleanHex(), + releaseNotes = normalizeReleaseNotes(manifest.string("releaseNotes", "release_notes", "notes", "body")), + fileName = manifest.string("fileName", "file_name"), + ) +} + +private fun parseGithubReleaseCandidate(sourceUrl: String, root: JsonObject): AndroidUpdateCandidate? { + val assets = root["assets"] as? JsonArray ?: return null + val apkAsset = assets.mapNotNull { it as? JsonObject } + .firstOrNull { asset -> + val name = asset.string("name").orEmpty() + val contentType = asset.string("content_type").orEmpty() + name.endsWith(".apk", ignoreCase = true) || contentType.equals(APK_MIME_TYPE, ignoreCase = true) + } ?: return null + val apkUrl = apkAsset.string("browser_download_url", "downloadUrl", "url")?.let { resolveUpdateUrl(sourceUrl, it) } ?: return null + val versionCode = root.long("versionCode", "version_code", "androidVersionCode") + return AndroidUpdateCandidate( + sourceUrl = sourceUrl, + apkUrl = apkUrl, + versionName = root.string("tag_name", "name")?.removePrefix("v"), + versionCode = versionCode, + sha256 = apkAsset.string("sha256", "digest")?.removePrefix("sha256:")?.cleanHex(), + releaseNotes = normalizeReleaseNotes(root.string("body")), + fileName = apkAsset.string("name"), + ) +} + +private fun directApkCandidate(sourceUrl: String, versionName: String?, versionCode: Long?, sha256: String?): AndroidUpdateCandidate = + AndroidUpdateCandidate( + sourceUrl = sourceUrl, + apkUrl = sourceUrl, + versionName = versionName, + versionCode = versionCode, + sha256 = sha256?.cleanHex(), + releaseNotes = null, + fileName = sourceUrl.toHttpUrlOrNull()?.pathSegments?.lastOrNull(), + ) + +private fun resolveUpdateUrl(sourceUrl: String, value: String): String? { + val trimmed = value.trim() + val url = trimmed.toHttpUrlOrNull() ?: sourceUrl.toHttpUrlOrNull()?.resolve(trimmed) + return url?.takeIf { it.scheme == "https" || it.isLoopbackHttp() }?.toString() +} + +private fun looksLikeApk(url: String, contentType: String): Boolean = + url.substringBefore("?").endsWith(".apk", ignoreCase = true) || + contentType.substringBefore(";").trim().equals(APK_MIME_TYPE, ignoreCase = true) + +private fun HttpUrl.isLoopbackHttp(): Boolean = + scheme == "http" && host.lowercase(Locale.US) in setOf("localhost", "127.0.0.1", "::1") + +private fun AndroidUpdateCandidate.safeFileName(): String { + val raw = fileName + ?: apkUrl.toHttpUrlOrNull()?.pathSegments?.lastOrNull() + ?: "OpenNOW-${versionName ?: versionCode ?: "update"}.apk" + val normalized = raw.substringBefore("?") + .replace(Regex("[^A-Za-z0-9._-]"), "_") + .takeIf { it.endsWith(".apk", ignoreCase = true) } + ?: "OpenNOW-${versionName ?: versionCode ?: "update"}.apk" + return normalized +} + +private fun File.sha256(): String { + val digest = MessageDigest.getInstance("SHA-256") + inputStream().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read == -1) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } +} + +private fun String.cleanHex(): String = + trim().lowercase(Locale.US).removePrefix("sha256:").filter { it in '0'..'9' || it in 'a'..'f' } + +private fun normalizeReleaseNotes(value: String?): String? = + value + ?.replace("\\r\\n", "\n") + ?.replace("\\n", "\n") + ?.replace("\r\n", "\n") + ?.replace('\r', '\n') + ?.takeIf { it.isNotBlank() } + +private fun JsonObject.string(vararg keys: String): String? = + keys.firstNotNullOfOrNull { key -> + this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } + } + +private fun JsonObject.long(vararg keys: String): Long? = + keys.firstNotNullOfOrNull { key -> + this[key]?.jsonPrimitive?.longOrNull + } + +private fun JsonObject.obj(key: String): JsonObject? = this[key] as? JsonObject diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/BugReports.kt b/android/app/src/main/java/com/opencloudgaming/opennow/BugReports.kt new file mode 100644 index 000000000..12e8a5052 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/BugReports.kt @@ -0,0 +1,121 @@ +package com.opencloudgaming.opennow + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +internal const val ANDROID_BUG_REPORT_ENDPOINT = + "https://api.printedwaste.com/releases/opennow/bug-reports" +internal const val ANDROID_BUG_REPORT_MAX_FILES = 5 +internal const val ANDROID_BUG_REPORT_MAX_FILE_BYTES = 10L * 1024L * 1024L + +internal data class AndroidBugReportAttachment( + val fileName: String, + val contentType: String, + val bytes: ByteArray, +) + +internal data class AndroidBugReport( + val title: String, + val description: String, + val versionName: String, + val versionCode: String, + val metadata: String, + val files: List, +) + +internal data class AndroidBugReportReceipt( + val reference: String?, +) + +internal fun buildAndroidBugReportMetadata( + logFileName: String, +): String = buildJsonObject { + put("source", "settings-advanced-debug-logs") + put("attachment", logFileName) +}.toString() + +internal fun buildAndroidBugReportRequest( + report: AndroidBugReport, + endpoint: String = ANDROID_BUG_REPORT_ENDPOINT, +): Request { + val title = report.title.trim() + val description = report.description.trim() + require(title.isNotEmpty()) { "Enter a short issue title" } + require(description.isNotEmpty()) { "Describe what happened" } + require(report.versionName.isNotBlank()) { "App version is unavailable" } + require(report.versionCode.isNotBlank()) { "App build is unavailable" } + require(report.files.size <= ANDROID_BUG_REPORT_MAX_FILES) { + "Bug reports support up to $ANDROID_BUG_REPORT_MAX_FILES files" + } + runCatching { OpenNowJson.parseToJsonElement(report.metadata).jsonObject } + .getOrElse { throw IllegalArgumentException("Bug report metadata must be a JSON object", it) } + + val multipart = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("title", title) + .addFormDataPart("description", description) + .addFormDataPart("versionName", report.versionName) + .addFormDataPart("versionCode", report.versionCode) + .addFormDataPart("platform", "android") + .addFormDataPart("metadata", report.metadata) + + report.files.forEach { attachment -> + require(attachment.fileName.isNotBlank()) { "Bug report files must have a name" } + require(attachment.bytes.size.toLong() <= ANDROID_BUG_REPORT_MAX_FILE_BYTES) { + "${attachment.fileName} is larger than 10 MiB" + } + val mediaType = attachment.contentType.toMediaType() + multipart.addFormDataPart( + "files", + attachment.fileName, + attachment.bytes.toRequestBody(mediaType), + ) + } + + return Request.Builder() + .url(endpoint) + .header("Accept", "application/json") + .post(multipart.build()) + .build() +} + +internal suspend fun uploadAndroidBugReport( + http: OkHttpClient, + report: AndroidBugReport, +): AndroidBugReportReceipt = withContext(Dispatchers.IO) { + http.newCall(buildAndroidBugReportRequest(report)).execute().use { response -> + val body = response.body.string().take(MAX_BUG_REPORT_RESPONSE_CHARS) + if (!response.isSuccessful) { + val detail = body + .lineSequence() + .joinToString(" ") { it.trim() } + .take(320) + .takeIf(String::isNotBlank) + error( + buildString { + append("Bug report upload failed (HTTP ${response.code})") + detail?.let { append(": $it") } + }, + ) + } + AndroidBugReportReceipt(reference = parseAndroidBugReportReference(body)) + } +} + +internal fun parseAndroidBugReportReference(body: String): String? = runCatching { + val json = OpenNowJson.parseToJsonElement(body).jsonObject + listOf("id", "reportId", "bugReportId") + .firstNotNullOfOrNull { key -> json[key]?.jsonPrimitive?.contentOrNull?.takeIf(String::isNotBlank) } +}.getOrNull() + +private const val MAX_BUG_REPORT_RESPONSE_CHARS = 64 * 1024 diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/CellularNetworkStatus.kt b/android/app/src/main/java/com/opencloudgaming/opennow/CellularNetworkStatus.kt new file mode 100644 index 000000000..31dce7f48 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/CellularNetworkStatus.kt @@ -0,0 +1,107 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import android.os.Build +import android.telephony.PhoneStateListener +import android.telephony.TelephonyCallback +import android.telephony.TelephonyDisplayInfo +import android.telephony.TelephonyManager + +/** + * Owns the carrier-facing cellular generation shown by compact stream stats. + * TelephonyDisplayInfo is intentionally used instead of guessing from bandwidth: + * it includes carrier display overrides such as 5G NSA and 5G+. + */ +internal object CellularNetworkStatus { + @Volatile + private var displayLabel: String? = null + + @Volatile + private var monitoringStarted = false + + private var retainedCallback: Any? = null + + fun displayLabel(context: Context): String? { + startMonitoring(context.applicationContext) + return displayLabel + } + + fun signalBars(context: Context): Int? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null + val telephony = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager ?: return null + return runCatching { telephony.signalStrength?.level?.coerceIn(0, 4) }.getOrNull() + } + + @Synchronized + private fun startMonitoring(context: Context) { + if (monitoringStarted || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + val telephony = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager ?: return + monitoringStarted = true + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + registerModernCallback(context, telephony) + } else { + registerAndroid11Listener(telephony) + } + }.onFailure { + monitoringStarted = false + retainedCallback = null + } + } + + @android.annotation.TargetApi(Build.VERSION_CODES.S) + private fun registerModernCallback(context: Context, telephony: TelephonyManager) { + val callback = object : TelephonyCallback(), TelephonyCallback.DisplayInfoListener { + override fun onDisplayInfoChanged(displayInfo: TelephonyDisplayInfo) { + displayLabel = cellularGenerationLabel(displayInfo.networkType, displayInfo.overrideNetworkType) + } + } + retainedCallback = callback + telephony.registerTelephonyCallback(context.mainExecutor, callback) + } + + @Suppress("DEPRECATION") + @android.annotation.TargetApi(Build.VERSION_CODES.R) + private fun registerAndroid11Listener(telephony: TelephonyManager) { + val listener = object : PhoneStateListener() { + override fun onDisplayInfoChanged(displayInfo: TelephonyDisplayInfo) { + displayLabel = cellularGenerationLabel(displayInfo.networkType, displayInfo.overrideNetworkType) + } + } + retainedCallback = listener + telephony.listen(listener, PhoneStateListener.LISTEN_DISPLAY_INFO_CHANGED) + } +} + +internal fun cellularGenerationLabel(networkType: Int, overrideNetworkType: Int): String? = when (overrideNetworkType) { + TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_ADVANCED, + TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA_MMWAVE, + -> "5G+" + TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA -> "5G" + TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_ADVANCED_PRO, + TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_CA, + -> "LTE+" + else -> when (networkType) { + TelephonyManager.NETWORK_TYPE_NR -> "5G" + TelephonyManager.NETWORK_TYPE_LTE -> "LTE" + TelephonyManager.NETWORK_TYPE_HSPAP -> "H+" + TelephonyManager.NETWORK_TYPE_HSPA, + TelephonyManager.NETWORK_TYPE_HSDPA, + TelephonyManager.NETWORK_TYPE_HSUPA, + -> "H" + TelephonyManager.NETWORK_TYPE_UMTS, + TelephonyManager.NETWORK_TYPE_TD_SCDMA, + TelephonyManager.NETWORK_TYPE_EVDO_0, + TelephonyManager.NETWORK_TYPE_EVDO_A, + TelephonyManager.NETWORK_TYPE_EVDO_B, + TelephonyManager.NETWORK_TYPE_EHRPD, + -> "3G" + TelephonyManager.NETWORK_TYPE_EDGE -> "E" + TelephonyManager.NETWORK_TYPE_GPRS, + TelephonyManager.NETWORK_TYPE_GSM, + TelephonyManager.NETWORK_TYPE_CDMA, + TelephonyManager.NETWORK_TYPE_1xRTT, + -> "2G" + else -> null + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/Diagnostics.kt b/android/app/src/main/java/com/opencloudgaming/opennow/Diagnostics.kt new file mode 100644 index 000000000..389a4bba2 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/Diagnostics.kt @@ -0,0 +1,260 @@ +package com.opencloudgaming.opennow + +import android.os.SystemClock +import android.util.Log +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Request +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import okio.Buffer +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal const val OPENNOW_DEBUG_LOG_TAG = "OpenNOWDebug" + +private const val DIAGNOSTIC_PAYLOAD_BODY_LIMIT = 20_000 +private const val HTTP_DIAGNOSTIC_LIMIT = 80 +private const val HTTP_DIAGNOSTIC_BODY_LIMIT = 4_000 +private const val HTTP_DIAGNOSTIC_MAX_REQUEST_CAPTURE_BYTES = 48_000L + +private val DebugPayloadJson = Json { + prettyPrint = true + ignoreUnknownKeys = true + explicitNulls = false + isLenient = true + encodeDefaults = true +} + +internal object OpenNowHttpDiagnostics { + private val lines = ArrayDeque() + + @Synchronized + fun record( + request: Request, + requestBody: String, + statusCode: Int?, + responseBody: String, + elapsedMs: Long, + error: Throwable? = null, + ) { + val status = statusCode?.toString() ?: "ERR:${error?.javaClass?.simpleName ?: "unknown"}" + val requestBytes = request.body?.safeContentLength()?.takeIf { it >= 0 }?.toString() ?: "none" + val responseBytes = if (responseBody.isBlank() && error != null) "none" else responseBody.length.toString() + val requestPreview = requestBody.takeIf { it.isNotBlank() }?.let(::singleLineDiagnosticPreview) + val responsePreview = responseBody.takeIf { it.isNotBlank() }?.let(::singleLineDiagnosticPreview) + val errorMessage = error?.let { "${it.javaClass.simpleName}: ${it.message.orEmpty()}".take(320) } + val line = buildString { + append(SystemClock.elapsedRealtime()) + append(' ') + append(request.method) + append(' ') + append(redactDiagnosticUrl(request.url.toString())) + append(" -> http=") + append(status) + append(" elapsedMs=") + append(elapsedMs) + append(" reqBytes=") + append(requestBytes) + append(" respBytes=") + append(responseBytes) + if (!requestPreview.isNullOrBlank()) { + append(" request=") + append(requestPreview) + } + if (!responsePreview.isNullOrBlank()) { + append(" response=") + append(responsePreview) + } + if (!errorMessage.isNullOrBlank()) { + append(" error=") + append(errorMessage) + } + } + lines.addLast(line) + while (lines.size > HTTP_DIAGNOSTIC_LIMIT) { + lines.removeFirst() + } + Log.d(OPENNOW_DEBUG_LOG_TAG, "http: $line") + } + + fun captureRequestBody(request: Request): String { + val body = request.body ?: return "" + val contentLength = body.safeContentLength() + if (contentLength > HTTP_DIAGNOSTIC_MAX_REQUEST_CAPTURE_BYTES) { + return "(request body omitted ${contentLength}B)" + } + if (body.isDuplex()) return "(duplex request body omitted)" + if (body.isOneShot()) return "(one-shot request body omitted)" + return runCatching { + val buffer = Buffer() + body.writeTo(buffer) + buffer.readUtf8() + }.getOrElse { error -> + "(request body unavailable ${error.javaClass.simpleName})" + } + } + + @Synchronized + fun snapshot(): String = + if (lines.isEmpty()) { + "network.diagnostics=empty" + } else { + buildString { + appendLine("network.diagnostics:") + lines.forEachIndexed { index, line -> + appendLine("network.${index + 1} $line") + } + }.trimEnd() + } + +} + +internal fun sanitizeDiagnosticLogPayload( + raw: String, + limit: Int = DIAGNOSTIC_PAYLOAD_BODY_LIMIT, +): String { + val trimmed = raw.trim() + if (trimmed.isBlank()) return "(empty)" + val formatted = runCatching { + val sanitized = redactDiagnosticJsonElement(OpenNowJson.parseToJsonElement(trimmed)) + DebugPayloadJson.encodeToString(JsonElement.serializer(), sanitized) + }.getOrElse { + redactDiagnosticText(trimmed) + } + val redacted = redactDiagnosticText(formatted) + return if (redacted.length <= limit) { + redacted + } else { + redacted.take(limit) + "\n... truncated ${redacted.length - limit} chars ..." + } +} + +internal fun redactDiagnosticUrl(raw: String): String { + val parsed = raw.toHttpUrlOrNull() ?: return redactDiagnosticText(raw) + val redactedNames = (0 until parsed.querySize) + .map { parsed.queryParameterName(it) } + .filter(::shouldRedactDiagnosticKey) + .distinct() + if (redactedNames.isEmpty()) return raw + val builder = parsed.newBuilder() + redactedNames.forEach { name -> builder.setQueryParameter(name, "[redacted]") } + return builder.build().toString() +} + +private fun redactDiagnosticJsonElement(element: JsonElement, keyHint: String? = null): JsonElement = + when { + keyHint != null && shouldRedactDiagnosticKey(keyHint) -> JsonPrimitive("[redacted]") + element is JsonObject -> JsonObject(element.mapValues { (key, value) -> redactDiagnosticJsonElement(value, key) }) + element is JsonArray -> JsonArray(element.map { redactDiagnosticJsonElement(it) }) + else -> element + } + +private fun shouldRedactDiagnosticKey(key: String): Boolean { + val normalized = key.lowercase(Locale.US).filter(Char::isLetterOrDigit) + return normalized.contains("authorization") || + normalized.contains("token") || + normalized.contains("credential") || + normalized.contains("password") || + normalized.contains("secret") || + normalized.contains("cookie") || + normalized == "code" || + normalized == "devicecode" || + normalized == "usercode" || + normalized == "verificationuricomplete" || + normalized == "deviceid" || + normalized == "devicehashid" || + normalized == "sub" || + normalized == "email" || + normalized == "userid" +} + +private fun redactDiagnosticText(text: String): String { + val sensitive = Regex( + """(?i)\b(authorization|access[_-]?token|id[_-]?token|refresh[_-]?token|client[_-]?token|device[_-]?code|user[_-]?code|verification[_-]?uri[_-]?complete|credential|password|secret|cookie|code|sub)(\s*[=:]\s*)([^\s,;&]+)""", + ) + return sensitive.replace(text) { match -> + "${match.groupValues[1]}${match.groupValues[2]}[redacted]" + } +} + +internal fun sanitizeDiagnosticExport(raw: String): String { + var sanitized = Regex("""(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+""").replace(raw, "Bearer [redacted]") + sanitized = redactDiagnosticText(sanitized) + sanitized = Regex( + """(?i)([\"']?(?:email|user(?:[_-]?id|[_-]?name)?|display[_-]?name|account[_-]?id|profile[_-]?id|session[_-]?id|server[_-]?ip|device[_-]?id|device[_-]?name|ip[_-]?address)[\"']?\s*:\s*)(\"(?:\\.|[^\"])*\"|'(?:\\.|[^'])*'|[^,}\r\n]+)""", + ).replace(sanitized) { match -> + "${match.groupValues[1]}\"[redacted]\"" + } + sanitized = Regex( + """(?im)\b(email|user|user[_-]?id|user[_-]?name|display[_-]?name|account|account[_-]?id|profile[_-]?id|session|session[_-]?id|server|server[_-]?ip|device|device[_-]?id|device[_-]?name|ip[_-]?address)(\s*[=:]\s*)(.*?)(?=\s+[a-z][a-z0-9_.-]*\s*[=:]|$)""", + ).replace(sanitized) { match -> + "${match.groupValues[1]}${match.groupValues[2]}[redacted]" + } + sanitized = Regex("""\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b""", RegexOption.IGNORE_CASE) + .replace(sanitized, "[redacted-email]") + sanitized = Regex("""\b(?:\d{1,3}\.){3}\d{1,3}\b""").replace(sanitized, "[redacted-ip]") + sanitized = Regex("""(?i)(? + val body = response.body.string().trim() + if (!response.isSuccessful) { + error("Diagnostics upload failed (HTTP ${response.code})") + } + val jsonUrl = runCatching { + OpenNowJson.parseToJsonElement(body).jsonObject["url"]?.jsonPrimitive?.content + }.getOrNull() + (jsonUrl ?: body.lineSequence().firstOrNull { it.startsWith("https://") }) + ?.trim() + ?.takeIf { it.startsWith("https://paste.rtech.support/") } + ?: error("Diagnostics upload returned no paste URL") + } +} + +private fun singleLineDiagnosticPreview(raw: String): String { + // Parsing and pretty-printing multi-hundred-kilobyte catalog responses used to run + // before the preview was truncated. Keep diagnostics bounded before any JSON work. + val bounded = if (raw.length > HTTP_DIAGNOSTIC_BODY_LIMIT * 2) { + raw.take(HTTP_DIAGNOSTIC_BODY_LIMIT * 2) + + "\n... omitted ${raw.length - (HTTP_DIAGNOSTIC_BODY_LIMIT * 2)} chars before formatting ..." + } else { + raw + } + return redactDiagnosticText(sanitizeDiagnosticLogPayload(bounded, HTTP_DIAGNOSTIC_BODY_LIMIT)) + .lineSequence() + .joinToString(" ") { it.trim() } + .take(HTTP_DIAGNOSTIC_BODY_LIMIT) +} + +private fun okhttp3.RequestBody.safeContentLength(): Long = + runCatching { contentLength() }.getOrDefault(-1L) diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/DisplayRefreshRate.kt b/android/app/src/main/java/com/opencloudgaming/opennow/DisplayRefreshRate.kt new file mode 100644 index 000000000..ba6311f80 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/DisplayRefreshRate.kt @@ -0,0 +1,99 @@ +package com.opencloudgaming.opennow + +import kotlin.math.abs +import kotlin.math.roundToInt + +internal data class DisplayRefreshMode( + val id: Int, + val refreshRate: Float, + val physicalWidth: Int, + val physicalHeight: Int, +) + +internal object DisplayRefreshDiagnostics { + @Volatile + private var latestSnapshot = "display.refresh=unavailable" + + fun update( + active: Boolean, + requestedFps: Int, + currentMode: DisplayRefreshMode?, + selectedMode: DisplayRefreshMode?, + supportedModes: List, + preferredModeId: Int, + preferredRefreshRate: Float, + applied: Boolean, + error: Throwable? = null, + ) { + latestSnapshot = buildString { + appendLine("display.refresh.active=$active requestedFps=$requestedFps applied=$applied") + appendLine("display.refresh.current=${currentMode.debugLabel()} selected=${selectedMode.debugLabel()}") + appendLine("display.refresh.preferredModeId=$preferredModeId preferredRefreshRate=${preferredRefreshRate.formatRefreshRate()}") + appendLine("display.refresh.supported=${supportedModes.supportedModesLabel()}") + error?.let { + appendLine("display.refresh.error=${it.javaClass.simpleName}:${it.message.orEmpty().take(120)}") + } + }.trimEnd() + } + + fun snapshot(): String = latestSnapshot +} + +internal fun selectStreamDisplayMode( + supportedModes: List, + currentMode: DisplayRefreshMode?, + requestedFps: Int, +): DisplayRefreshMode? { + if (supportedModes.isEmpty()) return null + val target = requestedFps.coerceIn(MIN_STREAM_DISPLAY_FPS, MAX_STREAM_DISPLAY_FPS).toFloat() + val resolutionMatched = currentMode?.let { current -> + supportedModes.filter { mode -> + mode.physicalWidth == current.physicalWidth && mode.physicalHeight == current.physicalHeight + } + }.orEmpty() + val candidates = resolutionMatched.ifEmpty { supportedModes } + val cadenceMatched = candidates.filter { mode -> + mode.refreshRate + STREAM_REFRESH_TOLERANCE_FPS >= target && + streamCadenceError(mode.refreshRate, target) <= STREAM_CADENCE_TOLERANCE + } + val currentCandidate = currentMode?.takeIf { current -> + cadenceMatched.any { mode -> mode.id == current.id } + } + if (currentCandidate != null) return currentCandidate + + return cadenceMatched.minByOrNull { it.refreshRate } + ?: candidates + .filter { it.refreshRate + STREAM_REFRESH_TOLERANCE_FPS >= target } + .minByOrNull { it.refreshRate } + ?: candidates.maxByOrNull { it.refreshRate } +} + +private fun streamCadenceError(refreshRate: Float, streamFps: Float): Float { + if (refreshRate <= 0f || streamFps <= 0f) return Float.MAX_VALUE + val ratio = refreshRate / streamFps + return abs(ratio - ratio.roundToInt().coerceAtLeast(1)) +} + +internal fun normalizedStreamDisplayFps(requestedFps: Int): Float = + requestedFps.coerceIn(MIN_STREAM_DISPLAY_FPS, MAX_STREAM_DISPLAY_FPS).toFloat() + +private fun List.supportedModesLabel(): String = + if (isEmpty()) { + "[]" + } else { + sortedWith(compareBy { it.physicalWidth * it.physicalHeight }.thenBy { it.refreshRate }.thenBy { it.id }) + .joinToString(prefix = "[", postfix = "]") { it.debugLabel() } + } + +private fun DisplayRefreshMode?.debugLabel(): String = + this?.let { "id=${it.id}:${it.physicalWidth}x${it.physicalHeight}@${it.refreshRate.formatRefreshRate()}Hz" } ?: "none" + +private fun Float.formatRefreshRate(): String { + val rounded = (this * 100f).roundToInt() / 100f + return if (rounded % 1f == 0f) rounded.toInt().toString() else rounded.toString() +} + +private const val MIN_STREAM_DISPLAY_FPS = 30 +private const val MAX_STREAM_DISPLAY_FPS = 240 +private const val STREAM_REFRESH_TOLERANCE_FPS = 0.5f +private const val STREAM_CADENCE_TOLERANCE = 0.01f diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/GfnApi.kt b/android/app/src/main/java/com/opencloudgaming/opennow/GfnApi.kt new file mode 100644 index 000000000..5719264f8 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/GfnApi.kt @@ -0,0 +1,2987 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.SystemClock +import android.util.Base64 +import androidx.browser.customtabs.CustomTabsIntent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import okhttp3.Dns +import okhttp3.FormBody +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Credentials +import okhttp3.dnsoverhttps.DnsOverHttps +import java.io.Closeable +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.BindException +import java.net.InetSocketAddress +import java.net.InetAddress +import java.net.Proxy +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException +import java.net.SocketTimeoutException +import java.net.URI +import java.net.UnknownHostException +import java.net.URLDecoder +import java.net.URLEncoder +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Locale +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.math.max +import kotlin.math.min + +private const val GFN_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 NVIDIACEFClient/HEAD/debb5919f6 GFN-PC/2.0.80.173" +private const val GFN_CLIENT_VERSION = "2.0.80.173" +private const val LCARS_CLIENT_ID = "ec7e38d4-03af-4b58-b131-cfb0495903ab" +private const val GFN_PLAY_ORIGIN = "https://play.geforcenow.com" +private const val GFN_PLAY_REFERER = "https://play.geforcenow.com/" +private const val NVIDIA_FILE_ORIGIN = "https://nvfile" +private const val NVIDIA_FILE_REFERER = "https://nvfile/" +private const val SERVICE_URLS_ENDPOINT = "https://pcs.geforcenow.com/v1/serviceUrls" +private const val TOKEN_ENDPOINT = "https://login.nvidia.com/token" +private const val CLIENT_TOKEN_ENDPOINT = "https://login.nvidia.com/client_token" +private const val USERINFO_ENDPOINT = "https://login.nvidia.com/userinfo" +private const val AUTH_ENDPOINT = "https://login.nvidia.com/authorize" +private const val DEVICE_AUTHORIZATION_ENDPOINT = "https://login.nvidia.com/device/authorize" +private const val GAMES_GRAPHQL_URL = "https://games.geforce.com/graphql" +private const val MES_URL = "https://mes.geforcenow.com/v4/subscriptions" +private const val PRINTEDWASTE_QUEUE_URL = "https://api.printedwaste.com/gfn/queue/" +private const val PRINTEDWASTE_SERVER_MAPPING_URL = "https://remote.printedwaste.com/config/GFN_SERVERID_TO_REGION_MAPPING" +private const val DEFAULT_STREAMING_SERVICE_URL = "https://prod.cloudmatchbeta.nvidiagrid.net/" +private const val CLIENT_ID = "ZU7sPN-miLujMD95LfOQ453IB0AtjM8sMyvgJ9wCXEQ" +private const val DEVICE_CODE_CLIENT_ID = "q61ddeJrVt7O90Nl-P-N7I36yctih4Ml6FyXLrb6j-U" +private const val DEFAULT_IDP_ID = "PDiAhv2kJTFeQ7WOPqiQ2tRZ7lGhR2X11dXvM4TZSxg" +private const val SCOPES = "openid consent email tk_client age" +private const val PANELS_QUERY_HASH = "f8e26265a5db5c20e1334a6872cf04b6e3970507697f6ae55a6ddefa5420daf0" +private const val APP_METADATA_QUERY_HASH = "39187e85b6dcf60b7279a5f233288b0a8b69a8b1dbcfb5b25555afdcb988f0d7" + +private object CloudMatchDesktopIdentity { + // WebRTC is the transport, but CloudMatch uses the desktop/native identity and + // monitor descriptor to allocate the full requested resolution and frame rate. + const val PLATFORM_NAME = "windows" + const val APP_LAUNCH_MODE = 1 + const val PERSIST_GAME_SETTINGS = true + const val STREAMER = "NVIDIA-CLASSIC" + const val CLIENT_TYPE = "NATIVE" + const val DEVICE_OS = "WINDOWS" + const val DEVICE_TYPE = "DESKTOP" +} +private const val LIBRARY_WITH_TIME_QUERY_HASH = "039e8c0d553972975485fee56e59f2549d2fdb518e247a42ab5022056a74406f" +private const val DEFAULT_LOCALE = "en_US" +private const val DEFAULT_CATALOG_FETCH_COUNT = 120 +private const val MAX_CATALOG_PAGES = 3 +private const val DEFAULT_SORT_ID = "relevance" +private const val SESSION_MODIFY_ACTION_AD_UPDATE = 6 +internal const val OPENNOW_STREAM_SETTINGS_METADATA_KEY = "OpenNOWStreamSettingsSignature" +private const val STORAGE_ADDON_TYPE = "STORAGE" +private const val TOTAL_STORAGE_SIZE_IN_GB = "TOTAL_STORAGE_SIZE_IN_GB" +private const val USED_STORAGE_SIZE_IN_GB = "USED_STORAGE_SIZE_IN_GB" +private const val STORAGE_METRO_REGION = "STORAGE_METRO_REGION" +private const val STORAGE_METRO_REGION_NAME = "STORAGE_METRO_REGION_NAME" +private const val ACCOUNT_LINKING_BASE_URL = "https://als.geforcenow.com/v1" +private const val ACCOUNT_LINKING_CLIENT_ID = "gfn-pc" +private const val ACCOUNT_LINKING_REDIRECT_URL = "http://localhost:2259/" + +private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() +private val GRAPHQL_MEDIA_TYPE = "application/graphql".toMediaType() +private val REDIRECT_PORTS = intArrayOf(2259, 6460, 7119, 8870, 9096) +private const val OAUTH_CALLBACK_TIMEOUT_MS = 120_000L +private const val OAUTH_CALLBACK_PROBE_TIMEOUT_MS = 2_000 +private const val OAUTH_CALLBACK_PROBE_PATH = "/opennow-callback-probe" +private const val DEVICE_CODE_MIN_POLL_INTERVAL_SECONDS = 5 +internal const val TOKEN_REFRESH_WINDOW_MS = 10 * 60 * 1000L +internal const val CLIENT_TOKEN_REFRESH_WINDOW_MS = 5 * 60 * 1000L +private val AUTH_RESTORE_MUTEX = Mutex() +private val READY_SESSION_STATUSES = setOf(2, 3) +private const val INVALID_SESSION_PROXY_MESSAGE = + "Invalid session proxy URL. Use http://host:port, https://host:port, socks4://host:port, or socks5://host:port." + +internal class SessionClaimNotReadyException( + val latestSession: SessionInfo?, +) : IllegalStateException("Session did not become ready after claiming.") + +val OpenNowJson: Json = Json { + ignoreUnknownKeys = true + explicitNulls = false + isLenient = true + encodeDefaults = true +} + +fun defaultHttpClient(): OkHttpClient = + OkHttpClient.Builder() + .addInterceptor { chain -> + val request = chain.request() + val canonicalUrl = canonicalizeGfnRequestUrl(request.url) + val canonicalRequest = if (canonicalUrl == request.url) { + request + } else { + request.newBuilder().url(canonicalUrl).build() + } + chain.proceed(canonicalRequest) + } + .dns(OpenNowDns) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .pingInterval(15, TimeUnit.SECONDS) + .build() + +internal fun canonicalizeGfnRequestUrl(url: HttpUrl): HttpUrl = + when (url.host.lowercase(Locale.US)) { + "games.geforcenow.com" -> url.newBuilder().host("games.geforce.com").build() + else -> url + } + +private fun metadataEntry(key: String, value: String): JsonObject = buildJsonObject { + put("key", key) + put("value", value) +} + +private fun hdrCapabilitiesJson(): JsonObject = + buildJsonObject { + put("version", 1) + put("hdrEdrSupportedFlagsInUint32", 1) + put("staticMetadataDescriptorId", 0) + } + +private fun hdrDisplayDataJson(): JsonObject = + buildJsonObject { + put("desiredContentMaxLuminance", 1000) + put("desiredContentMinLuminance", 0) + put("desiredContentMaxFrameAverageLuminance", 500) + } + +private data class StreamRequestProfile( + val width: Int, + val height: Int, + val hdrEnabled: Boolean, + val bitDepth: Int, + val chroma: Int, +) + +private fun StreamSettings.requestProfile(): StreamRequestProfile { + val compatible = withCodecColorCompatibility() + val (width, height) = streamResolutionPixels(compatible) + val hdrEnabled = compatible.hdrEnabled + return StreamRequestProfile( + width = width, + height = height, + hdrEnabled = hdrEnabled, + bitDepth = if (hdrEnabled || compatible.colorQuality.name.startsWith("TenBit")) 10 else 0, + chroma = if (compatible.colorQuality == ColorQuality.EightBit444 || compatible.colorQuality == ColorQuality.TenBit444) 2 else 0, + ) +} + +private fun monitorSettings(profile: StreamRequestProfile, fps: Int): JsonObject = + buildJsonObject { + put("monitorId", 0) + put("positionX", 0) + put("positionY", 0) + put("widthInPixels", profile.width) + put("heightInPixels", profile.height) + put("framesPerSecond", fps) + put("sdrHdrMode", if (profile.hdrEnabled) 1 else 0) + put("displayData", if (profile.hdrEnabled) hdrDisplayDataJson() else JsonNull) + put("hdr10PlusGamingData", JsonNull) + put("dpi", 100) + } + +private fun requestedStreamingFeatures(settings: StreamSettings, profile: StreamRequestProfile): JsonObject = + buildJsonObject { + put("reflex", settings.enableCloudGsync || settings.fps >= 120) + put("bitDepth", profile.bitDepth) + put("cloudGsync", settings.enableCloudGsync) + put("enabledL4S", settings.enableL4S) + put("trueHdr", profile.hdrEnabled) + put("mouseMovementFlags", 0) + put("supportedHidDevices", 0) + put("profile", 0) + put("fallbackToLogicalResolution", false) + put("hidDevices", JsonNull) + put("chromaFormat", profile.chroma) + put("prefilterMode", 0) + put("prefilterSharpness", 0) + put("prefilterNoiseReduction", 0) + put("hudStreamingMode", 0) + put("sdrColorSpace", 2) + put("hdrColorSpace", if (profile.hdrEnabled) 4 else 0) + } + +private fun baseWebRtcSessionMetadata(): JsonArray = buildJsonArray { + add(metadataEntry("SubSessionId", UUID.randomUUID().toString())) + add(metadataEntry("wssignaling", "1")) + add(metadataEntry("GSStreamerType", "WebRTC")) + add(metadataEntry("networkType", "Unknown")) + add(metadataEntry("ClientImeSupport", "0")) + add(metadataEntry("surroundAudioInfo", "2")) +} + +private fun webRtcSessionMetadata( + settings: StreamSettings, + profile: StreamRequestProfile, + physicalDisplayResolution: Pair? = null, +): JsonArray = buildJsonArray { + baseWebRtcSessionMetadata().forEach { add(it) } + val requestedResolution = profile.width to profile.height + // The desktop client describes the requested stream viewport here. Sending a + // differently shaped Android panel (for example 2688x1216 for a 1680x720 + // request) can make CloudMatch provision a different VM monitor mode even + // though clientRequestMonitorSettings is correct. + val (physicalWidth, physicalHeight) = physicalDisplayResolution + ?.takeIf { it == requestedResolution } + ?: requestedResolution + if (physicalWidth > 0 && physicalHeight > 0) { + add( + metadataEntry( + "clientPhysicalResolution", + buildJsonObject { + put("horizontalPixels", physicalWidth) + put("verticalPixels", physicalHeight) + }.toString(), + ), + ) + } + add(metadataEntry(OPENNOW_STREAM_SETTINGS_METADATA_KEY, streamSettingsSessionSignature(settings))) +} + +internal fun activeSessionMonitorSettings(session: JsonObject): JsonObject? = + session.arr("monitorSettings")?.firstOrNull()?.asObject() + ?: session.obj("sessionRequestData")?.arr("clientRequestMonitorSettings")?.firstOrNull()?.asObject() + +private fun monitorResolution(monitor: JsonObject?): String? { + val width = monitor?.int("widthInPixels") + ?: monitor?.int("horizontalPixels") + ?: monitor?.int("width") + val height = monitor?.int("heightInPixels") + ?: monitor?.int("verticalPixels") + ?: monitor?.int("height") + return if (width != null && height != null && width > 0 && height > 0) "${width}x$height" else null +} + +private fun selectedResolution(value: JsonElement?): String? { + val objectResolution = value.asObject()?.let(::monitorResolution) + if (objectResolution != null) return objectResolution + val arrayResolution = value.asArray()?.firstOrNull()?.asObject()?.let(::monitorResolution) + if (arrayResolution != null) return arrayResolution + val text = value.asString()?.trim().orEmpty() + val match = Regex("""(\d{3,5})\s*[xX]\s*(\d{3,5})""").find(text) ?: return null + return "${match.groupValues[1]}x${match.groupValues[2]}" +} + +internal fun extractSessionMonitorSnapshot(session: JsonObject): SessionMonitorSnapshot? { + val requested = session.obj("sessionRequestData") + ?.arr("clientRequestMonitorSettings") + ?.firstOrNull() + ?.asObject() + val returned = session.arr("monitorSettings")?.firstOrNull()?.asObject() + val snapshot = SessionMonitorSnapshot( + requestedResolution = monitorResolution(requested), + requestedFps = requested?.int("framesPerSecond"), + returnedResolution = monitorResolution(returned), + returnedFps = returned?.int("framesPerSecond"), + finalSelectedResolution = selectedResolution(session["finalSelectedScreenResolution"]), + ) + return snapshot.takeIf { + it.requestedResolution != null || + it.requestedFps != null || + it.returnedResolution != null || + it.returnedFps != null || + it.finalSelectedResolution != null + } +} + +internal fun activeSessionSettingsSignature(session: JsonObject): String? = + session.obj("sessionRequestData")?.arr("metaData")?.metadataValue(OPENNOW_STREAM_SETTINGS_METADATA_KEY) + ?: session.arr("metaData")?.metadataValue(OPENNOW_STREAM_SETTINGS_METADATA_KEY) + +private fun JsonArray.metadataValue(key: String): String? = + firstNotNullOfOrNull { item -> + item.asObject() + ?.takeIf { it.string("key") == key } + ?.string("value") + ?.takeIf(String::isNotBlank) + } + +internal fun buildMinimalClaimRequestBody( + appId: String, + deviceId: String, + settings: StreamSettings? = null, + physicalDisplayResolution: Pair? = null, +): JsonObject { + val profile = settings?.requestProfile() + return buildJsonObject { + put("action", 2) + put("data", "RESUME") + putJsonObject("sessionRequestData") { + put("audioMode", 2) + put("remoteControllersBitmap", 0) + put("sdrHdrMode", if (profile?.hdrEnabled == true) 1 else 0) + put("networkTestSessionId", JsonNull) + putJsonArray("availableSupportedControllers") {} + put("clientVersion", "30.0") + put("deviceHashId", deviceId) + put("internalTitle", JsonNull) + put("clientPlatformName", CloudMatchDesktopIdentity.PLATFORM_NAME) + if (settings != null && profile != null) { + putJsonArray("clientRequestMonitorSettings") { + add(monitorSettings(profile, settings.fps)) + } + } + put( + "metaData", + if (settings != null && profile != null) { + webRtcSessionMetadata(settings, profile, physicalDisplayResolution) + } else { + baseWebRtcSessionMetadata() + }, + ) + put("surroundAudioInfo", 0) + put("clientTimezoneOffset", java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis())) + put("clientIdentification", "GFN-PC") + put("parentSessionId", JsonNull) + put("appId", appId.toIntOrNull() ?: 0) + put("streamerVersion", 1) + put("appLaunchMode", CloudMatchDesktopIdentity.APP_LAUNCH_MODE) + put("sdkVersion", "1.0") + put("enhancedStreamMode", 1) + put("useOps", true) + put("clientDisplayHdrCapabilities", if (profile?.hdrEnabled == true) hdrCapabilitiesJson() else JsonNull) + put("accountLinked", true) + put("partnerCustomData", "") + put("enablePersistingInGameSettings", CloudMatchDesktopIdentity.PERSIST_GAME_SETTINGS) + put("secureRTSPSupported", false) + put("userAge", 26) + if (settings != null && profile != null) { + put("requestedStreamingFeatures", requestedStreamingFeatures(settings, profile)) + } + } + putJsonArray("metaData") {} + } +} + +private data class SessionProxyConfig( + val normalizedUrl: String, + val proxy: Proxy, + val username: String, + val password: String, +) + +private val sessionProxyClients = mutableMapOf() + +private fun sessionProxyHttpClient(settings: StreamSettings, fallback: OkHttpClient): OkHttpClient { + val proxyConfig = resolveSessionProxyConfig(settings) ?: return fallback + return synchronized(sessionProxyClients) { + sessionProxyClients.getOrPut(proxyConfig.normalizedUrl) { + fallback.newBuilder() + .proxy(proxyConfig.proxy) + .apply { + if (proxyConfig.username.isNotBlank()) { + proxyAuthenticator { _, response -> + if (response.request.header("Proxy-Authorization") != null) { + return@proxyAuthenticator null + } + response.request.newBuilder() + .header("Proxy-Authorization", Credentials.basic(proxyConfig.username, proxyConfig.password)) + .build() + } + } + } + .build() + } + } +} + +private fun resolveSessionProxyConfig(settings: StreamSettings): SessionProxyConfig? { + if (!settings.sessionProxyEnabled) return null + val raw = settings.sessionProxyUrl.trim() + if (raw.isBlank()) return null + val candidate = if (Regex("^[a-z][a-z0-9+.-]*://", RegexOption.IGNORE_CASE).containsMatchIn(raw)) raw else "http://$raw" + val uri = runCatching { URI(candidate) }.getOrNull() ?: error(INVALID_SESSION_PROXY_MESSAGE) + val scheme = uri.scheme?.lowercase(Locale.US) ?: error(INVALID_SESSION_PROXY_MESSAGE) + val host = uri.host?.takeIf { it.isNotBlank() } ?: error(INVALID_SESSION_PROXY_MESSAGE) + val port = uri.port.takeIf { it in 1..65535 } ?: error(INVALID_SESSION_PROXY_MESSAGE) + val proxyType = when (scheme) { + "http", "https" -> Proxy.Type.HTTP + "socks4", "socks5" -> Proxy.Type.SOCKS + else -> error(INVALID_SESSION_PROXY_MESSAGE) + } + val username = uri.userInfo?.substringBefore(":")?.let(::urlDecode).orEmpty() + val password = uri.userInfo?.substringAfter(":", "")?.let(::urlDecode).orEmpty() + val credentials = if (username.isBlank()) "" else "${urlEncode(username)}${if (password.isNotEmpty()) ":${urlEncode(password)}" else ""}@" + return SessionProxyConfig( + normalizedUrl = "$scheme://$credentials$host:$port", + proxy = Proxy(proxyType, InetSocketAddress.createUnresolved(host, port)), + username = username, + password = password, + ) +} + +private data class NamedDnsResolver(val name: String, val dns: Dns) + +private object OpenNowDns : Dns { + private val dohResolvers: List by lazy { + val bootstrapClient = OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() + listOf( + NamedDnsResolver( + name = "cloudflare-doh", + dns = DnsOverHttps.Builder() + .client(bootstrapClient) + .url("https://cloudflare-dns.com/dns-query".toHttpUrl()) + .bootstrapDnsHosts(ipv4(1, 1, 1, 1), ipv4(1, 0, 0, 1)) + .build(), + ), + NamedDnsResolver( + name = "google-doh", + dns = DnsOverHttps.Builder() + .client(bootstrapClient) + .url("https://dns.google/dns-query".toHttpUrl()) + .bootstrapDnsHosts(ipv4(8, 8, 8, 8), ipv4(8, 8, 4, 4)) + .build(), + ), + NamedDnsResolver( + name = "quad9-doh", + dns = DnsOverHttps.Builder() + .client(bootstrapClient) + .url("https://dns.quad9.net/dns-query".toHttpUrl()) + .bootstrapDnsHosts(ipv4(9, 9, 9, 9), ipv4(149, 112, 112, 112)) + .build(), + ), + ) + } + + override fun lookup(hostname: String): List { + val failures = mutableListOf() + val systemResult = runCatching { Dns.SYSTEM.lookup(hostname) } + .onFailure { failures += "system=${it.message ?: it::class.java.simpleName}" } + .getOrNull() + if (!systemResult.isNullOrEmpty()) return systemResult + + for (resolver in dohResolvers) { + val result = runCatching { resolver.dns.lookup(hostname) } + .onFailure { failures += "${resolver.name}=${it.message ?: it::class.java.simpleName}" } + .getOrNull() + if (!result.isNullOrEmpty()) return result + } + + throw UnknownHostException("$hostname: DNS lookup failed after system, Cloudflare, Google, and Quad9 (${failures.joinToString("; ")})") + } + + private fun ipv4(a: Int, b: Int, c: Int, d: Int): InetAddress = + InetAddress.getByAddress(byteArrayOf(a.toByte(), b.toByte(), c.toByte(), d.toByte())) +} + +private fun JsonObject.string(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull +private fun JsonObject.int(key: String): Int? = this[key]?.jsonPrimitive?.intOrNull +private fun JsonObject.long(key: String): Long? = this[key]?.jsonPrimitive?.longOrNull +private fun JsonObject.double(key: String): Double? = this[key]?.jsonPrimitive?.doubleOrNull +private fun JsonObject.boolean(key: String): Boolean? = this[key]?.jsonPrimitive?.booleanOrNull +private fun JsonObject.obj(key: String): JsonObject? = this[key] as? JsonObject +private fun JsonObject.arr(key: String): JsonArray? = this[key] as? JsonArray +private fun JsonElement?.asObject(): JsonObject? = this as? JsonObject +private fun JsonElement?.asArray(): JsonArray? = this as? JsonArray +private fun JsonElement?.asString(): String? = this?.jsonPrimitive?.contentOrNull +private fun JsonElement?.asInt(): Int? = this?.jsonPrimitive?.intOrNull +private fun JsonElement?.asDouble(): Double? = this?.jsonPrimitive?.doubleOrNull +private fun JsonElement?.asBoolean(): Boolean? = this?.jsonPrimitive?.booleanOrNull +private fun JsonObject.graphQlErrorMessage(): String? = + arr("errors") + ?.mapNotNull { it.asObject()?.string("message")?.takeIf(String::isNotBlank) } + ?.takeIf { it.isNotEmpty() } + ?.joinToString(", ") + +private fun JsonObject.checkGraphQlErrors(label: String = "GFN GraphQL"): JsonObject { + graphQlErrorMessage()?.let { message -> error("$label: $message") } + return this +} + +private suspend fun OkHttpClient.awaitText(request: Request): Pair = + withContext(Dispatchers.IO) { + val requestBody = OpenNowHttpDiagnostics.captureRequestBody(request) + val startedAtMs = SystemClock.elapsedRealtime() + try { + newCall(request).execute().use { response -> + val text = response.body?.string().orEmpty() + OpenNowHttpDiagnostics.record( + request = request, + requestBody = requestBody, + statusCode = response.code, + responseBody = text, + elapsedMs = SystemClock.elapsedRealtime() - startedAtMs, + ) + response.code to text + } + } catch (error: Throwable) { + OpenNowHttpDiagnostics.record( + request = request, + requestBody = requestBody, + statusCode = null, + responseBody = "", + elapsedMs = SystemClock.elapsedRealtime() - startedAtMs, + error = error, + ) + throw error + } + } + +private fun bearerAuthorization(token: String): String = "Bearer $token" +private fun gfnJwtAuthorization(token: String): String = "GFNJWT $token" + +private fun Headers.Builder.putDesktopLcars( + token: String? = null, + clientType: String = "NATIVE", + clientStreamer: String = "NVIDIA-CLASSIC", + accept: String = "application/json", + includeUserAgent: Boolean = false, + includeEmptyTokenAuthorization: Boolean = false, +): Headers.Builder { + add("Accept", accept) + if (token != null || includeEmptyTokenAuthorization) add("Authorization", gfnJwtAuthorization(token.orEmpty())) + add("nv-client-id", LCARS_CLIENT_ID) + add("nv-client-type", clientType) + add("nv-client-version", GFN_CLIENT_VERSION) + add("nv-client-streamer", clientStreamer) + add("nv-device-os", "WINDOWS") + add("nv-device-type", "DESKTOP") + if (includeUserAgent) add("User-Agent", GFN_USER_AGENT) + return this +} + +private fun desktopGraphQlHeaders(token: String? = null): Headers = + Headers.Builder() + .add("Accept", "application/json, text/plain, */*") + .add("Content-Type", "application/json") + .add("Origin", GFN_PLAY_ORIGIN) + .add("Referer", GFN_PLAY_REFERER) + .apply { + if (!token.isNullOrBlank()) add("Authorization", gfnJwtAuthorization(token)) + } + .add("nv-client-id", LCARS_CLIENT_ID) + .add("nv-client-type", "NATIVE") + .add("nv-client-version", GFN_CLIENT_VERSION) + .add("nv-client-streamer", "NVIDIA-CLASSIC") + .add("nv-device-os", "WINDOWS") + .add("nv-device-type", "DESKTOP") + .add("nv-device-make", "UNKNOWN") + .add("nv-device-model", "UNKNOWN") + .add("nv-browser-type", "CHROME") + .add("User-Agent", GFN_USER_AGENT) + .build() + +internal fun cloudMatchHeaders( + token: String, + clientId: String, + deviceId: String, + includeOrigin: Boolean, +): Headers = + Headers.Builder() + .add("User-Agent", GFN_USER_AGENT) + .add("Authorization", gfnJwtAuthorization(token)) + .add("Content-Type", "application/json") + .add("nv-browser-type", "CHROME") + .add("nv-client-id", clientId) + .add("nv-client-streamer", CloudMatchDesktopIdentity.STREAMER) + .add("nv-client-type", CloudMatchDesktopIdentity.CLIENT_TYPE) + .add("nv-client-version", GFN_CLIENT_VERSION) + .add("nv-device-make", "UNKNOWN") + .add("nv-device-model", "UNKNOWN") + .add("nv-device-os", CloudMatchDesktopIdentity.DEVICE_OS) + .add("nv-device-type", CloudMatchDesktopIdentity.DEVICE_TYPE) + .add("x-device-id", deviceId) + .apply { + if (includeOrigin) { + add("Origin", GFN_PLAY_ORIGIN) + add("Referer", GFN_PLAY_REFERER) + } + } + .build() + +private fun normalizeStreamingServiceUrl(value: String): String? { + val url = value.trim().toHttpUrlOrNull() ?: return null + if (url.scheme != "https") return null + val host = url.host + if (host.isBlank() || host.startsWith(".") || host.contains("..")) return null + val port = if (url.port != 443) ":${url.port}" else "" + return "https://$host$port/" +} + +private fun normalizeProvider(provider: LoginProvider): LoginProvider = + provider.copy(streamingServiceUrl = normalizeStreamingServiceUrl(provider.streamingServiceUrl) ?: DEFAULT_STREAMING_SERVICE_URL) + +fun defaultProvider(): LoginProvider = + LoginProvider( + idpId = DEFAULT_IDP_ID, + code = "NVIDIA", + displayName = "NVIDIA", + streamingServiceUrl = DEFAULT_STREAMING_SERVICE_URL, + priority = 0, + ) + +private fun nowMs(): Long = System.currentTimeMillis() +private fun expiresAt(seconds: Int?, defaultSeconds: Int = 86400): Long = nowMs() + ((seconds ?: defaultSeconds) * 1000L) +private fun isExpired(expiresAt: Long?): Boolean = expiresAt == null || expiresAt <= nowMs() +private fun isNearExpiry(expiresAt: Long?, windowMs: Long): Boolean = expiresAt == null || expiresAt - nowMs() < windowMs + +private fun JsonObject.firstString(vararg keys: String): String? = + keys.firstNotNullOfOrNull { key -> string(key)?.trim()?.takeIf(String::isNotEmpty) } + +private fun JsonObject.firstLong(vararg keys: String): Long? = + keys.firstNotNullOfOrNull(::long) + +private fun epochMilliseconds(value: Long): Long = + if (value in 1..9_999_999_999L) value * 1_000L else value + +internal fun parseManualAuthTokens(input: String, currentTimeMs: Long = nowMs()): AuthTokens { + val trimmed = input.trim() + require(trimmed.isNotEmpty()) { "Paste an NVIDIA access token or token-response JSON." } + require(trimmed.length <= 64_000) { "The pasted token data is too large." } + + val root = if (trimmed.startsWith('{')) { + runCatching { OpenNowJson.parseToJsonElement(trimmed).jsonObject } + .getOrElse { throw IllegalArgumentException("The pasted token JSON is invalid.", it) } + } else { + null + } + val tokenObject = root?.obj("tokens") ?: root + val rawAccessToken = tokenObject?.firstString("access_token", "accessToken") ?: trimmed + val accessToken = rawAccessToken.replaceFirst(Regex("^Bearer\\s+", RegexOption.IGNORE_CASE), "").trim() + require(accessToken.isNotEmpty() && !accessToken.startsWith('{')) { + "The pasted data does not contain an access token." + } + + val absoluteExpiry = tokenObject?.firstLong("expires_at", "expiresAt")?.let(::epochMilliseconds) + val expiresInSeconds = tokenObject?.firstLong("expires_in", "expiresIn") + require(expiresInSeconds == null || expiresInSeconds > 0) { "The pasted token expiry is invalid." } + val tokenExpiresAt = absoluteExpiry ?: currentTimeMs + (expiresInSeconds ?: 86_400L) * 1_000L + val clientTokenExpiresAt = tokenObject + ?.firstLong("client_token_expires_at", "clientTokenExpiresAt") + ?.let(::epochMilliseconds) + + return AuthTokens( + accessToken = accessToken, + refreshToken = tokenObject?.firstString("refresh_token", "refreshToken"), + idToken = tokenObject?.firstString("id_token", "idToken"), + expiresAt = tokenExpiresAt, + clientToken = tokenObject?.firstString("client_token", "clientToken"), + clientTokenExpiresAt = clientTokenExpiresAt, + authClientId = tokenObject?.firstString("auth_client_id", "authClientId"), + ) +} + +private fun randomBase64Url(byteCount: Int): String { + val bytes = ByteArray(byteCount) + SecureRandom().nextBytes(bytes) + return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) +} + +private fun sha256Base64Url(input: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray(Charsets.US_ASCII)) + return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) +} + +private fun decodeJwtPayload(token: String): JsonObject? { + val payload = token.split(".").getOrNull(1) ?: return null + return runCatching { + val json = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_WRAP), Charsets.UTF_8) + OpenNowJson.parseToJsonElement(json).jsonObject + }.getOrNull() +} + +private fun encoded(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name()) +private fun urlEncode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name()) +private fun urlDecode(value: String): String = URLDecoder.decode(value, Charsets.UTF_8.name()) + +class GfnAuthRepository( + private val context: Context, + private val authStore: AuthStore, + private val http: OkHttpClient = defaultHttpClient(), +) { + private val externalOAuthRedirects = Channel>(capacity = 4) + + private data class OAuthCallbackServers( + val port: Int, + val sockets: List, + ) : Closeable { + override fun close() { + sockets.forEach { socket -> runCatching { socket.close() } } + } + } + + suspend fun loginProviders(): List { + val request = Request.Builder() + .url(SERVICE_URLS_ENDPOINT) + .headers( + Headers.Builder() + .add("Accept", "application/json, text/plain, */*") + .add("Origin", NVIDIA_FILE_ORIGIN) + .add("Referer", NVIDIA_FILE_REFERER) + .add("User-Agent", GFN_USER_AGENT) + .build(), + ) + .build() + val (code, text) = http.awaitText(request) + if (code !in 200..299) return listOf(defaultProvider()) + val root = runCatching { OpenNowJson.parseToJsonElement(text).jsonObject }.getOrNull() ?: return listOf(defaultProvider()) + val providers = root.obj("gfnServiceInfo") + ?.arr("gfnServiceEndpoints") + ?.mapNotNull { item -> + val obj = item.asObject() ?: return@mapNotNull null + val idp = obj.string("idpId") ?: return@mapNotNull null + val codeValue = obj.string("loginProviderCode") ?: "NVIDIA" + val display = obj.string("loginProviderDisplayName") ?: codeValue + val url = obj.string("streamingServiceUrl") ?: return@mapNotNull null + val streamingServiceUrl = normalizeStreamingServiceUrl(url) ?: return@mapNotNull null + normalizeProvider( + LoginProvider( + idpId = idp, + code = codeValue, + displayName = display, + streamingServiceUrl = streamingServiceUrl, + priority = obj.int("loginProviderPriority") ?: 0, + ), + ) + } + ?.sortedWith(compareBy { it.priority }.thenBy { it.displayName }) + .orEmpty() + return providers.ifEmpty { listOf(defaultProvider()) } + } + + suspend fun restore( + forceRefresh: Boolean = false, + throwOnRefreshFailure: Boolean = forceRefresh, + removeExpiredSessionOnFailure: Boolean = true, + ): AuthSession? = + AUTH_RESTORE_MUTEX.withLock { + authStore.reload() + val restored = authStore.activeSession() ?: return@withLock null + var session = restored + if (session.tokens.clientToken.isNullOrBlank() || isNearExpiry(session.tokens.clientTokenExpiresAt, CLIENT_TOKEN_REFRESH_WINDOW_MS)) { + val withClientToken = runCatching { ensureClientToken(session.tokens) }.getOrElse { session.tokens } + if (withClientToken != session.tokens) { + val updatedSession = session.copy(tokens = withClientToken) + if (!authStore.updateSessionIfUnchanged(session, updatedSession)) { + return@withLock authStore.activeSession() + } + session = updatedSession + } + } + + val refreshed = if (forceRefresh || isNearExpiry(session.tokens.expiresAt, TOKEN_REFRESH_WINDOW_MS)) { + refreshSession( + session = session, + forceRefresh = forceRefresh || throwOnRefreshFailure, + removeExpiredSessionOnFailure = removeExpiredSessionOnFailure, + ) + } else { + session + } + + if (refreshed != session && !authStore.updateSessionIfUnchanged(session, refreshed)) { + return@withLock authStore.activeSession() + } + refreshed + } + + suspend fun login( + provider: LoginProvider, + onAuthorizationCodeReceived: suspend () -> Unit = {}, + ): AuthSession { + drainExternalOAuthRedirects() + val callbackServers = openAvailableCallbackServers() + val port = callbackServers.port + val verifier = randomBase64Url(64).take(86) + val challenge = sha256Base64Url(verifier) + val authUrl = buildAuthUrl(provider, challenge, port) + val code = coroutineScope { + val codeDeferred = async(Dispatchers.IO) { waitForAuthorizationCode(callbackServers) } + runCatching { + verifyCallbackListenerReachable(port) + }.onFailure { error -> + codeDeferred.cancel() + callbackServers.close() + throw IllegalStateException( + "OAuth callback listener was not reachable on localhost:$port before opening the browser.", + error, + ) + } + val customTabs = CustomTabsIntent.Builder().build() + customTabs.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { + customTabs.launchUrl(context, Uri.parse(authUrl)) + }.onFailure { + callbackServers.close() + throw it + } + codeDeferred.await() + } + onAuthorizationCodeReceived() + val tokens = ensureClientTokenBestEffort(exchangeAuthorizationCode(code, verifier, port)) + val session = buildSession(provider, tokens) + authStore.upsertSession(session) + return session + } + + fun handleOAuthRedirect(uri: Uri?): Boolean { + if (uri == null || !isLoopbackOAuthRedirect(uri)) return false + val params = uri.queryParameterNames + .associateWith { name -> uri.getQueryParameter(name).orEmpty() } + .filterValues { it.isNotBlank() } + if (!params.containsKey("code") && !params.containsKey("error")) return false + externalOAuthRedirects.trySend(params) + return true + } + + suspend fun loginWithDeviceCode(provider: LoginProvider, onPrompt: suspend (DeviceLoginPrompt) -> Unit): AuthSession { + check(provider.supportsDeviceCodeLogin) { "Code sign-in is only available for NVIDIA accounts." } + val deviceCode = requestDeviceCode(provider) + onPrompt(deviceCode.prompt) + val tokens = ensureClientTokenBestEffort(pollDeviceCodeToken(deviceCode)) + val session = buildSession(provider, tokens) + authStore.upsertSession(session) + return session + } + + suspend fun loginWithToken(provider: LoginProvider, tokenInput: String): AuthSession { + val parsedTokens = parseManualAuthTokens(tokenInput) + require(!isExpired(parsedTokens.expiresAt)) { "The pasted access token has expired." } + val tokens = ensureClientTokenBestEffort(parsedTokens) + val session = buildSession(provider, tokens, requireVerifiedIdentity = true) + authStore.upsertSession(session) + return session + } + + suspend fun logout(userId: String? = null) { + val activeId = userId ?: authStore.activeSession()?.user?.userId + if (activeId != null) authStore.removeSession(activeId) + } + + fun logoutAll() = authStore.clear() + + private data class ClientTokenResponse(val token: String, val expiresAt: Long) + + private suspend fun ensureClientTokenBestEffort(tokens: AuthTokens): AuthTokens = + runCatching { ensureClientToken(tokens) }.getOrElse { tokens } + + private suspend fun ensureClientToken(tokens: AuthTokens): AuthTokens { + val hasUsableClientToken = + !tokens.clientToken.isNullOrBlank() && + !isNearExpiry(tokens.clientTokenExpiresAt, CLIENT_TOKEN_REFRESH_WINDOW_MS) + if (hasUsableClientToken || isExpired(tokens.expiresAt)) return tokens + + val clientToken = requestClientToken(tokens.accessToken) + return tokens.copy( + clientToken = clientToken.token, + clientTokenExpiresAt = clientToken.expiresAt, + ) + } + + private suspend fun requestClientToken(accessToken: String): ClientTokenResponse { + val request = Request.Builder() + .url(CLIENT_TOKEN_ENDPOINT) + .headers(nvidiaFileHeaders(bearerToken = accessToken, includeReferer = false)) + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "Client token request failed ($code): ${text.take(400)}" } + val root = OpenNowJson.parseToJsonElement(text).jsonObject + return ClientTokenResponse( + token = requireNotNull(root.string("client_token")) { "Missing client token" }, + expiresAt = expiresAt(root.int("expires_in")), + ) + } + + private suspend fun refreshSession( + session: AuthSession, + forceRefresh: Boolean, + removeExpiredSessionOnFailure: Boolean, + ): AuthSession { + val tokens = session.tokens + val refreshErrors = mutableListOf() + val refreshClientIds = authenticationRefreshClientIds( + savedClientId = tokens.authClientId, + browserClientId = CLIENT_ID, + deviceClientId = DEVICE_CODE_CLIENT_ID, + ) + + if (!tokens.clientToken.isNullOrBlank()) { + for (clientId in refreshClientIds) { + runCatching { + val refreshed = mergeTokenSnapshot( + base = tokens, + root = refreshWithClientToken(tokens.clientToken, session.user.userId, clientId), + authClientId = clientId, + ) + return buildRefreshedSession(session, ensureClientTokenBestEffort(refreshed), source = "client token") + }.onFailure { error -> + refreshErrors += "client_token(${authClientLabel(clientId)}): ${error.message ?: "Unknown refresh error"}" + } + } + } + + val refresh = tokens.refreshToken + if (!refresh.isNullOrBlank()) { + for (clientId in refreshClientIds) { + runCatching { + val refreshed = refreshAuthTokens(refresh, tokens, clientId) + return buildRefreshedSession(session, ensureClientTokenBestEffort(refreshed), source = "refresh token") + }.onFailure { error -> + refreshErrors += "refresh_token(${authClientLabel(clientId)}): ${error.message ?: "Unknown refresh error"}" + } + } + } + + val hasRefreshMechanism = !tokens.clientToken.isNullOrBlank() || !tokens.refreshToken.isNullOrBlank() + if (!hasRefreshMechanism) { + if (isExpired(tokens.expiresAt)) { + if (removeExpiredSessionOnFailure) { + authStore.removeSession(session.user.userId) + } + error("Saved session expired and has no refresh mechanism. Please log in again.") + } + return session + } + + if (isExpired(tokens.expiresAt)) { + if (removeExpiredSessionOnFailure) { + authStore.removeSession(session.user.userId) + } + val detail = refreshErrors.takeIf { it.isNotEmpty() }?.joinToString(" | ") + error("Token refresh failed and the saved session expired. Please log in again.${detail?.let { " $it" }.orEmpty()}") + } + + if (forceRefresh && refreshErrors.isNotEmpty()) { + error("Token refresh failed. Using saved session token. ${refreshErrors.joinToString(" | ")}") + } + return session + } + + private suspend fun refreshWithClientToken(clientToken: String, userId: String, authClientId: String): JsonObject { + val body = FormBody.Builder() + .add("grant_type", "urn:ietf:params:oauth:grant-type:client_token") + .add("client_token", clientToken) + .add("client_id", authClientId) + .add("sub", userId) + .build() + val request = Request.Builder() + .url(TOKEN_ENDPOINT) + .headers(nvidiaFileHeaders(includeReferer = false)) + .post(body) + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "Client-token refresh failed ($code): ${text.take(400)}" } + return OpenNowJson.parseToJsonElement(text).jsonObject + } + + private suspend fun refreshAuthTokens(refresh: String, base: AuthTokens, authClientId: String): AuthTokens { + val body = FormBody.Builder() + .add("grant_type", "refresh_token") + .add("refresh_token", refresh) + .add("client_id", authClientId) + .build() + val request = Request.Builder() + .url(TOKEN_ENDPOINT) + .headers(nvidiaFileHeaders(includeReferer = false)) + .post(body) + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "Token refresh failed ($code): ${text.take(400)}" } + val root = OpenNowJson.parseToJsonElement(text).jsonObject + return AuthTokens( + accessToken = requireNotNull(root.string("access_token")) { "Missing access token" }, + refreshToken = root.string("refresh_token") ?: refresh, + idToken = root.string("id_token") ?: base.idToken, + expiresAt = expiresAt(root.int("expires_in")), + clientToken = base.clientToken, + clientTokenExpiresAt = base.clientTokenExpiresAt, + authClientId = authClientId, + ) + } + + private fun mergeTokenSnapshot(base: AuthTokens, root: JsonObject, authClientId: String): AuthTokens = + AuthTokens( + accessToken = requireNotNull(root.string("access_token")) { "Missing access token" }, + refreshToken = root.string("refresh_token") ?: base.refreshToken, + idToken = root.string("id_token") ?: base.idToken, + expiresAt = expiresAt(root.int("expires_in")), + clientToken = root.string("client_token") ?: base.clientToken, + clientTokenExpiresAt = base.clientTokenExpiresAt, + authClientId = authClientId, + ) + + private fun authClientLabel(clientId: String): String = + when (clientId) { + CLIENT_ID -> "browser" + DEVICE_CODE_CLIENT_ID -> "device" + else -> "saved" + } + + private suspend fun buildRefreshedSession(session: AuthSession, tokens: AuthTokens, source: String): AuthSession { + val refreshed = buildSession(session.provider, tokens, fallbackUser = session.user) + check(refreshed.user.userId == session.user.userId) { + "Token refresh via $source returned a different account than expected." + } + return refreshed + } + + private suspend fun exchangeAuthorizationCode(code: String, verifier: String, port: Int): AuthTokens { + val body = FormBody.Builder() + .add("grant_type", "authorization_code") + .add("code", code) + .add("redirect_uri", "http://localhost:$port") + .add("code_verifier", verifier) + .build() + val request = Request.Builder() + .url(TOKEN_ENDPOINT) + .headers(nvidiaFileHeaders(includeReferer = true)) + .post(body) + .build() + val (status, text) = http.awaitText(request) + check(status in 200..299) { "Token exchange failed ($status): ${text.take(400)}" } + val root = OpenNowJson.parseToJsonElement(text).jsonObject + return AuthTokens( + accessToken = requireNotNull(root.string("access_token")) { "Missing access token" }, + refreshToken = root.string("refresh_token"), + idToken = root.string("id_token"), + expiresAt = expiresAt(root.int("expires_in")), + clientToken = root.string("client_token"), + authClientId = CLIENT_ID, + ) + } + + private data class DeviceCodeChallenge( + val deviceCode: String, + val prompt: DeviceLoginPrompt, + val intervalSeconds: Int, + ) + + private suspend fun requestDeviceCode(provider: LoginProvider): DeviceCodeChallenge { + val body = FormBody.Builder() + .add("client_id", DEVICE_CODE_CLIENT_ID) + .add("scope", SCOPES) + .add("device_id", authStore.stableDeviceId()) + .add("display_name", androidDeviceDisplayName()) + .add("idp_id", provider.idpId) + .build() + val request = Request.Builder() + .url(DEVICE_AUTHORIZATION_ENDPOINT) + .headers(starfleetFormHeaders()) + .post(body) + .build() + val (status, text) = http.awaitText(request) + check(status in 200..299) { "Device sign-in failed ($status): ${text.take(400)}" } + val root = OpenNowJson.parseToJsonElement(text).jsonObject + val deviceCode = requireNotNull(root.string("device_code")) { "Missing device code" } + val userCode = requireNotNull(root.string("user_code")) { "Missing user code" } + val verificationUri = root.string("verification_uri") + ?: root.string("verification_url") + ?: "https://login.nvidia.com" + val expiresIn = root.int("expires_in") ?: 600 + val interval = (root.int("interval") ?: DEVICE_CODE_MIN_POLL_INTERVAL_SECONDS) + .coerceAtLeast(DEVICE_CODE_MIN_POLL_INTERVAL_SECONDS) + return DeviceCodeChallenge( + deviceCode = deviceCode, + intervalSeconds = interval, + prompt = DeviceLoginPrompt( + userCode = userCode, + verificationUri = verificationUri, + verificationUriComplete = root.string("verification_uri_complete"), + expiresAt = nowMs() + expiresIn * 1000L, + ), + ) + } + + private suspend fun pollDeviceCodeToken(challenge: DeviceCodeChallenge): AuthTokens { + var intervalSeconds = challenge.intervalSeconds + while (nowMs() < challenge.prompt.expiresAt) { + delay(intervalSeconds * 1000L) + val body = FormBody.Builder() + .add("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + .add("device_code", challenge.deviceCode) + .add("client_id", DEVICE_CODE_CLIENT_ID) + .build() + val request = Request.Builder() + .url(TOKEN_ENDPOINT) + .headers(starfleetFormHeaders()) + .post(body) + .build() + val (status, text) = http.awaitText(request) + val root = runCatching { OpenNowJson.parseToJsonElement(text).jsonObject }.getOrNull() + if (status in 200..299 && root != null) { + return AuthTokens( + accessToken = requireNotNull(root.string("access_token")) { "Missing access token" }, + refreshToken = root.string("refresh_token"), + idToken = root.string("id_token"), + expiresAt = expiresAt(root.int("expires_in")), + clientToken = root.string("client_token"), + authClientId = DEVICE_CODE_CLIENT_ID, + ) + } + val error = root?.string("error").orEmpty() + when (error) { + "authorization_pending" -> Unit + "slow_down" -> intervalSeconds += 5 + "access_denied" -> error("Device sign-in was cancelled.") + "expired_token" -> error("Device sign-in code expired.") + else -> check(status in 200..299) { "Device token exchange failed ($status): ${text.take(400)}" } + } + } + error("Device sign-in code expired.") + } + + private suspend fun buildSession( + provider: LoginProvider, + tokens: AuthTokens, + fallbackUser: AuthUser? = null, + requireVerifiedIdentity: Boolean = false, + ): AuthSession { + val userInfoResult = runCatching { fetchUserInfo(tokens.accessToken) } + if (requireVerifiedIdentity && userInfoResult.isFailure) { + throw IllegalArgumentException( + "NVIDIA did not accept the pasted access token.", + userInfoResult.exceptionOrNull(), + ) + } + val userInfo = userInfoResult.getOrDefault(JsonObject(emptyMap())) + val jwt = tokens.idToken?.let(::decodeJwtPayload) + val verifiedUserId = userInfo.string("sub") ?: userInfo.string("id") + if (requireVerifiedIdentity) { + require(!verifiedUserId.isNullOrBlank()) { "The pasted access token did not identify an NVIDIA account." } + } + val userId = verifiedUserId ?: jwt?.string("sub") ?: fallbackUser?.userId ?: "nvidia-user" + val email = userInfo.string("email") ?: jwt?.string("email") ?: fallbackUser?.email + val displayName = userInfo.string("name") + ?: userInfo.string("preferred_username") + ?: email + ?: fallbackUser?.displayName + ?: "NVIDIA Account" + val tier = userInfo.string("membershipTier") ?: jwt?.string("membershipTier") ?: fallbackUser?.membershipTier ?: "FREE" + return AuthSession( + provider = normalizeProvider(provider), + tokens = tokens, + user = AuthUser( + userId = userId, + displayName = displayName, + email = email, + avatarUrl = userInfo.string("picture") ?: fallbackUser?.avatarUrl, + membershipTier = tier, + ), + ) + } + + private suspend fun fetchUserInfo(accessToken: String): JsonObject { + val request = Request.Builder() + .url(USERINFO_ENDPOINT) + .headers(nvidiaFileHeaders(bearerToken = accessToken, includeReferer = true)) + .build() + val (code, text) = http.awaitText(request) + return if (code in 200..299) { + runCatching { OpenNowJson.parseToJsonElement(text).jsonObject }.getOrDefault(JsonObject(emptyMap())) + } else { + JsonObject(emptyMap()) + } + } + + private fun nvidiaFileHeaders(bearerToken: String? = null, includeReferer: Boolean): Headers = + Headers.Builder() + .apply { + if (bearerToken != null) add("Authorization", bearerAuthorization(bearerToken)) + add("Origin", NVIDIA_FILE_ORIGIN) + if (includeReferer) add("Referer", NVIDIA_FILE_REFERER) + add("Accept", "application/json, text/plain, */*") + add("User-Agent", GFN_USER_AGENT) + } + .build() + + private fun starfleetFormHeaders(): Headers = + Headers.Builder() + .add("Accept", "application/json, text/plain, */*") + .add("User-Agent", GFN_USER_AGENT) + .build() + + private fun androidDeviceDisplayName(): String { + val model = listOf(Build.MANUFACTURER, Build.MODEL) + .map { it.trim() } + .filter { it.isNotBlank() && !it.equals("unknown", ignoreCase = true) } + .distinctBy { it.lowercase(Locale.US) } + .joinToString(" ") + return model.ifBlank { "OpenNOW Android" } + } + + private fun buildAuthUrl(provider: LoginProvider, challenge: String, port: Int): String { + val deviceId = authStore.stableDeviceId() + val nonce = randomBase64Url(16) + val params = linkedMapOf( + "response_type" to "code", + "device_id" to deviceId, + "scope" to SCOPES, + "client_id" to CLIENT_ID, + "redirect_uri" to "http://localhost:$port", + "ui_locales" to "en_US", + "nonce" to nonce, + "prompt" to "select_account", + "code_challenge" to challenge, + "code_challenge_method" to "S256", + "idp_id" to provider.idpId, + ).map { (key, value) -> "${encoded(key)}=${encoded(value)}" }.joinToString("&") + return "$AUTH_ENDPOINT?$params" + } + + private suspend fun openAvailableCallbackServers(): OAuthCallbackServers = withContext(Dispatchers.IO) { + for (port in REDIRECT_PORTS) { + val server = runCatching { openCallbackServerSockets(port) }.getOrNull() + if (server != null) return@withContext server + } + error("No available OAuth callback ports") + } + + private suspend fun waitForAuthorizationCode(callbackServers: OAuthCallbackServers): String = withContext(Dispatchers.IO) { + callbackServers.use { + val deadline = System.currentTimeMillis() + OAUTH_CALLBACK_TIMEOUT_MS + while (System.currentTimeMillis() < deadline) { + externalOAuthRedirects.tryReceive().getOrNull()?.let { params -> + authorizationCodeFromParams(params)?.let { code -> return@withContext code } + } + for (server in callbackServers.sockets) { + val remainingMs = deadline - System.currentTimeMillis() + if (remainingMs <= 0L) break + server.soTimeout = minOf(500, remainingMs.coerceAtLeast(1)).toInt() + val socket = try { + server.accept() + } catch (_: SocketTimeoutException) { + null + } catch (error: SocketException) { + if (server.isClosed) null else throw error + } ?: continue + socket.use { callbackSocket -> + val params = runCatching { readCallbackQueryParams(callbackSocket) }.getOrDefault(emptyMap()) + params["error"]?.takeIf { it.isNotBlank() }?.let { error -> + writeCallbackResponse(callbackSocket, "Login failed or was cancelled.") + throw IllegalStateException(error) + } + val code = authorizationCodeFromParams(params) + if (code != null) { + writeCallbackResponse(callbackSocket, "Login complete. Return to OpenNOW.") + return@withContext code + } + writeCallbackResponse(callbackSocket, "Waiting for NVIDIA to finish sign-in.") + } + } + } + throw IllegalStateException("Timed out waiting for OAuth callback") + } + } + + private fun authorizationCodeFromParams(params: Map): String? { + params["error"]?.takeIf { it.isNotBlank() }?.let { error -> + throw IllegalStateException(error) + } + return params["code"]?.takeIf { code -> code.isNotBlank() } + } + + private fun isLoopbackOAuthRedirect(uri: Uri): Boolean { + if (uri.scheme != "http") return false + val host = uri.host?.lowercase(Locale.US) ?: return false + if (host != "localhost" && host != "127.0.0.1" && host != "::1") return false + return uri.port in REDIRECT_PORTS + } + + private fun drainExternalOAuthRedirects() { + while (externalOAuthRedirects.tryReceive().isSuccess) { + // discard stale browser callbacks from earlier attempts + } + } + + private suspend fun verifyCallbackListenerReachable(port: Int) = withContext(Dispatchers.IO) { + val failures = mutableListOf() + val reachable = listOf("127.0.0.1", "::1").any { host -> + runCatching { + probeCallbackListener(host, port) + }.onFailure { error -> + failures += "$host=${error.message ?: error::class.java.simpleName}" + }.getOrDefault(false) + } + check(reachable) { + "OAuth callback listener probe failed (${failures.joinToString("; ")})" + } + } + + private fun probeCallbackListener(host: String, port: Int): Boolean { + val address = InetAddress.getByName(host) + Socket().use { socket -> + socket.connect(InetSocketAddress(address, port), OAUTH_CALLBACK_PROBE_TIMEOUT_MS) + socket.soTimeout = OAUTH_CALLBACK_PROBE_TIMEOUT_MS + val hostHeader = if (host.contains(":")) "[$host]" else host + val writer = OutputStreamWriter(socket.getOutputStream()) + writer.write("GET $OAUTH_CALLBACK_PROBE_PATH HTTP/1.1\r\n") + writer.write("Host: $hostHeader:$port\r\n") + writer.write("Connection: close\r\n\r\n") + writer.flush() + val status = BufferedReader(InputStreamReader(socket.getInputStream())).use { reader -> + reader.readLine().orEmpty() + } + return status.startsWith("HTTP/1.1 200") || status.startsWith("HTTP/1.0 200") + } + } + + private fun openCallbackServerSockets(port: Int): OAuthCallbackServers { + val sockets = mutableListOf() + val failures = mutableListOf() + var portInUse = false + for (host in listOf("127.0.0.1", "::1")) { + runCatching { openCallbackServerSocket(port, host) } + .onSuccess { sockets += it } + .onFailure { error -> + failures += "$host=${error.message ?: error::class.java.simpleName}" + portInUse = portInUse || error is BindException + } + } + if (portInUse) { + sockets.forEach { socket -> runCatching { socket.close() } } + error("OAuth callback port $port is already in use") + } + if (sockets.isEmpty()) { + runCatching { openCallbackServerSocket(port, host = null) } + .onSuccess { sockets += it } + .onFailure { error -> failures += "wildcard=${error.message ?: error::class.java.simpleName}" } + } + if (sockets.isEmpty()) { + error("OAuth callback port $port unavailable (${failures.joinToString("; ")})") + } + return OAuthCallbackServers(port, sockets) + } + + private fun openCallbackServerSocket(port: Int, host: String?): ServerSocket { + val socket = ServerSocket() + return try { + socket.reuseAddress = true + val address = host?.let(InetAddress::getByName) + socket.bind(if (address == null) InetSocketAddress(port) else InetSocketAddress(address, port)) + socket + } catch (error: Throwable) { + runCatching { socket.close() } + throw error + } + } + + private fun readCallbackQueryParams(socket: Socket): Map { + socket.soTimeout = 2_000 + val reader = BufferedReader(InputStreamReader(socket.getInputStream())) + val requestLine = reader.readLine().orEmpty() + while (true) { + val line = reader.readLine() ?: break + if (line.isEmpty()) break + } + val target = requestLine.split(" ").getOrNull(1).orEmpty() + val query = target.substringAfter("?", "") + if (query.isBlank() || query == target) return emptyMap() + return query.split("&").mapNotNull { pair -> + val key = pair.substringBefore("=", "") + val value = pair.substringAfter("=", "") + if (key.isBlank()) null else key to Uri.decode(value) + }.toMap() + } + + private fun writeCallbackResponse(socket: Socket, message: String) { + val html = """ + OpenNOW Login + +
+

OpenNOW Login

$message

+
+ """.trimIndent() + val bytes = html.toByteArray() + val writer = OutputStreamWriter(socket.getOutputStream()) + writer.write("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: ${bytes.size}\r\nConnection: close\r\n\r\n") + writer.write(html) + writer.flush() + } +} + +internal data class CatalogCardArtwork( + val mobileImageUrl: String?, + val tvImageUrl: String?, +) + +internal fun catalogCardArtwork( + keyArt: String?, + gameBoxArt: String?, + heroImage: String?, + tvBanner: String?, +): CatalogCardArtwork = CatalogCardArtwork( + mobileImageUrl = gameBoxArt?.takeIf { it.isNotBlank() }, + tvImageUrl = listOf(gameBoxArt, keyArt, heroImage, tvBanner).firstOrNull { !it.isNullOrBlank() }, +) + +internal fun catalogScreenshotUrls(images: JsonObject?): List = + images?.arr("SCREENSHOTS") + ?.mapNotNull { it.asString()?.trim()?.takeIf(String::isNotBlank) } + ?.distinct() + .orEmpty() + +internal fun catalogGameDescription(app: JsonObject): String? = + app.string("description") ?: app.string("shortDescription") + +class GfnCatalogRepository( + private val http: OkHttpClient = defaultHttpClient(), +) { + private data class CachedVpcId(val value: String, val expiresAtElapsedMs: Long) + + private val vpcIdMutex = Mutex() + private val vpcIdCache = mutableMapOf() + + suspend fun fetchMainGames( + token: String, + providerStreamingBaseUrl: String, + includeSupplementalPublicVariants: Boolean = true, + ): List { + val vpcId = getVpcId(token, providerStreamingBaseUrl) + val panels = fetchPanels(token, listOf("MAIN"), vpcId, withLibraryTime = false) + val games = enrichGamesWithMetadata(token, vpcId, flattenPanels(panels)) + return if (includeSupplementalPublicVariants) mergePublicGameVariants(games, fetchPublicGames()) else games + } + + suspend fun fetchLibraryGames( + token: String, + providerStreamingBaseUrl: String, + includeSupplementalPublicVariants: Boolean = true, + ): List { + val vpcId = getVpcId(token, providerStreamingBaseUrl) + val panels = runCatching { fetchPanels(token, listOf("LIBRARY"), vpcId, withLibraryTime = true) } + .getOrElse { fetchPanels(token, listOf("LIBRARY"), vpcId, withLibraryTime = false) } + val games = enrichGamesWithMetadata(token, vpcId, flattenPanels(panels)) + return if (includeSupplementalPublicVariants) mergePublicGameVariants(games, fetchPublicGames()) else games + } + + suspend fun browseCatalog( + token: String, + providerStreamingBaseUrl: String, + searchQuery: String, + sortId: String = DEFAULT_SORT_ID, + filterIds: List = emptyList(), + maxPages: Int = MAX_CATALOG_PAGES, + includeSupplementalPublicVariants: Boolean = true, + ): CatalogBrowseResult { + val vpcId = getVpcId(token, providerStreamingBaseUrl) + val definitions = fetchFilterAndSortDefinitions(token) + val selectedSort = definitions.sortOptions.firstOrNull { it.id == sortId } + ?: definitions.sortOptions.firstOrNull { it.id == DEFAULT_SORT_ID } + ?: CatalogSortOption(DEFAULT_SORT_ID, "Relevance", "itemMetadata.relevance:DESC,sortName:ASC") + val selectedFilters = filterIds.filter { definitions.filterPayloadById.containsKey(it) } + val filters = selectedFilters.mapNotNull { definitions.filterPayloadById[it]?.asObject() } + .fold(mutableMapOf()) { acc, obj -> + acc.putAll(obj) + acc + } + val query = catalogQuery(searchQuery.isNotBlank()) + val collectedApps = mutableListOf() + var numberReturned = 0 + var numberSupported = 0 + var totalCount = 0 + var hasNextPage = false + var endCursor: String? = null + var cursor = "" + for (page in 0 until maxPages.coerceIn(1, MAX_CATALOG_PAGES)) { + val payload = postGraphQl( + query = query, + variables = buildJsonObject { + put("vpcId", vpcId) + put("locale", DEFAULT_LOCALE) + put("sortString", selectedSort.orderBy) + put("fetchCount", DEFAULT_CATALOG_FETCH_COUNT) + put("cursor", cursor) + put("filters", JsonObject(filters)) + if (searchQuery.isNotBlank()) put("searchString", searchQuery.trim()) + }, + token = token, + ).checkGraphQlErrors() + val apps = payload.obj("data")?.obj("apps") + val items = apps?.arr("items")?.mapNotNull { it.asObject() }.orEmpty() + collectedApps += items + numberReturned += apps?.int("numberReturned") ?: items.size + numberSupported = apps?.int("numberSupported") ?: numberSupported + totalCount = apps?.obj("pageInfo")?.int("totalCount") ?: totalCount + hasNextPage = apps?.obj("pageInfo")?.boolean("hasNextPage") ?: false + endCursor = apps?.obj("pageInfo")?.string("endCursor") + if (!hasNextPage || endCursor.isNullOrBlank()) break + cursor = endCursor.orEmpty() + } + val publicGames = if (includeSupplementalPublicVariants) fetchPublicGames() else emptyList() + val games = dedupeGames(collectedApps.map(::appToGame)) + val withSearchFallbacks = if (searchQuery.isBlank() || publicGames.isEmpty()) { + games + } else { + dedupeGames(games + publicGames.filter { it.matchesSearch(searchQuery) }) + } + val merged = if (publicGames.isEmpty()) withSearchFallbacks else mergePublicGameVariants(withSearchFallbacks, publicGames) + return CatalogBrowseResult( + games = merged, + numberReturned = numberReturned, + numberSupported = max(numberSupported, merged.size), + totalCount = max(totalCount, merged.size), + hasNextPage = hasNextPage, + endCursor = endCursor?.takeIf { it.isNotBlank() }, + searchQuery = searchQuery, + selectedSortId = selectedSort.id, + selectedFilterIds = selectedFilters, + filterGroups = definitions.filterGroups, + sortOptions = definitions.sortOptions, + ) + } + + suspend fun fetchPublicGames(): List { + val request = Request.Builder() + .url("https://static.nvidiagrid.net/supported-public-game-list/locales/gfnpc-en-US.json") + .header("User-Agent", GFN_USER_AGENT) + .build() + val (code, text) = http.awaitText(request) + if (code !in 200..299) return emptyList() + return OpenNowJson.parseToJsonElement(text).jsonArray + .mapNotNull { item -> + val obj = item.asObject() ?: return@mapNotNull null + if (obj.string("status") != "AVAILABLE") return@mapNotNull null + val title = obj.string("title") ?: return@mapNotNull null + val id = obj["id"]?.jsonPrimitive?.contentOrNull ?: title + val steamAppId = obj.string("steamUrl")?.substringAfter("/app/", "")?.substringBefore("/") + val store = obj.string("store") ?: if (obj.string("publisher")?.contains("ncsoft", true) == true) "NCSoft" else "Unknown" + val posterUrl = steamAppId?.takeIf { it.isNotBlank() }?.let { "https://cdn.cloudflare.steamstatic.com/steam/apps/$it/library_600x900.jpg" } + GameInfo( + id = id, + uuid = id, + launchAppId = id.takeIf { it.all(Char::isDigit) }, + title = title, + imageUrl = posterUrl, + tvCardImageUrl = posterUrl, + screenshotUrl = steamAppId?.takeIf { it.isNotBlank() }?.let { "https://cdn.cloudflare.steamstatic.com/steam/apps/$it/library_hero.jpg" }, + tvBannerUrl = steamAppId?.takeIf { it.isNotBlank() }?.let { "https://cdn.cloudflare.steamstatic.com/steam/apps/$it/library_hero.jpg" }, + searchText = listOf(title, store, obj.string("publisher")).filterNotNull().joinToString(" ").lowercase(), + selectedVariantIndex = 0, + variants = listOf(GameVariant(id = id, store = store)), + availableStores = displayStoresForVariants(listOf(GameVariant(id = id, store = store))), + ) + } + } + + suspend fun resolveLaunchAppId(token: String, appIdOrUuid: String, providerStreamingBaseUrl: String): String? { + if (appIdOrUuid.all(Char::isDigit)) return appIdOrUuid + val vpcId = getVpcId(token, providerStreamingBaseUrl) + val meta = fetchAppMetaData(token, listOf(appIdOrUuid), vpcId) + return meta.firstOrNull()?.let(::resolveNumericAppId) + } + + suspend fun getVpcId(token: String, providerStreamingBaseUrl: String): String { + val base = normalizeStreamingServiceUrl(providerStreamingBaseUrl) ?: DEFAULT_STREAMING_SERVICE_URL + val cacheKey = base.lowercase(Locale.US) + return vpcIdMutex.withLock { + val now = SystemClock.elapsedRealtime() + vpcIdCache[cacheKey] + ?.takeIf { it.expiresAtElapsedMs > now } + ?.value + ?.let { return@withLock it } + + val resolved = runCatching { + val request = Request.Builder() + .url("${base}v2/serverInfo") + .headers( + Headers.Builder() + .putDesktopLcars(token, includeUserAgent = true, includeEmptyTokenAuthorization = true) + .build(), + ) + .build() + val (code, text) = http.awaitText(request) + if (code !in 200..299) { + "GFN-PC" + } else { + OpenNowJson.parseToJsonElement(text).jsonObject.obj("requestStatus")?.string("serverId") ?: "GFN-PC" + } + }.getOrDefault("GFN-PC") + // Catalog, library, and subscription refreshes start together. Share their + // server identity instead of issuing the same request several times. Do not + // cache the fallback so a transient network failure can recover immediately. + if (resolved != "GFN-PC") { + vpcIdCache[cacheKey] = CachedVpcId(resolved, now + VPC_ID_CACHE_TTL_MS) + } + resolved + } + } + + private companion object { + const val VPC_ID_CACHE_TTL_MS = 5 * 60 * 1_000L + } + + private suspend fun fetchPanels(token: String, panelNames: List, vpcId: String, withLibraryTime: Boolean): JsonObject { + val variables = buildJsonObject { + put("vpcId", vpcId) + put("locale", DEFAULT_LOCALE) + putJsonArray("panelNames") { panelNames.forEach { add(JsonPrimitive(it)) } } + }.toString() + val extensions = buildJsonObject { + putJsonObject("persistedQuery") { + put("sha256Hash", if (withLibraryTime) LIBRARY_WITH_TIME_QUERY_HASH else PANELS_QUERY_HASH) + } + }.toString() + val requestType = if (panelNames.contains("LIBRARY")) "panels/Library" else "panels/MainV2" + val url = "$GAMES_GRAPHQL_URL?requestType=${encoded(requestType)}&extensions=${encoded(extensions)}&huId=${randomHuId()}&variables=${encoded(variables)}" + val request = Request.Builder() + .url(url) + .headers(desktopGraphQlHeaders(token).newBuilder().set("Content-Type", "application/graphql").build()) + .get() + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "Games GraphQL failed ($code): ${text.take(400)}" } + return OpenNowJson.parseToJsonElement(text).jsonObject + } + + private suspend fun fetchAppMetaData(token: String, appIds: List, vpcId: String): List { + if (appIds.isEmpty()) return emptyList() + val variables = buildJsonObject { + put("vpcId", vpcId) + put("locale", DEFAULT_LOCALE) + putJsonArray("appIds") { appIds.distinct().forEach { add(JsonPrimitive(it)) } } + }.toString() + val extensions = buildJsonObject { + putJsonObject("persistedQuery") { put("sha256Hash", APP_METADATA_QUERY_HASH) } + }.toString() + val url = "$GAMES_GRAPHQL_URL?requestType=appMetaData&extensions=${encoded(extensions)}&huId=${randomHuId()}&variables=${encoded(variables)}" + val request = Request.Builder() + .url(url) + .headers(desktopGraphQlHeaders(token).newBuilder().set("Content-Type", "application/graphql").build()) + .build() + val (code, text) = http.awaitText(request) + if (code !in 200..299) return emptyList() + return OpenNowJson.parseToJsonElement(text).jsonObject.checkGraphQlErrors("App metadata") + .obj("data")?.obj("apps")?.arr("items")?.mapNotNull { it.asObject() }.orEmpty() + } + + private suspend fun enrichGamesWithMetadata(token: String, vpcId: String, games: List): List { + val ids = games.mapNotNull { it.uuid }.distinct() + if (ids.isEmpty()) return games + val apps = ids.chunked(40).flatMap { fetchAppMetaData(token, it, vpcId) } + val byId = apps.associateBy { it.string("id").orEmpty() } + return dedupeGames(games.map { game -> + val app = byId[game.uuid] ?: return@map game + mergePanelGameWithMetadata(game, appToGame(app)) + }) + } + + private fun flattenPanels(payload: JsonObject): List { + val games = payload.checkGraphQlErrors("Games GraphQL").obj("data")?.arr("panels")?.flatMap { panel -> + panel.asObject()?.arr("sections")?.flatMap { section -> + section.asObject()?.arr("items")?.mapNotNull { item -> + val obj = item.asObject() + val app = obj?.obj("app") + if (obj?.string("__typename") == "GameItem" && app != null) { + appToGame(app).copy( + catalogSectionId = section.asObject()?.string("id"), + catalogSectionTitle = section.asObject()?.string("title"), + ) + } else null + }.orEmpty() + }.orEmpty() + }.orEmpty() + return dedupeGames(games) + } + + private fun appToGame(app: JsonObject): GameInfo { + val variants = app.arr("variants")?.mapNotNull { raw -> + val obj = raw.asObject() ?: return@mapNotNull null + val library = obj.obj("gfn")?.obj("library") + GameVariant( + id = obj.string("id") ?: return@mapNotNull null, + store = obj.string("appStore") ?: "Unknown", + supportedControls = obj.arr("supportedControls")?.mapNotNull { it.asString() }.orEmpty(), + librarySelected = library?.boolean("selected"), + libraryStatus = library?.string("status"), + lastPlayedDate = library?.string("lastPlayedDate"), + gfnStatus = obj.obj("gfn")?.string("status"), + ) + }.orEmpty() + val numericAppId = resolveNumericAppId(app) + val selectedVariantId = app.arr("variants") + ?.mapNotNull { it.asObject() } + ?.firstOrNull { it.obj("gfn")?.obj("library")?.boolean("selected") == true } + ?.string("id") + val selectedIndex = max(0, variants.indexOfFirst { it.id == (selectedVariantId ?: numericAppId) }) + val images = app.obj("images") + val cardArtwork = catalogCardArtwork( + keyArt = images?.string("KEY_ART"), + gameBoxArt = images?.string("GAME_BOX_ART"), + heroImage = images?.string("HERO_IMAGE"), + tvBanner = images?.string("TV_BANNER"), + ) + val screenshotUrl = listOf("HERO_IMAGE", "TV_BANNER", "KEY_ART", "GAME_BOX_ART") + .firstNotNullOfOrNull { images?.string(it) } + val screenshotUrls = catalogScreenshotUrls(images) + val tvBannerUrl = listOf("TV_BANNER", "HERO_IMAGE", "KEY_ART", "GAME_BOX_ART") + .firstNotNullOfOrNull { images?.string(it) } + val genres = extractLabels(app.arr("genres")) + val featureLabels = (extractLabels(app.arr("features")) + extractLabels(app.arr("gameFeatures")) + extractLabels(app.arr("appFeatures")) + genres).distinct() + val title = app.string("title") ?: app.string("id") ?: "Unknown Game" + val stores = displayStoresForVariants(variants) + return GameInfo( + id = app.string("id") ?: title, + uuid = app.string("id"), + launchAppId = numericAppId, + title = title, + description = catalogGameDescription(app), + longDescription = app.string("longDescription"), + featureLabels = featureLabels, + genres = genres, + imageUrl = cardArtwork.mobileImageUrl, + tvCardImageUrl = cardArtwork.tvImageUrl, + screenshotUrl = screenshotUrl, + screenshotUrls = screenshotUrls, + tvBannerUrl = tvBannerUrl, + playType = app.obj("gfn")?.string("playType"), + membershipTierLabel = app.obj("gfn")?.string("minimumMembershipTierLabel"), + publisherName = app.string("publisherName"), + contentRatings = extractLabels(app.arr("contentRatings")), + playabilityState = app.obj("gfn")?.string("playabilityState"), + availableStores = stores, + searchText = (listOf(title, app.string("publisherName")) + stores + genres + featureLabels).filterNotNull().joinToString(" ").lowercase(), + lastPlayed = variants.firstNotNullOfOrNull { it.lastPlayedDate }, + isInLibrary = variants.any(::isOwnedGameVariant), + selectedVariantIndex = min(selectedIndex, max(variants.size - 1, 0)), + variants = variants, + ) + } + + private fun resolveNumericAppId(app: JsonObject): String? { + val variants = app.arr("variants")?.mapNotNull { it.asObject() }.orEmpty() + val selected = variants.firstOrNull { it.obj("gfn")?.obj("library")?.boolean("selected") == true }?.string("id") + return selected?.takeIf { it.all(Char::isDigit) } + ?: variants.firstNotNullOfOrNull { it.string("id")?.takeIf { value -> value.isNumeric() } } + ?: app.string("id")?.takeIf { value -> value.isNumeric() } + } + + private suspend fun fetchFilterAndSortDefinitions(token: String): CatalogDefinitions { + val query = """ + query GetFilterGroupAndSortOrderDefinitions(${'$'}locale: String!) { + filterGroupDefinitions(language: ${'$'}locale) { id label filters { id label filters } } + sortOrderDefinitions(language: ${'$'}locale) { id label orderBy } + } + """.trimIndent() + val payload = postGraphQl(query, buildJsonObject { put("locale", DEFAULT_LOCALE) }, token).checkGraphQlErrors() + val data = payload.obj("data") + val filterPayloadById = mutableMapOf() + val groups = data?.arr("filterGroupDefinitions")?.mapNotNull { raw -> + val group = raw.asObject() ?: return@mapNotNull null + val options = group.arr("filters")?.mapNotNull { filterRaw -> + val filter = filterRaw.asObject() ?: return@mapNotNull null + val filterJson = filter.arr("filters")?.firstOrNull()?.asString() ?: return@mapNotNull null + val parsed = runCatching { OpenNowJson.parseToJsonElement(filterJson) }.getOrNull() ?: return@mapNotNull null + val id = filter.string("id") ?: return@mapNotNull null + filterPayloadById[id] = parsed + CatalogFilterOption( + id = id, + rawId = id, + label = filter.string("label") ?: id, + groupId = group.string("id") ?: "", + groupLabel = group.string("label") ?: "", + ) + }.orEmpty() + if (options.isEmpty()) null else CatalogFilterGroup( + id = group.string("id") ?: "", + label = group.string("label") ?: "", + options = options, + ) + }.orEmpty() + val sorts = data?.arr("sortOrderDefinitions")?.mapNotNull { + val obj = it.asObject() ?: return@mapNotNull null + CatalogSortOption(obj.string("id") ?: return@mapNotNull null, obj.string("label") ?: "", obj.string("orderBy") ?: "") + }.orEmpty() + return CatalogDefinitions(groups, sorts, filterPayloadById) + } + + private suspend fun postGraphQl(query: String, variables: JsonObject, token: String): JsonObject { + val body = buildJsonObject { + put("query", query) + put("variables", variables) + }.toString().toRequestBody(JSON_MEDIA_TYPE) + val request = Request.Builder() + .url(GAMES_GRAPHQL_URL) + .headers(desktopGraphQlHeaders(token)) + .post(body) + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "GFN GraphQL failed ($code): ${text.take(400)}" } + return OpenNowJson.parseToJsonElement(text).jsonObject + } + + private fun catalogQuery(hasSearch: Boolean): String { + val appFields = """ + numberReturned + numberSupported + pageInfo { hasNextPage endCursor totalCount } + items { + id + title + shortDescription + longDescription + publisherName + images { KEY_ART GAME_BOX_ART TV_BANNER HERO_IMAGE SCREENSHOTS } + variants { id appStore supportedControls gfn { status library { status selected } } } + gfn { playabilityState minimumMembershipTierLabel catalogSkuStrings { SKU_BASED_TAG } } + itemMetadata { campaignIds } + } + """.trimIndent() + return if (hasSearch) { + """ + query GetSearchFilterResults(${'$'}vpcId: String!, ${'$'}locale: String!, ${'$'}sortString: String!, ${'$'}fetchCount: Int!, ${'$'}cursor: String!, ${'$'}searchString: String!, ${'$'}filters: AppFilterFields!) { + apps(vpcId: ${'$'}vpcId, language: ${'$'}locale, orderBy: ${'$'}sortString, first: ${'$'}fetchCount, after: ${'$'}cursor, searchQuery: ${'$'}searchString, filters: ${'$'}filters) { + $appFields + } + } + """.trimIndent() + } else { + """ + query GetFilterBrowseResults(${'$'}vpcId: String!, ${'$'}locale: String!, ${'$'}sortString: String!, ${'$'}fetchCount: Int!, ${'$'}cursor: String!, ${'$'}filters: AppFilterFields!) { + apps(vpcId: ${'$'}vpcId, language: ${'$'}locale, orderBy: ${'$'}sortString, first: ${'$'}fetchCount, after: ${'$'}cursor, filters: ${'$'}filters) { + $appFields + } + } + """.trimIndent() + } + } + + private fun dedupeGames(games: List): List = + games.groupBy { game -> game.title.normalizedTitleKey().ifBlank { game.id } }.map { (_, bucket) -> + bucket.reduce { left, right -> + val variants = (left.variants + right.variants).distinctBy { it.id } + val selectedVariantId = left.variants.getOrNull(left.selectedVariantIndex)?.id + ?: right.variants.getOrNull(right.selectedVariantIndex)?.id + val selectedIndex = selectedVariantId?.let { id -> variants.indexOfFirst { it.id == id } } ?: -1 + left.copy( + launchAppId = left.launchAppId ?: right.launchAppId, + uuid = left.uuid ?: right.uuid, + description = left.description ?: right.description, + longDescription = left.longDescription ?: right.longDescription, + imageUrl = left.imageUrl ?: right.imageUrl, + tvCardImageUrl = left.tvCardImageUrl ?: right.tvCardImageUrl, + screenshotUrl = left.screenshotUrl ?: right.screenshotUrl, + screenshotUrls = (left.screenshotUrls + right.screenshotUrls).distinct(), + tvBannerUrl = left.tvBannerUrl ?: right.tvBannerUrl, + variants = variants, + availableStores = displayStoresForVariants(variants), + genres = (left.genres + right.genres).distinct(), + featureLabels = (left.featureLabels + right.featureLabels).distinct(), + isInLibrary = left.isInLibrary || right.isInLibrary, + searchText = listOfNotNull(left.searchText, right.searchText).joinToString(" ").ifBlank { null }, + selectedVariantIndex = if (selectedIndex >= 0) selectedIndex else left.selectedVariantIndex.coerceAtMost(max(variants.size - 1, 0)), + ) + } + } + + private fun mergePublicGameVariants(games: List, publicGames: List): List { + val publicByTitle = publicGames.associateBy { it.title.normalizedTitleKey() } + return games.map { game -> + val publicGame = publicByTitle[game.title.normalizedTitleKey()] ?: return@map game + val existingStores = game.variants.map { normalizeGameStore(it.store) }.toSet() + val supplemental = publicGame.variants.filter { !isPrimaryCatalogStoreValue(it.store) && normalizeGameStore(it.store) !in existingStores } + if (supplemental.isEmpty()) game else game.copy( + launchAppId = game.launchAppId ?: publicGame.launchAppId, + imageUrl = game.imageUrl ?: publicGame.imageUrl, + tvCardImageUrl = game.tvCardImageUrl ?: publicGame.tvCardImageUrl, + screenshotUrl = game.screenshotUrl ?: publicGame.screenshotUrl, + screenshotUrls = (game.screenshotUrls + publicGame.screenshotUrls).distinct(), + tvBannerUrl = game.tvBannerUrl ?: publicGame.tvBannerUrl, + variants = game.variants + supplemental, + availableStores = displayStoresForVariants(game.variants + supplemental), + searchText = listOfNotNull(game.searchText, publicGame.searchText).joinToString(" "), + ) + } + } + + private fun GameInfo.matchesSearch(query: String): Boolean { + val normalized = query.trim().lowercase() + if (normalized.isBlank()) return true + return (listOf(title, searchText) + availableStores + variants.map { it.store }) + .filterNotNull() + .any { it.lowercase().contains(normalized) } + } + + private fun extractLabels(array: JsonArray?): List = array?.mapNotNull { item -> + when (item) { + is JsonPrimitive -> item.contentOrNull + is JsonObject -> listOf("name", "label", "title", "displayName").firstNotNullOfOrNull { item.string(it) } + else -> null + }?.trim()?.takeIf { it.isNotBlank() } + }?.distinct().orEmpty() + + private fun randomHuId(): String = System.currentTimeMillis().toString(16) + UUID.randomUUID().toString().replace("-", "").take(8) + + private data class CatalogDefinitions( + val filterGroups: List, + val sortOptions: List, + val filterPayloadById: Map, + ) +} + +class GfnSubscriptionRepository( + private val http: OkHttpClient = defaultHttpClient(), +) { + suspend fun fetchSubscription(token: String, userId: String, vpcId: String = "NP-AMS-08"): SubscriptionInfo { + val url = "$MES_URL?serviceName=gfn_pc&languageCode=en_US&vpcId=${encoded(vpcId)}&userId=${encoded(userId)}" + val request = Request.Builder() + .url(url) + .headers(Headers.Builder().putDesktopLcars(token).build()) + .build() + val (code, text) = http.awaitText(request) + if (code !in 200..299) return SubscriptionInfo() + val data = OpenNowJson.parseToJsonElement(text).jsonObject + val allotted = data.double("allottedTimeInMinutes") ?: 0.0 + val purchased = data.double("purchasedTimeInMinutes") ?: 0.0 + val rolled = data.double("rolledOverTimeInMinutes") ?: 0.0 + val total = data.double("totalTimeInMinutes") ?: (allotted + purchased + rolled) + val remaining = data.double("remainingTimeInMinutes") ?: 0.0 + val resolutions = data.obj("features")?.arr("resolutions")?.mapNotNull { raw -> + val obj = raw.asObject() ?: return@mapNotNull null + EntitledResolution( + width = obj.int("widthInPixels") ?: return@mapNotNull null, + height = obj.int("heightInPixels") ?: return@mapNotNull null, + fps = obj.int("framesPerSecond") ?: return@mapNotNull null, + ) + }?.sortedWith(compareByDescending { it.width }.thenByDescending { it.height }.thenByDescending { it.fps }).orEmpty() + val subscription = data.obj("subscription") ?: data + val storageAddon = subscription.arr("addons") + ?.mapNotNull { it.asObject() } + ?.firstOrNull(::isActivePersistentStorageAddon) + ?.let(::parseStorageAddon) + return SubscriptionInfo( + membershipTier = data.string("membershipTier") ?: "FREE", + subscriptionType = data.string("type"), + subscriptionSubType = data.string("subType"), + allottedHours = allotted / 60.0, + purchasedHours = purchased / 60.0, + rolledOverHours = rolled / 60.0, + usedHours = max(total - remaining, 0.0) / 60.0, + remainingHours = remaining / 60.0, + totalHours = total / 60.0, + state = data.obj("currentSubscriptionState")?.string("state"), + isGamePlayAllowed = data.obj("currentSubscriptionState")?.boolean("isGamePlayAllowed"), + isUnlimited = data.string("subType") == "UNLIMITED", + storageAddon = storageAddon, + entitledResolutions = resolutions, + ) + } + + private fun parseStorageAddon(addon: JsonObject): StorageAddon { + val attributes = addon.arr("attributes") + ?.mapNotNull { it.asObject() } + ?.associate { attribute -> + attribute.string("key").orEmpty() to attribute.string("textValue").orEmpty() + } + .orEmpty() + val total = attributes[TOTAL_STORAGE_SIZE_IN_GB]?.toDoubleOrNull() + val used = attributes[USED_STORAGE_SIZE_IN_GB]?.toDoubleOrNull() + return StorageAddon( + type = addon.string("type") ?: STORAGE_ADDON_TYPE, + sizeGb = total, + usedGb = used, + regionName = attributes[STORAGE_METRO_REGION_NAME], + regionCode = attributes[STORAGE_METRO_REGION], + status = addon.string("status"), + subType = addon.string("subType"), + autoPayEnabled = addon.boolean("autoPayEnabled"), + ) + } + + private fun isActivePersistentStorageAddon(addon: JsonObject): Boolean = + addon.string("type") == STORAGE_ADDON_TYPE && + addon.string("subType") == "PERMANENT_STORAGE" && + addon.string("status") == "OK" +} + +class GfnAccountConnectorRepository( + private val http: OkHttpClient = defaultHttpClient(), +) { + suspend fun fetchConnectors(token: String): List { + val query = """ + query GetAccountConnectors(${'$'}locale: String!, ${'$'}stringsKey: [String]!) { + appStoreDefinitions(language: ${'$'}locale) { + store + label + sortOrder + features { + __typename + ... on AccountLinkingSso { + supported + } + ... on AccountGamesSyncing { + supported + } + } + accountLinkingMetadata { + isSupported + isRequired + label + } + } + userAccount { + storesData { + store + accountLinkingData { + userDisplayName + expiresIn + userIdentifier + accountSyncingData { + totalNumberOfSyncedGfnGames + syncState + syncDate + } + } + } + } + clientStrings(language: ${'$'}locale, keys: ${'$'}stringsKey) + } + """.trimIndent() + val payload = postGraphQl( + query, + buildJsonObject { + put("locale", DEFAULT_LOCALE) + putJsonArray("stringsKey") {} + }, + token, + ).checkGraphQlErrors("Account connectors") + val data = payload.obj("data") ?: return emptyList() + val userStores = data.obj("userAccount")?.arr("storesData") + ?.mapNotNull { it.asObject() } + ?.associateBy { normalizeGameStore(it.string("store").orEmpty()) } + .orEmpty() + val connectors = data.arr("appStoreDefinitions") + ?.mapNotNull { raw -> + val store = raw.asObject() ?: return@mapNotNull null + val storeId = store.string("store")?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + val metadata = store.obj("accountLinkingMetadata") + val featureSupported = store.arr("features") + ?.mapNotNull { it.asObject() } + ?.any { feature -> + feature.boolean("supported") == true && + feature.string("__typename") in setOf("AccountLinkingSso", "AccountGamesSyncing") + } == true + val supported = metadata?.boolean("isSupported") == true || featureSupported + val normalizedStoreId = normalizeGameStore(storeId) + if (!supported && normalizedStoreId !in userStores) return@mapNotNull null + val linked = userStores[normalizedStoreId]?.obj("accountLinkingData") + val sync = linked?.obj("accountSyncingData") + AccountConnector( + store = storeId, + label = metadata?.string("label") ?: store.string("label") ?: gameStoreDisplayName(storeId), + supported = supported, + required = metadata?.boolean("isRequired") ?: false, + userDisplayName = linked?.string("userDisplayName"), + userIdentifier = linked?.string("userIdentifier"), + expiresInSeconds = linked?.long("expiresIn"), + syncedGameCount = sync?.int("totalNumberOfSyncedGfnGames"), + syncState = sync?.string("syncState"), + syncDate = sync?.string("syncDate"), + ) + } + .orEmpty() + .ensureSteamConnector(userStores) + return connectors.sortedWith( + compareByDescending { it.isLinked } + .thenBy { accountConnectorSortRank(it.store) } + .thenBy { it.label.lowercase(Locale.US) }, + ) + } + + suspend fun loginUrl(store: String, accessToken: String): String { + val platform = accountLinkingPlatform(store) + val url = "$ACCOUNT_LINKING_BASE_URL/login_url" + .toHttpUrl() + .newBuilder() + .addQueryParameter("platform", platform) + .addQueryParameter("redirect_uri", ACCOUNT_LINKING_REDIRECT_URL) + .addQueryParameter("client_id", ACCOUNT_LINKING_CLIENT_ID) + .build() + val request = Request.Builder() + .url(url) + .headers(accountLinkingHeaders(accessToken)) + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "Store connection failed ($code): ${text.take(240)}" } + return OpenNowJson.parseToJsonElement(text).jsonObject.string("login_url") + ?: error("Store connection did not return a login URL") + } + + suspend fun disconnect(store: String, accessToken: String) { + val platform = accountLinkingPlatform(store) + val request = Request.Builder() + .url("$ACCOUNT_LINKING_BASE_URL/linking/${encoded(platform)}") + .headers(accountLinkingHeaders(accessToken)) + .delete() + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "Store disconnect failed ($code): ${text.take(240)}" } + } + + private suspend fun postGraphQl(query: String, variables: JsonObject, token: String): JsonObject { + val body = buildJsonObject { + put("query", query) + put("variables", variables) + }.toString().toRequestBody(JSON_MEDIA_TYPE) + val request = Request.Builder() + .url(GAMES_GRAPHQL_URL) + .headers(desktopGraphQlHeaders(token)) + .post(body) + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "Account connectors failed ($code): ${text.take(400)}" } + return OpenNowJson.parseToJsonElement(text).jsonObject + } + + private fun accountLinkingHeaders(accessToken: String): Headers = + Headers.Builder() + .add("Accept", "application/json, text/plain, */*") + .add("Authorization", bearerAuthorization(accessToken)) + .add("Origin", GFN_PLAY_ORIGIN) + .add("Referer", GFN_PLAY_REFERER) + .add("User-Agent", GFN_USER_AGENT) + .build() + + private fun List.ensureSteamConnector(userStores: Map): List { + if (any { normalizeGameStore(it.store) == "STEAM" }) return this + val linked = userStores["STEAM"]?.obj("accountLinkingData") + val sync = linked?.obj("accountSyncingData") + return this + AccountConnector( + store = "STEAM", + label = "Steam", + supported = true, + required = false, + userDisplayName = linked?.string("userDisplayName"), + userIdentifier = linked?.string("userIdentifier"), + expiresInSeconds = linked?.long("expiresIn"), + syncedGameCount = sync?.int("totalNumberOfSyncedGfnGames"), + syncState = sync?.string("syncState"), + syncDate = sync?.string("syncDate"), + ) + } + + private fun accountLinkingPlatform(store: String): String = + when (val normalized = normalizeGameStore(store).ifBlank { store }.uppercase(Locale.US)) { + "UBISOFT", "UBISOFT_CONNECT" -> "UPLAY" + "BATTLE_NET", "BLIZZARD" -> "BATTLENET" + "EPIC_GAMES", "EPIC_GAMES_STORE" -> "EPIC" + else -> normalized + } + + private fun accountConnectorSortRank(store: String): Int = + when (normalizeGameStore(store)) { + "STEAM" -> 0 + "EPIC", "EGS", "EPIC_GAMES_STORE" -> 1 + "XBOX", "XBOX_GAME_PASS", "GAME_PASS" -> 2 + "UBISOFT", "UBISOFT_CONNECT" -> 3 + else -> 10 + } +} + +class PrintedWasteRepository( + private val http: OkHttpClient = defaultHttpClient(), +) { + suspend fun fetchQueue(): Map { + val request = Request.Builder() + .url(PRINTEDWASTE_QUEUE_URL) + .header("User-Agent", "opennow-android") + .header("Accept", "application/json") + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "PrintedWaste queue returned HTTP $code" } + val payload = OpenNowJson.parseToJsonElement(text).jsonObject + check(payload.boolean("status") == true) { "PrintedWaste queue returned status:false" } + val data = payload.obj("data") ?: error("PrintedWaste queue missing data") + return data.mapNotNull { (zoneId, raw) -> + val zone = raw.asObject() ?: return@mapNotNull null + val queue = zone.int("QueuePosition") ?: return@mapNotNull null + val region = zone.string("Region")?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + zoneId to PrintedWasteZone( + QueuePosition = queue, + LastUpdated = zone.long("Last Updated") ?: 0L, + Region = region, + eta = zone.long("eta"), + ) + }.toMap().also { + check(it.isNotEmpty()) { "PrintedWaste queue returned no usable zones" } + } + } + + suspend fun fetchServerMapping(): Map { + val request = Request.Builder() + .url(PRINTEDWASTE_SERVER_MAPPING_URL) + .header("User-Agent", "opennow-android") + .header("Accept", "application/json") + .build() + val (code, text) = http.awaitText(request) + check(code in 200..299) { "PrintedWaste mapping returned HTTP $code" } + val payload = OpenNowJson.parseToJsonElement(text).jsonObject + check(payload.boolean("status") == true) { "PrintedWaste mapping returned status:false" } + val data = payload.obj("data") ?: error("PrintedWaste mapping missing data") + return data.mapNotNull { (zoneId, raw) -> + val zone = raw.asObject() ?: return@mapNotNull null + zoneId to PrintedWasteServerMappingEntry( + title = zone.string("title"), + region = zone.string("region"), + is4080Server = zone.boolean("is4080Server"), + is5080Server = zone.boolean("is5080Server"), + nuked = zone.boolean("nuked"), + ) + }.toMap() + } + + suspend fun pingRegions(regions: List): List = coroutineScope { + regions.map { region -> + async(Dispatchers.IO) { + val url = region.url.toHttpUrlOrNull() + ?: return@async PingResult(region.url, error = "Invalid URL") + val hostname = url.host + val port = if (url.isHttps) 443 else 80 + val validPings = mutableListOf() + + // The selector waits for the slowest region, so multi-second probes make + // one unreachable edge look like a frozen queue screen. A short warm-up + // plus two samples is enough to rank playable streaming regions while + // bounding the entire parallel pass to roughly two seconds. + tcpPing(hostname, port, timeoutMs = 750) + repeat(2) { index -> + if (index > 0) delay(50) + tcpPing(hostname, port, timeoutMs = 750)?.let(validPings::add) + } + + if (validPings.isEmpty()) { + PingResult(region.url, error = "All ping tests failed") + } else { + PingResult(region.url, pingMs = validPings.average().toLong()) + } + } + }.map { it.await() } + } + + private fun tcpPing(hostname: String, port: Int, timeoutMs: Int): Long? = + runCatching { + Socket().use { socket -> + val start = System.nanoTime() + socket.connect(InetSocketAddress(hostname, port), timeoutMs) + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start) + } + }.getOrNull() +} + +data class GfnSessionDiagnosticResponse( + val operation: String, + val method: String, + val url: String, + val statusCode: Int, + val requestBody: String, + val responseBody: String, +) + +class GfnSessionRepository( + private val authStore: AuthStore, + private val http: OkHttpClient = defaultHttpClient(), + private val physicalDisplayResolutionProvider: () -> Pair? = { null }, + private val diagnosticsSink: (GfnSessionDiagnosticResponse) -> Unit = {}, +) { + suspend fun createSession( + token: String, + streamingBaseUrl: String?, + appId: String, + internalTitle: String, + zone: String, + settings: StreamSettings, + accountLinked: Boolean = true, + ): SessionInfo { + require(appId.all(Char::isDigit)) { "Invalid launch appId '$appId'." } + val clientId = UUID.randomUUID().toString() + val deviceId = authStore.stableDeviceId() + val base = resolveLaunchSessionBaseUrl(token, resolveStreamingBaseUrl(zone, streamingBaseUrl)) + val body = buildSessionRequestBody( + appId = appId, + internalTitle = internalTitle, + settings = settings, + accountLinked = accountLinked, + deviceId = deviceId, + physicalDisplayResolution = physicalDisplayResolutionProvider(), + ) + val url = "$base/v2/session?keyboardLayout=${encoded(settings.keyboardLayout)}&languageCode=${encoded(settings.gameLanguage)}" + val host = Uri.parse(base).host.orEmpty() + val requestHttp = if (isZoneHostname(host)) sessionProxyHttpClient(settings, http) else http + val request = Request.Builder() + .url(url) + .headers(cloudMatchHeaders(token, clientId, deviceId, includeOrigin = true)) + .post(body.toString().toRequestBody(JSON_MEDIA_TYPE)) + .build() + val (code, text) = requestHttp.awaitText(request) + recordDiagnosticResponse("session.create", request, code, text) + val payload = OpenNowJson.parseToJsonElement(text).jsonObject + return toSessionInfo(zone, base, payload, clientId, deviceId) + } + + suspend fun pollSession( + token: String, + streamingBaseUrl: String?, + serverIp: String?, + zone: String, + sessionId: String, + clientId: String?, + deviceId: String?, + settings: StreamSettings, + ): SessionInfo { + val cid = clientId ?: UUID.randomUUID().toString() + val did = deviceId ?: authStore.stableDeviceId() + val base = resolvePollStopBase(zone, streamingBaseUrl, serverIp) + val host = Uri.parse(base).host.orEmpty() + val requestHttp = if (isZoneHostname(host)) sessionProxyHttpClient(settings, http) else http + val request = Request.Builder() + .url("$base/v2/session/$sessionId") + .headers(cloudMatchHeaders(token, cid, did, includeOrigin = false)) + .build() + val (code, text) = requestHttp.awaitText(request) + recordDiagnosticResponse("session.poll", request, code, text) + val payload = OpenNowJson.parseToJsonElement(text).jsonObject + val realServer = streamingServerIp(payload) + if (isZoneHostname(host) && realServer != null && !isZoneHostname(realServer) && READY_SESSION_STATUSES.contains(payload.obj("session")?.int("status"))) { + val directBase = "https://$realServer" + val directRequest = Request.Builder() + .url("$directBase/v2/session/$sessionId") + .headers(cloudMatchHeaders(token, cid, did, includeOrigin = false)) + .build() + val (code, directText) = http.awaitText(directRequest) + recordDiagnosticResponse("session.poll.direct", directRequest, code, directText) + if (code in 200..299) { + val directPayload = OpenNowJson.parseToJsonElement(directText).jsonObject + if (directPayload.obj("requestStatus")?.int("statusCode") == 1) { + return toSessionInfo(zone, directBase, directPayload, cid, did) + } + } + } + return toSessionInfo(zone, base, payload, cid, did) + } + + suspend fun stopSession(token: String, input: SessionInfo, settings: StreamSettings) { + val base = resolvePollStopBase(input.zone, input.streamingBaseUrl, input.serverIp) + val host = Uri.parse(base).host.orEmpty() + val requestHttp = if (isZoneHostname(host)) sessionProxyHttpClient(settings, http) else http + val cid = input.clientId ?: UUID.randomUUID().toString() + val did = input.deviceId ?: authStore.stableDeviceId() + val request = Request.Builder() + .url("$base/v2/session/${input.sessionId}") + .headers(cloudMatchHeaders(token, cid, did, includeOrigin = false)) + .delete() + .build() + val (code, text) = requestHttp.awaitText(request) + recordDiagnosticResponse("session.stop", request, code, text) + } + + suspend fun getActiveSessions(token: String, streamingBaseUrl: String, settings: StreamSettings): List { + val base = streamingBaseUrl.trim().trimEnd('/') + val host = Uri.parse(base).host.orEmpty() + val requestHttp = if (isZoneHostname(host)) sessionProxyHttpClient(settings, http) else http + val request = Request.Builder() + .url("$base/v2/session") + .headers(cloudMatchHeaders(token, UUID.randomUUID().toString(), authStore.stableDeviceId(), includeOrigin = false)) + .build() + val (code, text) = requestHttp.awaitText(request) + recordDiagnosticResponse("session.active", request, code, text) + if (code !in 200..299) return emptyList() + val payload = runCatching { OpenNowJson.parseToJsonElement(text).jsonObject }.getOrNull() ?: return emptyList() + if (payload.obj("requestStatus")?.int("statusCode") != 1) return emptyList() + return payload.arr("sessions")?.mapNotNull { raw -> + val s = raw.asObject() ?: return@mapNotNull null + val status = s.int("status") ?: return@mapNotNull null + if (status !in setOf(1, 2, 3)) return@mapNotNull null + val connIp = streamingServerIpFromSession(s) + val controlIp = s.obj("sessionControlInfo")?.string("ip") + val monitor = activeSessionMonitorSettings(s) + ActiveSessionInfo( + sessionId = s.string("sessionId") ?: return@mapNotNull null, + appId = s.obj("sessionRequestData")?.string("appId")?.toIntOrNull() ?: 0, + gpuType = s.string("gpuType"), + status = status, + queuePosition = extractQueuePosition(s), + seatSetupStep = s.obj("seatSetupInfo")?.int("seatSetupStep"), + streamingBaseUrl = base, + serverIp = connIp ?: controlIp, + signalingUrl = s.arr("connectionInfo") + ?.mapNotNull { it.asObject() } + ?.firstOrNull { it.int("usage") == 14 } + ?.let { connection -> + val serverIp = connIp ?: controlIp + serverIp?.let { buildSignalingUrl(connection.string("resourcePath") ?: "/nvst/", it).first } + } + ?: (connIp ?: controlIp)?.let { "wss://$it:443/nvst/" }, + resolution = monitor?.let { "${it.int("widthInPixels") ?: 0}x${it.int("heightInPixels") ?: 0}" }, + fps = monitor?.int("framesPerSecond"), + settingsSignature = activeSessionSettingsSignature(s), + ) + }.orEmpty() + } + + suspend fun claimSession(token: String, active: ActiveSessionInfo, settings: StreamSettings): SessionInfo { + val deviceId = authStore.stableDeviceId() + val clientId = UUID.randomUUID().toString() + val providerBase = normalizeStreamingServiceUrl(active.streamingBaseUrl.orEmpty())?.trimEnd('/') + val providerHost = providerBase?.let { Uri.parse(it).host.orEmpty() }.orEmpty() + val useProviderBaseForSessionOps = providerBase != null && !isZoneHostname(providerHost) + var effectiveServerIp = active.serverIp.orEmpty() + if (!useProviderBaseForSessionOps && effectiveServerIp.isBlank()) { + error("Missing server IP for session claim") + } + if (!useProviderBaseForSessionOps && isZoneHostname(effectiveServerIp)) { + val requestHttp = sessionProxyHttpClient(settings, http) + val prefetch = Request.Builder() + .url("https://$effectiveServerIp/v2/session/${active.sessionId}") + .headers(cloudMatchHeaders(token, clientId, deviceId, includeOrigin = false)) + .build() + val (code, text) = requestHttp.awaitText(prefetch) + recordDiagnosticResponse("session.claim.prefetch", prefetch, code, text) + if (code in 200..299) { + streamingServerIp(OpenNowJson.parseToJsonElement(text).jsonObject)?.let { effectiveServerIp = it } + } + } + val sessionBase = if (useProviderBaseForSessionOps) providerBase else "https://$effectiveServerIp" + val validationUrl = "$sessionBase/v2/session/${active.sessionId}" + val validationRequest = Request.Builder() + .url(validationUrl) + .headers(cloudMatchHeaders(token, clientId, deviceId, includeOrigin = false)) + .build() + val (validationCode, validationText) = http.awaitText(validationRequest) + recordDiagnosticResponse("session.claim.validation", validationRequest, validationCode, validationText) + val validation = runCatching { OpenNowJson.parseToJsonElement(validationText).jsonObject }.getOrNull() + val status = validation?.obj("session")?.int("status") + if (status != 1) { + val claimBody = buildClaimRequestBody( + appId = active.appId.toString(), + deviceId = deviceId, + settings = settings, + physicalDisplayResolution = physicalDisplayResolutionProvider(), + ) + val claimRequest = Request.Builder() + .url("$sessionBase/v2/session/${active.sessionId}?keyboardLayout=${encoded(settings.keyboardLayout)}&languageCode=${encoded(settings.gameLanguage)}") + .headers(cloudMatchHeaders(token, clientId, deviceId, includeOrigin = true)) + .put(claimBody.toString().toRequestBody(JSON_MEDIA_TYPE)) + .build() + val (claimCode, claimText) = http.awaitText(claimRequest) + recordDiagnosticResponse("session.claim.put", claimRequest, claimCode, claimText) + } + var latestSession: SessionInfo? = null + repeat(60) { attempt -> + if (attempt > 0) delay(1000) + val poll = Request.Builder() + .url(validationUrl) + .headers(cloudMatchHeaders(token, clientId, deviceId, includeOrigin = false)) + .build() + val (code, text) = http.awaitText(poll) + recordDiagnosticResponse("session.claim.poll", poll, code, text) + if (code in 200..299) { + val payload = OpenNowJson.parseToJsonElement(text).jsonObject + val pollStatus = payload.obj("session")?.int("status") + val polledSession = toSessionInfo("", sessionBase, payload, clientId, deviceId) + latestSession = polledSession + if (pollStatus in READY_SESSION_STATUSES) return polledSession + } + } + throw SessionClaimNotReadyException(latestSession) + } + + suspend fun stopActiveSession(token: String, active: ActiveSessionInfo, settings: StreamSettings) { + stopSession( + token = token, + input = SessionInfo( + sessionId = active.sessionId, + status = active.status, + queuePosition = active.queuePosition, + seatSetupStep = active.seatSetupStep, + streamingBaseUrl = active.streamingBaseUrl, + serverIp = active.serverIp.orEmpty(), + signalingServer = active.serverIp.orEmpty(), + signalingUrl = active.signalingUrl.orEmpty(), + gpuType = active.gpuType, + clientId = UUID.randomUUID().toString(), + deviceId = authStore.stableDeviceId(), + ), + settings = settings, + ) + } + + suspend fun reportSessionAd( + token: String, + session: SessionInfo, + adId: String, + action: String, + settings: StreamSettings, + watchedTimeInMs: Long? = null, + pausedTimeInMs: Long? = null, + cancelReason: String? = null, + errorInfo: String? = null, + ): SessionInfo { + val base = resolvePollStopBase(session.zone, session.streamingBaseUrl, session.serverIp) + val host = Uri.parse(base).host.orEmpty() + val requestHttp = if (isZoneHostname(host)) sessionProxyHttpClient(settings, http) else http + val cid = session.clientId ?: UUID.randomUUID().toString() + val did = session.deviceId ?: authStore.stableDeviceId() + val actionCode = mapOf("start" to 1, "pause" to 2, "resume" to 3, "finish" to 4, "cancel" to 5)[action] ?: 5 + val body = buildJsonObject { + put("action", SESSION_MODIFY_ACTION_AD_UPDATE) + putJsonArray("adUpdates") { + add(buildJsonObject { + put("adId", adId) + put("adAction", actionCode) + put("clientTimestamp", System.currentTimeMillis() / 1000) + if (watchedTimeInMs != null) { + put("watchedTimeInMs", max(0L, watchedTimeInMs)) + } + if (pausedTimeInMs != null) { + put("pausedTimeInMs", max(0L, pausedTimeInMs)) + } + if (!cancelReason.isNullOrBlank()) { + put("cancelReason", cancelReason) + } + if (!errorInfo.isNullOrBlank()) { + put("errorInfo", errorInfo) + } + }) + } + } + val request = Request.Builder() + .url("$base/v2/session/${session.sessionId}") + .headers(cloudMatchHeaders(token, cid, did, includeOrigin = true)) + .put(body.toString().toRequestBody(JSON_MEDIA_TYPE)) + .build() + val (code, text) = requestHttp.awaitText(request) + recordDiagnosticResponse("session.adUpdate", request, code, text) + check(code in 200..299) { "Queue ad update failed ($code): ${text.take(400)}" } + return toSessionInfo(session.zone, base, OpenNowJson.parseToJsonElement(text).jsonObject, cid, did) + } + + private fun recordDiagnosticResponse(operation: String, request: Request, statusCode: Int, responseBody: String) { + runCatching { + diagnosticsSink( + GfnSessionDiagnosticResponse( + operation = operation, + method = request.method, + url = request.url.toString(), + statusCode = statusCode, + requestBody = OpenNowHttpDiagnostics.captureRequestBody(request), + responseBody = responseBody, + ), + ) + } + } + + private fun buildSessionRequestBody( + appId: String, + internalTitle: String, + settings: StreamSettings, + accountLinked: Boolean, + deviceId: String, + physicalDisplayResolution: Pair?, + ): JsonObject { + val profile = settings.requestProfile() + return buildJsonObject { + putJsonObject("sessionRequestData") { + put("appId", appId) + if (internalTitle.isBlank()) put("internalTitle", JsonNull) else put("internalTitle", internalTitle) + putJsonArray("availableSupportedControllers") {} + put("networkTestSessionId", JsonNull) + put("parentSessionId", JsonNull) + put("clientIdentification", "GFN-PC") + put("deviceHashId", deviceId) + put("clientVersion", "30.0") + put("sdkVersion", "1.0") + put("streamerVersion", 1) + put("clientPlatformName", CloudMatchDesktopIdentity.PLATFORM_NAME) + putJsonArray("clientRequestMonitorSettings") { + add(monitorSettings(profile, settings.fps)) + } + put("useOps", true) + put("audioMode", 2) + put("metaData", webRtcSessionMetadata(settings, profile, physicalDisplayResolution)) + put("sdrHdrMode", if (profile.hdrEnabled) 1 else 0) + put("clientDisplayHdrCapabilities", if (profile.hdrEnabled) hdrCapabilitiesJson() else JsonNull) + put("surroundAudioInfo", 0) + put("remoteControllersBitmap", 0) + put("clientTimezoneOffset", java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis())) + put("enhancedStreamMode", 1) + put("appLaunchMode", CloudMatchDesktopIdentity.APP_LAUNCH_MODE) + put("secureRTSPSupported", false) + put("partnerCustomData", "") + put("accountLinked", accountLinked) + put("enablePersistingInGameSettings", CloudMatchDesktopIdentity.PERSIST_GAME_SETTINGS) + put("userAge", 26) + put("requestedStreamingFeatures", requestedStreamingFeatures(settings, profile)) + } + } + } + + private fun buildClaimRequestBody( + appId: String, + deviceId: String, + settings: StreamSettings, + physicalDisplayResolution: Pair?, + ): JsonObject = + buildMinimalClaimRequestBody(appId, deviceId, settings, physicalDisplayResolution) + + private suspend fun toSessionInfo(zone: String, base: String, payload: JsonObject, clientId: String, deviceId: String): SessionInfo { + val status = payload.obj("requestStatus")?.int("statusCode") + check(status == 1) { "CloudMatch returned status $status: ${payload.obj("requestStatus")?.string("statusDescription")}" } + val session = payload.obj("session") ?: error("CloudMatch response missing session") + val sessionStatus = session.int("status") ?: 0 + val signaling = runCatching { resolveSignaling(payload) }.getOrElse { error -> + if (sessionStatus in READY_SESSION_STATUSES) { + throw error + } + null + } + return SessionInfo( + sessionId = session.string("sessionId") ?: error("Missing session id"), + status = sessionStatus, + queuePosition = extractQueuePosition(session), + seatSetupStep = session.obj("seatSetupInfo")?.int("seatSetupStep"), + adState = extractAdState(session), + zone = payload.obj("requestStatus")?.string("serverId")?.takeIf { it.isNotBlank() } ?: zone, + streamingBaseUrl = base, + serverIp = signaling?.serverIp.orEmpty(), + signalingServer = signaling?.signalingServer.orEmpty(), + signalingUrl = signaling?.signalingUrl.orEmpty(), + gpuType = session.string("gpuType"), + iceServers = normalizeIceServers(payload), + mediaConnectionInfo = signaling?.mediaConnectionInfo, + negotiatedStreamProfile = extractNegotiatedStreamProfile(session), + monitorSnapshot = extractSessionMonitorSnapshot(session), + requestedStreamingFeatures = normalizeStreamingFeatures(session.obj("sessionRequestData")?.obj("requestedStreamingFeatures")), + finalizedStreamingFeatures = normalizeStreamingFeatures(session.obj("finalizedStreamingFeatures")), + clientId = clientId, + deviceId = deviceId, + ) + } + + private fun extractAdState(session: JsonObject): SessionAdState? { + val required = session.boolean("sessionAdsRequired") + ?: session.boolean("isAdsRequired") + ?: session.obj("sessionProgress")?.boolean("isAdsRequired") + ?: session.obj("progressInfo")?.boolean("isAdsRequired") + val ads = session.arr("sessionAds")?.mapIndexedNotNull { index, raw -> + val ad = raw.asObject() ?: return@mapIndexedNotNull null + val media = ad.arr("adMediaFiles")?.mapNotNull { + val m = it.asObject() ?: return@mapNotNull null + SessionAdMediaFile(m.string("mediaFileUrl"), m.string("encodingProfile")) + }?.sortedBy { + when (it.encodingProfile) { + "mp4deinterlaced720p" -> 0 + "webm" -> 1 + "hlsadaptive" -> 2 + else -> 99 + } + }.orEmpty() + val id = ad.string("adId") ?: "ad-${index + 1}" + if (media.isEmpty() && ad.string("adUrl") == null && ad.string("mediaUrl") == null && ad.string("title") == null) null else { + SessionAdInfo( + adId = id, + state = ad.int("adState"), + adState = ad.int("adState"), + adUrl = ad.string("adUrl"), + mediaUrl = ad.string("mediaUrl") ?: ad.string("videoUrl") ?: ad.string("url"), + adMediaFiles = media, + clickThroughUrl = ad.string("clickThroughUrl"), + adLengthInSeconds = ad.double("adLengthInSeconds"), + durationMs = ad.int("durationMs")?.toLong() ?: ad.int("durationInMs")?.toLong(), + title = ad.string("title"), + description = ad.string("description"), + ) + } + }.orEmpty() + val opportunityRaw = session.obj("opportunity") + val opportunity = opportunityRaw?.let { + SessionOpportunityInfo( + state = it.string("state"), + queuePaused = it.boolean("queuePaused"), + gracePeriodSeconds = it.int("gracePeriodSeconds"), + message = it.string("message"), + title = it.string("title"), + description = it.string("description"), + ) + } + val queuePaused = opportunity?.queuePaused ?: (opportunity?.state?.equals("graceperiodstart", true) == true) + val effectiveRequired = required ?: ads.isNotEmpty() + val message = opportunity?.message ?: opportunity?.description ?: if (queuePaused) "Resume ads to stay in queue." else if (effectiveRequired) "Finish ads to stay in queue." else null + if (!effectiveRequired && ads.isEmpty() && !queuePaused && message == null) return null + return SessionAdState( + isAdsRequired = effectiveRequired, + sessionAdsRequired = required, + isQueuePaused = queuePaused, + gracePeriodSeconds = opportunity?.gracePeriodSeconds, + message = message, + sessionAds = ads, + ads = ads, + opportunity = opportunity, + serverSentEmptyAds = session["sessionAds"] == null || session["sessionAds"] is JsonNull, + ) + } + + private fun extractQueuePosition(session: JsonObject): Int? = + session.int("queuePosition") + ?: session.obj("seatSetupInfo")?.int("queuePosition") + ?: session.obj("sessionProgress")?.int("queuePosition") + ?: session.obj("progressInfo")?.int("queuePosition") + + private fun normalizeStreamingFeatures(features: JsonObject?): StreamingFeatures? { + if (features == null) return null + val normalized = StreamingFeatures( + reflex = features.boolean("reflex"), + bitDepth = features.int("bitDepth"), + cloudGsync = features.boolean("cloudGsync"), + chromaFormat = features.int("chromaFormat"), + enabledL4S = features.boolean("enabledL4S"), + trueHdr = features.boolean("trueHdr"), + ) + return if (listOf(normalized.reflex, normalized.bitDepth, normalized.cloudGsync, normalized.chromaFormat, normalized.enabledL4S, normalized.trueHdr).all { it == null }) null else normalized + } + + private fun extractNegotiatedStreamProfile(session: JsonObject): NegotiatedStreamProfile? { + val monitorSnapshot = extractSessionMonitorSnapshot(session) + val finalized = session.obj("finalizedStreamingFeatures") + val requested = session.obj("sessionRequestData")?.obj("requestedStreamingFeatures") + val resolution = monitorSnapshot?.returnedResolution + ?: monitorSnapshot?.finalSelectedResolution + ?: monitorSnapshot?.requestedResolution + val fps = monitorSnapshot?.returnedFps ?: monitorSnapshot?.requestedFps + val bitDepth = finalized?.int("bitDepth") ?: requested?.int("bitDepth") + val chroma = finalized?.int("chromaFormat") ?: requested?.int("chromaFormat") + val cq = when { + bitDepth == 10 && chroma == 2 -> ColorQuality.TenBit444 + bitDepth == 10 -> ColorQuality.TenBit420 + chroma == 2 -> ColorQuality.EightBit444 + bitDepth == 0 -> ColorQuality.EightBit420 + else -> null + } + return NegotiatedStreamProfile( + resolution = resolution, + fps = fps, + colorQuality = cq, + enableL4S = finalized?.boolean("enabledL4S") ?: requested?.boolean("enabledL4S"), + enableCloudGsync = finalized?.boolean("cloudGsync") ?: requested?.boolean("cloudGsync"), + enableReflex = finalized?.boolean("reflex") ?: requested?.boolean("reflex"), + ).takeIf { + it.resolution != null || it.fps != null || it.colorQuality != null || it.enableL4S != null || it.enableCloudGsync != null || it.enableReflex != null + } + } + + private fun normalizeIceServers(payload: JsonObject): List { + val servers = payload.obj("session") + ?.obj("iceServerConfiguration") + ?.arr("iceServers") + ?.mapNotNull { raw -> + val obj = raw.asObject() ?: return@mapNotNull null + val urlsElement = obj["urls"] + val urls = when (urlsElement) { + is JsonArray -> urlsElement.mapNotNull { it.asString() } + is JsonPrimitive -> listOfNotNull(urlsElement.contentOrNull) + else -> emptyList() + } + if (urls.isEmpty()) null else IceServer(urls, obj.string("username"), obj.string("credential")) + } + .orEmpty() + return servers.ifEmpty { + listOf( + IceServer(listOf("stun:s1.stun.gamestream.nvidia.com:19308")), + IceServer(listOf("stun:stun.l.google.com:19302")), + IceServer(listOf("stun:stun1.l.google.com:19302")), + ) + } + } + + private data class SignalingResolution( + val serverIp: String, + val signalingServer: String, + val signalingUrl: String, + val mediaConnectionInfo: MediaConnectionInfo?, + ) + + private fun resolveSignaling(payload: JsonObject): SignalingResolution { + val session = payload.obj("session") ?: error("Missing session") + val connections = session.arr("connectionInfo")?.mapNotNull { it.asObject() }.orEmpty() + val serverIp = streamingServerIp(payload) ?: error("CloudMatch response did not include a signaling host") + val signalingConnection = connections.firstOrNull { it.int("usage") == 14 && it.string("ip") != null } ?: connections.firstOrNull { it.string("ip") != null } + val resourcePath = signalingConnection?.string("resourcePath") ?: "/nvst/" + val (url, host) = buildSignalingUrl(resourcePath, serverIp) + val effectiveHost = host ?: serverIp + return SignalingResolution( + serverIp = serverIp, + signalingServer = if (effectiveHost.contains(":")) effectiveHost else "$effectiveHost:443", + signalingUrl = url, + mediaConnectionInfo = resolveMediaConnectionInfo(connections, serverIp), + ) + } + + private fun resolveMediaConnectionInfo(connections: List, serverIp: String): MediaConnectionInfo? { + fun extractIp(conn: JsonObject): String? = conn.string("ip")?.let(::usableSessionHost) ?: conn.string("resourcePath")?.let(::extractHostFromUrl) + fun extractPort(conn: JsonObject): Int = conn.int("port") ?: conn.string("resourcePath")?.let { Uri.parse(it.replace("rtsps://", "https://").replace("rtsp://", "http://")).port } ?: 0 + listOf(2, 17).forEach { usage -> + connections.firstOrNull { it.int("usage") == usage }?.let { + val ip = extractIp(it) + val port = extractPort(it) + if (ip != null && port > 0) return MediaConnectionInfo(ip, port) + } + } + connections.filter { it.int("usage") == 14 }.sortedByDescending { it.int("port") ?: 0 }.forEach { + val port = extractPort(it) + if (port > 0) return MediaConnectionInfo(extractIp(it) ?: serverIp, port) + } + return null + } + + private fun streamingServerIp(payload: JsonObject): String? { + val session = payload.obj("session") ?: return null + return streamingServerIpFromSession(session) + } + + private fun streamingServerIpFromSession(session: JsonObject): String? { + val conn = session.arr("connectionInfo")?.mapNotNull { it.asObject() }?.firstOrNull { it.int("usage") == 14 } + conn?.string("ip")?.let(::usableSessionHost)?.let { return it } + conn?.string("resourcePath")?.let(::extractHostFromUrl)?.let { return it } + return session.obj("sessionControlInfo")?.string("ip")?.let(::usableSessionHost) + } + + private fun buildSignalingUrl(raw: String, serverIp: String): Pair = + when { + raw.startsWith("rtsps://") || raw.startsWith("rtsp://") -> { + val host = raw.substringAfter("://").substringBefore(":").substringBefore("/") + usableSessionHost(host)?.let { "wss://$it/nvst/" to it } ?: ("wss://$serverIp:443/nvst/" to null) + } + raw.startsWith("wss://") -> extractHostFromUrl(raw)?.let { raw to it } ?: ("wss://$serverIp:443/nvst/" to null) + raw.startsWith("/") -> "wss://$serverIp:443$raw" to null + else -> "wss://$serverIp:443/nvst/" to null + } + + private fun extractHostFromUrl(raw: String): String? { + val after = listOf("rtsps://", "rtsp://", "wss://", "https://").firstOrNull { raw.startsWith(it) }?.let { raw.removePrefix(it) } ?: return null + val host = after.substringBefore(":").substringBefore("/") + return usableSessionHost(host) + } + + private fun isZoneHostname(value: String): Boolean = + value.contains("cloudmatchbeta.nvidiagrid.net") || value.contains("cloudmatch.nvidiagrid.net") + + private suspend fun resolveLaunchSessionBaseUrl(token: String, base: String): String { + if (!isProviderRootStreamingBase(base)) return base + val regions = fetchDynamicRegions(http, token, base).first + return providerLaunchBaseUrl(base, regions) + } + + private fun resolveStreamingBaseUrl(zone: String, provided: String?): String { + normalizeStreamingServiceUrl(provided.orEmpty())?.let { return it.trimEnd('/') } + val safeZone = zone.trim().takeIf { it.isNotBlank() && !it.startsWith(".") && !it.contains("/") && !it.contains(":") } + return if (safeZone != null) "https://$safeZone.cloudmatchbeta.nvidiagrid.net" else DEFAULT_STREAMING_SERVICE_URL.trimEnd('/') + } + + private fun resolvePollStopBase(zone: String, provided: String?, serverIp: String?): String { + val base = resolveStreamingBaseUrl(zone, provided) + val host = serverIp?.takeIf { it.isNotBlank() } + return if (host != null && base.contains("cloudmatchbeta.nvidiagrid.net") && !isZoneHostname(host)) "https://$host" else base + } + +} + +internal fun usableSessionHost(value: String?): String? { + val host = value?.trim().orEmpty() + return host.takeIf { + it.isNotBlank() && + !it.startsWith(".") && + !it.endsWith(".") && + !it.contains("..") + } +} + +private fun isProviderRootStreamingBase(base: String): Boolean { + val url = base.toHttpUrlOrNull() ?: return false + val host = url.host.lowercase(Locale.US) + return host.startsWith("prod.") && host.endsWith(".geforcenow.nvidiagrid.net") +} + +internal fun providerLaunchBaseUrl(providerBase: String, regions: List): String { + val normalizedBase = normalizeStreamingServiceUrl(providerBase)?.trimEnd('/') ?: providerBase.trim().trimEnd('/') + if (!isProviderRootStreamingBase(normalizedBase)) return normalizedBase + val providerHost = normalizedBase.toHttpUrlOrNull()?.host?.lowercase(Locale.US) ?: return normalizedBase + val regionUrls = regions + .mapNotNull { normalizeStreamingServiceUrl(it.url)?.trimEnd('/') } + .distinct() + .filter { regionUrl -> + val regionHost = regionUrl.toHttpUrlOrNull()?.host?.lowercase(Locale.US) + regionHost != null && regionHost != providerHost + } + return if (regionUrls.size == 1) regionUrls.first() else normalizedBase +} + +suspend fun fetchDynamicRegions( + http: OkHttpClient, + token: String?, + streamingBaseUrl: String, +): Pair, String?> { + val base = normalizeStreamingServiceUrl(streamingBaseUrl) ?: return emptyList() to null + return runCatching { + val request = Request.Builder() + .url("${base}v2/serverInfo") + .headers( + Headers.Builder() + .putDesktopLcars(token, clientType = "BROWSER", clientStreamer = "WEBRTC") + .build(), + ) + .build() + val (code, text) = http.awaitText(request) + if (code !in 200..299) return@runCatching emptyList() to null + val data = OpenNowJson.parseToJsonElement(text).jsonObject + val vpcId = data.obj("requestStatus")?.string("serverId") + val regions = data.arr("metaData")?.mapNotNull { + val obj = it.asObject() ?: return@mapNotNull null + val key = obj.string("key") ?: return@mapNotNull null + val value = obj.string("value") ?: return@mapNotNull null + val regionUrl = normalizeStreamingServiceUrl(value) ?: return@mapNotNull null + if (key == "gfn-regions" || key.startsWith("gfn-")) null else StreamRegion(key, regionUrl) + }?.sortedBy { it.name }.orEmpty() + regions to vpcId + }.getOrDefault(emptyList() to null) +} + +private fun String.normalizedTitleKey(): String = trim().lowercase(Locale.US).replace(Regex("[^a-z0-9]+"), " ").trim() +private fun String.isNumeric(): Boolean = all(Char::isDigit) diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/LaunchErrors.kt b/android/app/src/main/java/com/opencloudgaming/opennow/LaunchErrors.kt new file mode 100644 index 000000000..8a6a57bfb --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/LaunchErrors.kt @@ -0,0 +1,37 @@ +package com.opencloudgaming.opennow + +private val CloudMatchStatus81Regex = Regex("""\bCloudMatch returned status\s+81\b""", RegexOption.IGNORE_CASE) +private val LaunchErrorWhitespaceRegex = Regex("""\s+""") + +internal fun normalizeLaunchErrorMessage(error: Throwable, gameTitle: String? = null): String { + val text = error.message ?: return "Launch failed" + return when { + isFreeTierEntitlementError(text) -> + "Your GeForce NOW account is on the Free tier. This game requires a Priority or Ultimate membership." + isLimitedModeStreamingError(text) -> limitedModeStreamingMessage(gameTitle) + text.contains("patch", ignoreCase = true) || text.contains("maintenance", ignoreCase = true) -> + "Game is patching or under maintenance. Try again when NVIDIA finishes updating it." + else -> text + } +} + +private fun isFreeTierEntitlementError(text: String): Boolean = + text.contains("ENTITLEMENT_FAILURE_STATUS", ignoreCase = true) || + text.contains("8A910006", ignoreCase = true) + +private fun isLimitedModeStreamingError(text: String): Boolean = + text.contains("STREAMING_NOT_ALLOWED_IN_LIMITED_MODE", ignoreCase = true) || + text.contains("8A91000D", ignoreCase = true) || + (CloudMatchStatus81Regex.containsMatchIn(text) && text.contains("limited", ignoreCase = true)) + +private fun limitedModeStreamingMessage(gameTitle: String?): String { + val title = gameTitle + ?.replace(LaunchErrorWhitespaceRegex, " ") + ?.trim() + .orEmpty() + return if (title.isNotBlank()) { + "$title is only available for Priority or Ultimate members" + } else { + "This game is only available for Priority or Ultimate members" + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/LocalTvConnector.kt b/android/app/src/main/java/com/opencloudgaming/opennow/LocalTvConnector.kt new file mode 100644 index 000000000..19982323b --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/LocalTvConnector.kt @@ -0,0 +1,603 @@ +package com.opencloudgaming.opennow + +import android.net.Uri +import android.os.Build +import android.util.Base64 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.decodeFromString +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.net.Inet4Address +import java.net.InetAddress +import java.net.NetworkInterface +import java.net.ServerSocket +import java.net.Socket +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.spec.ECGenParameterSpec +import java.security.spec.X509EncodedKeySpec +import java.util.Collections +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.KeyAgreement +import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +data class LocalTvConnectorState( + val hosting: Boolean = false, + val pairUri: String? = null, + val pairingCode: String? = null, + val pairedDeviceName: String? = null, + val pairedDeviceTrusted: Boolean = false, + val trustRequestedByDevice: Boolean = false, + val connectedTvName: String? = null, + val requestTrustedAccess: Boolean = true, + val busy: Boolean = false, + val error: String? = null, + val message: String? = null, +) { + val phoneConnected: Boolean get() = connectedTvName != null +} + +internal data class LocalTvLaunchRequest( + val gameId: String, + val title: String?, +) + +internal data class LocalTvRemoteRequest( + val action: String, + val value: String?, +) + +/** + * Ephemeral local-only pairing for handing a launch from an Android phone to an Android TV. + * The QR pins the TV's ECDH public key. Pairing and launch bodies are encrypted with AES-GCM; + * no account tokens, GFN credentials, or remote/cloud relay are involved. + */ +internal class LocalTvConnector { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val random = SecureRandom() + private val _state = MutableStateFlow(LocalTvConnectorState()) + val state: StateFlow = _state.asStateFlow() + private val _launchRequests = MutableSharedFlow(extraBufferCapacity = 4) + val launchRequests: SharedFlow = _launchRequests.asSharedFlow() + private val _signInRequests = MutableSharedFlow(extraBufferCapacity = 2) + val signInRequests: SharedFlow = _signInRequests.asSharedFlow() + private val _remoteRequests = MutableSharedFlow(extraBufferCapacity = 8) + val remoteRequests: SharedFlow = _remoteRequests.asSharedFlow() + + @Volatile private var serverSocket: ServerSocket? = null + @Volatile private var hostKeyPair: KeyPair? = null + @Volatile private var pairingCode: String? = null + @Volatile private var pairingExpiresAtMs: Long = 0L + @Volatile private var pairingAttempts: Int = 0 + @Volatile private var pairedClientPublicKey: ByteArray? = null + @Volatile private var pairedSharedKey: ByteArray? = null + private val recentRequestIds = Collections.synchronizedSet(LinkedHashSet()) + @Volatile private var phoneTarget: PhoneTarget? = null + + fun startHosting() { + if (serverSocket != null) return + _state.value = _state.value.copy(busy = true, error = null, connectedTvName = null) + scope.launch { + runCatching { + val address = privateLanAddress() ?: error("Connect the TV to a private Wi-Fi or Ethernet network first") + val keyPair = generateEcKeyPair() + val code = (random.nextInt(9_000) + 1_000).toString() + val server = ServerSocket(0, 8, address).apply { reuseAddress = true } + hostKeyPair = keyPair + pairingCode = code + pairingExpiresAtMs = System.currentTimeMillis() + PAIRING_LIFETIME_MS + pairingAttempts = 0 + pairedClientPublicKey = null + pairedSharedKey = null + serverSocket = server + val pairUri = Uri.Builder() + .scheme("opennow") + .authority("pair") + .appendQueryParameter("h", address.hostAddress) + .appendQueryParameter("p", server.localPort.toString()) + .appendQueryParameter("c", code) + .appendQueryParameter("k", base64Url(keyPair.public.encoded)) + .build() + .toString() + _state.value = LocalTvConnectorState( + hosting = true, + pairUri = pairUri, + pairingCode = code, + requestTrustedAccess = _state.value.requestTrustedAccess, + ) + acceptLoop(server, address) + }.onFailure { error -> + closeHost() + _state.value = LocalTvConnectorState( + error = error.message ?: "Could not start TV connector", + requestTrustedAccess = _state.value.requestTrustedAccess, + ) + } + } + } + + fun stopHosting() { + closeHost() + _state.value = LocalTvConnectorState( + connectedTvName = _state.value.connectedTvName, + requestTrustedAccess = _state.value.requestTrustedAccess, + ) + } + + fun refreshPairingCode() { + closeHost() + _state.value = LocalTvConnectorState(requestTrustedAccess = _state.value.requestTrustedAccess) + startHosting() + } + + fun setPhoneTrustRequest(enabled: Boolean) { + _state.value = _state.value.copy(requestTrustedAccess = enabled, error = null, message = null) + } + + fun setPairedDeviceTrusted(trusted: Boolean) { + if (_state.value.pairedDeviceName == null) return + _state.value = _state.value.copy( + pairedDeviceTrusted = trusted, + message = if (trusted) "Trusted remote access enabled" else "Sensitive remote controls disabled", + ) + } + + fun forgetPhoneTarget() { + phoneTarget = null + _state.value = _state.value.copy(connectedTvName = null, error = null) + } + + fun isPairUri(uri: Uri?): Boolean = + uri?.scheme.equals("opennow", ignoreCase = true) && uri?.host.equals("pair", ignoreCase = true) + + fun pairPhone(uri: Uri) { + if (!isPairUri(uri)) return + _state.value = _state.value.copy(busy = true, error = null) + scope.launch { + runCatching { + val host = uri.getQueryParameter("h")?.takeIf(::isPrivateIpv4Text) + ?: error("Pairing QR has no valid private TV address") + val port = uri.getQueryParameter("p")?.toIntOrNull()?.takeIf { it in 1..65535 } + ?: error("Pairing QR has no valid TV port") + val code = uri.getQueryParameter("c")?.takeIf { it.length == 4 && it.all(Char::isDigit) } + ?: error("Pairing QR has no valid code") + val hostPublic = decodePublicKey(uri.getQueryParameter("k") ?: error("Pairing QR has no security key")) + val phoneKeys = generateEcKeyPair() + val shared = deriveAesKey(phoneKeys, hostPublic.encoded) + val requestTrustedAccess = _state.value.requestTrustedAccess + val encrypted = encrypt(shared, "$code\n${safeDeviceName()}\n${if (requestTrustedAccess) 1 else 0}") + val response = sendFrame( + host = host, + port = port, + command = COMMAND_PAIR, + publicKey = phoneKeys.public.encoded, + iv = encrypted.iv, + ciphertext = encrypted.ciphertext, + ) + if (response.first != STATUS_OK) error(response.second.ifBlank { "TV rejected pairing" }) + phoneTarget = PhoneTarget(host, port, phoneKeys, shared) + _state.value = LocalTvConnectorState( + connectedTvName = response.second.ifBlank { "OpenNOW TV" }, + requestTrustedAccess = requestTrustedAccess, + ) + }.onFailure { error -> + phoneTarget = null + _state.value = LocalTvConnectorState( + error = error.message ?: "Could not pair with TV", + requestTrustedAccess = _state.value.requestTrustedAccess, + ) + } + } + } + + fun sendLaunch(gameId: String, title: String?) { + val target = phoneTarget + if (target == null) { + _state.value = _state.value.copy(error = "Pair with a TV first") + return + } + _state.value = _state.value.copy(busy = true, error = null) + scope.launch { + runCatching { + val requestId = UUID.randomUUID().toString() + val safeTitle = title.orEmpty().replace('\n', ' ').take(160) + val plaintext = "${System.currentTimeMillis()}\n$requestId\n${gameId.replace('\n', ' ').take(200)}\n$safeTitle" + val encrypted = encrypt(target.sharedKey, plaintext) + val response = sendFrame( + host = target.host, + port = target.port, + command = COMMAND_LAUNCH, + publicKey = target.phoneKeys.public.encoded, + iv = encrypted.iv, + ciphertext = encrypted.ciphertext, + ) + if (response.first != STATUS_OK) error(response.second.ifBlank { "TV rejected launch" }) + _state.value = _state.value.copy(busy = false, error = null) + }.onFailure { error -> + _state.value = _state.value.copy(busy = false, error = error.message ?: "Could not send launch to TV") + } + } + } + + fun sendSignIn(session: AuthSession) { + val target = phoneTarget + if (target == null) { + _state.value = _state.value.copy(error = "Pair with a TV first") + return + } + _state.value = _state.value.copy(busy = true, error = null, message = null) + scope.launch { + runCatching { + val requestId = UUID.randomUUID().toString() + val sessionJson = OpenNowJson.encodeToString(session) + val plaintext = "${System.currentTimeMillis()}\n$requestId\n$sessionJson" + val encrypted = encrypt(target.sharedKey, plaintext) + val response = sendFrame( + host = target.host, + port = target.port, + command = COMMAND_SIGN_IN, + publicKey = target.phoneKeys.public.encoded, + iv = encrypted.iv, + ciphertext = encrypted.ciphertext, + ) + if (response.first != STATUS_OK) error(response.second.ifBlank { "TV rejected sign-in" }) + _state.value = _state.value.copy(busy = false, error = null, message = "Sign-in sent securely to TV") + }.onFailure { error -> + _state.value = _state.value.copy(busy = false, error = error.message ?: "Could not sign in TV") + } + } + } + + fun sendRemoteAction(action: String, value: String? = null) { + val target = phoneTarget + if (target == null) { + _state.value = _state.value.copy(error = "Pair with a TV first") + return + } + val safeAction = action.trim().takeIf { it.matches(Regex("[a-z0-9_]{1,48}")) } ?: run { + _state.value = _state.value.copy(error = "Remote action is invalid") + return + } + val safeValue = value.orEmpty().replace('\n', ' ').take(240) + _state.value = _state.value.copy(busy = true, error = null, message = null) + scope.launch { + runCatching { + val plaintext = "${System.currentTimeMillis()}\n${UUID.randomUUID()}\n$safeAction\n$safeValue" + val encrypted = encrypt(target.sharedKey, plaintext) + val response = sendFrame( + host = target.host, + port = target.port, + command = COMMAND_REMOTE, + publicKey = target.phoneKeys.public.encoded, + iv = encrypted.iv, + ciphertext = encrypted.ciphertext, + ) + if (response.first != STATUS_OK) error(response.second.ifBlank { "TV rejected remote command" }) + _state.value = _state.value.copy(busy = false, error = null, message = response.second) + }.onFailure { error -> + _state.value = _state.value.copy(busy = false, error = error.message ?: "Could not control TV") + } + } + } + + private fun acceptLoop(server: ServerSocket, hostAddress: InetAddress) { + while (!server.isClosed) { + val socket = runCatching { server.accept() }.getOrElse { error -> + if (server.isClosed) return + throw error + } + scope.launch { + socket.use { client -> + client.soTimeout = SOCKET_TIMEOUT_MS + if (!isSamePrivateLan(client.inetAddress, hostAddress)) return@use + handleClient(client) + } + } + } + } + + private fun handleClient(socket: Socket) { + val input = DataInputStream(BufferedInputStream(socket.getInputStream())) + val output = DataOutputStream(BufferedOutputStream(socket.getOutputStream())) + val response = runCatching { + if (input.readInt() != PROTOCOL_MAGIC) error("Invalid connector request") + if (input.readUnsignedByte() != PROTOCOL_VERSION) error("Unsupported connector version") + val command = input.readUnsignedByte() + val publicKey = input.readSizedBytes(MAX_PUBLIC_KEY_BYTES) + val iv = input.readSizedBytes(MAX_IV_BYTES) + val ciphertext = input.readSizedBytes(MAX_CIPHERTEXT_BYTES) + when (command) { + COMMAND_PAIR -> handlePair(publicKey, iv, ciphertext) + COMMAND_LAUNCH -> handleLaunch(publicKey, iv, ciphertext) + COMMAND_SIGN_IN -> handleSignIn(publicKey, iv, ciphertext) + COMMAND_REMOTE -> handleRemote(publicKey, iv, ciphertext) + else -> STATUS_BAD_REQUEST to "Unknown connector command" + } + }.getOrElse { error -> STATUS_BAD_REQUEST to (error.message ?: "Invalid connector request") } + output.writeInt(response.first) + output.writeUTF(response.second.take(240)) + output.flush() + } + + @Synchronized + private fun handlePair(clientPublicKey: ByteArray, iv: ByteArray, ciphertext: ByteArray): Pair { + val keyPair = hostKeyPair ?: return STATUS_UNAVAILABLE to "TV pairing is no longer active" + val expectedCode = pairingCode ?: return STATUS_UNAVAILABLE to "TV pairing is no longer active" + if (System.currentTimeMillis() > pairingExpiresAtMs) return STATUS_FORBIDDEN to "Pairing QR expired; create a new one" + if (pairingAttempts >= MAX_PAIRING_ATTEMPTS) return STATUS_FORBIDDEN to "Too many pairing attempts; create a new QR" + pairingAttempts += 1 + val shared = deriveAesKey(keyPair, clientPublicKey) + val lines = decrypt(shared, iv, ciphertext).lines() + if (!MessageDigest.isEqual(lines.firstOrNull().orEmpty().toByteArray(), expectedCode.toByteArray())) { + return STATUS_FORBIDDEN to "Pairing code did not match" + } + pairedClientPublicKey = clientPublicKey.copyOf() + pairedSharedKey = shared + pairingCode = null + val deviceName = lines.getOrNull(1)?.take(80)?.ifBlank { "Android phone" } ?: "Android phone" + val trustRequested = lines.getOrNull(2) == "1" + _state.value = _state.value.copy( + pairedDeviceName = deviceName, + pairedDeviceTrusted = false, + trustRequestedByDevice = trustRequested, + busy = false, + error = null, + message = if (trustRequested) "$deviceName requested trusted remote access" else "$deviceName paired for game launching", + ) + return STATUS_OK to safeDeviceName() + } + + private fun handleLaunch(clientPublicKey: ByteArray, iv: ByteArray, ciphertext: ByteArray): Pair { + val expectedClient = pairedClientPublicKey ?: return STATUS_FORBIDDEN to "Pair the phone again" + val shared = pairedSharedKey ?: return STATUS_FORBIDDEN to "Pair the phone again" + if (!MessageDigest.isEqual(clientPublicKey, expectedClient)) return STATUS_FORBIDDEN to "Phone is not paired" + val lines = decrypt(shared, iv, ciphertext).lines() + val timestamp = lines.getOrNull(0)?.toLongOrNull() ?: return STATUS_BAD_REQUEST to "Launch has no timestamp" + if (kotlin.math.abs(System.currentTimeMillis() - timestamp) > LAUNCH_MAX_AGE_MS) { + return STATUS_FORBIDDEN to "Launch request expired" + } + val requestId = lines.getOrNull(1).orEmpty() + if (requestId.isBlank() || !recentRequestIds.add(requestId)) return STATUS_FORBIDDEN to "Launch request was already used" + synchronized(recentRequestIds) { + while (recentRequestIds.size > MAX_RECENT_REQUEST_IDS) { + recentRequestIds.iterator().run { next(); remove() } + } + } + val gameId = lines.getOrNull(2)?.takeIf { it.isNotBlank() } ?: return STATUS_BAD_REQUEST to "Launch has no game" + _launchRequests.tryEmit(LocalTvLaunchRequest(gameId = gameId, title = lines.getOrNull(3)?.takeIf { it.isNotBlank() })) + return STATUS_OK to "Launch sent" + } + + private fun handleSignIn(clientPublicKey: ByteArray, iv: ByteArray, ciphertext: ByteArray): Pair { + val expectedClient = pairedClientPublicKey ?: return STATUS_FORBIDDEN to "Pair the phone again" + val shared = pairedSharedKey ?: return STATUS_FORBIDDEN to "Pair the phone again" + if (!MessageDigest.isEqual(clientPublicKey, expectedClient)) return STATUS_FORBIDDEN to "Phone is not paired" + if (!_state.value.pairedDeviceTrusted) return STATUS_FORBIDDEN to "Trust this phone on the TV before switching accounts" + val plaintext = decrypt(shared, iv, ciphertext) + val firstBreak = plaintext.indexOf('\n') + val secondBreak = plaintext.indexOf('\n', firstBreak + 1) + if (firstBreak <= 0 || secondBreak <= firstBreak) return STATUS_BAD_REQUEST to "Sign-in request is malformed" + val timestamp = plaintext.substring(0, firstBreak).toLongOrNull() ?: return STATUS_BAD_REQUEST to "Sign-in has no timestamp" + if (kotlin.math.abs(System.currentTimeMillis() - timestamp) > SIGN_IN_MAX_AGE_MS) { + return STATUS_FORBIDDEN to "Sign-in request expired" + } + val requestId = plaintext.substring(firstBreak + 1, secondBreak) + if (requestId.isBlank() || !recentRequestIds.add(requestId)) return STATUS_FORBIDDEN to "Sign-in request was already used" + val session = runCatching { OpenNowJson.decodeFromString(plaintext.substring(secondBreak + 1)) } + .getOrElse { return STATUS_BAD_REQUEST to "Sign-in data could not be read" } + if (session.tokens.accessToken.isBlank() || session.user.userId.isBlank() || session.provider.code.isBlank()) { + return STATUS_BAD_REQUEST to "Sign-in data is incomplete" + } + _signInRequests.tryEmit(session) + return STATUS_OK to "Sign-in received" + } + + private fun handleRemote(clientPublicKey: ByteArray, iv: ByteArray, ciphertext: ByteArray): Pair { + val expectedClient = pairedClientPublicKey ?: return STATUS_FORBIDDEN to "Pair the phone again" + val shared = pairedSharedKey ?: return STATUS_FORBIDDEN to "Pair the phone again" + if (!MessageDigest.isEqual(clientPublicKey, expectedClient)) return STATUS_FORBIDDEN to "Phone is not paired" + if (!_state.value.pairedDeviceTrusted) return STATUS_FORBIDDEN to "Trust this phone on the TV to use remote controls" + val lines = decrypt(shared, iv, ciphertext).lines() + val timestamp = lines.getOrNull(0)?.toLongOrNull() ?: return STATUS_BAD_REQUEST to "Remote command has no timestamp" + if (kotlin.math.abs(System.currentTimeMillis() - timestamp) > REMOTE_MAX_AGE_MS) { + return STATUS_FORBIDDEN to "Remote command expired" + } + val requestId = lines.getOrNull(1).orEmpty() + if (!rememberRequestId(requestId)) return STATUS_FORBIDDEN to "Remote command was already used" + val action = lines.getOrNull(2)?.takeIf { it.matches(Regex("[a-z0-9_]{1,48}")) } + ?: return STATUS_BAD_REQUEST to "Remote command is invalid" + _remoteRequests.tryEmit(LocalTvRemoteRequest(action, lines.getOrNull(3)?.takeIf(String::isNotBlank))) + return STATUS_OK to "TV command sent" + } + + private fun rememberRequestId(requestId: String): Boolean { + if (requestId.isBlank() || !recentRequestIds.add(requestId)) return false + synchronized(recentRequestIds) { + while (recentRequestIds.size > MAX_RECENT_REQUEST_IDS) { + recentRequestIds.iterator().run { next(); remove() } + } + } + return true + } + + private fun sendFrame( + host: String, + port: Int, + command: Int, + publicKey: ByteArray, + iv: ByteArray, + ciphertext: ByteArray, + ): Pair = Socket().use { socket -> + socket.connect(java.net.InetSocketAddress(host, port), SOCKET_TIMEOUT_MS) + socket.soTimeout = SOCKET_TIMEOUT_MS + val output = DataOutputStream(BufferedOutputStream(socket.getOutputStream())) + output.writeInt(PROTOCOL_MAGIC) + output.writeByte(PROTOCOL_VERSION) + output.writeByte(command) + output.writeSizedBytes(publicKey) + output.writeSizedBytes(iv) + output.writeSizedBytes(ciphertext) + output.flush() + val input = DataInputStream(BufferedInputStream(socket.getInputStream())) + input.readInt() to input.readUTF() + } + + private fun closeHost() { + runCatching { serverSocket?.close() } + serverSocket = null + hostKeyPair = null + pairingCode = null + pairingExpiresAtMs = 0L + pairingAttempts = 0 + pairedClientPublicKey = null + pairedSharedKey = null + recentRequestIds.clear() + } + + fun close() { + closeHost() + scope.cancel() + } + + private data class PhoneTarget( + val host: String, + val port: Int, + val phoneKeys: KeyPair, + val sharedKey: ByteArray, + ) + + private data class EncryptedPayload(val iv: ByteArray, val ciphertext: ByteArray) + + private fun encrypt(key: ByteArray, plaintext: String): EncryptedPayload { + val iv = ByteArray(12).also(random::nextBytes) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv)) + return EncryptedPayload(iv, cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))) + } + + private fun decrypt(key: ByteArray, iv: ByteArray, ciphertext: ByteArray): String { + require(iv.size == 12) { "Invalid encrypted request" } + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv)) + return cipher.doFinal(ciphertext).toString(Charsets.UTF_8) + } + + private fun generateEcKeyPair(): KeyPair = KeyPairGenerator.getInstance("EC").run { + initialize(ECGenParameterSpec("secp256r1"), random) + generateKeyPair() + } + + private fun decodePublicKey(encoded: String) = + KeyFactory.getInstance("EC").generatePublic(X509EncodedKeySpec(base64UrlDecode(encoded))) + + private fun deriveAesKey(ownKeys: KeyPair, peerPublicBytes: ByteArray): ByteArray { + val peer = KeyFactory.getInstance("EC").generatePublic(X509EncodedKeySpec(peerPublicBytes)) + val sharedSecret = KeyAgreement.getInstance("ECDH").run { + init(ownKeys.private) + doPhase(peer, true) + generateSecret() + } + val salt = "OpenNOW-local-tv-v1".toByteArray() + val prk = Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(salt, "HmacSHA256")) + doFinal(sharedSecret) + } + return Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(prk, "HmacSHA256")) + doFinal("launch-key\u0001".toByteArray()).copyOf(32) + } + } + + private fun privateLanAddress(): Inet4Address? = + NetworkInterface.getNetworkInterfaces()?.toList().orEmpty() + .asSequence() + .filter { it.isUp && !it.isLoopback } + .flatMap { it.inetAddresses.toList().asSequence() } + .filterIsInstance() + .firstOrNull { it.isSiteLocalAddress && !it.isLoopbackAddress } + + private fun isSamePrivateLan(remote: InetAddress, local: InetAddress): Boolean { + if (remote.isLoopbackAddress && local.isLoopbackAddress) return true + val remote4 = remote as? Inet4Address ?: return false + val local4 = local as? Inet4Address ?: return false + if (!remote4.isSiteLocalAddress || !local4.isSiteLocalAddress) return false + val remoteBytes = remote4.address + val localBytes = local4.address + return remoteBytes[0] == localBytes[0] && remoteBytes[1] == localBytes[1] + } + + private fun isPrivateIpv4Text(value: String): Boolean = + runCatching { InetAddress.getByName(value) as? Inet4Address } + .getOrNull() + ?.isSiteLocalAddress == true + + private fun safeDeviceName(): String = + listOf(Build.MANUFACTURER, Build.MODEL) + .map(String::trim) + .filter(String::isNotBlank) + .distinct() + .joinToString(" ") + .take(80) + .ifBlank { "OpenNOW Android" } + + private fun base64Url(bytes: ByteArray): String = + Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + + private fun base64UrlDecode(value: String): ByteArray = + Base64.decode(value, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + + private fun DataOutputStream.writeSizedBytes(bytes: ByteArray) { + writeInt(bytes.size) + write(bytes) + } + + private fun DataInputStream.readSizedBytes(maxBytes: Int): ByteArray { + val size = readInt() + require(size in 1..maxBytes) { "Connector request is too large" } + return ByteArray(size).also(::readFully) + } + + private fun java.util.Enumeration.toList(): List = Collections.list(this) + + private companion object { + const val PROTOCOL_MAGIC = 0x4f4e5456 + const val PROTOCOL_VERSION = 1 + const val COMMAND_PAIR = 1 + const val COMMAND_LAUNCH = 2 + const val COMMAND_SIGN_IN = 3 + const val COMMAND_REMOTE = 4 + const val STATUS_OK = 200 + const val STATUS_BAD_REQUEST = 400 + const val STATUS_FORBIDDEN = 403 + const val STATUS_UNAVAILABLE = 503 + const val SOCKET_TIMEOUT_MS = 5_000 + const val LAUNCH_MAX_AGE_MS = 60_000L + const val SIGN_IN_MAX_AGE_MS = 60_000L + const val REMOTE_MAX_AGE_MS = 60_000L + const val PAIRING_LIFETIME_MS = 5L * 60L * 1000L + const val MAX_PAIRING_ATTEMPTS = 5 + const val MAX_RECENT_REQUEST_IDS = 64 + const val MAX_PUBLIC_KEY_BYTES = 512 + const val MAX_IV_BYTES = 32 + const val MAX_CIPHERTEXT_BYTES = 65_536 + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/LowLatencyVideoDecoder.kt b/android/app/src/main/java/com/opencloudgaming/opennow/LowLatencyVideoDecoder.kt new file mode 100644 index 000000000..2e4e36310 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/LowLatencyVideoDecoder.kt @@ -0,0 +1,353 @@ +package com.opencloudgaming.opennow + +import android.media.MediaFormat +import android.os.Build +import android.util.Log +import org.webrtc.EncodedImage +import org.webrtc.VideoCodecStatus +import org.webrtc.VideoDecoder +import java.lang.reflect.Field +import java.lang.reflect.InvocationHandler +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.lang.reflect.Proxy +import java.util.Locale + +class LowLatencyVideoDecoder(private val delegate: VideoDecoder) : VideoDecoder { + + private var patched = false + + override fun initDecode(settings: VideoDecoder.Settings?, decodeCallback: VideoDecoder.Callback?): VideoCodecStatus { + NativeInputDiagnostics.add("LowLatencyVideoDecoder initDecode called on delegate class ${delegate.javaClass.name}") + patchMediaCodecWrapperFactory() + return delegate.initDecode(settings, decodeCallback) + } + + override fun release(): VideoCodecStatus { + return delegate.release() + } + + override fun decode(frame: EncodedImage?, info: VideoDecoder.DecodeInfo?): VideoCodecStatus { + return delegate.decode(frame, info) + } + + override fun getImplementationName(): String { + return delegate.implementationName + "+opennow-low-latency" + } + + private fun patchMediaCodecWrapperFactory() { + if (patched) { + return + } + patched = true + + try { + val factoryField = findMediaCodecWrapperFactoryField(delegate.javaClass) + if (factoryField == null) { + val msg = "MediaCodecWrapperFactory field not found on ${delegate.javaClass.name}" + Log.w(TAG, msg) + NativeInputDiagnostics.add("LowLatencyVideoDecoder: $msg") + return + } + + factoryField.isAccessible = true + val originalFactory = factoryField.get(delegate) + if (originalFactory == null) { + val msg = "MediaCodecWrapperFactory is null on ${delegate.javaClass.name}" + Log.w(TAG, msg) + NativeInputDiagnostics.add("LowLatencyVideoDecoder: $msg") + return + } + + val factoryInterface = factoryField.type + val proxyFactory = Proxy.newProxyInstance( + factoryInterface.classLoader, + arrayOf(factoryInterface), + MediaCodecWrapperFactoryHandler(originalFactory) + ) + factoryField.set(delegate, proxyFactory) + val msg = "Successfully patched MediaCodecWrapperFactory on ${delegate.javaClass.name}" + Log.i(TAG, msg) + NativeInputDiagnostics.add("LowLatencyVideoDecoder: $msg") + } catch (tr: Throwable) { + val msg = "Failed to install low latency MediaCodec wrapper: ${tr.message}" + Log.w(TAG, msg, tr) + NativeInputDiagnostics.add("LowLatencyVideoDecoder: $msg") + } + } + + private fun findMediaCodecWrapperFactoryField(clazz: Class<*>?): Field? { + var current = clazz + while (current != null) { + for (field in current.declaredFields) { + if ("org.webrtc.MediaCodecWrapperFactory" == field.type.name || + field.name.lowercase(Locale.US).contains("mediacodecwrapperfactory") + ) { + return field + } + } + current = current.superclass + } + return null + } + + private class MediaCodecWrapperFactoryHandler(private val delegateFactory: Any) : InvocationHandler { + override fun invoke(proxy: Any?, method: Method, args: Array?): Any? { + val originalCodecName = if ("createByCodecName" == method.name && args != null && args.isNotEmpty() && args[0] is String) { + args[0] as String + } else { + "" + } + + val modifiedCodecName = if (originalCodecName.isNotEmpty()) { + getLowLatencyCodecNameIfApplicable(originalCodecName) + } else { + originalCodecName + } + + val finalArgs = if (modifiedCodecName != originalCodecName && args != null) { + Array(args.size) { i -> + if (i == 0) modifiedCodecName else args[i] + } + } else { + args + } + + val result = invokeDelegate(delegateFactory, method, finalArgs) + if ("createByCodecName" != method.name || result == null) { + return result + } + + val codecName = modifiedCodecName + NativeInputDiagnostics.add("LowLatencyVideoDecoder: createByCodecName called for codecName=$codecName") + + var codecWrapperInterface: Class<*>? = if (result.javaClass.interfaces.isNotEmpty()) { + result.javaClass.interfaces[0] + } else { + null + } + + if (codecWrapperInterface == null || "org.webrtc.MediaCodecWrapper" != codecWrapperInterface.name) { + codecWrapperInterface = findInterface(result.javaClass, "org.webrtc.MediaCodecWrapper") + } + + if (codecWrapperInterface == null) { + NativeInputDiagnostics.add("LowLatencyVideoDecoder: MediaCodecWrapper interface not found on ${result.javaClass.name}") + return result + } + + NativeInputDiagnostics.add("LowLatencyVideoDecoder: Successfully wrapping MediaCodecWrapper of class ${result.javaClass.name}") + return Proxy.newProxyInstance( + codecWrapperInterface.classLoader, + arrayOf(codecWrapperInterface), + MediaCodecWrapperHandler(result, codecName) + ) + } + } + + private class MediaCodecWrapperHandler(private val delegateCodec: Any, private val codecName: String) : InvocationHandler { + override fun invoke(proxy: Any?, method: Method, args: Array?): Any? { + if ("configure" == method.name && args != null && args.isNotEmpty() && args[0] is MediaFormat) { + val format = args[0] as MediaFormat + NativeInputDiagnostics.add("LowLatencyVideoDecoder: Intercepted configure() for codec=$codecName. Format before: $format") + applyLowLatencyFormat(format, codecName) + NativeInputDiagnostics.add("LowLatencyVideoDecoder: Format after: $format") + } + val result = invokeDelegate(delegateCodec, method, args) + if ("start" == method.name) { + NativeInputDiagnostics.add("LowLatencyVideoDecoder: Intercepted start() for codec=$codecName") + applyLowLatencyParameters(delegateCodec) + } + return result + } + } + + companion object { + private const val TAG = "LowLatencyDecoder" + private const val OPERATING_RATE = 0x7FFF + + private fun findInterface(clazz: Class<*>?, interfaceName: String): Class<*>? { + var current = clazz + while (current != null) { + for (item in current.interfaces) { + if (interfaceName == item.name) { + return item + } + } + current = current.superclass + } + return null + } + + private fun invokeDelegate(target: Any, method: Method, args: Array?): Any? { + return try { + method.isAccessible = true + if (args == null) { + method.invoke(target) + } else { + method.invoke(target, *args) + } + } catch (ex: InvocationTargetException) { + throw ex.cause ?: ex + } catch (ex: SecurityException) { + if (args == null) { + method.invoke(target) + } else { + method.invoke(target, *args) + } + } + } + + private fun applyLowLatencyFormat(format: MediaFormat, codecName: String) { + putInt(format, "low-latency", 1) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + putInt(format, "priority", 0) + putInt(format, "operating-rate", OPERATING_RATE) + } + + putInt(format, "allow-frame-drop", 1) + putInt(format, "vdec-lowlatency", 1) + putInt(format, "vendor.low-latency.enable", 1) + + val normalizedCodecName = codecName.lowercase(Locale.US) + if (isQualcommDecoder(normalizedCodecName)) { + putInt(format, "vendor.qti-ext-dec-picture-order.enable", 1) + putInt(format, "vendor.qti-ext-dec-low-latency.enable", 1) + putInt(format, "vendor.qti-ext-output-sw-fence-enable.value", 1) + putInt(format, "vendor.qti-ext-output-fence.enable", 1) + putInt(format, "vendor.qti-ext-output-fence.fence_type", 1) + putInt(format, "vendor.rtc-ext-dec-low-latency.enable", 1) + } + + if (isHiSiliconDecoder(normalizedCodecName)) { + putInt(format, "vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req", 1) + putInt(format, "vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-rdy", -1) + } + + if (isMediaTekDecoder(normalizedCodecName)) { + putInt(format, "vendor.mtk-dec-low-latency", 1) + putInt(format, "vendor.mtk-dec-lowlatency", 1) + putInt(format, "vendor.mtk-ext-dec-low-latency.enable", 1) + putInt(format, "vendor.mtk-ext-dec-lowlatency.enable", 1) + putInt(format, "vendor.mtk-vdec-lowlatency", 1) + putInt(format, "vendor.mtk-vdec-low-latency", 1) + putInt(format, "vendor.mtk.vdec.lowlatency", 1) + putInt(format, "vendor.mtk.vdec.low-latency", 1) + putInt(format, "vendor.mtk.dec.lowlatency", 1) + putInt(format, "vendor.mtk.dec.low-latency", 1) + putInt(format, "vendor.mtk.ext.dec.lowlatency.enable", 1) + } + + Log.i(TAG, "Applied low latency decoder format for codec=$codecName") + } + + private fun isQualcommDecoder(codecName: String): Boolean { + return codecName.contains("qcom") || codecName.contains("qti") + } + + private fun isHiSiliconDecoder(codecName: String): Boolean { + val hardware = (Build.HARDWARE ?: "").lowercase(Locale.US) + val board = (Build.BOARD ?: "").lowercase(Locale.US) + val manufacturer = (Build.MANUFACTURER ?: "").lowercase(Locale.US) + return codecName.contains("hisi") || + codecName.contains("kirin") || + hardware.contains("hisi") || + hardware.contains("kirin") || + board.contains("hisi") || + board.contains("kirin") || + manufacturer.contains("huawei") + } + + private fun isMediaTekDecoder(codecName: String): Boolean { + val hardware = (Build.HARDWARE ?: "").lowercase(Locale.US) + val board = (Build.BOARD ?: "").lowercase(Locale.US) + val manufacturer = (Build.MANUFACTURER ?: "").lowercase(Locale.US) + return codecName.contains("mtk") || + codecName.contains("mediatek") || + hardware.contains("mtk") || + hardware.contains("mediatek") || + board.contains("mtk") || + board.contains("mediatek") || + manufacturer.contains("mediatek") + } + + private fun getLowLatencyCodecNameIfApplicable(codecName: String): String { + val normalized = codecName.lowercase(Locale.US) + if (normalized.startsWith("c2.mtk.") && normalized.endsWith(".decoder")) { + val lowLatencyName = "$codecName.lowlatency" + if (isCodecSupported(lowLatencyName)) { + Log.i(TAG, "LowLatencyVideoDecoder: Found MediaTek low latency variant: $lowLatencyName") + return lowLatencyName + } + } + return codecName + } + + private fun isCodecSupported(name: String): Boolean { + try { + val list = android.media.MediaCodecList(android.media.MediaCodecList.ALL_CODECS) + for (info in list.codecInfos) { + if (info.name.equals(name, ignoreCase = true)) { + return true + } + } + } catch (tr: Throwable) { + Log.w(TAG, "Failed to check if codec is supported", tr) + } + return false + } + + private fun putInt(format: MediaFormat, key: String, value: Int) { + try { + format.setInteger(key, value) + } catch (tr: Throwable) { + Log.w(TAG, "Failed to set MediaFormat key $key", tr) + } + } + + private fun applyLowLatencyParameters(delegateCodec: Any) { + try { + val field = findMediaCodecField(delegateCodec.javaClass) ?: return + field.isAccessible = true + val mediaCodec = field.get(delegateCodec) as? android.media.MediaCodec ?: return + + val bundle = android.os.Bundle() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + bundle.putInt(android.media.MediaCodec.PARAMETER_KEY_LOW_LATENCY, 1) + } + bundle.putInt("vendor.mtk-dec-low-latency", 1) + bundle.putInt("vendor.mtk-dec-lowlatency", 1) + bundle.putInt("vendor.mtk-ext-dec-low-latency.enable", 1) + bundle.putInt("vendor.mtk-ext-dec-lowlatency.enable", 1) + bundle.putInt("vendor.mtk-vdec-lowlatency", 1) + bundle.putInt("vendor.mtk-vdec-low-latency", 1) + bundle.putInt("vendor.mtk.vdec.lowlatency", 1) + bundle.putInt("vendor.mtk.vdec.low-latency", 1) + bundle.putInt("vendor.mtk.dec.lowlatency", 1) + bundle.putInt("vendor.mtk.dec.low-latency", 1) + bundle.putInt("vendor.mtk.ext.dec.lowlatency.enable", 1) + + mediaCodec.setParameters(bundle) + Log.i(TAG, "LowLatencyVideoDecoder: Successfully set MediaCodec parameters: $bundle") + NativeInputDiagnostics.add("LowLatencyVideoDecoder: Successfully set MediaCodec parameters: $bundle") + } catch (tr: Throwable) { + Log.w(TAG, "Failed to apply dynamic MediaCodec parameters", tr) + NativeInputDiagnostics.add("LowLatencyVideoDecoder: Failed to apply dynamic MediaCodec parameters: ${tr.message}") + } + } + + private fun findMediaCodecField(clazz: Class<*>?): Field? { + var current = clazz + while (current != null) { + for (field in current.declaredFields) { + if (field.type == android.media.MediaCodec::class.java) { + return field + } + } + current = current.superclass + } + return null + } + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/MainActivity.kt b/android/app/src/main/java/com/opencloudgaming/opennow/MainActivity.kt new file mode 100644 index 000000000..82dbf1ec8 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/MainActivity.kt @@ -0,0 +1,551 @@ +package com.opencloudgaming.opennow + +import android.Manifest +import android.app.PictureInPictureParams +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.media.AudioManager +import android.os.Build +import android.os.Bundle +import android.os.SystemClock +import android.util.Log +import android.util.Rational +import android.view.Display +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.PointerIcon +import android.view.View +import android.view.ViewGroup +import android.view.WindowInsets +import android.view.WindowInsetsController +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.core.view.WindowCompat +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class MainActivity : ComponentActivity() { + private val viewModel: OpenNowViewModel by viewModels() + private val queueStatusNotifier by lazy { AndroidQueueStatusNotifier(this) } + private val streamKeepAliveNotifier by lazy { AndroidStreamKeepAliveNotifier(this) } + private var notificationPermissionRequested = false + private var lastHatXKeyCode: Int? = null + private var lastHatYKeyCode: Int? = null + private var streamSystemUiActive = false + private var streamDisplayRefreshActive = false + private var streamDisplayRefreshFps = 60 + private var streamSystemUiEnforcerJob: Job? = null + private var lastStreamSystemUiInputReapplyMs = 0L + private var defaultRequestedOrientation = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR + private var phoneStreamOrientationLocked = false + private var streamPictureInPictureReady = false + private var streamPictureInPictureAspectRatio = Rational(16, 9) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + defaultRequestedOrientation = requestedOrientation + volumeControlStream = AudioManager.STREAM_MUSIC + setContent { + OpenNowApp(viewModel) + } + lifecycleScope.launch { + viewModel.state.collect { state -> + requestQueueNotificationPermissionIfNeeded(state) + queueStatusNotifier.update(state) + streamKeepAliveNotifier.update(state) + val streamActive = state.page == AppPage.Stream && state.streamStatus != "idle" + applyPhoneStreamOrientationLock( + shouldLockPhoneStreamLandscape(state, resources.configuration.smallestScreenWidthDp), + ) + updateStreamPictureInPicture( + ready = state.page == AppPage.Stream && + state.streamStatus == "streaming" && + state.streamSession?.isReadyForStream() == true, + settings = state.activeStreamSettings ?: state.settings.stream, + ) + applyStreamSystemUi(streamActive) + applyStreamDisplayRefreshRate(streamActive, state.activeStreamSettings?.fps ?: state.settings.stream.fps) + } + } + viewModel.handleExternalLaunchIntent(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + viewModel.handleExternalLaunchIntent(intent) + } + + override fun onResume() { + super.onResume() + viewModel.setAndroidPictureInPictureActive(isInPictureInPictureMode) + if (streamSystemUiActive) { + applyStreamSystemUi(true, force = true) + applyStreamDisplayRefreshRate(streamDisplayRefreshActive, streamDisplayRefreshFps, force = true) + } + if (phoneStreamOrientationLocked) { + applyPhoneStreamOrientationLock(true, force = true) + } + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (streamSystemUiActive && event.action == KeyEvent.ACTION_DOWN && event.shouldReapplyStreamSystemUi()) { + enforceStreamSystemUiFromInput() + } + if (streamSystemUiActive && event.isAndroidVolumeKey()) { + return super.dispatchKeyEvent(event) + } + if (NativeStreamInputRouter.dispatchKey(event)) { + return true + } + val normalizedStreamUiKeyCode = NativeStreamInputRouter.normalizedStreamUiKeyCode(event) + if (normalizedStreamUiKeyCode != null && normalizedStreamUiKeyCode != event.keyCode) { + return dispatchSyntheticStreamUiKey(normalizedStreamUiKeyCode, event) + } + if (NativeStreamInputRouter.isControllerAppBackKey(event)) { + if (event.action == KeyEvent.ACTION_UP) { + viewModel.handleControllerBackNavigation() + } + return true + } + val normalizedAppUiKeyCode = NativeStreamInputRouter.normalizedAppUiKeyCode(event) + if (normalizedAppUiKeyCode != null && normalizedAppUiKeyCode != event.keyCode) { + return dispatchSyntheticStreamUiKey(normalizedAppUiKeyCode, event) + } + return super.dispatchKeyEvent(event) + } + + override fun onUserLeaveHint() { + super.onUserLeaveHint() + enterStreamPictureInPictureIfReady() + } + + override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + viewModel.setAndroidPictureInPictureActive(isInPictureInPictureMode) + } + + private fun KeyEvent.isAndroidVolumeKey(): Boolean = + keyCode == KeyEvent.KEYCODE_VOLUME_UP || + keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + keyCode == KeyEvent.KEYCODE_VOLUME_MUTE + + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + if (streamSystemUiActive && (event.isMouseLikePointerEvent() || event.isControllerMotionEvent())) { + enforceStreamSystemUiFromInput() + } + return NativeStreamInputRouter.dispatchMotion(event) || + dispatchGamepadHatNavigation(event) || + super.dispatchGenericMotionEvent(event) + } + + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + try { + val decorView = window?.decorView + if (decorView != null && NativeStreamInputRouter.dispatchExternalMouseTouch(event, decorView.width, decorView.height)) return true + if (decorView != null && NativeStreamInputRouter.shouldForwardTouchBeforeViews(event, decorView.width, decorView.height)) { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + NativeInputDiagnostics.add("activity touch forwardBeforeViews size=${decorView.width}x${decorView.height}") + } + val forwarded = NativeStreamInputRouter.dispatchTouch(event, decorView.width, decorView.height) + if (NativeStreamInputRouter.shouldCaptureTouchBeforeViews(event, decorView.width, decorView.height) && forwarded) { + return true + } + } + val handled = super.dispatchTouchEvent(event) + if (handled) { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + NativeInputDiagnostics.add("activity touch consumedByView action=${event.actionMasked}") + } + return true + } + return if (decorView != null) { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + NativeInputDiagnostics.add("activity touch fallback size=${decorView.width}x${decorView.height}") + } + NativeStreamInputRouter.dispatchTouch(event, decorView.width, decorView.height) + } else { + false + } + } finally { + NativeStreamInputRouter.postDispatchTouch(event) + } + } + + override fun onDestroy() { + if (isFinishing) { + queueStatusNotifier.cancel() + // Keep the foreground service alive long enough for onTaskRemoved() + // to end the exact cloud session. Normal in-app exits already move + // stream state to idle and cancel the service through update(). + if (!shouldKeepAndroidStreamAlive(viewModel.state.value)) { + streamKeepAliveNotifier.cancel() + } + } + super.onDestroy() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus && streamSystemUiActive) { + applyStreamSystemUi(true, force = true) + applyStreamDisplayRefreshRate(streamDisplayRefreshActive, streamDisplayRefreshFps, force = true) + } + } + + fun enforceStreamSystemUiFromInput() { + if (!streamSystemUiActive) return + val now = SystemClock.uptimeMillis() + if (now - lastStreamSystemUiInputReapplyMs < STREAM_SYSTEM_UI_INPUT_REAPPLY_MS) return + lastStreamSystemUiInputReapplyMs = now + applyStreamSystemUi(true, force = true) + } + + private fun applyStreamSystemUi(active: Boolean, force: Boolean = false) { + if (!force && streamSystemUiActive == active) { + applyStreamKeepAwake(active) + updateStreamSystemUiEnforcer(active) + return + } + streamSystemUiActive = active + applyStreamPointerIcon(active) + applyStreamKeepAwake(active) + updateStreamSystemUiEnforcer(active) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + WindowCompat.setDecorFitsSystemWindows(window, false) + window.insetsController?.let { controller -> + if (active) { + controller.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars()) + } else { + controller.show(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars()) + } + } + } else { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = if (active) { + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + } else { + 0 + } + } + } + + private fun updateStreamPictureInPicture(ready: Boolean, settings: StreamSettings) { + val aspectRatio = pictureInPictureAspectRatioFor(settings) + val shouldUpdateParams = streamPictureInPictureReady != ready || + streamPictureInPictureAspectRatio != aspectRatio + streamPictureInPictureReady = ready + streamPictureInPictureAspectRatio = aspectRatio + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && shouldUpdateParams) { + runCatching { + setPictureInPictureParams(buildStreamPictureInPictureParams()) + }.onFailure { error -> + Log.w(MAIN_ACTIVITY_LOG_TAG, "Unable to update stream picture-in-picture params", error) + } + } + } + + private fun enterStreamPictureInPictureIfReady() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + if (!streamPictureInPictureReady || isInPictureInPictureMode) return + runCatching { + enterPictureInPictureMode(buildStreamPictureInPictureParams()) + }.onFailure { error -> + Log.w(MAIN_ACTIVITY_LOG_TAG, "Unable to enter stream picture-in-picture", error) + } + } + + private fun buildStreamPictureInPictureParams(): PictureInPictureParams = + PictureInPictureParams.Builder() + .setAspectRatio(streamPictureInPictureAspectRatio) + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setAutoEnterEnabled(streamPictureInPictureReady) + setSeamlessResizeEnabled(true) + } + } + .build() + + private fun pictureInPictureAspectRatioFor(settings: StreamSettings): Rational { + val (width, height) = streamResolutionPixels(settings) + if (width <= 0 || height <= 0) return Rational(16, 9) + val ratio = width.toFloat() / height.toFloat() + return if (ratio in MIN_PIP_ASPECT_RATIO..MAX_PIP_ASPECT_RATIO) { + Rational(width, height) + } else { + Rational(16, 9) + } + } + + private fun applyStreamKeepAwake(active: Boolean) { + if (active) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + private fun applyPhoneStreamOrientationLock(active: Boolean, force: Boolean = false) { + if (!force && phoneStreamOrientationLocked == active) return + phoneStreamOrientationLocked = active + val nextOrientation = if (active) { + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } else { + defaultRequestedOrientation + } + if (requestedOrientation != nextOrientation) { + requestedOrientation = nextOrientation + } + } + + private fun updateStreamSystemUiEnforcer(active: Boolean) { + if (!active) { + streamSystemUiEnforcerJob?.cancel() + streamSystemUiEnforcerJob = null + return + } + if (streamSystemUiEnforcerJob?.isActive == true) return + streamSystemUiEnforcerJob = lifecycleScope.launch { + while (streamSystemUiActive) { + delay(STREAM_SYSTEM_UI_ENFORCE_INTERVAL_MS) + val navigationBarsVisible = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.decorView.rootWindowInsets?.isVisible(WindowInsets.Type.navigationBars()) == true + } else { + false + } + if (shouldPeriodicallyEnforceStreamSystemUi(streamSystemUiActive, navigationBarsVisible)) { + applyStreamSystemUi(true, force = true) + } + } + } + } + + private fun applyStreamPointerIcon(active: Boolean) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return + runCatching { + val icon = if (active) PointerIcon.getSystemIcon(this, PointerIcon.TYPE_NULL) else null + window.decorView.applyPointerIconRecursive(icon) + }.onFailure { error -> + Log.w(MAIN_ACTIVITY_LOG_TAG, "Unable to apply stream pointer icon", error) + } + } + + private fun applyStreamDisplayRefreshRate(active: Boolean, requestedFps: Int, force: Boolean = false) { + streamDisplayRefreshActive = active + streamDisplayRefreshFps = requestedFps + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + DisplayRefreshDiagnostics.update( + active = active, + requestedFps = requestedFps, + currentMode = null, + selectedMode = null, + supportedModes = emptyList(), + preferredModeId = 0, + preferredRefreshRate = 0f, + applied = false, + ) + return + } + + val display = window.decorView.display + val supportedModes = display?.supportedModes.orEmpty().map { it.toDisplayRefreshMode() } + val currentMode = display?.mode?.toDisplayRefreshMode() + val selectedMode = if (active) { + selectStreamDisplayMode( + supportedModes = supportedModes, + currentMode = currentMode, + requestedFps = requestedFps, + ) + } else { + null + } + val preferredModeId = selectedMode?.id ?: 0 + val preferredRefreshRate = selectedMode?.refreshRate ?: if (active) normalizedStreamDisplayFps(requestedFps) else 0f + val attributes = window.attributes + if (!force && + attributes.preferredDisplayModeId == preferredModeId && + kotlin.math.abs(attributes.preferredRefreshRate - preferredRefreshRate) < 0.01f + ) { + DisplayRefreshDiagnostics.update( + active = active, + requestedFps = requestedFps, + currentMode = currentMode, + selectedMode = selectedMode, + supportedModes = supportedModes, + preferredModeId = preferredModeId, + preferredRefreshRate = preferredRefreshRate, + applied = true, + ) + return + } + var applied = false + var failure: Throwable? = null + runCatching { + window.attributes = attributes.apply { + preferredDisplayModeId = preferredModeId + this.preferredRefreshRate = preferredRefreshRate + } + applied = true + }.onFailure { error -> + failure = error + Log.w(MAIN_ACTIVITY_LOG_TAG, "Unable to apply stream display refresh preference", error) + } + DisplayRefreshDiagnostics.update( + active = active, + requestedFps = requestedFps, + currentMode = currentMode, + selectedMode = selectedMode, + supportedModes = supportedModes, + preferredModeId = preferredModeId, + preferredRefreshRate = preferredRefreshRate, + applied = applied, + error = failure, + ) + } + + private fun Display.Mode.toDisplayRefreshMode(): DisplayRefreshMode = + DisplayRefreshMode( + id = modeId, + refreshRate = refreshRate, + physicalWidth = physicalWidth, + physicalHeight = physicalHeight, + ) + + @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == 4210 && grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { + queueStatusNotifier.update(viewModel.state.value) + } + } + + private fun requestQueueNotificationPermissionIfNeeded(state: OpenNowUiState) { + if (notificationPermissionRequested) return + if (!shouldShowQueueLaunchStatus(state)) return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) return + notificationPermissionRequested = true + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4210) + } + + private fun dispatchGamepadHatNavigation(event: MotionEvent): Boolean { + if (event.actionMasked != MotionEvent.ACTION_MOVE) return false + if ((event.source and InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) return false + + val nextX = when { + event.getAxisValue(MotionEvent.AXIS_HAT_X) <= -0.5f -> KeyEvent.KEYCODE_DPAD_LEFT + event.getAxisValue(MotionEvent.AXIS_HAT_X) >= 0.5f -> KeyEvent.KEYCODE_DPAD_RIGHT + else -> null + } + val nextY = when { + event.getAxisValue(MotionEvent.AXIS_HAT_Y) <= -0.5f -> KeyEvent.KEYCODE_DPAD_UP + event.getAxisValue(MotionEvent.AXIS_HAT_Y) >= 0.5f -> KeyEvent.KEYCODE_DPAD_DOWN + else -> null + } + + val handledX = updateSyntheticDpadKey(lastHatXKeyCode, nextX, event) + val handledY = updateSyntheticDpadKey(lastHatYKeyCode, nextY, event) + lastHatXKeyCode = nextX + lastHatYKeyCode = nextY + return handledX || handledY + } + + private fun updateSyntheticDpadKey(previous: Int?, next: Int?, sourceEvent: MotionEvent): Boolean { + var handled = false + if (previous != null && previous != next) { + handled = dispatchSyntheticDpadKey(previous, KeyEvent.ACTION_UP, sourceEvent) || handled + } + if (next != null && previous != next) { + handled = dispatchSyntheticDpadKey(next, KeyEvent.ACTION_DOWN, sourceEvent) || handled + } + return handled + } + + private fun dispatchSyntheticDpadKey(keyCode: Int, action: Int, sourceEvent: MotionEvent): Boolean { + val event = KeyEvent( + sourceEvent.downTime, + sourceEvent.eventTime, + action, + keyCode, + 0, + sourceEvent.metaState, + sourceEvent.deviceId, + 0, + 0, + InputDevice.SOURCE_DPAD, + ) + return super.dispatchKeyEvent(event) + } + + private fun dispatchSyntheticStreamUiKey(keyCode: Int, sourceEvent: KeyEvent): Boolean { + val event = KeyEvent( + sourceEvent.downTime, + sourceEvent.eventTime, + sourceEvent.action, + keyCode, + sourceEvent.repeatCount, + sourceEvent.metaState, + sourceEvent.deviceId, + sourceEvent.scanCode, + sourceEvent.flags, + InputDevice.SOURCE_DPAD, + ) + return super.dispatchKeyEvent(event) + } + + private fun View.applyPointerIconRecursive(icon: PointerIcon?) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return + pointerIcon = icon + if (this is ViewGroup) { + for (index in 0 until childCount) { + getChildAt(index).applyPointerIconRecursive(icon) + } + } + } + + private fun MotionEvent.isMouseLikePointerEvent(): Boolean { + val controllerSource = + AndroidControllerInput.hasControllerSource(source) || + AndroidControllerInput.isControllerEvent(source, deviceId) + return (source and InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE || + (source and InputDevice.SOURCE_MOUSE_RELATIVE) == InputDevice.SOURCE_MOUSE_RELATIVE || + ((source and InputDevice.SOURCE_TOUCHPAD) == InputDevice.SOURCE_TOUCHPAD && !controllerSource) + } + + private fun MotionEvent.isControllerMotionEvent(): Boolean = + AndroidControllerInput.isControllerEvent(source, deviceId) + + private fun KeyEvent.shouldReapplyStreamSystemUi(): Boolean = + keyCode == KeyEvent.KEYCODE_MENU || + AndroidControllerInput.isControllerEvent(source, deviceId) || + keyCode in KeyEvent.KEYCODE_BUTTON_A..KeyEvent.KEYCODE_BUTTON_MODE + + private companion object { + private const val MAIN_ACTIVITY_LOG_TAG = "OpenNOWMainActivity" + private const val STREAM_SYSTEM_UI_ENFORCE_INTERVAL_MS = 500L + private const val STREAM_SYSTEM_UI_INPUT_REAPPLY_MS = 250L + private const val MIN_PIP_ASPECT_RATIO = 1f / 2.39f + private const val MAX_PIP_ASPECT_RATIO = 2.39f + } +} + +internal fun shouldPeriodicallyEnforceStreamSystemUi( + streamActive: Boolean, + navigationBarsVisible: Boolean, +): Boolean = streamActive && !navigationBarsVisible diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/Models.kt b/android/app/src/main/java/com/opencloudgaming/opennow/Models.kt new file mode 100644 index 000000000..bc2932e88 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/Models.kt @@ -0,0 +1,1744 @@ +package com.opencloudgaming.opennow + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import androidx.compose.runtime.Immutable +import java.util.Locale +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.roundToInt + +@Serializable +enum class VideoCodec { + H264, + H265, + AV1, +} + +@Serializable +enum class ColorQuality { + @kotlinx.serialization.SerialName("8bit_420") + EightBit420, + + @kotlinx.serialization.SerialName("8bit_444") + EightBit444, + + @kotlinx.serialization.SerialName("10bit_420") + TenBit420, + + @kotlinx.serialization.SerialName("10bit_444") + TenBit444, +} + +@Serializable +enum class StreamPreset { + @kotlinx.serialization.SerialName("recommended") + Recommended, + + @kotlinx.serialization.SerialName("custom") + Custom, + + @kotlinx.serialization.SerialName("low_data_saver") + LowDataSaver, + + @kotlinx.serialization.SerialName("medium") + Medium, + + @kotlinx.serialization.SerialName("high") + High, +} + +@Serializable +enum class MicrophoneMode { + @kotlinx.serialization.SerialName("disabled") + Disabled, + + @kotlinx.serialization.SerialName("push-to-talk") + PushToTalk, + + @kotlinx.serialization.SerialName("voice-activity") + VoiceActivity, +} + +@Serializable +enum class UiAccent { + OpenNow, + Pixel, + HotPink, + Lime, + Coral, + Violet, +} + +@Serializable +enum class StreamStatsStyle { + Compact, + Detailed, +} + +@Serializable +enum class StreamStatsPosition { + Left, + Center, + Right, +} + +@Serializable +enum class CatalogBackgroundPreset { + @kotlinx.serialization.SerialName("colorful-abstract") + ColorfulAbstract, + + @kotlinx.serialization.SerialName("original") + Original, +} + +@Serializable +data class StreamStatsMetrics( + val fps: Boolean = false, + val ping: Boolean = true, + val bitrate: Boolean = false, + val battery: Boolean = true, + val connection: Boolean = true, + val resolution: Boolean = false, + val codec: Boolean = false, + val location: Boolean = true, + val latency: Boolean = true, + val packetLoss: Boolean = true, +) { + fun enabledCount(): Int = listOf(fps, ping, bitrate, battery, connection, resolution, codec, location, latency, packetLoss).count { it } +} + +@Serializable +enum class IntroMusicStartMode { + @kotlinx.serialization.SerialName("muted") + Muted, + + @kotlinx.serialization.SerialName("playing") + Playing, +} + +@Serializable +enum class AppLaunchPage { + @kotlinx.serialization.SerialName("store") + Store, + + @kotlinx.serialization.SerialName("library") + Library, +} + +enum class SessionTimerMode { + Countdown, + Stopwatch, +} + +data class SmartSessionLimit( + val tierLabel: String, + val limitHours: Int, + val mode: SessionTimerMode, +) + +internal val SESSION_WARNING_THRESHOLDS_SECONDS = listOf( + 30 * 60, + 10 * 60, + 5 * 60, + 3 * 60, + 60, +) + +internal fun sessionElapsedSeconds(startedAtMs: Long, nowMs: Long): Int = + ((nowMs - startedAtMs).coerceAtLeast(0L) / 1000L).toInt() + +internal fun sessionRemainingSeconds(limit: SmartSessionLimit, startedAtMs: Long, nowMs: Long): Int { + val limitSeconds = limit.limitHours * 60 * 60 + return (limitSeconds - sessionElapsedSeconds(startedAtMs, nowMs)).coerceAtLeast(0) +} + +internal fun sessionWarningThresholdCrossed(previousRemainingSeconds: Int?, remainingSeconds: Int): Int? { + val previous = previousRemainingSeconds ?: return null + return SESSION_WARNING_THRESHOLDS_SECONDS + .filter { threshold -> previous > threshold && remainingSeconds <= threshold } + .minOrNull() +} + +@Serializable +data class StreamSettings( + val resolution: String = "1920x1080", + val aspectRatio: String = "16:9", + val fps: Int = 60, + val maxBitrateMbps: Int = 75, + val codec: VideoCodec = VideoCodec.H264, + val colorQuality: ColorQuality = ColorQuality.TenBit420, + val hdrEnabled: Boolean = false, + val region: String = "", + val keyboardLayout: String = "en-US", + val gameLanguage: String = "en_US", + val sessionProxyEnabled: Boolean = false, + val sessionProxyUrl: String = "", + val enableL4S: Boolean = false, + val enableCloudGsync: Boolean = false, + val mouseSensitivity: Float = 1f, + val mouseAcceleration: Int = 1, + val streamSharpeningEnabled: Boolean = false, + val streamSharpeningAmount: Float = 0.25f, + val microphoneMode: MicrophoneMode = MicrophoneMode.Disabled, + val microphoneDeviceId: String = "", +) + +internal fun StreamSettings.withMicrophoneSettingsFrom(source: StreamSettings): StreamSettings = + copy( + microphoneMode = source.microphoneMode, + microphoneDeviceId = source.microphoneDeviceId, + ) + +@Serializable +enum class TouchControllerStyle { + V1, + V2 +} + +@Serializable +enum class TouchJoystickMode { + Fixed, + Dynamic, +} + +@Serializable +data class TouchOffset(val x: Float = 0f, val y: Float = 0f) + +@Serializable +data class AndroidTouchSettings( + val enabled: Boolean = true, + val mousePad: Boolean = true, + val opacity: Float = 0.82f, + val scale: Float = 1f, + val buttonScale: Float = 1.102468f, + val stickScale: Float = 1f, + val joystickMode: TouchJoystickMode = TouchJoystickMode.Fixed, + val joystickDeadZone: Float = 0f, + val edgePaddingDp: Float = 14f, + val bottomPaddingDp: Float = 10f, + val leftOffsetXDp: Float = 0f, + val leftOffsetYDp: Float = 0f, + val rightOffsetXDp: Float = 0f, + val rightOffsetYDp: Float = 0f, + val mouseDirectClick: Boolean = false, + val offsets: Map = mapOf( + "lstick_landscape" to TouchOffset(-67.02336f, 1.4236208f), + "l3_landscape" to TouchOffset(-159.65048f, 119.79623f), + "lt_landscape" to TouchOffset(32.63997f, -52.50644f), + "dpad_landscape" to TouchOffset(47.38254f, -131.70163f), + "rb_landscape" to TouchOffset(-124.94213f, -107.18774f), + "lb_landscape" to TouchOffset(119.051155f, -100.54266f), + "face_landscape" to TouchOffset(-20.225464f, -132.01855f), + "rstick_landscape" to TouchOffset(96.44574f, -7.9870353f), + "r3_landscape" to TouchOffset(191.65938f, 125.07891f), + "rt_landscape" to TouchOffset(-30.344517f, -57.420998f) + ), + val touchControllerStyle: TouchControllerStyle = TouchControllerStyle.V1, +) { + fun getOffset(key: String): TouchOffset = offsets[key] ?: TouchOffset() + + fun withOffset(key: String, x: Float, y: Float): AndroidTouchSettings { + val newOffsets = offsets.toMutableMap() + newOffsets[key] = TouchOffset(x, y) + return this.copy(offsets = newOffsets) + } + + fun withResetOffsets(): AndroidTouchSettings { + val defaultSettings = AndroidTouchSettings() + return this.copy( + opacity = defaultSettings.opacity, + scale = defaultSettings.scale, + buttonScale = defaultSettings.buttonScale, + stickScale = defaultSettings.stickScale, + edgePaddingDp = defaultSettings.edgePaddingDp, + bottomPaddingDp = defaultSettings.bottomPaddingDp, + leftOffsetXDp = defaultSettings.leftOffsetXDp, + leftOffsetYDp = defaultSettings.leftOffsetYDp, + rightOffsetXDp = defaultSettings.rightOffsetXDp, + rightOffsetYDp = defaultSettings.rightOffsetYDp, + offsets = defaultSettings.offsets + ) + } +} + +@Serializable +data class AppSettings( + val stream: StreamSettings = StreamSettings(), + val streamPreset: StreamPreset = StreamPreset.Recommended, + val posterSizeScale: Float = 1f, + val compactGameCards: Boolean = true, + val handheldLandscapeFourColumnGrid: Boolean = false, + val handheldLandscapeSquareCards: Boolean = false, + val nerdCatalogBackground: Boolean = false, + val catalogBackgroundPreset: CatalogBackgroundPreset = CatalogBackgroundPreset.ColorfulAbstract, + val nerdCatalogBackgroundUri: String? = null, + val tvSafeAreaPaddingDp: Float = 16f, + val tvLayoutProfileVersion: Int = 0, + val localTvRemoteEnabled: Boolean = false, + val showGameStoreLabels: Boolean = true, + val expressiveUi: Boolean = true, + val dynamicColor: Boolean = false, + val uiAccent: UiAccent = UiAccent.OpenNow, + val launchPage: AppLaunchPage = AppLaunchPage.Store, + val nerdMode: Boolean = false, + val hideStreamButtons: Boolean = false, + val showAntiAfkIndicator: Boolean = true, + val showStatsOnLaunch: Boolean = false, + val streamStatsStyle: StreamStatsStyle = StreamStatsStyle.Compact, + val streamStatsPosition: StreamStatsPosition = StreamStatsPosition.Right, + val streamStatsMetrics: StreamStatsMetrics = StreamStatsMetrics(), + val phoneRumbleFallback: Boolean = true, + val hideServerSelector: Boolean = false, + val controllerMode: Boolean = false, + val controllerUiSounds: Boolean = true, + val controllerMouseEmulation: Boolean = false, + val controllerBackgroundAnimations: Boolean = true, + val controllerThemeStyle: String = "aurora", + val controllerThemeColor: ControllerThemeRgb = ControllerThemeRgb(), + val controllerLibraryGameBackdrop: Boolean = true, + val autoLoadControllerLibrary: Boolean = false, + val autoFullScreen: Boolean = true, + val streamIntroMusic: Boolean = false, + val streamIntroStartMode: IntroMusicStartMode = IntroMusicStartMode.Muted, + val queueReadyMusic: Boolean = false, + @SerialName("stretchStreamToFill") + val legacyCropStreamToFill: Boolean = false, + @SerialName("stretchStreamToZoom") + val stretchStreamToFit: Boolean = false, + val streamPresentationProfileVersion: Int = 0, + val favoriteGameIds: List = emptyList(), + val defaultGameVariantIds: Map = emptyMap(), + val sessionCounterEnabled: Boolean = true, + val sessionClockShowEveryMinutes: Int = 60, + val sessionClockShowDurationSeconds: Int = 30, + val clipboardPaste: Boolean = true, + val androidTouch: AndroidTouchSettings = AndroidTouchSettings(), + val androidStreamGuideDismissed: Boolean = false, + val androidPhysicalControllerPromptDismissed: Boolean = false, + val discordRichPresence: Boolean = false, + val autoCheckForUpdates: Boolean = true, + val analyticsOptOut: Boolean = true, + val analyticsConsentAsked: Boolean = false, + val allowEscapeToExitFullscreen: Boolean = false, + val nativeLowLatencyDecoder: Boolean = false, +) + +internal const val MIN_GAME_CARD_SCALE = 0.75f +internal const val MAX_GAME_CARD_SCALE = 1.4f +internal const val STREAM_PRESENTATION_PROFILE_VERSION = 1 + +internal fun AppSettings.withCurrentStreamPresentationDefaults(androidTvProfile: Boolean): AppSettings { + if (streamPresentationProfileVersion >= STREAM_PRESENTATION_PROFILE_VERSION) return this + return copy( + legacyCropStreamToFill = false, + stretchStreamToFit = !androidTvProfile, + streamPresentationProfileVersion = STREAM_PRESENTATION_PROFILE_VERSION, + ) +} + +internal val AppSettings.analyticsSharingEnabled: Boolean + get() = analyticsConsentAsked && !analyticsOptOut + +internal fun streamResolutionPixels(settings: StreamSettings): Pair { + if (!isKnownStreamResolution(settings.resolution)) { + parseResolutionPixelsOrNull(settings.resolution)?.let { return it } + } + return parseResolutionPixels(normalizeStreamResolutionForAspect(settings.resolution, settings.aspectRatio)) +} + +internal data class StreamResolutionMismatch( + val actualResolution: String, + val expectedResolution: String, + val serverNegotiatedResolution: String? = null, +) + +internal enum class StreamResolutionChangeSource { + ServerNegotiatedFallback, + ProviderOrGameModeChange, +} + +internal data class ActiveStreamModeStatus( + val requestedResolution: String, + val displayedResolution: String, + val serverNegotiatedResolution: String? = null, + val serverFinalSelectedResolution: String? = null, + val resolutionSource: StreamResolutionChangeSource? = null, + val safeVideoRecoveryActive: Boolean = false, + val transportCodec: VideoCodec, +) + +internal val StreamResolutionMismatch.isServerNegotiatedFallback: Boolean + get() = serverNegotiatedResolution == actualResolution + +internal fun streamRuntimeResolutionMismatch( + settings: StreamSettings, + actualResolution: String?, + serverNegotiatedResolution: String? = null, +): StreamResolutionMismatch? { + val actualPixels = parseResolutionPixelsOrNull(actualResolution) + ?.takeIf { (width, height) -> width >= 320 && height >= 180 } + ?: return null + val expectedPixels = streamResolutionPixels(settings) + if (actualPixels == expectedPixels) return null + val negotiatedPixels = parseResolutionPixelsOrNull(serverNegotiatedResolution) + return StreamResolutionMismatch( + actualResolution = "${actualPixels.first}x${actualPixels.second}", + expectedResolution = "${expectedPixels.first}x${expectedPixels.second}", + serverNegotiatedResolution = negotiatedPixels + ?.takeIf { it == actualPixels } + ?.let { "${it.first}x${it.second}" }, + ) +} + +internal fun activeStreamModeStatus( + requestedSettings: StreamSettings, + transportSettings: StreamSettings, + decodedResolution: String?, + serverNegotiatedResolution: String? = null, + serverFinalSelectedResolution: String? = null, +): ActiveStreamModeStatus? { + val requestedPixels = streamResolutionPixels(requestedSettings) + val requestedResolution = "${requestedPixels.first}x${requestedPixels.second}" + val decodedPixels = parseResolutionPixelsOrNull(decodedResolution) + ?.takeIf { (width, height) -> width >= 320 && height >= 180 } + val negotiatedPixels = parseResolutionPixelsOrNull(serverNegotiatedResolution) + ?.takeIf { (width, height) -> width >= 320 && height >= 180 } + val finalSelectedPixels = parseResolutionPixelsOrNull(serverFinalSelectedResolution) + ?.takeIf { (width, height) -> width >= 320 && height >= 180 } + val displayedPixels = decodedPixels ?: finalSelectedPixels ?: negotiatedPixels ?: requestedPixels + val resolutionSource = when { + displayedPixels == requestedPixels -> null + negotiatedPixels == displayedPixels || finalSelectedPixels == displayedPixels -> + StreamResolutionChangeSource.ServerNegotiatedFallback + else -> StreamResolutionChangeSource.ProviderOrGameModeChange + } + val safeVideoRecoveryActive = !requestedSettings.hasSameVideoTransportProfile(transportSettings) + if (resolutionSource == null && !safeVideoRecoveryActive) return null + return ActiveStreamModeStatus( + requestedResolution = requestedResolution, + displayedResolution = "${displayedPixels.first}x${displayedPixels.second}", + serverNegotiatedResolution = negotiatedPixels?.let { "${it.first}x${it.second}" }, + serverFinalSelectedResolution = finalSelectedPixels?.let { "${it.first}x${it.second}" }, + resolutionSource = resolutionSource, + safeVideoRecoveryActive = safeVideoRecoveryActive, + transportCodec = transportSettings.codec, + ) +} + +private fun StreamSettings.hasSameVideoTransportProfile(other: StreamSettings): Boolean = + resolution == other.resolution && + aspectRatio == other.aspectRatio && + fps == other.fps && + maxBitrateMbps == other.maxBitrateMbps && + codec == other.codec && + colorQuality == other.colorQuality && + hdrEnabled == other.hdrEnabled && + enableCloudGsync == other.enableCloudGsync + +internal fun streamResolutionOptionsForAspect(aspectRatio: String): List = + STREAM_RESOLUTION_OPTIONS.filter { it.aspectRatio == aspectRatio }.map { it.value } + +internal fun streamResolutionChoicesForAspect(aspectRatio: String): List = + STREAM_RESOLUTION_OPTIONS.filter { it.aspectRatio == aspectRatio }.map { it.toChoice() } + +internal fun streamAspectRatioOptions(): List = + STREAM_RESOLUTION_OPTIONS.map { it.aspectRatio }.distinct() + +internal fun streamAspectRatioForResolution(resolution: String): String? = + STREAM_RESOLUTION_OPTIONS.firstOrNull { it.value == resolution }?.aspectRatio + +internal fun normalizeStreamResolutionForAspect(resolution: String, aspectRatio: String): String { + val normalizedAspect = aspectRatio.trim() + val options = streamResolutionOptionsForAspect(normalizedAspect) + if (options.isEmpty()) return resolution + if (STREAM_RESOLUTION_OPTIONS.any { it.value == resolution && it.aspectRatio == normalizedAspect }) { + return resolution + } + + val tier = STREAM_RESOLUTION_OPTIONS.firstOrNull { it.value == resolution }?.tier + ?: resolutionTierForHeight(parseResolutionPixels(resolution).second) + PREFERRED_RESOLUTION_BY_TIER_AND_ASPECT[tier]?.get(normalizedAspect)?.let { preferred -> + if (preferred in options) return preferred + } + + val requestedPixels = parseResolutionPixels(resolution).let { it.first * it.second } + return options.minWithOrNull( + compareBy { option -> + val pixels = parseResolutionPixels(option).let { it.first * it.second } + abs(pixels - requestedPixels) + }.thenBy { option -> + parseResolutionPixels(option).first * parseResolutionPixels(option).second + }, + ) ?: options.first() +} + +internal fun normalizeStreamResolutionForAspectAndPlan( + resolution: String, + aspectRatio: String, + subscriptionInfo: SubscriptionInfo?, + fallbackMembershipTier: String?, +): String { + val customResolution = customStreamResolutionOrNull(resolution) + if (customResolution != null && customResolutionAllowedForPlan(customResolution, subscriptionInfo, fallbackMembershipTier)) { + return "${customResolution.first}x${customResolution.second}" + } + + val normalized = normalizeStreamResolutionForAspect(resolution, aspectRatio) + val choices = streamResolutionChoicesForAspect(aspectRatio) + val current = choices.firstOrNull { it.value == normalized } + if (current?.isAvailableFor(subscriptionInfo, fallbackMembershipTier) == true) return normalized + + val availableChoices = choices.filter { it.isAvailableFor(subscriptionInfo, fallbackMembershipTier) } + .ifEmpty { + streamResolutionChoicesForAspect("16:9").filter { it.isAvailableFor(subscriptionInfo, fallbackMembershipTier) } + } + if (availableChoices.isEmpty()) return normalized + + val requestedPixels = parseResolutionPixels(normalized).let { it.first * it.second } + return availableChoices + .filter { it.width * it.height <= requestedPixels } + .maxByOrNull { it.width * it.height } + ?.value + ?: availableChoices.minByOrNull { it.width * it.height }?.value + ?: normalized +} + +internal fun parseResolutionPixels(value: String): Pair { + val parts = value.split("x") + val width = parts.getOrNull(0)?.toIntOrNull() + val height = parts.getOrNull(1)?.toIntOrNull() + return if (width != null && height != null && width > 0 && height > 0) width to height else 1920 to 1080 +} + +internal fun streamSettingsSessionSignature(settings: StreamSettings): String { + val compatible = settings.withCodecColorCompatibility() + val (width, height) = streamResolutionPixels(compatible) + return listOf( + "opennow-android-stream-v1", + "res=${width}x$height", + "fps=${compatible.fps}", + "bitrate=${compatible.maxBitrateMbps}", + "codec=${compatible.codec.name}", + "color=${compatible.colorQuality.name}", + "hdr=${if (compatible.hdrEnabled) 1 else 0}", + "l4s=${if (compatible.enableL4S) 1 else 0}", + "gsync=${if (compatible.enableCloudGsync) 1 else 0}", + "keyboard=${compatible.keyboardLayout.trim()}", + "language=${compatible.gameLanguage.trim()}", + ).joinToString(";") +} + +internal data class StreamResolutionOption( + val value: String, + val aspectRatio: String, + val tier: String, + val requiredPlan: StreamResolutionPlan = StreamResolutionPlan.Free, +) { + fun toChoice(): StreamResolutionChoice { + val (width, height) = parseResolutionPixels(value) + return StreamResolutionChoice( + value = value, + width = width, + height = height, + aspectRatio = aspectRatio, + requiredPlan = requiredPlan, + ) + } +} + +internal enum class StreamResolutionPlan { + Free, + Priority, + Ultimate, +} + +internal data class StreamResolutionChoice( + val value: String, + val width: Int, + val height: Int, + val aspectRatio: String, + val requiredPlan: StreamResolutionPlan, +) { + val label: String + get() = "$width x $height" + + val requiredPlanLabel: String? + get() = when (requiredPlan) { + StreamResolutionPlan.Free -> null + StreamResolutionPlan.Priority -> "Priority" + StreamResolutionPlan.Ultimate -> "Ultimate" + } + + fun isAvailableFor(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): Boolean { + if (requiredPlan == StreamResolutionPlan.Free) return true + return streamResolutionPlanRank(effectiveStreamingPlan(subscriptionInfo, fallbackMembershipTier)) >= streamResolutionPlanRank(requiredPlan) + } +} + +internal fun hasUltimateStreamingPlan(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): Boolean = + streamResolutionPlanRank(effectiveStreamingPlan(subscriptionInfo, fallbackMembershipTier)) >= + streamResolutionPlanRank(StreamResolutionPlan.Ultimate) + +internal fun hasHdrStreamingPlan(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): Boolean = + streamResolutionPlanRank(effectiveStreamingPlan(subscriptionInfo, fallbackMembershipTier)) >= + streamResolutionPlanRank(StreamResolutionPlan.Priority) + +internal fun maxStreamFpsFor(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): Int = + if (hasUltimateStreamingPlan(subscriptionInfo, fallbackMembershipTier)) MAX_ULTIMATE_STREAM_FPS else MAX_STANDARD_STREAM_FPS + +internal fun StreamSettings.withFpsAllowed(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): StreamSettings { + val maxFps = maxStreamFpsFor(subscriptionInfo, fallbackMembershipTier) + val allowedFps = fps.coerceIn(30, maxFps) + return if (allowedFps == fps) this else copy(fps = allowedFps) +} + +internal fun smartSessionLimitFor(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): SmartSessionLimit { + return when (effectiveStreamingPlan(subscriptionInfo, fallbackMembershipTier)) { + StreamResolutionPlan.Ultimate -> SmartSessionLimit("Ultimate", 8, SessionTimerMode.Stopwatch) + StreamResolutionPlan.Priority -> SmartSessionLimit("Performance", 6, SessionTimerMode.Stopwatch) + StreamResolutionPlan.Free -> SmartSessionLimit("Free", 1, SessionTimerMode.Countdown) + } +} + +internal fun monthlyHourLimitFor(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): Double? { + val reported = subscriptionInfo?.totalHours?.takeIf { it > 0.0 } + if (reported != null) return reported + return when (effectiveStreamingPlan(subscriptionInfo, fallbackMembershipTier)) { + StreamResolutionPlan.Free -> null + StreamResolutionPlan.Priority, + StreamResolutionPlan.Ultimate, + -> 100.0 + } +} + +internal fun monthlyHoursRemainingFor(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): Double? { + val reported = subscriptionInfo?.remainingHours?.takeIf { it > 0.0 } + if (reported != null) return reported + val limit = monthlyHourLimitFor(subscriptionInfo, fallbackMembershipTier) ?: return null + return (limit - (subscriptionInfo?.usedHours ?: 0.0)).coerceAtLeast(0.0) +} + +internal fun StreamSettings.withHdrAllowed(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): StreamSettings = + if (hdrEnabled && !hasHdrStreamingPlan(subscriptionInfo, fallbackMembershipTier)) copy(hdrEnabled = false).withCodecColorCompatibility() else withCodecColorCompatibility() + +internal fun VideoCodec.availableForAndroidSettings(): Boolean = + true + +internal fun ColorQuality.availableForAndroidSettings(): Boolean = + !isChroma444() + +internal fun ColorQuality.availableForCodec(codec: VideoCodec): Boolean = + availableForAndroidSettings() && codec.availableForAndroidSettings() + +internal fun StreamSettings.withAndroidSettingsAvailability(): StreamSettings { + val availableCodec = if (codec.availableForAndroidSettings()) codec else VideoCodec.H264 + val normalized = if (availableCodec == codec) this else copy(codec = availableCodec) + return normalized.withCodecColorCompatibility() +} + +internal fun StreamSettings.withCodecColorCompatibility(): StreamSettings { + val compatibleColor = when { + colorQuality.isChroma444() -> colorQuality.asChroma420() + hdrEnabled && !colorQuality.isTenBit() -> ColorQuality.TenBit420 + else -> colorQuality + } + return if (compatibleColor == colorQuality) this else copy(colorQuality = compatibleColor) +} + +internal fun StreamSettings.applyingStreamPreset(preset: StreamPreset): StreamSettings { + if (preset == StreamPreset.Custom) return this + val target = streamPresetTargetForAspect(preset, aspectRatio) + return copy( + resolution = target.resolution, + aspectRatio = target.aspectRatio, + fps = target.fps, + maxBitrateMbps = target.maxBitrateMbps, + colorQuality = ColorQuality.EightBit420, + hdrEnabled = false, + enableCloudGsync = false, + ).withAndroidSettingsAvailability() +} + +internal fun StreamSettings.withResolutionAllowed(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?): StreamSettings { + val customResolution = customStreamResolutionOrNull(resolution) + if (customResolution != null && customResolutionAllowedForPlan(customResolution, subscriptionInfo, fallbackMembershipTier)) { + val normalizedResolution = "${customResolution.first}x${customResolution.second}" + return if (normalizedResolution == resolution) this else copy(resolution = normalizedResolution) + } + + val allowedAspectRatio = if (streamResolutionChoicesForAspect(aspectRatio).any { it.isAvailableFor(subscriptionInfo, fallbackMembershipTier) }) { + aspectRatio + } else { + "16:9" + } + val allowedResolution = normalizeStreamResolutionForAspectAndPlan(resolution, allowedAspectRatio, subscriptionInfo, fallbackMembershipTier) + return if (allowedResolution == resolution && allowedAspectRatio == aspectRatio) this else copy(resolution = allowedResolution, aspectRatio = allowedAspectRatio) +} + +private fun isKnownStreamResolution(resolution: String): Boolean = + STREAM_RESOLUTION_OPTIONS.any { it.value == resolution } + +private fun customStreamResolutionOrNull(resolution: String): Pair? = + parseResolutionPixelsOrNull(resolution)?.takeUnless { + isKnownStreamResolution(resolution) || resolution in UNSUPPORTED_LEGACY_STREAM_RESOLUTIONS + } + +private val UNSUPPORTED_LEGACY_STREAM_RESOLUTIONS = setOf( + "1600x720", + "2400x1080", + "3200x1440", + "4800x2160", +) + +private fun customResolutionAllowedForPlan( + resolution: Pair, + subscriptionInfo: SubscriptionInfo?, + fallbackMembershipTier: String?, +): Boolean { + val availableChoices = STREAM_RESOLUTION_OPTIONS + .map { it.toChoice() } + .filter { it.isAvailableFor(subscriptionInfo, fallbackMembershipTier) } + if (availableChoices.isEmpty()) return false + + val (width, height) = resolution + val pixels = width * height + return width <= availableChoices.maxOf { it.width } && + height <= availableChoices.maxOf { it.height } && + pixels <= availableChoices.maxOf { it.width * it.height } +} + +internal val STREAM_RESOLUTION_OPTIONS = listOf( + StreamResolutionOption("1280x720", "16:9", "720"), + StreamResolutionOption("1366x768", "16:9", "768"), + StreamResolutionOption("1600x900", "16:9", "900"), + StreamResolutionOption("1280x800", "16:10", "720"), + StreamResolutionOption("1440x900", "16:10", "900"), + StreamResolutionOption("1680x1050", "16:10", "1050"), + StreamResolutionOption("1920x1080", "16:9", "1080"), + StreamResolutionOption("1920x1200", "16:10", "1080"), + StreamResolutionOption("1024x768", "4:3", "768"), + StreamResolutionOption("1112x834", "4:3", "834"), + StreamResolutionOption("1600x1200", "4:3", "1080"), + StreamResolutionOption("1280x1024", "5:4", "1050"), + StreamResolutionOption("1680x720", "21:9", "720"), + StreamResolutionOption("2560x1080", "21:9", "1080", StreamResolutionPlan.Priority), + StreamResolutionOption("3840x1080", "32:9", "1080", StreamResolutionPlan.Priority), + StreamResolutionOption("2560x1440", "16:9", "1440", StreamResolutionPlan.Priority), + StreamResolutionOption("2560x1600", "16:10", "1440", StreamResolutionPlan.Priority), + StreamResolutionOption("3440x1440", "21:9", "1440", StreamResolutionPlan.Priority), + StreamResolutionOption("5120x1440", "32:9", "1440", StreamResolutionPlan.Priority), + StreamResolutionOption("3840x1600", "24:10", "1440", StreamResolutionPlan.Priority), + StreamResolutionOption("3840x2160", "16:9", "2160", StreamResolutionPlan.Ultimate), + StreamResolutionOption("3456x2160", "16:10", "2160", StreamResolutionPlan.Ultimate), + StreamResolutionOption("5120x2160", "21:9", "2160", StreamResolutionPlan.Ultimate), + StreamResolutionOption("5120x2880", "16:9", "2880", StreamResolutionPlan.Ultimate), +) + +private val PREFERRED_RESOLUTION_BY_TIER_AND_ASPECT = mapOf( + "720" to mapOf("16:9" to "1280x720", "16:10" to "1280x800", "4:3" to "1024x768", "21:9" to "1680x720"), + "768" to mapOf("16:9" to "1366x768", "4:3" to "1024x768"), + "834" to mapOf("4:3" to "1112x834"), + "900" to mapOf("16:9" to "1600x900", "16:10" to "1440x900"), + "1050" to mapOf("16:10" to "1680x1050", "5:4" to "1280x1024"), + "1080" to mapOf("16:9" to "1920x1080", "16:10" to "1920x1200", "4:3" to "1600x1200", "21:9" to "2560x1080", "32:9" to "3840x1080"), + "1440" to mapOf("16:9" to "2560x1440", "16:10" to "2560x1600", "21:9" to "3440x1440", "24:10" to "3840x1600", "32:9" to "5120x1440"), + "2160" to mapOf("16:9" to "3840x2160", "16:10" to "3456x2160", "21:9" to "5120x2160"), + "2880" to mapOf("16:9" to "5120x2880"), +) + +private data class StreamPresetTarget( + val resolution: String, + val aspectRatio: String, + val fps: Int, + val maxBitrateMbps: Int, +) + +private fun streamPresetTargetForAspect(preset: StreamPreset, aspectRatio: String): StreamPresetTarget { + val normalizedAspect = aspectRatio.takeIf { streamResolutionOptionsForAspect(it).isNotEmpty() } ?: "16:9" + val maxHeight = when (preset) { + StreamPreset.Custom -> Int.MAX_VALUE + StreamPreset.Recommended -> 1200 + StreamPreset.LowDataSaver -> 800 + StreamPreset.Medium -> 1200 + StreamPreset.High -> 1600 + } + val options = STREAM_RESOLUTION_OPTIONS + .filter { it.aspectRatio == normalizedAspect } + .sortedBy { it.pixelCount() } + val resolution = options + .filter { parseResolutionPixels(it.value).second <= maxHeight } + .maxByOrNull { it.pixelCount() } + ?: options.firstOrNull() + ?: StreamResolutionOption("1280x720", "16:9", "720") + + return when (preset) { + StreamPreset.Custom -> StreamPresetTarget(resolution.value, resolution.aspectRatio, 60, 75) + StreamPreset.Recommended -> StreamPresetTarget(resolution.value, resolution.aspectRatio, 60, 35) + StreamPreset.LowDataSaver -> StreamPresetTarget(resolution.value, resolution.aspectRatio, 30, 12) + StreamPreset.Medium -> StreamPresetTarget(resolution.value, resolution.aspectRatio, 60, 35) + StreamPreset.High -> StreamPresetTarget(resolution.value, resolution.aspectRatio, MAX_ULTIMATE_STREAM_FPS, 75) + } +} + +private fun ColorQuality.isChroma444(): Boolean = + this == ColorQuality.EightBit444 || this == ColorQuality.TenBit444 + +private fun ColorQuality.isTenBit(): Boolean = + this == ColorQuality.TenBit420 || this == ColorQuality.TenBit444 + +private fun ColorQuality.asChroma420(): ColorQuality = + when (this) { + ColorQuality.TenBit444 -> ColorQuality.TenBit420 + ColorQuality.EightBit444 -> ColorQuality.EightBit420 + else -> this + } + +private fun resolutionTierForHeight(height: Int): String = + when { + height >= 2600 -> "2880" + height >= 2000 -> "2160" + height >= 1320 -> "1440" + height >= 1120 -> "1080" + height >= 975 -> "1050" + height >= 850 -> "900" + height >= 800 -> "834" + height >= 740 -> "768" + else -> "720" + } + +private fun planForMembershipTier(membershipTier: String?): StreamResolutionPlan { + val normalized = membershipTier.orEmpty().uppercase(Locale.US).replace(Regex("[^A-Z0-9]+"), "") + return when { + normalized.contains("ULTIMATE") || normalized.contains("RTX3080") -> StreamResolutionPlan.Ultimate + normalized.contains("PRIORITY") || normalized.contains("PERFORMANCE") || normalized.contains("FOUNDERS") -> StreamResolutionPlan.Priority + else -> StreamResolutionPlan.Free + } +} + +private fun effectiveStreamingPlan( + subscriptionInfo: SubscriptionInfo?, + fallbackMembershipTier: String?, +): StreamResolutionPlan = + listOf( + planForMembershipTier(subscriptionInfo?.membershipTier), + planForMembershipTier(fallbackMembershipTier), + ).maxBy { streamResolutionPlanRank(it) } + +private fun streamResolutionPlanRank(plan: StreamResolutionPlan): Int = + when (plan) { + StreamResolutionPlan.Free -> 0 + StreamResolutionPlan.Priority -> 1 + StreamResolutionPlan.Ultimate -> 2 + } + +@Serializable +data class ControllerThemeRgb( + val r: Int = 124, + val g: Int = 241, + val b: Int = 177, +) + +@Serializable +data class LoginProvider( + val idpId: String, + val code: String, + val displayName: String, + val streamingServiceUrl: String, + val priority: Int = 0, +) + +val LoginProvider.supportsDeviceCodeLogin: Boolean + get() = code.equals("NVIDIA", ignoreCase = true) + +@Serializable +data class AuthTokens( + val accessToken: String, + val refreshToken: String? = null, + val idToken: String? = null, + val expiresAt: Long, + val clientToken: String? = null, + val clientTokenExpiresAt: Long? = null, + val authClientId: String? = null, +) + +@Serializable +data class AuthUser( + val userId: String, + val displayName: String, + val email: String? = null, + val avatarUrl: String? = null, + val membershipTier: String = "FREE", +) + +@Serializable +data class AuthSession( + val provider: LoginProvider, + val tokens: AuthTokens, + val user: AuthUser, +) + +data class DeviceLoginPrompt( + val userCode: String, + val verificationUri: String, + val verificationUriComplete: String? = null, + val expiresAt: Long, +) + +@Serializable +data class SavedAccount( + val userId: String, + val displayName: String, + val email: String? = null, + val avatarUrl: String? = null, + val membershipTier: String = "FREE", + val providerCode: String = "NVIDIA", +) + +@Serializable +data class PersistedAuthState( + val sessions: List = emptyList(), + val activeUserId: String? = null, + val selectedProvider: LoginProvider? = null, +) + +@Serializable +data class StreamRegion( + val name: String, + val url: String, + val pingMs: Long? = null, +) + +@Serializable +data class EntitledResolution( + val width: Int, + val height: Int, + val fps: Int, +) + +@Serializable +data class StorageAddon( + val type: String = "PERMANENT_STORAGE", + val sizeGb: Double? = null, + val usedGb: Double? = null, + val regionName: String? = null, + val regionCode: String? = null, + val status: String? = null, + val subType: String? = null, + val autoPayEnabled: Boolean? = null, +) + +@Serializable +data class SubscriptionInfo( + val membershipTier: String = "FREE", + val subscriptionType: String? = null, + val subscriptionSubType: String? = null, + val allottedHours: Double = 0.0, + val purchasedHours: Double = 0.0, + val rolledOverHours: Double = 0.0, + val usedHours: Double = 0.0, + val remainingHours: Double = 0.0, + val totalHours: Double = 0.0, + val state: String? = null, + val isGamePlayAllowed: Boolean? = null, + val isUnlimited: Boolean = false, + val storageAddon: StorageAddon? = null, + val entitledResolutions: List = emptyList(), +) + +@Serializable +data class AccountConnector( + val store: String, + val label: String, + val supported: Boolean = true, + val required: Boolean = false, + val userDisplayName: String? = null, + val userIdentifier: String? = null, + val expiresInSeconds: Long? = null, + val syncedGameCount: Int? = null, + val syncState: String? = null, + val syncDate: String? = null, +) + +val AccountConnector.isLinked: Boolean + get() = !userDisplayName.isNullOrBlank() || + !userIdentifier.isNullOrBlank() || + expiresInSeconds != null || + syncedGameCount != null || + !syncState.isNullOrBlank() || + !syncDate.isNullOrBlank() + +@Immutable +@Serializable +data class GameVariant( + val id: String, + val store: String, + val supportedControls: List = emptyList(), + val librarySelected: Boolean? = null, + val libraryStatus: String? = null, + val lastPlayedDate: String? = null, + val gfnStatus: String? = null, +) + +@Immutable +@Serializable +data class GameInfo( + val id: String, + val uuid: String? = null, + val launchAppId: String? = null, + val title: String, + val catalogSectionId: String? = null, + val catalogSectionTitle: String? = null, + val description: String? = null, + val longDescription: String? = null, + val featureLabels: List = emptyList(), + val genres: List = emptyList(), + val imageUrl: String? = null, + val tvCardImageUrl: String? = null, + val screenshotUrl: String? = null, + val screenshotUrls: List = emptyList(), + val tvBannerUrl: String? = null, + val playType: String? = null, + val membershipTierLabel: String? = null, + val publisherName: String? = null, + val contentRatings: List = emptyList(), + val playabilityState: String? = null, + val availableStores: List = emptyList(), + val searchText: String? = null, + val lastPlayed: String? = null, + val isInLibrary: Boolean = false, + val selectedVariantIndex: Int = 0, + val variants: List = emptyList(), +) + +private val primaryCatalogStoreKeys = setOf( + "STEAM", + "EPIC", + "EPIC_GAMES_STORE", + "EGS", + "XBOX", + "XBOX_GAME_PASS", + "MICROSOFT", + "MICROSOFT_STORE", +) + +private val ownedLibraryStatuses = setOf("MANUAL", "PLATFORM_SYNC", "IN_LIBRARY") + +internal fun isOwnedLibraryStatus(status: String?): Boolean = + status in ownedLibraryStatuses + +internal fun isOwnedGameVariant(variant: GameVariant): Boolean = + isOwnedLibraryStatus(variant.libraryStatus) + +internal fun isGameInLibrary(game: GameInfo): Boolean = + game.isInLibrary || game.variants.any(::isOwnedGameVariant) + +internal fun gameTrackingKey(game: GameInfo): String = + game.uuid?.takeIf { it.isNotBlank() } + ?: game.launchAppId?.takeIf { it.isNotBlank() } + ?: game.id + +internal fun shouldLaunchWithAccountLinked(game: GameInfo, selectedVariant: GameVariant?): Boolean { + if (game.playType == "INSTALL_TO_PLAY") return false + if (selectedVariant?.let(::isOwnedGameVariant) == true) return true + return isGameInLibrary(game) +} + +internal fun mergeKnownLibraryGames(vararg groups: List): List { + val byKey = linkedMapOf() + for (game in groups.flatMap { it }) { + if (!isGameInLibrary(game)) continue + val key = game.uuid ?: game.id + val existing = byKey[key] + byKey[key] = if (existing == null) game.copy(isInLibrary = true) else mergeGameInfo(existing, game).copy(isInLibrary = true) + } + return byKey.values.toList() +} + +internal fun mergePanelGameWithMetadata(panelGame: GameInfo, metadataGame: GameInfo): GameInfo = + mergeGameInfo(panelGame, metadataGame).copy( + imageUrl = metadataGame.imageUrl ?: panelGame.imageUrl, + tvCardImageUrl = metadataGame.tvCardImageUrl ?: panelGame.tvCardImageUrl, + screenshotUrl = metadataGame.screenshotUrl ?: panelGame.screenshotUrl, + screenshotUrls = (metadataGame.screenshotUrls + panelGame.screenshotUrls).distinct(), + tvBannerUrl = metadataGame.tvBannerUrl ?: panelGame.tvBannerUrl, + ) + +internal fun normalizeGameStore(store: String): String = + store.uppercase(Locale.US).replace(Regex("[\\s-]+"), "_") + +internal fun splitGameStoreKeys(store: String): List = + store.split(",") + .map { normalizeGameStore(it.trim()) } + .filter { it.isNotBlank() } + +internal fun isPrimaryCatalogStoreValue(store: String): Boolean { + val storeKeys = splitGameStoreKeys(store) + return storeKeys.isNotEmpty() && storeKeys.all { it in primaryCatalogStoreKeys } +} + +internal fun gameStoreDisplayName(store: String): String { + val parts = store.split(",") + .map { it.trim() } + .filter { it.isNotBlank() } + .ifEmpty { listOf(store.trim()) } + return parts.map { part -> + when (normalizeGameStore(part)) { + "EPIC", "EGS", "EPIC_GAMES_STORE" -> "Epic" + "STEAM" -> "Steam" + "XBOX", "XBOX_GAME_PASS" -> "Xbox" + "MICROSOFT", "MICROSOFT_STORE" -> "Microsoft Store" + else -> part.replace('_', ' ').lowercase(Locale.US) + .split(Regex("\\s+")) + .filter { it.isNotBlank() } + .joinToString(" ") { word -> word.replaceFirstChar { char -> char.titlecase(Locale.US) } } + .ifBlank { "Unknown" } + } + }.distinct().joinToString(" / ") +} + +internal fun launchableGameVariants(variants: List): List { + val uniqueVariants = variants.distinctBy { it.id } + val individualPrimaryStores = uniqueVariants + .map { splitGameStoreKeys(it.store) } + .filter { it.size == 1 && it.first() in primaryCatalogStoreKeys } + .flatten() + .toSet() + val filtered = uniqueVariants.filterNot { variant -> + val storeKeys = splitGameStoreKeys(variant.store) + storeKeys.size > 1 && storeKeys.all { it in individualPrimaryStores } + } + val byStore = linkedMapOf() + for (variant in filtered) { + val storeKey = splitGameStoreKeys(variant.store).joinToString(",").ifBlank { normalizeGameStore(variant.store) } + val existing = byStore[storeKey] + if (existing == null || variantLaunchRank(variant) > variantLaunchRank(existing)) { + byStore[storeKey] = variant + } + } + return byStore.values.toList() +} + +internal fun displayStoresForVariants(variants: List): List = + launchableGameVariants(variants) + .flatMap { variant -> gameStoreDisplayName(variant.store).split(" / ") } + .map { it.trim() } + .filter { it.isNotBlank() } + .distinctBy { normalizeGameStore(it) } + +internal fun libraryStoreDisplayNames(game: GameInfo): List { + val variants = when { + game.variants.any(::isOwnedGameVariant) -> game.variants.filter(::isOwnedGameVariant) + game.isInLibrary -> listOfNotNull(game.variants.firstOrNull { it.librarySelected == true }) + .ifEmpty { listOfNotNull(game.variants.getOrNull(game.selectedVariantIndex)) } + .ifEmpty { game.variants.take(1) } + else -> emptyList() + } + val variantStores = variants + .flatMap { variant -> gameStoreDisplayName(variant.store).split(" / ") } + .map { it.trim() } + .filter { it.isNotBlank() } + .distinctBy { normalizeGameStore(it) } + if (variantStores.isNotEmpty()) return variantStores + if (!game.isInLibrary) return emptyList() + return game.availableStores + .map(::gameStoreDisplayName) + .map { it.trim() } + .filter { it.isNotBlank() } + .distinctBy { normalizeGameStore(it) } +} + +private fun mergeGameInfo(left: GameInfo, right: GameInfo): GameInfo { + val variants = (left.variants + right.variants).distinctBy { it.id } + val selectedVariantId = left.variants.getOrNull(left.selectedVariantIndex)?.id + ?: right.variants.getOrNull(right.selectedVariantIndex)?.id + val selectedIndex = selectedVariantId?.let { id -> variants.indexOfFirst { it.id == id } } ?: -1 + return left.copy( + uuid = left.uuid ?: right.uuid, + launchAppId = left.launchAppId ?: right.launchAppId, + description = left.description ?: right.description, + longDescription = left.longDescription ?: right.longDescription, + imageUrl = left.imageUrl ?: right.imageUrl, + tvCardImageUrl = left.tvCardImageUrl ?: right.tvCardImageUrl, + screenshotUrl = left.screenshotUrl ?: right.screenshotUrl, + screenshotUrls = (left.screenshotUrls + right.screenshotUrls).distinct(), + tvBannerUrl = left.tvBannerUrl ?: right.tvBannerUrl, + playType = left.playType ?: right.playType, + membershipTierLabel = left.membershipTierLabel ?: right.membershipTierLabel, + publisherName = left.publisherName ?: right.publisherName, + contentRatings = (left.contentRatings + right.contentRatings).distinct(), + playabilityState = left.playabilityState ?: right.playabilityState, + variants = variants, + availableStores = displayStoresForVariants(variants), + genres = (left.genres + right.genres).distinct(), + featureLabels = (left.featureLabels + right.featureLabels).distinct(), + searchText = listOfNotNull(left.searchText, right.searchText).joinToString(" ").ifBlank { null }, + lastPlayed = left.lastPlayed ?: right.lastPlayed, + isInLibrary = left.isInLibrary || right.isInLibrary, + selectedVariantIndex = if (selectedIndex >= 0) selectedIndex else left.selectedVariantIndex.coerceAtMost(max(variants.size - 1, 0)), + ) +} + +private fun variantLaunchRank(variant: GameVariant): Int = + when { + variant.librarySelected == true && isOwnedGameVariant(variant) -> 4 + isOwnedGameVariant(variant) -> 3 + variant.id.all(Char::isDigit) -> 2 + else -> 1 + } + +@Serializable +data class CatalogFilterOption( + val id: String, + val rawId: String, + val label: String, + val groupId: String, + val groupLabel: String, +) + +@Serializable +data class CatalogFilterGroup( + val id: String, + val label: String, + val options: List, +) + +@Serializable +data class CatalogSortOption( + val id: String, + val label: String, + val orderBy: String, +) + +@Serializable +data class CatalogBrowseResult( + val games: List, + val numberReturned: Int = games.size, + val numberSupported: Int = games.size, + val totalCount: Int = games.size, + val hasNextPage: Boolean = false, + val endCursor: String? = null, + val searchQuery: String = "", + val selectedSortId: String = "relevance", + val selectedFilterIds: List = emptyList(), + val filterGroups: List = emptyList(), + val sortOptions: List = emptyList(), +) + +@Serializable +data class PrintedWasteZone( + val QueuePosition: Int, + val LastUpdated: Long = 0, + val Region: String, + val eta: Long? = null, +) + +@Serializable +data class PrintedWasteServerMappingEntry( + val title: String? = null, + val region: String? = null, + val is4080Server: Boolean? = null, + val is5080Server: Boolean? = null, + val nuked: Boolean? = null, +) + +@Serializable +data class PingResult( + val url: String, + val pingMs: Long? = null, + val error: String? = null, +) + +@Serializable +data class IceServer( + val urls: List, + val username: String? = null, + val credential: String? = null, +) + +@Serializable +data class MediaConnectionInfo( + val ip: String, + val port: Int, +) + +@Serializable +data class NegotiatedStreamProfile( + val resolution: String? = null, + val fps: Int? = null, + val codec: VideoCodec? = null, + val colorQuality: ColorQuality? = null, + val enableL4S: Boolean? = null, + val enableCloudGsync: Boolean? = null, + val enableReflex: Boolean? = null, +) + +@Serializable +data class SessionMonitorSnapshot( + val requestedResolution: String? = null, + val requestedFps: Int? = null, + val returnedResolution: String? = null, + val returnedFps: Int? = null, + val finalSelectedResolution: String? = null, +) + +@Serializable +data class SessionAdMediaFile( + val mediaFileUrl: String? = null, + val encodingProfile: String? = null, +) + +@Serializable +data class SessionAdInfo( + val adId: String, + val state: Int? = null, + val adState: Int? = null, + val adUrl: String? = null, + val mediaUrl: String? = null, + val adMediaFiles: List = emptyList(), + val clickThroughUrl: String? = null, + val adLengthInSeconds: Double? = null, + val durationMs: Long? = null, + val title: String? = null, + val description: String? = null, +) + +@Serializable +data class SessionOpportunityInfo( + val state: String? = null, + val queuePaused: Boolean? = null, + val gracePeriodSeconds: Int? = null, + val message: String? = null, + val title: String? = null, + val description: String? = null, +) + +@Serializable +data class SessionAdState( + val isAdsRequired: Boolean = false, + val sessionAdsRequired: Boolean? = null, + val isQueuePaused: Boolean? = null, + val gracePeriodSeconds: Int? = null, + val message: String? = null, + val sessionAds: List = emptyList(), + val ads: List = emptyList(), + val opportunity: SessionOpportunityInfo? = null, + val serverSentEmptyAds: Boolean = false, +) + +@Serializable +data class StreamingFeatures( + val reflex: Boolean? = null, + val bitDepth: Int? = null, + val cloudGsync: Boolean? = null, + val chromaFormat: Int? = null, + val enabledL4S: Boolean? = null, + val trueHdr: Boolean? = null, +) + +@Serializable +data class SessionInfo( + val sessionId: String, + val status: Int, + val timerStartedAtMs: Long? = null, + val queuePosition: Int? = null, + val seatSetupStep: Int? = null, + val adState: SessionAdState? = null, + val zone: String = "", + val streamingBaseUrl: String? = null, + val serverIp: String, + val signalingServer: String, + val signalingUrl: String, + val gpuType: String? = null, + val iceServers: List = emptyList(), + val mediaConnectionInfo: MediaConnectionInfo? = null, + val negotiatedStreamProfile: NegotiatedStreamProfile? = null, + val monitorSnapshot: SessionMonitorSnapshot? = null, + val requestedStreamingFeatures: StreamingFeatures? = null, + val finalizedStreamingFeatures: StreamingFeatures? = null, + val clientId: String? = null, + val deviceId: String? = null, +) + +@Serializable +data class ActiveSessionInfo( + val sessionId: String, + val appId: Int, + val gpuType: String? = null, + val status: Int, + val queuePosition: Int? = null, + val seatSetupStep: Int? = null, + val streamingBaseUrl: String? = null, + val serverIp: String? = null, + val signalingUrl: String? = null, + val resolution: String? = null, + val fps: Int? = null, + val settingsSignature: String? = null, +) + +internal fun SessionInfo.isReadyForStream(): Boolean = + status in setOf(2, 3) && + serverIp.isNotBlank() && + signalingServer.isNotBlank() && + signalingUrl.isNotBlank() + +internal fun ActiveSessionInfo.isReadyForClaim(): Boolean = + status in setOf(2, 3) && !serverIp.isNullOrBlank() + +internal fun ActiveSessionInfo.matchesStreamGeometry(settings: StreamSettings): Boolean { + val activeResolution = parseResolutionPixelsOrNull(resolution) + val expectedResolution = streamResolutionPixels(settings) + val activeFps = fps?.takeIf { it > 0 } + return activeResolution == expectedResolution && activeFps == settings.fps +} + +internal fun ActiveSessionInfo.matchesStreamSettings(settings: StreamSettings): Boolean = + settingsSignature == streamSettingsSessionSignature(settings) && matchesStreamGeometry(settings) + +internal fun activeSessionRecoveryCandidate( + sessions: List, + previousSessionId: String, + launchAppId: Int?, + settings: StreamSettings, +): ActiveSessionInfo? { + val readySessions = sessions.filter { it.isReadyForClaim() } + return readySessions.firstOrNull { + it.sessionId == previousSessionId && it.matchesStreamGeometry(settings) + } ?: launchAppId?.let { appId -> + readySessions.firstOrNull { + it.appId == appId && it.matchesStreamSettings(settings) + } + } +} + +internal fun activeSessionLaunchConflict( + sessions: List, + launchAppId: Int?, + settings: StreamSettings, +): ActiveSessionInfo? = + sessions + .filter { it.status in setOf(1, 2, 3) } + .sortedWith( + compareByDescending { launchAppId != null && it.appId == launchAppId } + .thenByDescending { it.matchesStreamSettings(settings) } + .thenByDescending { it.isReadyForClaim() } + .thenBy { it.queuePosition ?: Int.MAX_VALUE }, + ) + .firstOrNull() + +internal fun parseResolutionPixelsOrNull(value: String?): Pair? { + val parts = value?.split("x") ?: return null + val width = parts.getOrNull(0)?.toIntOrNull() + val height = parts.getOrNull(1)?.toIntOrNull() + return if (width != null && height != null && width > 0 && height > 0) width to height else null +} + +data class CodecCapability( + val codec: VideoCodec, + val decoderAvailable: Boolean, + val encoderAvailable: Boolean, + val hardwareDecoder: Boolean, + val hardwareEncoder: Boolean, + val decoderName: String? = null, + val encoderName: String? = null, + val realtimeSafe: Boolean = hardwareDecoder, + val nativeDecoderAvailable: Boolean? = null, + val webRtcDecoderAvailable: Boolean? = null, + val webRtcHardwareDecoderAvailable: Boolean? = null, + val webRtcDecoderName: String? = null, + val webRtcCodecProfiles: List = emptyList(), + val maxSupportedWidth: Int? = null, + val maxSupportedHeight: Int? = null, + val maxFpsByResolution: Map = emptyMap(), +) + +data class RuntimeCodecReport( + val capabilities: List, + val nativeRuntimeSummary: String, + val androidTvProfile: Boolean, + val lowPowerGpuProfile: Boolean, + val constrainedRuntimeProfile: Boolean = false, +) + +data class StreamRuntimeStats( + val bitrateKbps: Int? = null, + val pingMs: Int? = null, + val fps: Int? = null, + val resolution: String? = null, + val codec: String? = null, + val decodeMs: Double? = null, + val jitterMs: Double? = null, + val packetLossPct: Double? = null, + val packetsLostDelta: Long? = null, + val packetsReceivedDelta: Long? = null, +) + +internal fun CodecCapability.streamingDecoderAvailable(): Boolean = + webRtcDecoderAvailable ?: decoderAvailable + +internal fun CodecCapability.streamingHardwareDecoderAvailable(): Boolean = + webRtcHardwareDecoderAvailable ?: (nativeDecoderAvailable?.let { it && hardwareDecoder } ?: hardwareDecoder) + +internal fun CodecCapability.streamingDecoderName(): String? = + webRtcDecoderName ?: decoderName + +internal fun CodecCapability.streamingRealtimeSafe(): Boolean = + streamingDecoderUsableForLaunch() + +internal fun CodecCapability.streamingDecoderUsableForLaunch(): Boolean { + if (codec == VideoCodec.H264) return webRtcDecoderAvailable ?: decoderAvailable + + // The stream is decoded by the WebRTC decoder factory, so its successful hardware + // probe is authoritative. The Media NDK probe is only a secondary diagnostic and can + // legitimately disagree on devices whose codec is exposed through WebRTC's factory. + if (webRtcDecoderAvailable != null) { + return webRtcDecoderAvailable && webRtcHardwareDecoderAvailable == true + } + + return nativeDecoderAvailable == true && hardwareDecoder && realtimeSafe +} + +private fun RuntimeCodecReport.bestStreamingFallbackCodec(): VideoCodec = + listOf(VideoCodec.H264, VideoCodec.H265, VideoCodec.AV1) + .firstOrNull { codec -> capabilities.firstOrNull { it.codec == codec }?.streamingDecoderUsableForLaunch() == true } + ?: VideoCodec.H264 + +internal fun StreamSettings.adjustedForDevice(report: RuntimeCodecReport?): StreamSettings { + val availableSettings = withAndroidSettingsAvailability() + if (availableSettings != this) return availableSettings.adjustedForDevice(report) + + if ( + report?.androidTvProfile == true && + report.lowPowerGpuProfile && + !report.constrainedRuntimeProfile + ) { + val requestedCapability = report.capabilities.firstOrNull { it.codec == codec } + val requestedCodecUsable = requestedCapability?.streamingDecoderUsableForLaunch() ?: (codec == VideoCodec.H264) + val usableCodec = if (requestedCodecUsable) codec else report.bestStreamingFallbackCodec() + val effectiveCodec = if (report.capabilities.firstOrNull { it.codec == usableCodec }.supportsStreamResolution(this) != false) { + usableCodec + } else { + report.bestStreamingCodecForResolution(copy(codec = usableCodec)) ?: usableCodec + } + val effectiveCapability = report.capabilities.firstOrNull { it.codec == effectiveCodec } + val lowPowerProfile = copy( + codec = effectiveCodec, + colorQuality = ColorQuality.EightBit420, + maxBitrateMbps = minOf(maxBitrateMbps, LOW_POWER_TV_BITRATE_CAP_MBPS), + fps = minOf(fps, LOW_POWER_TV_FPS_CAP), + hdrEnabled = false, + enableCloudGsync = false, + ).withStableAndroidCloudMatchProfile() + .withoutAndroidTvSharpening(report) + return if (effectiveCapability.launchResolutionSupport(lowPowerProfile) == true) { + lowPowerProfile.copy(resolution = normalizeStreamResolutionForAspect(resolution, aspectRatio)) + } else { + lowPowerProfile.cappedResolution(LOW_POWER_TV_MAX_WIDTH, LOW_POWER_TV_MAX_HEIGHT, strict = false) + } + } + + val capability = report?.capabilities?.firstOrNull { it.codec == codec } + val codecSupported = capability?.streamingDecoderUsableForLaunch() ?: true + val effectiveCodec = when { + !codecSupported -> report.bestStreamingFallbackCodec() + capability.supportsStreamResolution(this) != false -> codec + else -> report?.bestStreamingCodecForResolution(this) ?: codec + } + + val profileBitrateCap = when { + !codecSupported -> 35 + report?.constrainedRuntimeProfile == true -> 75 + report?.lowPowerGpuProfile == true -> 25 + report?.androidTvProfile == true -> 35 + effectiveCodec == VideoCodec.H264 -> 75 + else -> 75 + } + + val adjusted = (if (effectiveCodec == codec) this else copy(codec = effectiveCodec)).withCodecColorCompatibility() + val effectiveCapability = report?.capabilities?.firstOrNull { it.codec == effectiveCodec } + val maxWidth = effectiveCapability?.maxSupportedWidth + val maxHeight = effectiveCapability?.maxSupportedHeight + + val capped = when (effectiveCodec) { + VideoCodec.H264 -> adjusted.copy(colorQuality = ColorQuality.EightBit420, maxBitrateMbps = minOf(adjusted.maxBitrateMbps, profileBitrateCap)) + VideoCodec.H265, + VideoCodec.AV1 -> adjusted.copy( + colorQuality = adjusted.androidWebRtcColorQuality(), + maxBitrateMbps = minOf(adjusted.maxBitrateMbps, profileBitrateCap), + ) + }.withStableAndroidCloudMatchProfile() + .withoutAndroidTvSharpening(report) + + val capabilityCap = if (effectiveCapability.launchResolutionSupport(capped) == true) { + capped.copy(resolution = normalizeStreamResolutionForAspect(capped.resolution, capped.aspectRatio)) + } else if (maxWidth != null && maxHeight != null) { + capped.cappedResolution(maxWidth, maxHeight, strict = false) + } else { + capped + } + + val maxSupportedFps = effectiveCapability?.maxFpsByResolution?.get(capabilityCap.resolution) + return capabilityCap.copy( + fps = cloudStreamFpsAtOrBelow( + requestedFps = capabilityCap.fps, + decoderMaxFps = maxSupportedFps, + ), + ) +} + +internal fun cloudStreamFpsAtOrBelow(requestedFps: Int, decoderMaxFps: Int?): Int { + if (decoderMaxFps == null) return requestedFps + val cappedFps = minOf(requestedFps, decoderMaxFps) + return ((cappedFps / STREAM_FPS_STEP) * STREAM_FPS_STEP).coerceAtLeast(MIN_STREAM_FPS) +} + +private fun RuntimeCodecReport.bestStreamingCodecForResolution(settings: StreamSettings): VideoCodec? = + listOf(VideoCodec.H265, VideoCodec.AV1, VideoCodec.H264) + .asSequence() + .filter { it != settings.codec } + .mapNotNull { candidate -> capabilities.firstOrNull { it.codec == candidate } } + .firstOrNull { capability -> + capability.streamingDecoderUsableForLaunch() && capability.supportsStreamResolution(settings) == true + } + ?.codec + +private fun CodecCapability?.launchResolutionSupport(settings: StreamSettings): Boolean? { + this ?: return null + val probedSupport = supportsStreamResolution(settings) + if (probedSupport == true) return true + + val normalized = normalizeStreamResolutionForAspect(settings.resolution, settings.aspectRatio) + val (width, height) = parseResolutionPixels(normalized) + // Some Android TV codec implementations omit or underreport VideoCapabilities even + // though WebRTC successfully opens their hardware decoder. Honor the selected 1440p + // profile in that confirmed path; keep higher unknown profiles on the safety cap. + val confirmedHardware1440pPath = streamingDecoderUsableForLaunch() && + streamingHardwareDecoderAvailable() && + width * height <= ANDROID_1440P_PIXEL_BUDGET + return if (confirmedHardware1440pPath) true else probedSupport +} + +private fun CodecCapability?.supportsStreamResolution(settings: StreamSettings): Boolean? { + this ?: return null + val maxWidth = maxSupportedWidth ?: return null + val maxHeight = maxSupportedHeight ?: return null + val normalized = normalizeStreamResolutionForAspect(settings.resolution, settings.aspectRatio) + val (width, height) = parseResolutionPixels(normalized) + val maxPixelCount = (maxWidth * maxHeight * DECODER_RESOLUTION_HEADROOM).roundToInt() + return width <= maxWidth * 2 && + height <= maxHeight * 2 && + width * height <= maxPixelCount +} + +internal fun StreamSettings.androidSafeVideoFallback(): StreamSettings = + copy( + fps = minOf(fps, 60), + maxBitrateMbps = minOf(maxBitrateMbps, 75), + codec = VideoCodec.H264, + colorQuality = ColorQuality.EightBit420, + hdrEnabled = false, + enableCloudGsync = false, + streamSharpeningEnabled = false, + ) + +private fun StreamSettings.androidWebRtcColorQuality(): ColorQuality { + val compatible = withCodecColorCompatibility() + if (compatible.hdrEnabled) return when (compatible.colorQuality) { + ColorQuality.TenBit420, + ColorQuality.TenBit444, + -> compatible.colorQuality + else -> ColorQuality.TenBit420 + } + return when (compatible.colorQuality) { + ColorQuality.EightBit420, + ColorQuality.EightBit444, + -> compatible.colorQuality + else -> ColorQuality.EightBit420 + } +} + +private fun StreamSettings.withStableAndroidCloudMatchProfile(): StreamSettings { + return copy( + fps = minOf(fps, MAX_ULTIMATE_STREAM_FPS), + hdrEnabled = hdrEnabled && codec != VideoCodec.H264, + enableCloudGsync = enableCloudGsync && codec != VideoCodec.H264, + ) +} + +internal fun StreamSettings.lowPowerPerformanceWarningReasons(report: RuntimeCodecReport?): List { + if (report?.lowPowerGpuProfile != true && report?.constrainedRuntimeProfile != true) return emptyList() + + val normalizedResolution = normalizeStreamResolutionForAspect(resolution, aspectRatio) + val (width, height) = parseResolutionPixels(normalizedResolution) + return buildList { + if (width * height > LOW_POWER_RECOMMENDED_PIXEL_COUNT) add("$normalizedResolution resolution") + if (fps > LOW_POWER_RECOMMENDED_FPS) add("$fps FPS") + if (maxBitrateMbps > LOW_POWER_RECOMMENDED_BITRATE_MBPS) add("$maxBitrateMbps Mbps bitrate") + if (hdrEnabled) add("HDR") + if (enableCloudGsync) add("Cloud G-Sync") + if (streamSharpeningEnabled) add("stream sharpening") + } +} + +private fun StreamSettings.withoutAndroidTvSharpening(report: RuntimeCodecReport?): StreamSettings = + if ( + report?.androidTvProfile == true && + report.constrainedRuntimeProfile == false && + streamSharpeningEnabled + ) { + copy(streamSharpeningEnabled = false) + } else { + this + } + +private fun StreamSettings.cappedResolution(maxWidth: Int, maxHeight: Int, strict: Boolean = false): StreamSettings { + val normalized = normalizeStreamResolutionForAspect(resolution, aspectRatio) + val (width, height) = parseResolutionPixels(normalized) + + val fits = if (strict) { + width <= maxWidth && height <= maxHeight + } else { + val maxPixelCount = (maxWidth * maxHeight * DECODER_RESOLUTION_HEADROOM).roundToInt() + width <= maxWidth * 2 && height <= maxHeight * 2 && (width * height) <= maxPixelCount + } + + if (fits) return copy(resolution = normalized) + + val sameAspect = STREAM_RESOLUTION_OPTIONS + .filter { it.aspectRatio == aspectRatio && it.fitsWithin(maxWidth, maxHeight, strict) } + .maxByOrNull { it.pixelCount() } + val fallback = STREAM_RESOLUTION_OPTIONS + .filter { it.fitsWithin(maxWidth, maxHeight, strict) } + .maxWithOrNull(compareBy { it.pixelCount() }.thenBy { if (it.aspectRatio == "16:9") 1 else 0 }) + val capped = sameAspect ?: fallback ?: StreamResolutionOption("1280x720", "16:9", "720") + return copy(resolution = capped.value, aspectRatio = capped.aspectRatio) +} + +private fun StreamResolutionOption.fitsWithin(maxWidth: Int, maxHeight: Int, strict: Boolean = false): Boolean { + val (width, height) = parseResolutionPixels(value) + return if (strict) { + width <= maxWidth && height <= maxHeight + } else { + val maxPixelCount = (maxWidth * maxHeight * DECODER_RESOLUTION_HEADROOM).roundToInt() + width <= maxWidth * 2 && height <= maxHeight * 2 && (width * height) <= maxPixelCount + } +} + +private fun StreamResolutionOption.pixelCount(): Int { + val (width, height) = parseResolutionPixels(value) + return width * height +} + +private const val LOW_POWER_TV_MAX_WIDTH = 1920 +private const val LOW_POWER_TV_MAX_HEIGHT = 1080 +private const val LOW_POWER_TV_BITRATE_CAP_MBPS = 25 +private const val LOW_POWER_TV_FPS_CAP = 60 +private const val MAX_STANDARD_STREAM_FPS = 60 +private const val MAX_ULTIMATE_STREAM_FPS = 240 +private const val MIN_STREAM_FPS = 30 +private const val STREAM_FPS_STEP = 30 +private const val LOW_POWER_RECOMMENDED_PIXEL_COUNT = 1280 * 720 +private const val LOW_POWER_RECOMMENDED_FPS = 30 +private const val LOW_POWER_RECOMMENDED_BITRATE_MBPS = 12 +private const val ANDROID_1440P_PIXEL_BUDGET = 2560 * 1440 +private const val DECODER_RESOLUTION_HEADROOM = 1.4f diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowAnalytics.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowAnalytics.kt new file mode 100644 index 000000000..e2affaeaf --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowAnalytics.kt @@ -0,0 +1,143 @@ +package com.opencloudgaming.opennow + +import android.app.Application +import android.util.Log +import com.posthog.PostHog +import com.posthog.android.PostHogAndroid +import com.posthog.android.PostHogAndroidConfig + +private const val ANALYTICS_LOG_TAG = "OpenNowAnalytics" +private const val ANALYTICS_FLUSH_INTERVAL_SECONDS = 10 + +internal object OpenNowAnalytics { + fun setup(application: Application, settings: AppSettings) { + val token = BuildConfig.POSTHOG_PROJECT_TOKEN.trim() + if (token.isEmpty()) { + Log.w(ANALYTICS_LOG_TAG, "PostHog disabled because no project token is configured.") + return + } + + val config = PostHogAndroidConfig( + apiKey = token, + host = BuildConfig.POSTHOG_HOST, + ).apply { applyOpenNowSettings(settings) } + + runCatching { + PostHogAndroid.setup(application, config) + applyOptOut(!settings.analyticsSharingEnabled) + }.onFailure { error -> + Log.w(ANALYTICS_LOG_TAG, "PostHog setup failed.", error) + } + } + + fun applyOptOut(optedOut: Boolean) { + runPostHogOperation("opt-out update") { + if (optedOut) { + PostHog.optOut() + } else { + PostHog.optIn() + } + } + } + + fun capture(event: String, properties: Map? = null) { + runPostHogOperation("capture") { + PostHog.capture( + event = event, + properties = sanitizedAnalyticsProperties(properties), + ) + flushReleaseQueue() + } + } + + fun reset() { + runPostHogOperation("reset") { + val optedOut = PostHog.isOptOut() + PostHog.reset() + applyOptOut(optedOut) + } + } + + private fun flushReleaseQueue() { + if (BuildConfig.DEBUG) return + PostHog.flush() + } + + private inline fun runPostHogOperation(operation: String, block: () -> Unit) { + runCatching(block).onFailure { error -> + Log.w(ANALYTICS_LOG_TAG, "PostHog $operation failed.", error) + } + } +} + +internal fun PostHogAndroidConfig.applyOpenNowSettings(settings: AppSettings) { + optOut = !settings.analyticsSharingEnabled + captureApplicationLifecycleEvents = true + captureDeepLinks = false + captureScreenViews = true + flushIntervalSeconds = ANALYTICS_FLUSH_INTERVAL_SECONDS + sessionReplay = false + sessionReplayConfig.apply { + maskAllTextInputs = true + maskAllImages = true + screenshot = false + captureLogcat = false + } + errorTrackingConfig.autoCapture = true + addBeforeSend { event -> + event.copy( + properties = sanitizedAnalyticsProperties( + properties = event.properties, + redactExceptionText = event.event == "\$exception", + ).toMutableMap(), + ) + } +} + +internal fun sanitizedAnalyticsProperties( + properties: Map?, + redactExceptionText: Boolean = false, +): Map = + buildMap { + put("\$geoip_disable", true) + properties.orEmpty().forEach { (key, value) -> + if (!isSensitiveAnalyticsProperty(key, redactExceptionText)) { + put(key, sanitizeAnalyticsValue(value, redactExceptionText)) + } + } + } + +private fun sanitizeAnalyticsValue(value: Any, redactExceptionText: Boolean): Any = + when (value) { + is String -> sanitizeDiagnosticExport(value).take(500) + is Map<*, *> -> value.entries + .mapNotNull { (key, nestedValue) -> + val stringKey = key as? String ?: return@mapNotNull null + val presentValue = nestedValue ?: return@mapNotNull null + stringKey.takeUnless { isSensitiveAnalyticsProperty(it, redactExceptionText) } + ?.let { it to sanitizeAnalyticsValue(presentValue, redactExceptionText) } + } + .toMap() + is Iterable<*> -> value.mapNotNull { it?.let { item -> sanitizeAnalyticsValue(item, redactExceptionText) } } + is Array<*> -> value.mapNotNull { it?.let { item -> sanitizeAnalyticsValue(item, redactExceptionText) } } + else -> value + } + +private fun isSensitiveAnalyticsProperty(key: String, redactExceptionText: Boolean): Boolean { + val normalized = key.lowercase().filter(Char::isLetterOrDigit) + return (redactExceptionText && normalized in setOf("message", "value", "exceptionmessage")) || normalized in setOf( + "authorization", + "credential", + "cookie", + "displayname", + "email", + "errormessage", + "password", + "query", + "searchquery", + "secret", + "token", + "userid", + "username", + ) || normalized.endsWith("token") || normalized.endsWith("credential") +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowApplication.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowApplication.kt new file mode 100644 index 000000000..1b8984ee4 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowApplication.kt @@ -0,0 +1,53 @@ +package com.opencloudgaming.opennow + +import android.app.Application +import android.content.pm.PackageManager +import android.content.res.Configuration +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class OpenNowApplication : Application() { + private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + internal val httpClient by lazy(::defaultHttpClient) + internal val authStore by lazy { AuthStore(this) } + internal val authRepository by lazy { GfnAuthRepository(this, authStore, httpClient) } + internal val localTvConnector by lazy { LocalTvConnector() } + + override fun onCreate() { + super.onCreate() + + if (isTelevisionDevice()) { + startupScope.launch { + val settings = SettingsStore(this@OpenNowApplication).settings.value + delay(TV_BACKGROUND_SERVICE_START_DELAY_MS) + withContext(Dispatchers.Main) { + initializeBackgroundServices(settings) + } + } + } else { + initializeBackgroundServices(SettingsStore(this).settings.value) + } + } + + override fun onTerminate() { + localTvConnector.close() + super.onTerminate() + } + + private fun initializeBackgroundServices(settings: AppSettings) { + OpenNowAnalytics.setup(this, settings) + AndroidAuthRefreshScheduler.schedule(this) + } + + private fun isTelevisionDevice(): Boolean = + packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) || + resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK == Configuration.UI_MODE_TYPE_TELEVISION + + private companion object { + const val TV_BACKGROUND_SERVICE_START_DELAY_MS = 2_500L + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowButtons.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowButtons.kt new file mode 100644 index 000000000..6640e5f23 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowButtons.kt @@ -0,0 +1,65 @@ +package com.opencloudgaming.opennow + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.Button as MaterialButton +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp + +internal val LocalControllerFocusEnabled = staticCompositionLocalOf { false } + +/** + * Shared primary action button with an unmistakable controller/TV focus state. + * Touch does not normally focus buttons, so the treatment stays out of the phone UI. + */ +@Composable +internal fun Button( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: Shape = ButtonDefaults.shape, + colors: ButtonColors = ButtonDefaults.buttonColors(), + elevation: ButtonElevation? = ButtonDefaults.buttonElevation(), + border: BorderStroke? = null, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + interactionSource: MutableInteractionSource? = null, + content: @Composable RowScope.() -> Unit, +) { + val controllerFocusEnabled = LocalControllerFocusEnabled.current + var focused by remember { mutableStateOf(false) } + + MaterialButton( + onClick = onClick, + modifier = modifier.onFocusChanged { focusState -> + focused = focusState.isFocused || focusState.hasFocus + }, + enabled = enabled, + shape = shape, + colors = colors, + elevation = elevation, + border = if (focused && controllerFocusEnabled) BorderStroke(4.dp, Color.White) else border, + contentPadding = contentPadding, + interactionSource = interactionSource, + content = content, + ) +} + +internal fun shouldShowControllerFocus( + focused: Boolean, + tvProfile: Boolean, + physicalControllerConnected: Boolean, +): Boolean = focused && (tvProfile || physicalControllerConnected) diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowScreens.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowScreens.kt new file mode 100644 index 000000000..2125c833d --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowScreens.kt @@ -0,0 +1,12466 @@ +package com.opencloudgaming.opennow + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.content.Intent +import android.hardware.input.InputManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.Uri +import android.os.Build +import android.speech.RecognizerIntent +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.PointerIcon +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items as gridItems +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.NavigationRailItemDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.darkColorScheme +import androidx.compose.material.icons.rounded.BatteryFull +import androidx.compose.material.icons.rounded.SignalCellular0Bar +import androidx.compose.material.icons.rounded.SignalCellular4Bar +import androidx.compose.material.icons.rounded.SignalCellularAlt +import androidx.compose.material.icons.rounded.SignalCellularAlt1Bar +import androidx.compose.material.icons.rounded.SignalCellularAlt2Bar +import androidx.compose.material.icons.rounded.SignalWifi0Bar +import androidx.compose.material.icons.rounded.Wifi +import androidx.compose.material.icons.rounded.Wifi1Bar +import androidx.compose.material.icons.rounded.Wifi2Bar +import androidx.compose.material.icons.rounded.WifiOff +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.key +import androidx.compose.runtime.setValue +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.zIndex +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.core.content.ContextCompat +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import coil3.compose.AsyncImage +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.io.File +import java.text.DateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.min +import kotlin.math.floor +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.sqrt + +private val Green = Color(0xff6af0a0) +private val Background = Color(0xff090b0d) +private val Panel = Color(0xff11161a) +private val PanelAlt = Color(0xff171d22) +private val TextPrimary = Color(0xffeef3f5) +private val TextMuted = Color(0xff98a4aa) +private val ChromeScrim = Color.Black.copy(alpha = 0.16f) +private val TopBarCompactControlHeight = 30.dp +private const val DEVICE_LOGIN_SIDE_BY_SIDE_MIN_WIDTH_DP = 520 +private const val COMPACT_STREAM_DEVICE_STATUS_REFRESH_MS = 5_000L +private const val QUEUE_POSITION_VISUAL_SETTLE_MS = 1100L +private val UiAccent.color: Color + get() = when (this) { + UiAccent.OpenNow -> Green + UiAccent.Pixel -> Color(0xff8ab4f8) + UiAccent.HotPink -> Color(0xffff4fb8) + UiAccent.Lime -> Color(0xffc7ef6b) + UiAccent.Coral -> Color(0xffff8d7a) + UiAccent.Violet -> Color(0xffc7a4ff) + } + +@Composable +internal fun uiAccentLabel(accent: UiAccent): String = when (accent) { + UiAccent.OpenNow -> stringResource(R.string.accent_opennow) + UiAccent.Pixel -> stringResource(R.string.accent_pixel) + UiAccent.HotPink -> stringResource(R.string.accent_hot_pink) + UiAccent.Lime -> stringResource(R.string.accent_lime) + UiAccent.Coral -> stringResource(R.string.accent_coral) + UiAccent.Violet -> stringResource(R.string.accent_violet) +} + +@Composable +fun OpenNowTheme(settings: AppSettings, content: @Composable () -> Unit) { + val context = LocalContext.current + val accent = settings.uiAccent.color + val fallbackScheme = darkColorScheme( + primary = accent, + onPrimary = Color(0xff08090c), + background = Background, + surface = Panel, + surfaceVariant = PanelAlt, + onBackground = TextPrimary, + onSurface = TextPrimary, + onSurfaceVariant = TextMuted, + ) + val colorScheme = if (settings.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicDarkColorScheme(context).copy( + primary = accent, + onPrimary = Color(0xff08090c), + secondary = accent, + tertiary = Green, + ) + } else { + fallbackScheme + } + MaterialTheme( + colorScheme = colorScheme, + content = content, + ) +} + +@Composable +fun OpenNowApp(viewModel: OpenNowViewModel) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val physicalControllerConnected = rememberPhysicalControllerConnected(enabled = !state.androidTvProfile) + val controllerFocusEnabled = shouldShowControllerFocus( + focused = true, + tvProfile = state.androidTvProfile, + physicalControllerConnected = physicalControllerConnected, + ) + val launchAudioController = remember(context) { AndroidNerdAudioController(context.applicationContext) } + val playIntroOnAppLaunch = remember { state.settings.streamIntroMusic } + val introStartsMutedOnLaunch = remember { + state.settings.streamIntroMusic && state.settings.streamIntroStartMode == IntroMusicStartMode.Muted + } + val musicControlsEnabled = state.settings.streamIntroMusic || state.settings.queueReadyMusic + val streamActive = state.page == AppPage.Stream || state.streamStatus != "idle" + var launchIntroStarted by remember { mutableStateOf(false) } + var launchMusicMuted by remember { mutableStateOf(introStartsMutedOnLaunch) } + var launchMusicPlaying by remember { mutableStateOf(false) } + var previousStreamStatus by remember { mutableStateOf(state.streamStatus) } + var queuedForStartCue by remember { mutableStateOf(false) } + var lastStartCueSessionId by remember { mutableStateOf(null) } + var hiddenUpdatePromptKey by remember { mutableStateOf(null) } + val updatePromptKey = state.androidUpdate.visibleNoticeKey(state.dismissedAndroidUpdateNoticeKey) + val showAnalyticsConsent = !state.settings.analyticsConsentAsked + val diagnosticDialogVisible = state.diagnosticShare.awaitingConsent || + state.diagnosticShare.uploading || + state.diagnosticShare.pasteUrl != null + val showSessionReport = state.sessionReport != null && !showAnalyticsConsent && !diagnosticDialogVisible + val showUpdatePrompt = updatePromptKey != null && + updatePromptKey != hiddenUpdatePromptKey && + !showAnalyticsConsent && + !showSessionReport && + !diagnosticDialogVisible && + state.androidUpdate.status in setOf(AndroidUpdateStatus.Available, AndroidUpdateStatus.Downloaded) + + DisposableEffect(launchAudioController) { + onDispose { + launchAudioController.release() + } + } + LaunchedEffect( + playIntroOnAppLaunch, + state.settings.streamIntroMusic, + state.settings.queueReadyMusic, + launchMusicMuted, + state.page, + state.streamStatus, + state.launchPhase, + state.queuePosition, + state.streamSession?.sessionId, + state.streamSession?.queuePosition, + state.streamSession?.seatSetupStep, + ) { + val sessionId = state.streamSession?.sessionId + val queueReadyForStream = + previousStreamStatus == "queue" && + state.streamStatus == "connecting" && + sessionId != null && + sessionId != lastStartCueSessionId + previousStreamStatus = state.streamStatus + if (state.streamStatus == "queue") { + queuedForStartCue = queuedForStartCue || + queueDisplayPosition(state) != null || + state.launchPhase.equals("Queue", ignoreCase = true) + } + + if (!musicControlsEnabled) { + launchIntroStarted = false + launchMusicMuted = false + launchAudioController.stopAll { launchMusicPlaying = it } + if (state.streamStatus == "idle") { + queuedForStartCue = false + } + return@LaunchedEffect + } + if (!state.settings.streamIntroMusic && launchMusicMuted) { + launchMusicMuted = false + } + if (!state.settings.streamIntroMusic) { + launchAudioController.stopIntro { launchMusicPlaying = it } + } + if (!state.settings.queueReadyMusic) { + launchAudioController.stopQueueReadyReminder { launchMusicPlaying = it } + } + + if (!state.settings.streamIntroMusic && !state.settings.queueReadyMusic) { + launchAudioController.stopAll { launchMusicPlaying = it } + } else if (queueReadyForStream && queuedForStartCue) { + launchMusicMuted = false + lastStartCueSessionId = sessionId + queuedForStartCue = false + launchAudioController.startQueueReadyReminder(enabled = state.settings.queueReadyMusic) { launchMusicPlaying = it } + } else if (launchMusicMuted) { + launchAudioController.stopIntro { launchMusicPlaying = it } + } else if (playIntroOnAppLaunch && state.settings.streamIntroMusic && !streamActive) { + if (!launchIntroStarted) { + launchIntroStarted = true + launchAudioController.startIntro(enabled = true) { launchMusicPlaying = it } + } + } else { + launchAudioController.stopIntro { launchMusicPlaying = it } + } + if (state.streamStatus == "idle") { + queuedForStartCue = false + } + } + val musicControl = TopBarMusicControl( + visible = musicControlsEnabled, + playing = launchMusicPlaying, + muted = launchMusicMuted, + onToggle = { + when { + launchMusicMuted -> { + launchMusicMuted = false + if (state.settings.streamIntroMusic && !streamActive) { + launchIntroStarted = true + launchAudioController.startIntro(enabled = true) { launchMusicPlaying = it } + } + } + launchMusicPlaying -> { + launchMusicMuted = true + launchAudioController.stopAll { launchMusicPlaying = it } + } + state.settings.streamIntroMusic && !streamActive -> { + launchIntroStarted = true + launchAudioController.startIntro(enabled = true) { launchMusicPlaying = it } + } + else -> { + launchMusicMuted = true + launchAudioController.stopAll { launchMusicPlaying = it } + } + } + }, + ) + + OpenNowTheme(state.settings) { + val primaryColor = MaterialTheme.colorScheme.primary + CompositionLocalProvider( + LocalTvLoadingProfile provides state.androidTvProfile, + LocalControllerFocusEnabled provides controllerFocusEnabled, + ) { + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .drawWithCache { + val brush = Brush.radialGradient( + colors = listOf( + primaryColor.copy(alpha = 0.15f), + Color.Transparent + ), + center = Offset(size.width, 0f), + radius = size.width.coerceAtLeast(size.height) * 0.8f + ) + onDrawBehind { + drawRect(brush) + } + } + ) { + Surface(Modifier.fillMaxSize(), color = Color.Transparent) { + when { + state.authSession != null -> MainShell(state, viewModel, musicControl) + else -> LoginScreen(state, viewModel) + } + } + state.sessionReport?.takeIf { showSessionReport }?.let { report -> + SessionReportDialog( + report = report, + onDismiss = viewModel::dismissSessionReport, + ) + } + updatePromptKey?.takeIf { showUpdatePrompt }?.let { promptKey -> + AndroidUpdatePromptDialog( + update = state.androidUpdate, + onPrimary = { + hiddenUpdatePromptKey = promptKey + when (state.androidUpdate.status) { + AndroidUpdateStatus.Available -> viewModel.downloadAndroidUpdate() + AndroidUpdateStatus.Downloaded -> viewModel.installAndroidUpdate() + else -> Unit + } + }, + onDetails = { + hiddenUpdatePromptKey = promptKey + viewModel.openAndroidUpdateSettings() + }, + onDismiss = viewModel::dismissAndroidUpdateNotice, + ) + } + if (showAnalyticsConsent) { + AnalyticsConsentDialog( + onAllow = { + viewModel.updateSettings( + state.settings.copy( + analyticsConsentAsked = true, + analyticsOptOut = false, + ), + ) + }, + onDecline = { + viewModel.updateSettings( + state.settings.copy( + analyticsConsentAsked = true, + analyticsOptOut = true, + ), + ) + }, + ) + } + DiagnosticShareDialog( + state = state, + onUpload = viewModel::uploadDiagnosticShare, + onDismiss = viewModel::dismissDiagnosticShare, + ) + } + } + } +} + +@Composable +private fun SessionReportDialog( + report: SessionReport, + onDismiss: () -> Unit, +) { + val scoreColor = when (report.rating) { + SessionReportRating.Excellent -> Green + SessionReportRating.Good -> Color(0xffc7ef6b) + SessionReportRating.Fair -> Color(0xffffc95a) + SessionReportRating.Poor -> Color(0xffff8d7a) + } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Session report") }, + text = { + Column( + modifier = Modifier + .heightIn(max = 560.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Surface( + color = scoreColor.copy(alpha = 0.12f), + shape = RoundedCornerShape(18.dp), + border = BorderStroke(1.dp, scoreColor.copy(alpha = 0.38f)), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(Modifier.weight(1f)) { + Text(report.gameTitle, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + "${formatSessionTimerDuration(report.durationSeconds)} • ${report.sampleCount} quality samples", + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + "${report.score}/100", + color = scoreColor, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Black, + ) + Text(report.rating.label, color = scoreColor, style = MaterialTheme.typography.labelMedium) + } + } + } + if (report.limitedData) { + Text( + "This was a short session, so the score is based on limited samples and may vary more than usual.", + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + Text("Connection", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + SessionReportMetric( + label = "Latency", + value = report.averagePingMs?.let { "$it ms avg" } ?: "Not measured", + detail = report.peakPingMs?.let { "$it ms peak" }, + ) + SessionReportMetric( + label = "Stream speed", + value = formatRuntimeBitrate(report.averageBitrateKbps), + detail = report.peakBitrateKbps?.let { "${formatRuntimeBitrate(it)} peak" }, + ) + SessionReportMetric( + label = "Packet loss", + value = report.packetLossPct?.let { "%.2f%%".format(Locale.US, it) } ?: "Not measured", + detail = report.packetLossPct?.let { if (it <= 0.5) "Stable" else "May affect clarity" }, + ) + SessionReportMetric( + label = "Jitter", + value = report.averageJitterMs?.let { "%.1f ms".format(Locale.US, it) } ?: "Not measured", + detail = "Timing variation", + ) + SessionReportMetric( + label = "Frame rate", + value = report.averageFps?.let { "%.1f / %d".format(Locale.US, it, report.targetFps) } ?: "Not measured", + detail = "Average / target FPS", + ) + SessionReportMetric( + label = "Decode", + value = report.averageDecodeMs?.let { "%.1f ms".format(Locale.US, it) } ?: "Not measured", + detail = "Per video frame", + ) + } + val networkLabel = when (report.networkKind) { + AndroidNetworkKind.Wifi -> report.wifiBand.label + else -> report.networkKind.label + } + Text( + buildString { + append("Network: $networkLabel") + report.estimatedLinkDownstreamKbps?.let { + append(" • Android link estimate ${formatRuntimeBitrate(it)}") + } + }, + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + Text("Delivered profile", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Text( + buildString { + append(formatRuntimeResolution(report.deliveredResolution ?: report.requestedResolution)) + append(" • ") + append(report.deliveredCodec ?: report.requestedCodec.name) + if ( + normalizeSessionReportResolution(report.deliveredResolution) != + normalizeSessionReportResolution(report.requestedResolution) || + report.deliveredCodec?.contains(report.requestedCodec.name, ignoreCase = true) == false + ) { + append(" (requested ${formatRuntimeResolution(report.requestedResolution)} • ${report.requestedCodec.name})") + } + }, + color = TextPrimary, + style = MaterialTheme.typography.bodyMedium, + ) + if (report.downgrades.isNotEmpty()) { + Text("Why the profile changed", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + report.downgrades.forEach { finding -> SessionReportFindingRow(finding) } + } + Text("What to do next", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + report.recommendations.forEach { finding -> SessionReportFindingRow(finding) } + } + }, + confirmButton = { Button(onClick = onDismiss) { Text("Done") } }, + ) +} + +@Composable +private fun SessionReportMetric( + label: String, + value: String, + detail: String?, +) { + Surface( + modifier = Modifier.width(136.dp), + color = PanelAlt, + shape = RoundedCornerShape(12.dp), + ) { + Column(Modifier.padding(horizontal = 11.dp, vertical = 9.dp)) { + Text(label, color = TextMuted, style = MaterialTheme.typography.labelSmall) + Text(value, color = TextPrimary, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + detail?.let { Text(it, color = TextMuted, style = MaterialTheme.typography.labelSmall, maxLines = 1) } + } + } +} + +@Composable +private fun SessionReportFindingRow(finding: SessionReportFinding) { + val titleColor = if (finding.kind == SessionReportFindingKind.Warning) Color(0xffffc95a) else Green + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(finding.title, color = titleColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Text(finding.detail, color = TextMuted, style = MaterialTheme.typography.bodySmall) + } +} + +private fun normalizeSessionReportResolution(value: String?): Pair? = + value?.let(::parseResolutionPixelsOrNull) + +@Composable +private fun DiagnosticShareDialog( + state: OpenNowUiState, + onUpload: () -> Unit, + onDismiss: () -> Unit, +) { + val share = state.diagnosticShare + if (!share.awaitingConsent && !share.uploading && share.pasteUrl == null) return + val clipboard = LocalClipboardManager.current + LaunchedEffect(share.clipboardSummary, state.androidTvProfile) { + if (!state.androidTvProfile) { + share.clipboardSummary?.let { clipboard.setText(AnnotatedString(it)) } + } + } + when { + share.uploading -> AlertDialog( + onDismissRequest = {}, + title = { Text("Preparing diagnostics") }, + text = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + CircularProgressIndicator(Modifier.size(28.dp)) + Text("Removing sensitive values and creating a temporary paste…") + } + }, + confirmButton = {}, + ) + share.pasteUrl != null -> { + val qrCode = remember(share.pasteUrl) { QrCode.encodeText(share.pasteUrl) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (state.androidTvProfile) "Scan diagnostics link" else "Diagnostics copied") }, + text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (state.androidTvProfile) { + if (qrCode != null) { + QrCodeView(qrCode, Modifier.size(240.dp)) + Text("Scan this QR code on your phone. The sanitized paste expires within 24 hours.") + } else { + Text("Could not create the QR code. Close this dialog and try again.") + } + } else { + Text("Device, account type, stream profile, current status, and the temporary paste URL were copied to the clipboard.") + Text( + share.pasteUrl, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + ) + } + } + }, + confirmButton = { Button(onClick = onDismiss) { Text("Done") } }, + ) + } + else -> AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Create temporary diagnostics paste?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("OpenNOW will remove tokens, account identifiers, email addresses, session IDs, and network addresses before uploading.") + Text("The randomized link is unlisted but not encrypted, and the paste service deletes uploads within 24 hours.", color = TextMuted) + share.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { Button(onClick = onUpload) { Text(if (share.error == null) "Sanitize and upload" else "Retry") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) + } +} + +@Composable +private fun AnalyticsConsentDialog( + onAllow: () -> Unit, + onDecline: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDecline, + title = { Text("Share diagnostics?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + "Share anonymous diagnostics to help us find patterns in bugs, crashes, and performance problems. Sensitive data is removed, and we do not sell your data.", + ) + Text( + "If sharing is off during a crash, we may not have enough information to investigate your report. It is off by default and can be changed in Privacy settings.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { + Button(onClick = onAllow) { + Text("Share analytics") + } + }, + dismissButton = { + TextButton(onClick = onDecline) { + Text("Keep off") + } + }, + ) +} + +@Composable +private fun AndroidUpdatePromptDialog( + update: AndroidUpdateState, + onPrimary: () -> Unit, + onDetails: () -> Unit, + onDismiss: () -> Unit, +) { + val version = update.availableVersionName?.let { "Version $it" } + ?: update.availableVersionCode?.let { "Build $it" } + ?: "A new build" + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (update.status == AndroidUpdateStatus.Downloaded) "Update ready" else "OpenNOW update available") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + if (update.status == AndroidUpdateStatus.Downloaded) { + "$version is downloaded and ready to install." + } else { + "$version is available for this device." + }, + ) + update.releaseNotes?.trim()?.takeIf { it.isNotBlank() }?.let { notes -> + Text( + notes, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + maxLines = 8, + overflow = TextOverflow.Ellipsis, + ) + } + } + }, + confirmButton = { + Button(onClick = onPrimary) { + Text(if (update.status == AndroidUpdateStatus.Downloaded) "Install" else "Download") + } + }, + dismissButton = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onDetails) { + Text("Details") + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + } + }, + ) +} + +@Composable +private fun LoadingScreen(text: String) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { + OpenNowMark(72.dp) + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + Text(text, color = TextMuted) + } + } +} + +@Composable +private fun LoginScreen(state: OpenNowUiState, viewModel: OpenNowViewModel) { + val signInFocusRequester = remember { FocusRequester() } + val context = LocalContext.current + var tokenDialogVisible by remember { mutableStateOf(false) } + var tokenInput by remember { mutableStateOf("") } + var pendingLogText by remember { mutableStateOf("") } + val logExportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + runCatching { + context.contentResolver.openOutputStream(uri)?.use { output -> + output.write(pendingLogText.toByteArray(Charsets.UTF_8)) + } ?: error("Could not open log file") + }.onSuccess { + Toast.makeText(context, "Logs exported", Toast.LENGTH_SHORT).show() + }.onFailure { error -> + Toast.makeText(context, error.message ?: "Could not export logs", Toast.LENGTH_LONG).show() + } + } + val tvLogin = state.androidTvProfile + val deviceCodeLoginAvailable = state.selectedProvider.supportsDeviceCodeLogin + val preferDeviceCodeLogin = tvLogin && deviceCodeLoginAvailable + val deviceLoginPrompt = state.deviceLoginPrompt.takeIf { deviceCodeLoginAvailable } + val normalLoginBusy = state.launchPhase.isNotBlank() && deviceLoginPrompt == null + LaunchedEffect(preferDeviceCodeLogin, deviceLoginPrompt == null) { + if (preferDeviceCodeLogin && deviceLoginPrompt == null) { + runCatching { signInFocusRequester.requestFocus() } + } + } + if (preferDeviceCodeLogin && deviceLoginPrompt != null) { + TvDeviceLoginScreen( + prompt = deviceLoginPrompt, + phase = state.launchPhase, + onCancel = viewModel::cancelLogin, + ) + return + } + BoxWithConstraints(Modifier.fillMaxSize()) { + val compactForPhonePairing = tvLogin && state.localTvConnector.hosting + val dedicatedPhonePairing = shouldUseDedicatedTvPairingLayout( + tvProfile = tvLogin, + hosting = state.localTvConnector.hosting, + availableWidthDp = maxWidth.value, + availableHeightDp = maxHeight.value, + ) + if (dedicatedPhonePairing) { + TvPhoneSignInConnector( + state = state, + viewModel = viewModel, + dedicated = true, + modifier = Modifier.fillMaxSize(), + ) + } else { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(if (compactForPhonePairing) 12.dp else 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + OpenNowMark( + size = if (compactForPhonePairing) 56.dp else 88.dp, + modifier = Modifier.clickable(onClick = viewModel::recordLoginIconTap), + ) + Spacer(Modifier.height(if (compactForPhonePairing) 8.dp else 20.dp)) + Text( + "OpenNOW", + color = TextPrimary, + style = if (compactForPhonePairing) MaterialTheme.typography.headlineLarge else MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + ) + Text( + "Native Android GeForce NOW client", + color = TextMuted, + style = if (compactForPhonePairing) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodyLarge, + ) + Spacer(Modifier.height(if (compactForPhonePairing) 12.dp else 28.dp)) + ProviderPicker(state.providers, state.selectedProvider, viewModel::selectProvider) + Spacer(Modifier.height(if (compactForPhonePairing) 8.dp else 16.dp)) + deviceLoginPrompt?.let { prompt -> + DeviceLoginPanel(prompt = prompt, phase = state.launchPhase, onCancel = viewModel::cancelLogin) + } ?: Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { viewModel.login() }, + enabled = !normalLoginBusy, + modifier = Modifier.focusRequester(signInFocusRequester), + colors = ButtonDefaults.buttonColors( + disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.72f), + disabledContentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + if (normalLoginBusy) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(10.dp)) + } + Text( + when { + state.launchPhase.isNotBlank() -> state.launchPhase + preferDeviceCodeLogin -> stringResource(R.string.login_tv_start, state.selectedProvider.displayName) + else -> stringResource(R.string.login_with_provider, state.selectedProvider.displayName) + }, + ) + } + if (!tvLogin && deviceCodeLoginAvailable) { + TextButton(onClick = { viewModel.loginWithCode() }, enabled = !normalLoginBusy) { + Text("Use code sign-in") + } + } + if (tvLogin) { + TvPhoneSignInConnector(state = state, viewModel = viewModel) + } + } + if (state.error != null) { + Spacer(Modifier.height(14.dp)) + Text(state.error.orEmpty(), color = Color(0xffff9f9f)) + } + } + } + } + + if (state.loginToolsVisible) { + AlertDialog( + onDismissRequest = viewModel::dismissLoginTools, + title = { Text("Sign-in tools") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Use a token to sign in without the browser, or export diagnostics before signing in.") + Button( + onClick = { + viewModel.dismissLoginTools() + tokenDialogVisible = true + }, + enabled = !normalLoginBusy, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Sign in with token") + } + OutlinedButton( + onClick = { + viewModel.dismissLoginTools() + if (tvLogin) { + viewModel.requestDiagnosticShare() + } else { + pendingLogText = viewModel.sanitizedDebugLogText() + logExportLauncher.launch(viewModel.debugLogFileName()) + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (tvLogin) "Export logs with QR" else "Export logs") + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = viewModel::dismissLoginTools) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + if (tokenDialogVisible) { + val submitToken = { + val submittedToken = tokenInput + tokenInput = "" + tokenDialogVisible = false + viewModel.loginWithToken(submittedToken) + } + AlertDialog( + onDismissRequest = { + tokenInput = "" + tokenDialogVisible = false + }, + title = { Text("Sign in with token") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Paste an NVIDIA access token or token-response JSON. OpenNOW verifies the access token before saving the account.") + OutlinedTextField( + value = tokenInput, + onValueChange = { tokenInput = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Access token") }, + minLines = 3, + maxLines = 6, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { if (tokenInput.isNotBlank() && !normalLoginBusy) submitToken() }, + ), + singleLine = false, + ) + Text( + "Only use credentials for an account you control.", + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { + Button( + onClick = submitToken, + enabled = tokenInput.isNotBlank() && !normalLoginBusy, + ) { + Text("Sign in") + } + }, + dismissButton = { + TextButton( + onClick = { + tokenInput = "" + tokenDialogVisible = false + }, + ) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } +} + +internal fun shouldUseDedicatedTvPairingLayout( + tvProfile: Boolean, + hosting: Boolean, + availableWidthDp: Float, + availableHeightDp: Float, +): Boolean = tvProfile && hosting && (availableHeightDp < 500f || availableWidthDp < 760f) + +@Composable +private fun TvPhoneSignInConnector( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + dedicated: Boolean = false, + modifier: Modifier = Modifier, +) { + val connector = state.localTvConnector + if (!connector.hosting) { + OutlinedButton( + onClick = viewModel::startLocalTvConnector, + enabled = !connector.busy, + modifier = modifier, + ) { + Text(if (connector.busy) "Starting phone pairing…" else "Sign in from OpenNOW on phone") + } + } else { + val qrCode = remember(connector.pairUri) { connector.pairUri?.let(QrCode::encodeText) } + Card( + colors = CardDefaults.cardColors(containerColor = PanelAlt), + shape = RoundedCornerShape(if (dedicated) 26.dp else 18.dp), + modifier = if (dedicated) { + modifier.padding(12.dp) + } else { + modifier.fillMaxWidth().padding(top = 8.dp) + }, + ) { + BoxWithConstraints(if (dedicated) Modifier.fillMaxSize() else Modifier.fillMaxWidth()) { + val qrSize = if (dedicated) { + minOf(maxHeight - 40.dp, maxWidth * 0.36f, 240.dp).coerceAtLeast(152.dp) + } else { + 188.dp + } + Row( + (if (dedicated) Modifier.fillMaxSize() else Modifier.fillMaxWidth()) + .padding(if (dedicated) 18.dp else 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(if (dedicated) 20.dp else 12.dp), + ) { + if (connector.pairedDeviceName == null) { + qrCode?.let { code -> + Surface( + shape = RoundedCornerShape(18.dp), + color = Color.White, + border = BorderStroke(3.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.55f)), + ) { + QrCodeView(code, Modifier.size(qrSize)) + } + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(if (dedicated) 10.dp else 8.dp)) { + Text( + if (connector.pairedDeviceName == null) "Pair your phone" else "Phone connected", + color = TextPrimary, + style = if (dedicated) MaterialTheme.typography.headlineMedium else MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + if (connector.pairedDeviceName == null) { + "Your TV and phone must be on the same Wi-Fi. Scan the QR code with your phone camera; the pairing code expires after five minutes." + } else { + "${connector.pairedDeviceName} can launch games. Approve trust below for settings, overlays, sessions, and account switching." + }, + color = TextMuted, + style = if (dedicated) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodySmall, + maxLines = if (dedicated) 3 else 2, + overflow = TextOverflow.Ellipsis, + ) + if (connector.pairedDeviceName == null) { + PairingCodeDisplay(connector.pairingCode, compact = !dedicated) + } + if (connector.pairedDeviceName != null) { + SettingSwitch( + label = "Trust this phone", + checked = connector.pairedDeviceTrusted, + description = "Required before the phone can transfer an account or control TV settings and sessions.", + ) { trusted -> viewModel.setLocalTvDeviceTrusted(trusted) } + } + OutlinedButton(onClick = viewModel::stopLocalTvConnector) { + Text(if (connector.pairedDeviceName == null) "Cancel pairing" else "Disconnect phone") + } + } + } + } + } + } + connector.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } +} + +@Composable +private fun PairingCodeDisplay(code: String?, compact: Boolean) { + val digits = code?.takeIf { it.length == 4 && it.all(Char::isDigit) } ?: "----" + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("PAIRING CODE", color = TextMuted, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold) + Row(horizontalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 8.dp)) { + digits.forEach { digit -> + Surface( + modifier = Modifier.size(if (compact) 38.dp else 46.dp), + shape = RoundedCornerShape(12.dp), + color = Color.White.copy(alpha = 0.07f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.13f)), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + digit.toString(), + color = TextPrimary, + style = if (compact) MaterialTheme.typography.titleMedium else MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + } +} + +@Composable +private fun TvDeviceLoginScreen(prompt: DeviceLoginPrompt, phase: String, onCancel: () -> Unit) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize().padding(horizontal = 48.dp, vertical = 36.dp), + contentAlignment = Alignment.Center, + ) { + val landscape = maxWidth >= 720.dp + val qrMaxSize = minOf( + maxWidth * if (landscape) 0.28f else 0.68f, + maxHeight * if (landscape) 0.58f else 0.38f, + 340.dp, + ) + DeviceLoginPanel( + prompt = prompt, + phase = phase, + onCancel = onCancel, + modifier = Modifier.fillMaxWidth(if (landscape) 0.86f else 1f), + qrMaxSize = qrMaxSize, + preferLandscapeLayout = landscape, + focusCancelOnPrompt = false, + ) + } +} + +@Composable +internal fun DeviceLoginPanel( + prompt: DeviceLoginPrompt, + phase: String, + onCancel: () -> Unit, + modifier: Modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + qrMaxSize: androidx.compose.ui.unit.Dp = 360.dp, + preferLandscapeLayout: Boolean = false, + focusCancelOnPrompt: Boolean = true, +) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val configuration = LocalConfiguration.current + val initialFocusRequester = remember { FocusRequester() } + val sideBySideLayout = shouldUseSideBySideDeviceLoginLayout( + orientation = configuration.orientation, + preferLandscapeLayout = preferLandscapeLayout, + availableWidthDp = configuration.screenWidthDp, + ) + val launchUrl = remember(prompt.verificationUriComplete, prompt.verificationUri) { + prompt.verificationUriComplete ?: prompt.verificationUri + } + val qrContent = launchUrl + var urlActionMessage by remember(launchUrl) { mutableStateOf(null) } + val qrCode = remember(qrContent, prompt.verificationUri) { + QrCode.encodeText(qrContent) ?: QrCode.encodeText(prompt.verificationUri) + } + val remainingSeconds by produceState(initialValue = secondsUntil(prompt.expiresAt), prompt.expiresAt) { + while (value > 0) { + delay(1000L) + value = secondsUntil(prompt.expiresAt) + } + } + LaunchedEffect(prompt.userCode, focusCancelOnPrompt) { + runCatching { initialFocusRequester.requestFocus() } + } + Card( + colors = CardDefaults.cardColors(containerColor = PanelAlt, contentColor = TextPrimary), + shape = RoundedCornerShape(14.dp), + modifier = modifier, + ) { + if (sideBySideLayout) { + Row( + Modifier.fillMaxWidth().padding(24.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DeviceLoginQr( + qrCode = qrCode, + qrMaxSize = qrMaxSize, + modifier = Modifier.weight(0.9f), + ) + DeviceLoginControls( + launchUrl = launchUrl, + prompt = prompt, + phase = phase, + remainingSeconds = remainingSeconds, + urlActionMessage = urlActionMessage, + onUrlActionMessage = { urlActionMessage = it }, + onCancel = onCancel, + focusRequester = initialFocusRequester, + focusCancel = focusCancelOnPrompt, + context = context, + clipboardManager = clipboardManager, + modifier = Modifier.weight(1.1f), + showTitle = true, + ) + } + } else { + Column( + Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(stringResource(R.string.login_tv_title), color = TextPrimary, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + DeviceLoginQr(qrCode = qrCode, qrMaxSize = qrMaxSize) + DeviceLoginControls( + launchUrl = launchUrl, + prompt = prompt, + phase = phase, + remainingSeconds = remainingSeconds, + urlActionMessage = urlActionMessage, + onUrlActionMessage = { urlActionMessage = it }, + onCancel = onCancel, + focusRequester = initialFocusRequester, + focusCancel = focusCancelOnPrompt, + context = context, + clipboardManager = clipboardManager, + showTitle = false, + ) + } + } + } +} + +internal fun shouldUseSideBySideDeviceLoginLayout( + orientation: Int, + preferLandscapeLayout: Boolean, + availableWidthDp: Int, +): Boolean = + preferLandscapeLayout || + (orientation == Configuration.ORIENTATION_LANDSCAPE && availableWidthDp >= DEVICE_LOGIN_SIDE_BY_SIDE_MIN_WIDTH_DP) + +@Composable +private fun DeviceLoginQr(qrCode: QrCode?, qrMaxSize: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) { + qrCode?.let { + BoxWithConstraints( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val qrDisplaySize = minOf(maxWidth * 0.92f, qrMaxSize) + QrCodeView(it, Modifier.size(qrDisplaySize)) + } + } +} + +@Composable +private fun DeviceLoginControls( + launchUrl: String, + prompt: DeviceLoginPrompt, + phase: String, + remainingSeconds: Int, + urlActionMessage: String?, + onUrlActionMessage: (String) -> Unit, + onCancel: () -> Unit, + focusRequester: FocusRequester, + focusCancel: Boolean, + context: android.content.Context, + clipboardManager: androidx.compose.ui.platform.ClipboardManager, + modifier: Modifier = Modifier, + showTitle: Boolean = true, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (showTitle) { + Text(stringResource(R.string.login_tv_title), color = TextPrimary, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + TextButton( + onClick = { + val opened = openExternalUrl(context, launchUrl) + if (opened) { + onUrlActionMessage("Opening sign-in URL") + } else { + clipboardManager.setText(AnnotatedString(launchUrl)) + onUrlActionMessage("URL copied") + } + }, + modifier = if (focusCancel) Modifier else Modifier.focusRequester(focusRequester), + ) { + Text( + launchUrl, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Text(prompt.userCode, style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = Color.White) + Text(stringResource(R.string.login_tv_status, phase.ifBlank { stringResource(R.string.login_tv_waiting) }), color = TextMuted) + urlActionMessage?.let { + Text(it, color = TextMuted, style = MaterialTheme.typography.bodySmall) + } + Text(stringResource(R.string.login_tv_expires, remainingSeconds / 60, remainingSeconds % 60), color = TextMuted) + OutlinedButton( + onClick = onCancel, + modifier = if (focusCancel) Modifier.focusRequester(focusRequester) else Modifier, + ) { + Text(stringResource(R.string.action_cancel)) + } + } +} + +internal fun openExternalUrl(context: android.content.Context, url: String): Boolean { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return runCatching { + context.startActivity(intent) + true + }.getOrDefault(false) +} + +@Composable +internal fun QrCodeView(qrCode: QrCode, modifier: Modifier = Modifier) { + Canvas( + modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color.White), + ) { + val quiet = 4 + val cells = qrCode.size + quiet * 2 + val cellSize = floor(min(size.width, size.height) / cells).coerceAtLeast(1f) + val qrSize = cellSize * cells + val originX = floor((size.width - qrSize) / 2f) + val originY = floor((size.height - qrSize) / 2f) + for (y in 0 until qrCode.size) { + for (x in 0 until qrCode.size) { + if (!qrCode.isDark(x, y)) continue + drawRect( + color = Color.Black, + topLeft = Offset(originX + (x + quiet) * cellSize, originY + (y + quiet) * cellSize), + size = Size(cellSize, cellSize), + ) + } + } + } +} + +private fun secondsUntil(deadlineMs: Long): Int = + ((deadlineMs - System.currentTimeMillis()).coerceAtLeast(0L) / 1000L).toInt() + +private fun isPhoneLandscape(width: androidx.compose.ui.unit.Dp, height: androidx.compose.ui.unit.Dp): Boolean = + width > height && minOf(width, height) < PHONE_NAV_RAIL_MAX_SMALLEST_WIDTH + +private fun isPhonePortrait(width: androidx.compose.ui.unit.Dp, height: androidx.compose.ui.unit.Dp): Boolean = + height >= width && minOf(width, height) < PHONE_NAV_RAIL_MAX_SMALLEST_WIDTH + +@Composable +internal fun rememberPhysicalControllerConnected(enabled: Boolean): Boolean { + val context = LocalContext.current.applicationContext + var connected by remember { mutableStateOf(enabled && hasConnectedPhysicalController()) } + DisposableEffect(context, enabled) { + fun refresh() { + connected = enabled && hasConnectedPhysicalController() + } + refresh() + if (!enabled) { + onDispose {} + } else { + val inputManager = context.getSystemService(Context.INPUT_SERVICE) as? InputManager + val listener = object : InputManager.InputDeviceListener { + override fun onInputDeviceAdded(deviceId: Int) = refresh() + override fun onInputDeviceRemoved(deviceId: Int) = refresh() + override fun onInputDeviceChanged(deviceId: Int) = refresh() + } + inputManager?.registerInputDeviceListener(listener, null) + onDispose { + inputManager?.unregisterInputDeviceListener(listener) + } + } + } + return connected +} + +private fun hasConnectedPhysicalController(): Boolean = + InputDevice.getDeviceIds().any { deviceId -> + AndroidControllerInput.isControllerDevice(InputDevice.getDevice(deviceId)) + } + +internal sealed interface CatalogWallpaperSelection { + data class BuiltIn(val preset: CatalogBackgroundPreset) : CatalogWallpaperSelection + data class Custom(val source: String) : CatalogWallpaperSelection +} + +internal fun catalogWallpaperSelection( + preset: CatalogBackgroundPreset, + customSource: String?, +): CatalogWallpaperSelection = + customSource + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.let(CatalogWallpaperSelection::Custom) + ?: CatalogWallpaperSelection.BuiltIn(preset) + +internal fun shouldShowCatalogWallpaper(settings: AppSettings): Boolean = + settings.nerdCatalogBackground + +@Composable +private fun CatalogWallpaperBackdrop( + settings: AppSettings, + tvProfile: Boolean, + width: Dp, + height: Dp, +) { + val showBackdrop = shouldShowCatalogWallpaper(settings) + if (!showBackdrop) { + return + } + val wallpaper = catalogWallpaperSelection( + preset = settings.catalogBackgroundPreset, + customSource = settings.nerdCatalogBackgroundUri, + ) + val scrimAlpha = when { + tvProfile -> 0.48f + width > height -> 0.28f + else -> 0.36f + } + Box(Modifier.fillMaxSize().clipToBounds()) { + when (wallpaper) { + is CatalogWallpaperSelection.BuiltIn -> { + CatalogBuiltInWallpaperBackdrop(wallpaper.preset, Modifier.matchParentSize()) + } + is CatalogWallpaperSelection.Custom -> { + val fallbackPainter = painterResource(settings.catalogBackgroundPreset.drawableRes) + AsyncImage( + model = imageDataForSource(wallpaper.source), + contentDescription = null, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop, + placeholder = fallbackPainter, + error = fallbackPainter, + fallback = fallbackPainter, + ) + } + } + Box( + Modifier + .matchParentSize() + .background(Color.Black.copy(alpha = scrimAlpha)), + ) + } +} + +private val CatalogBackgroundPreset.drawableRes: Int + get() = when (this) { + CatalogBackgroundPreset.ColorfulAbstract -> R.drawable.catalog_colorful_abstract_background + CatalogBackgroundPreset.Original -> R.drawable.catalog_default_background + } + +@Composable +private fun CatalogBuiltInWallpaperBackdrop( + preset: CatalogBackgroundPreset, + modifier: Modifier = Modifier, +) { + Image( + painter = painterResource(preset.drawableRes), + contentDescription = null, + modifier = modifier.background(Color(0xff07100b)), + contentScale = ContentScale.Crop, + ) +} + +@Composable +private fun MainShell( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + musicControl: TopBarMusicControl, +) { + val context = LocalContext.current + val inStream = state.page == AppPage.Stream + val streamingActive = inStream && state.streamStatus != "idle" + val modalPickerOpen = state.pendingPrintedWasteGame != null || state.pendingStoreChoiceGame != null + val tvProfile = state.androidTvProfile + val navAudioController = remember(context) { AndroidNerdAudioController(context.applicationContext) } + var visibleSearchTarget by remember { mutableStateOf(null) } + var settingsSearchQuery by remember { mutableStateOf("") } + var settingsDetailRouteOpen by remember { mutableStateOf(false) } + var settingsBackRequestToken by remember { mutableStateOf(0) } + val tvStreamReturnFocusRequester = remember { FocusRequester() } + var previouslyInStream by remember { mutableStateOf(inStream) } + val navigationToneEnabled = state.settings.controllerUiSounds && !inStream + val showMinimizedQueueDock = state.streamLaunchMinimized && shouldShowQueueLaunchStatus(state) + DisposableEffect(navAudioController) { + onDispose { + navAudioController.release() + } + } + LaunchedEffect(state.page) { + if (state.page != AppPage.Settings) { + settingsDetailRouteOpen = false + } + } + LaunchedEffect(inStream, tvProfile) { + val shouldRestoreFocus = shouldRestoreTvNavigationFocus( + previouslyInStream = previouslyInStream, + currentlyInStream = inStream, + tvProfile = tvProfile, + ) + previouslyInStream = inStream + if (shouldRestoreFocus) { + delay(120) + repeat(3) { attempt -> + if (runCatching { tvStreamReturnFocusRequester.requestFocus() }.isSuccess) { + return@LaunchedEffect + } + if (attempt < 2) delay(80) + } + } + } + fun revealSearch( + target: SearchTarget = when (state.page) { + AppPage.Library -> SearchTarget.Library + AppPage.Settings -> SearchTarget.Settings + else -> SearchTarget.Store + }, + ) { + visibleSearchTarget = target + if (target == SearchTarget.Store && state.page != AppPage.Home) { + viewModel.setPage(AppPage.Home) + } else if (target == SearchTarget.Library && state.page != AppPage.Library) { + viewModel.setPage(AppPage.Library) + } else if (target == SearchTarget.Settings && state.page != AppPage.Settings) { + viewModel.setPage(AppPage.Settings) + } + } + fun navigateFromAppChrome(page: AppPage) { + if (page == AppPage.Settings) { + viewModel.recordSettingsIconTap() + } + visibleSearchTarget = null + viewModel.setPage(page) + } + BackHandler(enabled = state.selectedGame != null && !inStream) { + viewModel.clearSelectedGame() + } + BackHandler(enabled = tvProfile && !inStream && state.selectedGame == null && state.page != AppPage.Home) { + viewModel.setPage(AppPage.Home) + } + BoxWithConstraints(Modifier.fillMaxSize()) { + var phoneLandscapeScrollChromeHidden by remember { mutableStateOf(false) } + val horizontalChrome = maxWidth > maxHeight + val phoneLandscapeChrome = !tvProfile && !inStream && isPhoneLandscape(maxWidth, maxHeight) + val portraitChrome = !inStream && maxHeight >= maxWidth + val showNavigationRail = !inStream && (tvProfile || phoneLandscapeChrome) + val scrollChromePage = state.page == AppPage.Home || state.page == AppPage.Library + val tvCatalogChrome = tvProfile && scrollChromePage + val storeControlsInTopBar = (phoneLandscapeChrome || tvCatalogChrome) && state.page == AppPage.Home + val libraryControlsInTopBar = (phoneLandscapeChrome || tvCatalogChrome) && state.page == AppPage.Library + val screenEdgePadding = appContentEdgePaddingDp( + settings = state.settings, + inStream = inStream, + tvProfile = tvProfile, + ).dp + LaunchedEffect(phoneLandscapeChrome, scrollChromePage) { + if (!phoneLandscapeChrome || !scrollChromePage) { + phoneLandscapeScrollChromeHidden = false + } + } + Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) + if (!inStream && (state.page == AppPage.Home || state.page == AppPage.Library)) { + CatalogWallpaperBackdrop( + settings = state.settings, + tvProfile = tvProfile, + width = maxWidth, + height = maxHeight, + ) + } + + Scaffold( + containerColor = Color.Transparent, + contentWindowInsets = if (streamingActive || tvProfile) WindowInsets(0, 0, 0, 0) else ScaffoldDefaults.contentWindowInsets, + bottomBar = { + if (!inStream && !showNavigationRail) { + Column { + if (showMinimizedQueueDock) { + MinimizedQueueDock( + state = state, + onRestore = viewModel::restoreStreamLaunch, + onCancel = viewModel::stopStream, + ) + } + NavigationBar( + containerColor = if (state.page == AppPage.Settings) SettingsBackground else MaterialTheme.colorScheme.background, + tonalElevation = 0.dp, + ) { + BottomNavItem( + selected = state.page == AppPage.Home && visibleSearchTarget != SearchTarget.Store, + onClick = { + visibleSearchTarget = null + viewModel.setPage(AppPage.Home) + }, + iconRes = R.drawable.ic_tab_store, + label = stringResource(R.string.nav_store), + ) + BottomNavItem( + selected = visibleSearchTarget != null, + onClick = { revealSearch() }, + iconRes = R.drawable.ic_search, + label = stringResource(R.string.nav_search), + ) + BottomNavItem( + selected = state.page == AppPage.Library && visibleSearchTarget != SearchTarget.Library, + onClick = { + visibleSearchTarget = null + viewModel.setPage(AppPage.Library) + }, + iconRes = R.drawable.ic_tab_library, + label = stringResource(R.string.nav_library), + ) + BottomNavItem( + selected = state.page == AppPage.Settings && visibleSearchTarget != SearchTarget.Settings, + onClick = { + navigateFromAppChrome(AppPage.Settings) + }, + iconRes = R.drawable.ic_tab_settings, + label = stringResource(R.string.nav_settings), + ) + } + } + } + }, + ) { padding -> + Box( + Modifier + .fillMaxSize() + .padding(padding) + .onPreviewKeyEvent { event -> + if (isNavigationToneKey(event)) { + navAudioController.playButtonTone(navigationToneEnabled) + } + false + }, + ) { + Row( + Modifier + .fillMaxSize() + .padding(screenEdgePadding), + ) { + if (showNavigationRail) { + AppNavigationRail( + state = state, + activeSearchTarget = visibleSearchTarget, + showAppIcon = showNavigationRail && horizontalChrome, + largeIcons = phoneLandscapeChrome, + showSettingsBack = shouldShowSettingsBackRail( + tvProfile = tvProfile, + settingsPageOpen = state.page == AppPage.Settings, + horizontalChrome = horizontalChrome, + detailRouteOpen = settingsDetailRouteOpen, + ), + showCatalogControllerActions = false, + onNavigate = { page -> + navigateFromAppChrome(page) + }, + onSearch = { revealSearch(it) }, + onSettingsBack = { settingsBackRequestToken += 1 }, + streamReturnFocusRequester = tvStreamReturnFocusRequester, + ) + } + Column( + Modifier + .weight(1f) + .fillMaxHeight(), + ) { + AnimatedVisibility( + visible = + portraitChrome || + (phoneLandscapeChrome && !phoneLandscapeScrollChromeHidden) || + tvCatalogChrome, + ) { + if (!inStream) { + TopStatusBar( + state = state, + onResumeActiveSession = viewModel::resumeActiveSession, + onOpenStreamSettings = viewModel::openStreamSettings, + musicControl = musicControl, + showLogo = portraitChrome, + ) { + if (storeControlsInTopBar) { + StoreCatalogToolbar( + state = state, + onSortChange = viewModel::setCatalogSort, + onFilterToggle = viewModel::toggleCatalogFilter, + modifier = Modifier.widthIn(max = 220.dp), + compact = true, + ) + } else if (libraryControlsInTopBar) { + val orderedLibraryGames = remember(state.libraryGames, state.settings.favoriteGameIds) { + favoriteOrderedGames(state.libraryGames, state.settings.favoriteGameIds) + } + val visibleLibraryGames = remember(orderedLibraryGames, state.librarySearch, state.libraryFilterIds) { + orderedLibraryGames.filter { game -> + gameMatchesSearch(game, state.librarySearch) && + gameMatchesLibraryFilters(game, state.libraryFilterIds) + } + } + val libraryFilterOptions = remember(orderedLibraryGames) { + libraryStoreFilterOptions(orderedLibraryGames) + } + LibraryFilterControls( + gameCount = visibleLibraryGames.size, + totalCount = state.libraryGames.size, + options = libraryFilterOptions, + selectedIds = state.libraryFilterIds, + onToggle = viewModel::toggleLibraryFilter, + modifier = Modifier.widthIn(max = 190.dp), + compact = true, + showSelectedChips = false, + ) + } + } + } + } + Box( + Modifier + .weight(1f) + .fillMaxWidth(), + ) { + when (state.page) { + AppPage.Home -> HomeScreen( + state = state, + viewModel = viewModel, + tvProfile = tvProfile, + hideChromeWhenScrolled = phoneLandscapeChrome, + controlsInTopBar = storeControlsInTopBar, + searchRequested = visibleSearchTarget == SearchTarget.Store, + onSearchDismissed = { + if (visibleSearchTarget == SearchTarget.Store) visibleSearchTarget = null + }, + onScrollChromeHiddenChange = { phoneLandscapeScrollChromeHidden = it }, + ) + AppPage.Library -> LibraryScreen( + state = state, + viewModel = viewModel, + tvProfile = tvProfile, + hideChromeWhenScrolled = phoneLandscapeChrome, + controlsInTopBar = libraryControlsInTopBar, + searchRequested = visibleSearchTarget == SearchTarget.Library, + onSearchDismissed = { + if (visibleSearchTarget == SearchTarget.Library) visibleSearchTarget = null + }, + onScrollChromeHiddenChange = { phoneLandscapeScrollChromeHidden = it }, + ) + AppPage.Settings -> SettingsScreen( + state = state, + viewModel = viewModel, + tvProfile = tvProfile, + searchRequested = visibleSearchTarget == SearchTarget.Settings, + searchQuery = settingsSearchQuery, + backRequestToken = settingsBackRequestToken, + onSearchQueryChange = { next -> + settingsSearchQuery = next + if (next.isBlank() && visibleSearchTarget == SearchTarget.Settings) { + visibleSearchTarget = null + } + }, + onDetailRouteChange = { settingsDetailRouteOpen = it }, + ) + AppPage.Stream -> StreamScreen(state, viewModel) + } + } + if (showMinimizedQueueDock && showNavigationRail) { + MinimizedQueueDock( + state = state, + onRestore = viewModel::restoreStreamLaunch, + onCancel = viewModel::stopStream, + ) + } + } + } + AnimatedVisibility( + visible = state.selectedGame != null && !inStream && !modalPickerOpen, + enter = fadeIn() + slideInVertically(initialOffsetY = { it / 3 }) + scaleIn(initialScale = 0.96f), + exit = fadeOut() + slideOutVertically(targetOffsetY = { it / 3 }) + scaleOut(targetScale = 0.96f), + modifier = Modifier.align(Alignment.Center), + ) { + state.selectedGame?.let { game -> + GameDetailsSheet( + game = game, + favorite = game.id in state.settings.favoriteGameIds, + defaultVariantId = state.settings.defaultGameVariantIds[game.id], + fullScreen = tvProfile, + safeAreaPadding = screenEdgePadding, + onPlay = viewModel::play, + onChooseStore = viewModel::chooseStore, + onFavorite = viewModel::updateFavorites, + connectedTvName = state.localTvConnector.connectedTvName, + onPlayOnTv = viewModel::playOnLocalTv, + onDismiss = viewModel::clearSelectedGame, + ) + } + } + state.pendingPrintedWasteGame?.let { game -> + AnimatedLaunchOverlay(Modifier.align(Alignment.Center)) { + PrintedWasteSelector(state, game, viewModel) + } + } + state.pendingStoreChoiceGame?.let { game -> + AnimatedLaunchOverlay(Modifier.align(Alignment.Center)) { + StoreLaunchSelector( + game = game, + defaultVariantId = state.settings.defaultGameVariantIds[game.id], + onLaunch = viewModel::playVariant, + onSetDefaultStore = viewModel::setDefaultGameVariant, + onDismiss = viewModel::dismissStoreChoice, + ) + } + } + } + } + } +} + +internal fun shouldRestoreTvNavigationFocus( + previouslyInStream: Boolean, + currentlyInStream: Boolean, + tvProfile: Boolean, +): Boolean = tvProfile && previouslyInStream && !currentlyInStream + +@Composable +private fun AppNavigationRail( + state: OpenNowUiState, + activeSearchTarget: SearchTarget?, + showAppIcon: Boolean, + largeIcons: Boolean, + showSettingsBack: Boolean, + showCatalogControllerActions: Boolean, + onNavigate: (AppPage) -> Unit, + onSearch: (SearchTarget) -> Unit, + onSettingsBack: () -> Unit, + streamReturnFocusRequester: FocusRequester, +) { + Box( + modifier = Modifier + .width(APP_NAV_RAIL_WIDTH) + .fillMaxHeight() + .padding(start = 6.dp, top = 8.dp, end = 6.dp, bottom = 8.dp), + ) { + Surface( + modifier = Modifier.fillMaxSize(), + shape = RoundedCornerShape(26.dp), + color = ChromeScrim, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + BoxWithConstraints(Modifier.fillMaxSize()) { + val canFitCatalogControllerActions = maxHeight >= 440.dp + if (showAppIcon) { + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .focusProperties { canFocus = false } + .clickable { onNavigate(AppPage.Home) } + .padding(top = 12.dp, bottom = 8.dp), + contentAlignment = Alignment.Center, + ) { + OpenNowAppIcon( + if (largeIcons) 44.dp else 34.dp, + ) + } + } + Column( + modifier = Modifier + .align(if (showSettingsBack) Alignment.BottomCenter else Alignment.Center) + .fillMaxWidth() + .padding(bottom = if (showSettingsBack) 8.dp else 0.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (!showAppIcon) { + Spacer(Modifier.height(8.dp)) + } + AppNavigationRailItem( + selected = state.page == AppPage.Home && activeSearchTarget != SearchTarget.Store, + onClick = { onNavigate(AppPage.Home) }, + iconRes = R.drawable.ic_tab_store, + label = stringResource(R.string.nav_store), + iconSize = if (largeIcons) 30.dp else 24.dp, + focusRequester = streamReturnFocusRequester, + ) + AppNavigationRailItem( + selected = activeSearchTarget != null, + onClick = { + onSearch( + when (state.page) { + AppPage.Library -> SearchTarget.Library + AppPage.Settings -> SearchTarget.Settings + else -> SearchTarget.Store + }, + ) + }, + iconRes = R.drawable.ic_search, + label = stringResource(R.string.nav_search), + iconSize = if (largeIcons) 30.dp else 24.dp, + ) + AppNavigationRailItem( + selected = state.page == AppPage.Library && activeSearchTarget != SearchTarget.Library, + onClick = { onNavigate(AppPage.Library) }, + iconRes = R.drawable.ic_tab_library, + label = stringResource(R.string.nav_library), + iconSize = if (largeIcons) 30.dp else 24.dp, + ) + AppNavigationRailItem( + selected = state.page == AppPage.Settings && activeSearchTarget != SearchTarget.Settings, + onClick = { onNavigate(AppPage.Settings) }, + iconRes = R.drawable.ic_tab_settings, + label = stringResource(R.string.nav_settings), + iconSize = if (largeIcons) 30.dp else 24.dp, + showConnectionDot = shouldShowLocalTvConnectionDot( + tvProfile = state.androidTvProfile, + pairedDeviceName = state.localTvConnector.pairedDeviceName, + ), + ) + AnimatedVisibility(visible = showCatalogControllerActions && canFitCatalogControllerActions) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(8.dp)) + ControllerCatalogRailActionHints() + } + } + AnimatedVisibility(visible = showSettingsBack) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(6.dp)) + AppNavigationRailItem( + selected = false, + onClick = onSettingsBack, + iconRes = R.drawable.ic_arrow_back, + label = "Back", + iconSize = if (largeIcons) 30.dp else 24.dp, + ) + } + } + } + } + } + } +} + +internal fun shouldShowLocalTvConnectionDot(tvProfile: Boolean, pairedDeviceName: String?): Boolean = + tvProfile && !pairedDeviceName.isNullOrBlank() + +internal fun shouldShowSettingsBackRail( + tvProfile: Boolean, + settingsPageOpen: Boolean, + horizontalChrome: Boolean, + detailRouteOpen: Boolean, +): Boolean = !tvProfile && settingsPageOpen && horizontalChrome && detailRouteOpen + +@Composable +private fun AppNavigationRailItem( + selected: Boolean, + onClick: () -> Unit, + iconRes: Int, + label: String, + modifier: Modifier = Modifier, + iconSize: Dp = 24.dp, + focusRequester: FocusRequester? = null, + showConnectionDot: Boolean = false, +) { + var focused by remember { mutableStateOf(false) } + val accent = MaterialTheme.colorScheme.primary + NavigationRailItem( + selected = selected, + onClick = onClick, + modifier = modifier + .onFocusChanged { focused = it.isFocused } + .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) + .then( + if (focused) Modifier.border(2.dp, accent, RoundedCornerShape(12.dp)) else Modifier + ), + colors = NavigationRailItemDefaults.colors( + selectedIconColor = accent, + selectedTextColor = accent, + indicatorColor = if (focused) accent.copy(alpha = 0.35f) else accent.copy(alpha = 0.18f), + unselectedIconColor = if (focused) Color.White else TextMuted, + unselectedTextColor = if (focused) Color.White else TextMuted, + ), + icon = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + painter = painterResource(iconRes), + contentDescription = label, + modifier = Modifier.size(iconSize), + ) + if (showConnectionDot) { + Spacer(Modifier.height(2.dp)) + Box( + Modifier + .size(6.dp) + .clip(CircleShape) + .background(Color(0xffb56cff)), + ) + } + } + }, + label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ) +} + +private data class TopBarMusicControl( + val visible: Boolean, + val playing: Boolean, + val muted: Boolean, + val onToggle: () -> Unit, +) + +@Composable +private fun RowScope.BottomNavItem(selected: Boolean, onClick: () -> Unit, iconRes: Int, label: String) { + NavigationBarItem( + selected = selected, + onClick = onClick, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), + unselectedIconColor = TextMuted, + unselectedTextColor = TextMuted, + ), + icon = { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + }, + label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ) +} + +@Composable +private fun TopStatusBar( + state: OpenNowUiState, + onResumeActiveSession: () -> Unit, + onOpenStreamSettings: () -> Unit, + musicControl: TopBarMusicControl, + showLogo: Boolean = true, + content: @Composable RowScope.() -> Unit = {}, +) { + val displayName = state.authSession?.user?.displayName ?: "OpenNOW" + val tier = state.subscriptionInfo?.membershipTier ?: state.authSession?.user?.membershipTier ?: "GFN" + val barScrim = if (showLogo) ChromeScrim else Color.Transparent + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 5.dp), + shape = RoundedCornerShape(24.dp), + color = barScrim, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (showLogo) { + OpenNowMark(30.dp) + Spacer(Modifier.width(8.dp)) + } + Row( + Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + listOf(displayName, tier).filter { it.isNotBlank() }.joinToString(" • "), + color = TextPrimary, + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + content() + if (state.settings.nerdMode) { + TopStatusDetails(state, onOpenStreamSettings) + } + } + if (musicControl.visible) { + Spacer(Modifier.width(6.dp)) + TopBarMusicButton(musicControl) + } + if (state.activeSession != null) { + Spacer(Modifier.width(6.dp)) + ElevatedButton( + onClick = onResumeActiveSession, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 7.dp), + ) { + Text(stringResource(R.string.action_resume), style = MaterialTheme.typography.labelMedium) + } + } + } + } +} + +@Composable +private fun TopStatusDetails( + state: OpenNowUiState, + onOpenStreamSettings: () -> Unit, +) { + val stream = state.activeStreamSettings ?: state.settings.stream + val summary = streamStatusSummary(stream) + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(999.dp) + Row( + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier + .height(TopBarCompactControlHeight) + .onFocusChanged { focused = it.isFocused } + .semantics { contentDescription = "Open Stream settings: $summary" } + .clickable(onClick = onOpenStreamSettings) + .then( + if (focused) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, shape) else Modifier, + ), + shape = shape, + color = if (focused) MaterialTheme.colorScheme.primary.copy(alpha = 0.22f) else PanelAlt.copy(alpha = 0.9f), + tonalElevation = 0.dp, + ) { + Box(Modifier.fillMaxHeight().padding(horizontal = 8.dp), contentAlignment = Alignment.Center) { + Text( + summary, + color = TextMuted, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun TopBarMusicButton(control: TopBarMusicControl) { + val description = when { + control.muted -> "Music muted" + control.playing -> "Music playing" + else -> "Music ready" + } + Surface( + modifier = Modifier + .width(38.dp) + .height(TopBarCompactControlHeight) + .semantics { contentDescription = description } + .clickable(onClick = control.onToggle), + shape = RoundedCornerShape(999.dp), + color = if (control.muted) Color(0xff33181c).copy(alpha = 0.92f) else PanelAlt.copy(alpha = 0.78f), + tonalElevation = 0.dp, + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (control.muted) { + Icon( + painter = painterResource(R.drawable.ic_volume_off), + contentDescription = null, + tint = Color(0xffffb8bf), + modifier = Modifier.size(17.dp), + ) + } else { + MusicBars(playing = control.playing) + } + } + } +} + +@Composable +private fun MusicBars(playing: Boolean, modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition(label = "top-bar-music-bars") + val phase by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 820, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "top-bar-music-bars-phase", + ) + val color = MaterialTheme.colorScheme.primary + Canvas(modifier.size(width = 18.dp, height = 16.dp)) { + val barWidth = size.width / 5.8f + val gap = (size.width - barWidth * 3f) / 2f + repeat(3) { index -> + val wave = if (playing) { + ((sin((phase.toDouble() * 6.283185307179586) + index * 1.35) + 1.0) / 2.0).toFloat() + } else { + 0.36f + index * 0.12f + } + val barHeight = size.height * (0.32f + wave * 0.58f) + val left = index * (barWidth + gap) + drawRoundRect( + color = color, + topLeft = Offset(left, size.height - barHeight), + size = Size(barWidth, barHeight), + cornerRadius = CornerRadius(barWidth, barWidth), + ) + } + } +} + +private fun streamStatusSummary(stream: StreamSettings): String = + listOf( + formatTopBarResolution(stream.resolution), + stream.aspectRatio, + stream.codec.name, + "${stream.fps} FPS", + ).filter { it.isNotBlank() }.joinToString(" • ") + +private fun formatTopBarResolution(resolution: String): String { + val parts = resolution.lowercase(Locale.US).split("x", limit = 2) + return if (parts.size == 2 && parts.all { it.trim().isNotBlank() }) { + "${parts[0].trim()} × ${parts[1].trim()}" + } else { + resolution + } +} + +@Composable +internal fun NativeSearchField( + query: String, + onQueryChange: (String) -> Unit, + placeholder: String, + searching: Boolean = false, + modifier: Modifier = Modifier, + focusRequester: FocusRequester? = null, + onOpen: (() -> Unit)? = null, +) { + val focusManager = LocalFocusManager.current + val speechLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val spoken = result.data + ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + ?.firstOrNull() + if (!spoken.isNullOrBlank()) onQueryChange(spoken) + } + } + val voiceSearchIntent = remember(placeholder) { + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) + .putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + .putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault()) + .putExtra(RecognizerIntent.EXTRA_PROMPT, placeholder) + } + Surface( + modifier = modifier.height(56.dp), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f), + tonalElevation = 2.dp, + ) { + Row( + Modifier + .fillMaxSize() + .padding(start = 18.dp, end = if (query.isBlank()) 18.dp else 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_search), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier + .weight(1f) + .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) + .onFocusChanged { if (it.isFocused) onOpen?.invoke() } + .onPreviewKeyEvent { handleDpadFocusMove(it, focusManager) }, + decorationBox = { innerTextField -> + Box(Modifier.fillMaxWidth()) { + if (query.isBlank()) { + Text( + placeholder, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + innerTextField() + } + }, + ) + if (query.isNotBlank()) { + if (searching) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = { onQueryChange("") }) { + Icon( + painter = painterResource(R.drawable.ic_clear), + contentDescription = stringResource(R.string.search_clear), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(26.dp), + ) + } + } else { + if (searching) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = { runCatching { speechLauncher.launch(voiceSearchIntent) } }) { + Icon( + painter = painterResource(R.drawable.ic_mic), + contentDescription = stringResource(R.string.search_voice), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + } + } + } +} + +private fun handleDpadFocusMove(event: androidx.compose.ui.input.key.KeyEvent, focusManager: FocusManager): Boolean { + if (event.type != KeyEventType.KeyDown) return false + val direction = when (event.key) { + Key.DirectionUp -> FocusDirection.Up + Key.DirectionDown -> FocusDirection.Down + Key.DirectionLeft -> FocusDirection.Left + Key.DirectionRight -> FocusDirection.Right + else -> return false + } + return focusManager.moveFocus(direction) +} + +internal fun Modifier.lockedFocusGroup(): Modifier = + focusProperties { onExit = { cancelFocusChange() } } + .focusGroup() + +private fun isNavigationToneKey(event: androidx.compose.ui.input.key.KeyEvent): Boolean = + event.type == KeyEventType.KeyDown && + event.key in setOf( + Key.DirectionUp, + Key.DirectionDown, + Key.DirectionLeft, + Key.DirectionRight, + ) + +internal fun handleVerticalDpadFocusMove(event: androidx.compose.ui.input.key.KeyEvent, focusManager: FocusManager): Boolean { + if (event.type != KeyEventType.KeyDown) return false + val direction = when (event.key) { + Key.DirectionUp -> FocusDirection.Up + Key.DirectionDown -> FocusDirection.Down + else -> return false + } + return focusManager.moveFocus(direction) +} + +/** + * Key event handler for Compose Sliders when navigated by TV remote or D-pad controller. + * - D-pad Up/Down → moves focus to the next/previous focusable element. + * - D-pad Left → decrements the slider value by [step], clamped to [min]. + * - D-pad Right → increments the slider value by [step], clamped to [max]. + * Returns true when the event is consumed (Left/Right) so that Compose does not + * move focus sideways instead of changing the value. + */ +internal fun handleSliderDpadInput( + event: androidx.compose.ui.input.key.KeyEvent, + value: Float, + min: Float, + max: Float, + step: Float, + focusManager: FocusManager, + onValueAdjusted: (Float) -> Unit, +): Boolean { + if (event.type != KeyEventType.KeyDown) return false + return when (event.key) { + Key.DirectionUp -> focusManager.moveFocus(FocusDirection.Up) + Key.DirectionDown -> focusManager.moveFocus(FocusDirection.Down) + Key.DirectionLeft -> { + val newValue = (value - step).coerceIn(min, max) + onValueAdjusted(newValue) + true + } + Key.DirectionRight -> { + val newValue = (value + step).coerceIn(min, max) + onValueAdjusted(newValue) + true + } + else -> false + } +} + +internal fun isTvActivateKey(event: androidx.compose.ui.input.key.KeyEvent): Boolean = + event.type == KeyEventType.KeyUp && + event.key in setOf( + Key.DirectionCenter, + Key.Enter, + Key.NumPadEnter, + ) + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun HomeScreen( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + tvProfile: Boolean, + hideChromeWhenScrolled: Boolean, + controlsInTopBar: Boolean, + searchRequested: Boolean, + onSearchDismissed: () -> Unit, + onScrollChromeHiddenChange: (Boolean) -> Unit, +) { + val visibleGames = state.games.ifEmpty { state.catalogResult.games } + val searchingCatalog = state.loadingGames && state.catalogSearch.isNotBlank() + val gridState = rememberLazyGridState() + val searchFocusRequester = remember { FocusRequester() } + val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val showSearch = searchRequested || state.catalogSearch.isNotBlank() + val showScrollActions = gridState.firstVisibleItemIndex > 0 || gridState.firstVisibleItemScrollOffset > 80 + val scrolledAwayFromTop = gridState.firstVisibleItemIndex > 0 || gridState.firstVisibleItemScrollOffset > 0 + val hideScrollChrome = hideChromeWhenScrolled && scrolledAwayFromTop + LaunchedEffect(hideScrollChrome) { + onScrollChromeHiddenChange(hideScrollChrome) + } + DisposableEffect(Unit) { + onDispose { onScrollChromeHiddenChange(false) } + } + LaunchedEffect(searchRequested) { + if (searchRequested) { + delay(90) + runCatching { searchFocusRequester.requestFocus() } + keyboardController?.show() + } + } + SwipeToRefreshContainer( + refreshing = state.loadingGames, + enabled = !tvProfile, + showRefreshIndicator = !searchingCatalog, + onRefresh = viewModel::refreshGames, + modifier = Modifier.fillMaxSize(), + ) { + BoxWithConstraints(Modifier.fillMaxSize()) { + Column( + Modifier + .fillMaxSize() + .padding( + start = 12.dp, + top = if (controlsInTopBar) 4.dp else 12.dp, + end = 12.dp, + bottom = 12.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + AnimatedVisibility(visible = showSearch) { + NativeSearchField( + modifier = Modifier.fillMaxWidth(), + query = state.catalogSearch, + onQueryChange = { next -> + viewModel.setCatalogSearch(next) + if (next.isBlank()) onSearchDismissed() + }, + placeholder = stringResource(R.string.search_games), + searching = searchingCatalog, + focusRequester = searchFocusRequester, + onOpen = { + if (gridState.firstVisibleItemIndex > 0 || gridState.firstVisibleItemScrollOffset > 0) { + scope.launch { gridState.animateScrollToItem(0) } + } + }, + ) + } + Box( + Modifier + .weight(1f) + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + focusManager.clearFocus() + keyboardController?.hide() + } + }, + ) { + if (state.loadingGames && visibleGames.isEmpty()) { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp)) { + StoreScrollableControls( + state = state, + onSortChange = viewModel::setCatalogSort, + onFilterToggle = viewModel::toggleCatalogFilter, + showToolbar = !controlsInTopBar, + ) + RefreshingGamesPlaceholder( + settings = state.settings, + tvProfile = tvProfile, + storeLayout = true, + modifier = Modifier.weight(1f), + ) + } + } else { + StoreGameGrid( + games = visibleGames, + favoriteIds = state.settings.favoriteGameIds, + settings = state.settings, + tvProfile = tvProfile, + state = state, + onSelect = viewModel::selectGame, + onFavorite = viewModel::updateFavorites, + onPlay = viewModel::play, + onChooseStore = viewModel::chooseStore, + onSortChange = viewModel::setCatalogSort, + onFilterToggle = viewModel::toggleCatalogFilter, + onClearSearch = { + viewModel.setCatalogSearch("") + onSearchDismissed() + }, + onClearFilters = viewModel::clearCatalogFilters, + gridState = gridState, + showToolbar = !controlsInTopBar, + modifier = Modifier.fillMaxSize(), + ) + } + if (showScrollActions) { + Box(Modifier.align(Alignment.BottomEnd).padding(2.dp)) { + StoreScrollActionButton( + iconRes = R.drawable.ic_arrow_up, + contentDescription = stringResource(R.string.action_scroll_top), + ) { + scope.launch { gridState.animateScrollToItem(0) } + } + } + } + } + } + } + } +} + +@Composable +private fun StoreScrollableControls( + state: OpenNowUiState, + onSortChange: (String) -> Unit, + onFilterToggle: (String) -> Unit, + showToolbar: Boolean = true, +) { + val filterGroups = catalogVisibleFilterGroups(state.catalogResult.filterGroups) + val filterOptions = catalogFilterOptions(filterGroups) + val hasSelectedFilters = state.catalogFilterIds.isNotEmpty() + val hasError = !state.error.isNullOrBlank() + if (!showToolbar && !hasSelectedFilters && !hasError) return + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (showToolbar) { + StoreCatalogToolbar( + state = state, + onSortChange = onSortChange, + onFilterToggle = onFilterToggle, + modifier = Modifier.fillMaxWidth(), + ) + } + SelectedFilterChips(options = filterOptions, selectedIds = state.catalogFilterIds, onToggle = onFilterToggle) + InlineErrorNotice(error = state.error) + } +} + +@Composable +private fun StoreCatalogToolbar( + state: OpenNowUiState, + onSortChange: (String) -> Unit, + onFilterToggle: (String) -> Unit, + modifier: Modifier = Modifier, + compact: Boolean = false, +) { + val filterGroups = catalogVisibleFilterGroups(state.catalogResult.filterGroups) + val filterOptions = catalogFilterOptions(filterGroups) + Row( + modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SortPicker( + options = state.catalogResult.sortOptions, + selected = state.catalogSortId, + onSelect = onSortChange, + modifier = Modifier.width(if (compact) 118.dp else 172.dp), + compact = compact, + ) + if (filterOptions.isNotEmpty()) { + FilterMenu(options = filterOptions, selectedIds = state.catalogFilterIds, onToggle = onFilterToggle, compact = compact) + } + } +} + +@Composable +private fun InlineErrorNotice(error: String?) { + if (error.isNullOrBlank()) return + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = Color(0xff33181c), + tonalElevation = 0.dp, + ) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Text( + compactErrorTitle(error), + color = Color(0xffffb8bf), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + compactErrorBody(error), + color = Color(0xffffb8bf), + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private fun compactErrorTitle(error: String): String = + when { + error.contains("DNS lookup failed", ignoreCase = true) -> "Network lookup failed" + error.contains("Unable to resolve host", ignoreCase = true) -> "Network lookup failed" + else -> "Something went wrong" + } + +private fun compactErrorBody(error: String): String = + error + .replace('\n', ' ') + .replace(Regex("\\s+"), " ") + .let { if (it.length > 180) "${it.take(177)}..." else it } + +@Composable +private fun StoreScrollActionButton(iconRes: Int, contentDescription: String, onClick: () -> Unit) { + Surface( + shape = CircleShape, + color = PanelAlt.copy(alpha = 0.96f), + tonalElevation = 4.dp, + shadowElevation = 4.dp, + ) { + IconButton(onClick = onClick, modifier = Modifier.size(44.dp)) { + Icon( + painter = painterResource(iconRes), + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + } + } +} + +@Composable +private fun LibraryScreen( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + tvProfile: Boolean, + hideChromeWhenScrolled: Boolean, + controlsInTopBar: Boolean, + searchRequested: Boolean, + onSearchDismissed: () -> Unit, + onScrollChromeHiddenChange: (Boolean) -> Unit, +) { + val orderedGames = remember(state.libraryGames, state.settings.favoriteGameIds) { + favoriteOrderedGames(state.libraryGames, state.settings.favoriteGameIds) + } + val filterOptions = remember(orderedGames) { + libraryStoreFilterOptions(orderedGames) + } + val games = remember(orderedGames, state.librarySearch, state.libraryFilterIds) { + orderedGames.filter { game -> + gameMatchesSearch(game, state.librarySearch) && gameMatchesLibraryFilters(game, state.libraryFilterIds) + } + } + val gridState = rememberLazyGridState() + val searchFocusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val showSearch = searchRequested || state.librarySearch.isNotBlank() + val scrolledAwayFromTop = gridState.firstVisibleItemIndex > 0 || gridState.firstVisibleItemScrollOffset > 0 + val hideScrollChrome = hideChromeWhenScrolled && scrolledAwayFromTop + LaunchedEffect(hideScrollChrome) { + onScrollChromeHiddenChange(hideScrollChrome) + } + DisposableEffect(Unit) { + onDispose { onScrollChromeHiddenChange(false) } + } + LaunchedEffect(searchRequested) { + if (searchRequested) { + delay(90) + runCatching { searchFocusRequester.requestFocus() } + keyboardController?.show() + } + } + SwipeToRefreshContainer( + refreshing = state.loadingGames, + enabled = !tvProfile, + onRefresh = viewModel::refreshGames, + modifier = Modifier.fillMaxSize(), + ) { + BoxWithConstraints(Modifier.fillMaxSize()) { + Column( + Modifier + .fillMaxSize() + .padding( + start = 12.dp, + top = if (controlsInTopBar) 4.dp else 12.dp, + end = 12.dp, + bottom = 12.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + AnimatedVisibility(visible = showSearch) { + NativeSearchField( + modifier = Modifier.fillMaxWidth(), + query = state.librarySearch, + onQueryChange = { next -> + viewModel.setLibrarySearch(next) + if (next.isBlank()) onSearchDismissed() + }, + placeholder = "Search library", + focusRequester = searchFocusRequester, + ) + } + LibraryFilterControls( + gameCount = games.size, + totalCount = state.libraryGames.size, + options = filterOptions, + selectedIds = state.libraryFilterIds, + onToggle = viewModel::toggleLibraryFilter, + showToolbar = !controlsInTopBar, + ) + if (state.loadingGames && state.libraryGames.isEmpty()) { + RefreshingGamesPlaceholder( + settings = state.settings, + tvProfile = tvProfile, + modifier = Modifier.weight(1f), + ) + } else { + GameGrid( + games, + state.settings.favoriteGameIds, + state.settings, + tvProfile, + viewModel::selectGame, + viewModel::updateFavorites, + viewModel::play, + viewModel::chooseStore, + modifier = Modifier.weight(1f), + gridState = gridState, + emptyContent = { + val hasSearch = state.librarySearch.isNotBlank() + val hasFilters = state.libraryFilterIds.isNotEmpty() + if ((hasSearch || hasFilters) && state.libraryGames.isNotEmpty()) { + SearchEmptyState( + title = stringResource(R.string.library_empty_search_title), + message = when { + hasSearch && hasFilters -> stringResource(R.string.library_empty_search_filters_body) + hasSearch -> stringResource(R.string.library_empty_search_body) + else -> stringResource(R.string.library_empty_filters_body) + }, + onClearSearch = if (hasSearch) { + { + viewModel.setLibrarySearch("") + onSearchDismissed() + } + } else { + null + }, + onClearFilters = if (hasFilters) { + viewModel::clearLibraryFilters + } else { + null + }, + ) + } else { + Text(stringResource(R.string.no_games_loaded), color = TextMuted) + } + }, + ) + } + } + } + } +} + +@Composable +private fun LibraryFilterControls( + gameCount: Int, + totalCount: Int, + options: List, + selectedIds: List, + onToggle: (String) -> Unit, + modifier: Modifier = Modifier, + compact: Boolean = false, + showToolbar: Boolean = true, + showSelectedChips: Boolean = true, +) { + if (!showToolbar && (!showSelectedChips || selectedIds.isEmpty())) return + Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (showToolbar) { + Row( + if (compact) Modifier else Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val countModifier = if (compact) Modifier else Modifier.weight(1f) + Text( + text = if (gameCount == totalCount) { + stringResource(R.string.library_count, totalCount) + } else { + "$gameCount / ${stringResource(R.string.library_count, totalCount)}" + }, + color = TextMuted, + style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start, + modifier = countModifier, + ) + if (options.isNotEmpty()) { + FilterMenu(options = options, selectedIds = selectedIds, onToggle = onToggle, compact = compact) + } + } + } + if (showSelectedChips) { + SelectedFilterChips(options = options, selectedIds = selectedIds, onToggle = onToggle) + } + } +} + +private fun libraryStoreFilterOptions(games: List): List { + val labelsById = linkedMapOf() + games.forEach { game -> + libraryStoreFilterIds(game).forEach { (id, label) -> + labelsById.putIfAbsent(id, label) + } + } + return labelsById.entries + .sortedBy { it.value.lowercase(Locale.US) } + .map { (id, label) -> + CatalogFilterOption( + id = id, + rawId = id.removePrefix(LIBRARY_STORE_FILTER_PREFIX), + label = label, + groupId = "library_store", + groupLabel = "Launcher", + ) + } +} + +private fun gameMatchesLibraryFilters(game: GameInfo, selectedIds: List): Boolean { + if (selectedIds.isEmpty()) return true + val gameFilterIds = libraryStoreFilterIds(game).map { it.first }.toSet() + return selectedIds.any { it in gameFilterIds } +} + +private fun libraryStoreFilterIds(game: GameInfo): List> { + val labels = libraryStoreDisplayNames(game) + return labels + .mapNotNull { label -> + val normalized = normalizeGameStore(label) + if (normalized.isBlank()) return@mapNotNull null + LIBRARY_STORE_FILTER_PREFIX + normalized to label + } + .distinctBy { it.first } +} + +private const val LIBRARY_STORE_FILTER_PREFIX = "library_store:" + +@Composable +private fun ActiveSessionResumeCard( + state: OpenNowUiState, + onResumeActiveSession: () -> Unit, + modifier: Modifier = Modifier, +) { + val active = state.activeSession ?: return + val game = activeSessionGame(state, active) + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + color = PanelAlt.copy(alpha = 0.92f), + tonalElevation = 3.dp, + ) { + Row( + Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + UrlImage( + game?.imageUrl, + Modifier + .width(44.dp) + .height(58.dp) + .clip(RoundedCornerShape(10.dp)), + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Resume cloud session", color = TextPrimary, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + game?.title ?: "App ${active.appId}", + color = TextMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + activeSessionSummary(active), + color = TextMuted, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Button(onClick = onResumeActiveSession, contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp)) { + Text(stringResource(R.string.action_resume), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +private fun activeSessionGame(state: OpenNowUiState, active: ActiveSessionInfo): GameInfo? = + (state.games + state.libraryGames).firstOrNull { game -> + game.launchAppId == active.appId.toString() || + game.variants.any { variant -> variant.id == active.appId.toString() } + } + +private fun activeSessionSummary(active: ActiveSessionInfo): String = + listOfNotNull( + when (active.status) { + 1 -> active.queuePosition?.takeIf { it > 0 }?.let { "Queue $it" } ?: "Starting" + 2, 3 -> "Ready" + else -> "Active" + }, + active.resolution, + active.fps?.let { "${it} FPS" }, + active.gpuType, + active.sessionId.take(8).takeIf { it.isNotBlank() }?.let { "Session $it" }, + ).joinToString(" - ") + +@Composable +private fun SearchEmptyState( + title: String, + message: String, + onClearSearch: (() -> Unit)? = null, + onClearFilters: (() -> Unit)? = null, +) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + title, + color = TextPrimary, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Text( + message, + color = TextMuted, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + onClearSearch?.let { clearSearch -> + OutlinedButton(onClick = clearSearch) { + Text(stringResource(R.string.search_clear), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + onClearFilters?.let { clearFilters -> + OutlinedButton(onClick = clearFilters) { + Text(stringResource(R.string.action_clear_filters), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +private fun RefreshingGamesPlaceholder( + settings: AppSettings, + tvProfile: Boolean, + storeLayout: Boolean = false, + modifier: Modifier = Modifier, +) { + GameGridSkeleton( + settings = settings, + tvProfile = tvProfile, + storeLayout = storeLayout, + modifier = modifier, + ) +} + +private val LocalShimmerOffset = staticCompositionLocalOf?> { null } +private val LocalTvLoadingPulse = staticCompositionLocalOf?> { null } +private val LocalTvLoadingProfile = staticCompositionLocalOf { false } +private val LocalTouchControllerStyle = staticCompositionLocalOf { TouchControllerStyle.V1 } +private const val SHIMMER_CYCLE_DURATION_MS = 760 + +@Composable +private fun GameGridSkeleton( + settings: AppSettings, + tvProfile: Boolean, + storeLayout: Boolean, + modifier: Modifier = Modifier, +) { + val scale = settings.posterSizeScale.coerceIn(MIN_GAME_CARD_SCALE, MAX_GAME_CARD_SCALE) + val compact = settings.compactGameCards + val landscapeLayout = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + + val shimmerOffset: State? + val tvPulse: State? + if (tvProfile) { + val transition = rememberInfiniteTransition(label = "loading-pulse-global") + val pulse = transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "loading-pulse-global", + ) + shimmerOffset = null + tvPulse = pulse + } else { + val transition = rememberInfiniteTransition(label = "shimmer-global") + val shimmer = transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = SHIMMER_CYCLE_DURATION_MS, easing = LinearEasing), + ), + label = "shimmer-offset-global", + ) + shimmerOffset = shimmer + tvPulse = null + } + + CompositionLocalProvider( + LocalShimmerOffset provides shimmerOffset, + LocalTvLoadingPulse provides tvPulse, + ) { + BoxWithConstraints(modifier.fillMaxSize()) { + val gridSpec = gameGridSpec(maxWidth, compact, landscapeLayout, settings, handheldLayout = !tvProfile) + val placeholderItems = remember(gridSpec.columns, storeLayout) { + List(gridSpec.columns * if (storeLayout) 4 else 3) { it } + } + LazyVerticalGrid( + modifier = Modifier.fillMaxSize(), + columns = GridCells.Fixed(gridSpec.columns), + contentPadding = gridSpec.contentPadding, + horizontalArrangement = Arrangement.spacedBy(gridSpec.horizontalSpacing), + verticalArrangement = Arrangement.spacedBy(gridSpec.verticalSpacing), + userScrollEnabled = false, + ) { + if (storeLayout) { + item(span = { GridItemSpan(maxLineSpan) }) { + StoreStartRailsSkeleton( + settings = settings, + tvProfile = tvProfile, + ) + } + } + gridItems(placeholderItems, key = { it }) { + GameCardSkeleton( + cardHeight = gridSpec.cardHeight * scale, + squareCard = gridSpec.squareCards, + thumbnailPlayOverlay = !tvProfile, + showStoreLabels = shouldShowGameStoreLabels( + tvProfile = tvProfile, + enabled = settings.showGameStoreLabels, + ), + ) + } + } + } + } +} + +@Composable +private fun StoreStartRailsSkeleton( + settings: AppSettings, + tvProfile: Boolean, +) { + val landscapeLayout = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + Column( + Modifier + .fillMaxWidth() + .padding(top = 2.dp, bottom = 6.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + repeat(2) { + StoreRailSectionSkeleton( + expressiveUi = settings.expressiveUi, + tvProfile = tvProfile, + landscapeLayout = landscapeLayout, + cardScale = settings.posterSizeScale, + ) + } + } +} + +@Composable +private fun StoreRailSectionSkeleton( + expressiveUi: Boolean, + tvProfile: Boolean, + landscapeLayout: Boolean, + cardScale: Float, +) { + val spacing = 10.dp + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + SkeletonLine(widthFraction = 0.34f, height = 15.dp) + BoxWithConstraints( + Modifier + .fillMaxWidth() + .clipToBounds(), + ) { + val baseCardWidth = storeRailCardWidth(tvProfile, landscapeLayout) + val visibleCount = storeRailVisibleCardCount( + availableWidthDp = maxWidth.value, + baseCardWidthDp = baseCardWidth.value, + spacingDp = spacing.value, + cardScale = cardScale, + ) + val fittedCardWidth = ((maxWidth.value - spacing.value * (visibleCount - 1)) / visibleCount) + .coerceAtLeast(1f) + .dp + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing), + ) { + repeat(visibleCount) { + StoreRailGameCardSkeleton( + width = fittedCardWidth, + expressiveUi = expressiveUi, + portraitCard = !tvProfile, + ) + } + } + } + } +} + +@Composable +private fun StoreRailGameCardSkeleton( + width: Dp, + expressiveUi: Boolean, + portraitCard: Boolean, +) { + val shape = RoundedCornerShape(if (expressiveUi) 12.dp else 8.dp) + Surface( + modifier = Modifier + .width(width) + .aspectRatio(if (portraitCard) GAME_BOX_ART_ASPECT_RATIO else 1f) + .border(1.dp, Color.White.copy(alpha = 0.08f), shape), + shape = shape, + color = Color.Black, + tonalElevation = 0.dp, + shadowElevation = 1.dp, + ) { + Box(Modifier.fillMaxSize().clip(shape)) { + LoadingShimmer(Modifier.fillMaxSize()) + SkeletonCircle( + size = 44.dp, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(6.dp), + ) + SkeletonCircle( + size = 44.dp, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(6.dp), + ) + } + } +} + +@Composable +private fun GameCardSkeleton( + cardHeight: Dp, + squareCard: Boolean, + thumbnailPlayOverlay: Boolean, + showStoreLabels: Boolean, +) { + val cardShape = RoundedCornerShape(12.dp) + Card( + modifier = Modifier + .fillMaxWidth() + .then( + when { + thumbnailPlayOverlay -> Modifier.aspectRatio(GAME_BOX_ART_ASPECT_RATIO) + squareCard -> Modifier.aspectRatio(1f) + else -> Modifier.height(cardHeight) + }, + ), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f)), + shape = cardShape, + ) { + if (thumbnailPlayOverlay) { + Box(Modifier.fillMaxSize()) { + LoadingShimmer(Modifier.fillMaxSize()) + SkeletonCircle( + size = 44.dp, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp), + ) + SkeletonCircle( + size = 44.dp, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(8.dp), + ) + } + } else { + Column(Modifier.fillMaxSize()) { + Box(Modifier.weight(1f).fillMaxWidth()) { + LoadingShimmer(Modifier.fillMaxSize()) + SkeletonCircle( + size = 44.dp, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + ) + } + if (showStoreLabels) { + Column(Modifier.padding(horizontal = 9.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) { + SkeletonLine(widthFraction = 0.62f) + } + } + Box(Modifier.padding(start = 9.dp, end = 9.dp, bottom = 9.dp)) { + LoadingShimmer( + Modifier + .fillMaxWidth() + .height(48.dp) + .clip(RoundedCornerShape(999.dp)), + ) + } + } + } + } +} + +@Composable +private fun SkeletonLine(widthFraction: Float, height: Dp = 9.dp) { + LoadingShimmer( + Modifier + .fillMaxWidth(widthFraction) + .height(height) + .clip(RoundedCornerShape(999.dp)), + ) +} + +@Composable +private fun SkeletonCircle(size: Dp, modifier: Modifier = Modifier) { + LoadingShimmer( + modifier + .size(size) + .clip(CircleShape), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SwipeToRefreshContainer( + refreshing: Boolean, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + showRefreshIndicator: Boolean = true, + content: @Composable () -> Unit, +) { + if (!enabled) { + Box(modifier) { + content() + } + return + } + val pullRefreshState = rememberPullToRefreshState() + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = onRefresh, + modifier = modifier, + state = pullRefreshState, + indicator = { + if (showRefreshIndicator) { + PullToRefreshDefaults.Indicator( + state = pullRefreshState, + isRefreshing = refreshing, + modifier = Modifier.align(Alignment.TopCenter), + ) + } + }, + ) { + content() + } +} + +@Composable +private fun GameGrid( + games: List, + favoriteIds: List, + settings: AppSettings, + tvProfile: Boolean, + onSelect: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, + modifier: Modifier = Modifier, + gridState: androidx.compose.foundation.lazy.grid.LazyGridState = rememberLazyGridState(), + emptyContent: (@Composable () -> Unit)? = null, +) { + if (games.isEmpty()) { + Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (emptyContent != null) { + emptyContent() + } else { + Text(stringResource(R.string.no_games_loaded), color = TextMuted) + } + } + return + } + val scale = settings.posterSizeScale.coerceIn(MIN_GAME_CARD_SCALE, MAX_GAME_CARD_SCALE) + val compact = settings.compactGameCards + val landscapeLayout = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val physicalControllerConnected = rememberPhysicalControllerConnected(enabled = landscapeLayout && !tvProfile) + val controllerActionMode = landscapeLayout && !tvProfile && physicalControllerConnected + BoxWithConstraints(modifier.fillMaxSize()) { + val gridSpec = gameGridSpec(maxWidth, compact, landscapeLayout, settings, handheldLayout = !tvProfile) + LazyVerticalGrid( + modifier = Modifier.fillMaxSize(), + state = gridState, + columns = GridCells.Fixed(gridSpec.columns), + contentPadding = gridSpec.contentPadding, + horizontalArrangement = Arrangement.spacedBy(gridSpec.horizontalSpacing), + verticalArrangement = Arrangement.spacedBy(gridSpec.verticalSpacing), + ) { + gridItems(games, key = { it.id }) { game -> + GameCard( + game = game, + favorite = game.id in favoriteIds, + tvProfile = tvProfile, + expressiveUi = settings.expressiveUi, + controllerBackgroundAnimations = settings.controllerBackgroundAnimations, + showGameStoreLabels = shouldShowGameStoreLabels( + tvProfile = tvProfile, + enabled = settings.showGameStoreLabels, + ), + cardHeight = gridSpec.cardHeight * scale, + squareCard = gridSpec.squareCards, + thumbnailPlayOverlay = !tvProfile, + controllerActionMode = controllerActionMode, + onSelect = onSelect, + onFavorite = onFavorite, + onPlay = onPlay, + onChooseStore = onChooseStore, + ) + } + } + } +} + +@Composable +private fun StoreGameGrid( + games: List, + favoriteIds: List, + settings: AppSettings, + tvProfile: Boolean, + state: OpenNowUiState, + onSelect: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, + onSortChange: (String) -> Unit, + onFilterToggle: (String) -> Unit, + onClearSearch: () -> Unit, + onClearFilters: () -> Unit, + gridState: androidx.compose.foundation.lazy.grid.LazyGridState, + showToolbar: Boolean = true, + modifier: Modifier = Modifier, +) { + if (games.isEmpty()) { + Column(modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp)) { + StoreScrollableControls(state, onSortChange, onFilterToggle, showToolbar = showToolbar) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + val hasSearch = state.catalogSearch.isNotBlank() + val hasFilters = state.catalogFilterIds.isNotEmpty() + if (hasSearch || hasFilters) { + SearchEmptyState( + title = stringResource(R.string.store_empty_search_title), + message = when { + hasSearch && hasFilters -> stringResource(R.string.store_empty_search_filters_body) + hasSearch -> stringResource(R.string.store_empty_search_body) + else -> stringResource(R.string.store_empty_filters_body) + }, + onClearSearch = if (hasSearch) onClearSearch else null, + onClearFilters = if (hasFilters) onClearFilters else null, + ) + } else { + Text(stringResource(R.string.no_games_loaded), color = TextMuted) + } + } + } + return + } + val scale = settings.posterSizeScale.coerceIn(MIN_GAME_CARD_SCALE, MAX_GAME_CARD_SCALE) + val compact = settings.compactGameCards + val landscapeLayout = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val physicalControllerConnected = rememberPhysicalControllerConnected(enabled = landscapeLayout && !tvProfile) + val controllerActionMode = landscapeLayout && !tvProfile && physicalControllerConnected + val showControlsHeader = showToolbar || state.catalogFilterIds.isNotEmpty() || !state.error.isNullOrBlank() + BoxWithConstraints(modifier.fillMaxSize()) { + val gridSpec = gameGridSpec(maxWidth, compact, landscapeLayout, settings, handheldLayout = !tvProfile) + LazyVerticalGrid( + modifier = Modifier.fillMaxSize(), + state = gridState, + columns = GridCells.Fixed(gridSpec.columns), + contentPadding = gridSpec.contentPadding, + horizontalArrangement = Arrangement.spacedBy(gridSpec.horizontalSpacing), + verticalArrangement = Arrangement.spacedBy(gridSpec.verticalSpacing), + ) { + if (showControlsHeader) { + item(span = { GridItemSpan(maxLineSpan) }) { + StoreScrollableControls(state, onSortChange, onFilterToggle, showToolbar = showToolbar) + } + } + item(span = { GridItemSpan(maxLineSpan) }) { + StoreStartRails( + games = games, + libraryGames = state.libraryGames, + favoriteIds = favoriteIds, + queuedGameKeys = state.queuedGameKeys, + settings = settings, + tvProfile = tvProfile, + controllerActionMode = controllerActionMode, + onSelect = onSelect, + onFavorite = onFavorite, + onPlay = onPlay, + onChooseStore = onChooseStore, + ) + } + if (games.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + Text( + text = "Recommendations", + color = TextPrimary, + fontWeight = FontWeight.ExtraBold, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) + ) + } + } + gridItems(games, key = { it.id }) { game -> + GameCard( + game = game, + favorite = game.id in favoriteIds, + tvProfile = tvProfile, + expressiveUi = settings.expressiveUi, + controllerBackgroundAnimations = settings.controllerBackgroundAnimations, + showGameStoreLabels = shouldShowGameStoreLabels( + tvProfile = tvProfile, + enabled = settings.showGameStoreLabels, + ), + cardHeight = gridSpec.cardHeight * scale, + squareCard = gridSpec.squareCards, + thumbnailPlayOverlay = !tvProfile, + controllerActionMode = controllerActionMode, + onSelect = onSelect, + onFavorite = onFavorite, + onPlay = onPlay, + onChooseStore = onChooseStore, + ) + } + } + } +} + +@Composable +private fun StoreStartRails( + games: List, + libraryGames: List, + favoriteIds: List, + queuedGameKeys: List, + settings: AppSettings, + tvProfile: Boolean, + controllerActionMode: Boolean, + onSelect: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, +) { + val jumpBackIn = remember(games, libraryGames, favoriteIds, queuedGameKeys) { + jumpBackInGames(games, libraryGames, favoriteIds, queuedGameKeys) + } + val comingNext = remember(games, jumpBackIn) { + comingNextStoreGames( + games = games, + excludedGames = jumpBackIn, + ) + } + if (jumpBackIn.isEmpty() && comingNext.isEmpty()) return + Column( + Modifier + .fillMaxWidth() + .padding(top = 2.dp, bottom = 6.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (jumpBackIn.isNotEmpty()) { + StoreRailSection( + title = stringResource(R.string.store_jump_back_in), + games = jumpBackIn, + favoriteIds = favoriteIds, + settings = settings, + tvProfile = tvProfile, + controllerActionMode = controllerActionMode, + onSelect = onSelect, + onFavorite = onFavorite, + onPlay = onPlay, + onChooseStore = onChooseStore, + ) + } + if (comingNext.isNotEmpty()) { + StoreRailSection( + title = stringResource(R.string.store_coming_next), + games = comingNext, + favoriteIds = favoriteIds, + settings = settings, + tvProfile = tvProfile, + controllerActionMode = controllerActionMode, + onSelect = onSelect, + onFavorite = onFavorite, + onPlay = onPlay, + onChooseStore = onChooseStore, + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun StoreComingNextCarousel( + title: String, + games: List, + favoriteIds: List, + settings: AppSettings, + tvProfile: Boolean, + controllerActionMode: Boolean, + onSelect: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, +) { + if (games.isEmpty()) return + val context = LocalContext.current + val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + var page by remember(games) { mutableIntStateOf(0) } + var focused by remember { mutableStateOf(false) } + val enhancedControllerFocus = shouldShowEnhancedControllerFocus( + focused = focused, + tvProfile = tvProfile, + controllerActionMode = controllerActionMode, + ) + LaunchedEffect(games, page, focused) { + if (games.size > 1 && !focused && settings.controllerBackgroundAnimations) { + delay(6_000L) + page = (page + 1) % games.size + } + } + Column( + Modifier + .fillMaxWidth() + .padding(top = 6.dp), + verticalArrangement = Arrangement.spacedBy(9.dp), + ) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + title, + color = TextPrimary, + fontWeight = FontWeight.ExtraBold, + style = MaterialTheme.typography.titleLarge, + ) + Text( + "Fresh arrivals from GeForce NOW", + color = TextMuted, + style = MaterialTheme.typography.labelMedium, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { + games.forEachIndexed { index, _ -> + Box( + Modifier + .width(if (index == page) 22.dp else 7.dp) + .height(5.dp) + .clip(CircleShape) + .background(if (index == page) MaterialTheme.colorScheme.primary else TextMuted.copy(alpha = 0.32f)), + ) + } + } + } + AnimatedContent( + targetState = page, + transitionSpec = { fadeIn(tween(240)) togetherWith fadeOut(tween(180)) }, + label = "coming-next-carousel", + ) { targetPage -> + val featured = games[targetPage.coerceIn(games.indices)] + val shape = RoundedCornerShape(if (settings.expressiveUi) 24.dp else 16.dp) + Surface( + modifier = Modifier + .fillMaxWidth() + .height(if (landscape) 176.dp else 218.dp) + .onFocusChanged { focused = it.isFocused || it.hasFocus } + .border( + width = if (focused) 3.dp else 1.dp, + color = when { + enhancedControllerFocus -> Color.Transparent + focused -> Color.White + else -> Color.White.copy(alpha = 0.08f) + }, + shape = shape, + ) + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false + when { + controllerActionMode && event.key == Key.DirectionLeft && games.size > 1 -> { + page = (page - 1 + games.size) % games.size + true + } + controllerActionMode && event.key == Key.DirectionRight && games.size > 1 -> { + page = (page + 1) % games.size + true + } + !tvProfile && controllerActionMode && handleCatalogControllerAction( + event = event, + onFavorite = { onFavorite(featured.id) }, + onPlay = { onPlay(featured) }, + ) -> true + isTvActivateKey(event) -> { + onSelect(featured) + true + } + else -> false + } + } + .focusable() + .combinedClickable( + onClick = { onSelect(featured) }, + onLongClick = { onChooseStore(featured) }, + onLongClickLabel = stringResource(R.string.store_selector_play_long_press), + ), + shape = shape, + color = Panel, + tonalElevation = if (focused) 5.dp else 0.dp, + shadowElevation = if (focused) 9.dp else 1.dp, + ) { + Box(Modifier.fillMaxSize()) { + UrlImage(gameHeroImageUrl(context, featured), Modifier.fillMaxSize()) + Box( + Modifier + .matchParentSize() + .background( + Brush.horizontalGradient( + listOf(Color.Black.copy(alpha = 0.88f), Color.Black.copy(alpha = 0.3f), Color.Transparent), + ), + ), + ) + Column( + Modifier + .align(Alignment.BottomStart) + .fillMaxWidth(0.66f) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + Text( + featured.title, + color = Color.White, + style = if (landscape) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Black, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + listOfNotNull(featured.publisherName, displayStoresForGame(featured).takeIf { it.isNotBlank() }) + .distinct() + .joinToString(" • "), + color = Color.White.copy(alpha = 0.72f), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (shouldShowCatalogCardActions(tvProfile, controllerActionMode)) { + ThumbnailPlayButton( + onClick = { onPlay(featured) }, + onLongClick = { onChooseStore(featured) }, + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + buttonSize = 50.dp, + ) + FavoriteIconButton( + favorite = featured.id in favoriteIds, + onClick = { onFavorite(featured.id) }, + modifier = Modifier.align(Alignment.TopEnd).padding(14.dp), + size = 38.dp, + ) + } + ControllerFocusFrame( + visible = enhancedControllerFocus, + animate = settings.controllerBackgroundAnimations, + cornerRadius = if (settings.expressiveUi) 24.dp else 16.dp, + ) + } + } + } + } +} + +@Composable +private fun StoreRailSection( + title: String, + games: List, + favoriteIds: List, + settings: AppSettings, + tvProfile: Boolean, + controllerActionMode: Boolean, + onSelect: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, +) { + val landscapeLayout = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + title, + color = TextPrimary, + fontWeight = FontWeight.ExtraBold, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BoxWithConstraints(Modifier.fillMaxWidth()) { + val spacing = 10.dp + val baseCardWidth = storeRailCardWidth(tvProfile, landscapeLayout) + val visibleCount = storeRailVisibleCardCount( + availableWidthDp = maxWidth.value, + baseCardWidthDp = baseCardWidth.value, + spacingDp = spacing.value, + cardScale = settings.posterSizeScale, + ) + val cardWidth = ((maxWidth.value - spacing.value * (visibleCount - 1) - 4.dp.value) / visibleCount) + .coerceAtLeast(1f) + .dp + LazyRow( + horizontalArrangement = Arrangement.spacedBy(spacing), + contentPadding = PaddingValues(horizontal = 2.dp), + ) { + items(games, key = { storeRailGameKey(it) }) { game -> + StoreRailGameCard( + game = game, + favorite = game.id in favoriteIds, + tvProfile = tvProfile, + expressiveUi = settings.expressiveUi, + controllerBackgroundAnimations = settings.controllerBackgroundAnimations, + width = cardWidth, + controllerActionMode = controllerActionMode, + onSelect = onSelect, + onFavorite = onFavorite, + onPlay = onPlay, + onChooseStore = onChooseStore, + ) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun StoreRailGameCard( + game: GameInfo, + favorite: Boolean, + tvProfile: Boolean, + expressiveUi: Boolean, + controllerBackgroundAnimations: Boolean, + width: Dp, + controllerActionMode: Boolean, + onSelect: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, +) { + var focused by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + val shape = RoundedCornerShape(if (expressiveUi) 12.dp else 8.dp) + val actionButtonSize = 34.dp + val enhancedControllerFocus = shouldShowEnhancedControllerFocus( + focused = focused, + tvProfile = tvProfile, + controllerActionMode = controllerActionMode, + ) + Surface( + modifier = Modifier + .width(width) + .aspectRatio(if (tvProfile) 1f else GAME_BOX_ART_ASPECT_RATIO) + .onFocusChanged { focused = it.isFocused || it.hasFocus } + .border( + width = if (focused) 3.dp else 1.dp, + color = when { + enhancedControllerFocus -> Color.Transparent + focused -> Color.White + else -> Color.White.copy(alpha = 0.08f) + }, + shape = shape, + ) + .onPreviewKeyEvent { event -> + when { + !tvProfile && controllerActionMode && handleCatalogControllerAction( + event = event, + onFavorite = { onFavorite(game.id) }, + onPlay = { onPlay(game) }, + ) -> true + isTvActivateKey(event) -> { + onSelect(game) + true + } + else -> handleDpadFocusMove(event, focusManager) + } + } + .focusable() + .combinedClickable( + onClick = { onSelect(game) }, + onLongClick = { onChooseStore(game) }, + onLongClickLabel = stringResource(R.string.store_selector_play_long_press), + ), + shape = shape, + color = Color.Black, + tonalElevation = if (focused) 4.dp else 0.dp, + shadowElevation = if (focused) 8.dp else 1.dp, + ) { + Box(Modifier.fillMaxSize().clip(shape)) { + UrlImage( + catalogCardImageUrl(game, tvProfile), + Modifier.fillMaxSize(), + contentScale = if (tvProfile) ContentScale.Crop else ContentScale.Fit, + ) + if (shouldOverlayCatalogCardTitle(tvProfile)) { + GameCardTitleOverlay(game.title) + } + if (shouldShowCatalogCardActions(tvProfile, controllerActionMode)) { + ThumbnailPlayButton( + onClick = { onPlay(game) }, + onLongClick = { onChooseStore(game) }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp), + buttonSize = actionButtonSize, + ) + FavoriteIconButton( + favorite = favorite, + onClick = { onFavorite(game.id) }, + modifier = Modifier + .align(Alignment.TopStart) + .padding(6.dp), + size = actionButtonSize, + ) + } + ControllerFocusFrame( + visible = enhancedControllerFocus, + animate = controllerBackgroundAnimations, + cornerRadius = if (expressiveUi) 12.dp else 8.dp, + ) + } + } +} + +private fun jumpBackInGames( + games: List, + libraryGames: List, + favoriteIds: List, + queuedGameKeys: List, +): List { + val favoriteSet = favoriteIds.toSet() + val combined = distinctStoreGames(libraryGames + games) + val byKey = combined.associateBy(::storeRailGameKey) + val queued = queuedGameKeys.mapNotNull(byKey::get) + val favorites = combined.filter { it.id in favoriteSet } + val recent = combined + .filter { it.recentPlaySortKey() != null } + .sortedByDescending { it.recentPlaySortKey() } + val owned = combined.filter(::isGameInLibrary) + return distinctStoreGames(queued + favorites + recent + owned).take(STORE_RAIL_GAME_LIMIT) +} + +internal fun comingNextStoreGames( + games: List, + excludedGames: List, +): List { + val excludedKeys = excludedGames.map(::storeRailGameKey).toSet() + return distinctStoreGames(games) + .filterNot { storeRailGameKey(it) in excludedKeys } + .filter(GameInfo::isNewOrUpdatedCatalogSection) + .take(STORE_RAIL_GAME_LIMIT) +} + +private fun GameInfo.isNewOrUpdatedCatalogSection(): Boolean { + val section = catalogSectionTitle?.lowercase(Locale.US)?.trim().orEmpty() + return section.contains("new") || + section.contains("recent") || + section.contains("updated") || + section.contains("just added") +} + +private fun GameInfo.recentPlaySortKey(): String? = + listOfNotNull( + lastPlayed?.takeIf { it.isNotBlank() }, + variants.mapNotNull { it.lastPlayedDate?.takeIf(String::isNotBlank) }.maxOrNull(), + ).maxOrNull() + +private fun distinctStoreGames(games: List): List { + val byKey = linkedMapOf() + games.forEach { game -> + byKey.putIfAbsent(storeRailGameKey(game), game) + } + return byKey.values.toList() +} + +private fun storeRailGameKey(game: GameInfo): String = + gameTrackingKey(game) + +private const val STORE_RAIL_GAME_LIMIT = 14 +private const val GAME_BOX_ART_ASPECT_RATIO = 628f / 888f + +internal fun shouldShowEnhancedControllerFocus( + focused: Boolean, + tvProfile: Boolean, + controllerActionMode: Boolean, +): Boolean = focused && (tvProfile || controllerActionMode) + +internal fun shouldInitiallyFocusGameDetailsPlay(tvProfile: Boolean): Boolean = tvProfile + +internal fun controllerFocusPulseStrokeWidthDp(progress: Float): Float = + 4f + (9f * progress.coerceIn(0f, 1f)) + +internal fun controllerFocusPulseAlpha(progress: Float): Float { + val remaining = 1f - progress.coerceIn(0f, 1f) + return 0.58f * remaining * remaining +} + +private data class GameGridSpec( + val columns: Int, + val cardHeight: Dp, + val horizontalSpacing: Dp, + val verticalSpacing: Dp, + val contentPadding: PaddingValues, + val squareCards: Boolean, +) + +private fun storeRailCardWidth(tvProfile: Boolean, landscapeLayout: Boolean): Dp = + when { + tvProfile -> 158.dp + landscapeLayout -> 146.dp + else -> 142.dp + } + +@Composable +private fun BoxScope.ControllerFocusFrame( + visible: Boolean, + animate: Boolean, + cornerRadius: Dp, +) { + if (!visible) return + val accent = MaterialTheme.colorScheme.primary + if (animate) { + val transition = rememberInfiniteTransition(label = "controller-focus-pulse") + val pulseProgress by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1_100, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Restart, + ), + label = "controller-focus-pulse-progress", + ) + ControllerFocusFrameCanvas( + accent = accent, + cornerRadius = cornerRadius, + pulseProgress = pulseProgress, + ) + } else { + ControllerFocusFrameCanvas( + accent = accent, + cornerRadius = cornerRadius, + pulseProgress = null, + ) + } +} + +@Composable +private fun BoxScope.ControllerFocusFrameCanvas( + accent: Color, + cornerRadius: Dp, + pulseProgress: Float?, +) { + Canvas(Modifier.matchParentSize().padding(2.dp)) { + val outerRadius = (cornerRadius - 2.dp).toPx().coerceAtLeast(0f) + + // Keep every animated pixel on the card edge. The pulse expands by + // widening the outer stroke and fading; it never creates an inner box. + drawRoundRect( + color = accent.copy(alpha = 0.18f), + cornerRadius = CornerRadius(outerRadius, outerRadius), + style = Stroke(width = 8.dp.toPx()), + ) + pulseProgress?.let { progress -> + drawRoundRect( + color = accent.copy(alpha = controllerFocusPulseAlpha(progress)), + cornerRadius = CornerRadius(outerRadius, outerRadius), + style = Stroke(width = controllerFocusPulseStrokeWidthDp(progress).dp.toPx()), + ) + } + drawRoundRect( + color = Color.White.copy(alpha = 0.96f), + cornerRadius = CornerRadius(outerRadius, outerRadius), + style = Stroke(width = 2.dp.toPx()), + ) + } +} + +private fun gameGridSpec( + maxWidth: androidx.compose.ui.unit.Dp, + compact: Boolean, + landscapeLayout: Boolean, + settings: AppSettings, + handheldLayout: Boolean, +): GameGridSpec { + val minimumPortraitColumns = if (!landscapeLayout && handheldLayout) 3 else 2 + val compactHorizontalSpacing = if (compact) 8.dp else 10.dp + val compactVerticalSpacing = if (compact) 10.dp else 12.dp + val landscapeHorizontalSpacing = if (handheldLayout) 10.dp else compactHorizontalSpacing + val landscapeContentHorizontalPadding = 4.dp + return when { + handheldLayout -> GameGridSpec( + columns = scaledGameCardColumnCount( + baseColumns = if (landscapeLayout) { + landscapePosterColumnCount( + maxWidth = maxWidth, + horizontalSpacing = landscapeHorizontalSpacing, + horizontalContentPadding = landscapeContentHorizontalPadding, + handheldLayout = true, + ) + } else { + 3 + }, + cardScale = settings.posterSizeScale, + minimumColumns = if (landscapeLayout) 4 else 2, + ), + cardHeight = if (compact) 188.dp else 214.dp, + horizontalSpacing = landscapeHorizontalSpacing, + verticalSpacing = if (landscapeLayout) 16.dp else compactVerticalSpacing, + contentPadding = PaddingValues(horizontal = landscapeContentHorizontalPadding, vertical = 4.dp), + squareCards = false, + ) + landscapeLayout -> GameGridSpec( + columns = scaledGameCardColumnCount( + baseColumns = landscapePosterColumnCount( + maxWidth = maxWidth, + horizontalSpacing = landscapeHorizontalSpacing, + horizontalContentPadding = landscapeContentHorizontalPadding, + handheldLayout = handheldLayout, + ), + cardScale = settings.posterSizeScale, + minimumColumns = 3, + ), + cardHeight = if (compact) 188.dp else 214.dp, + horizontalSpacing = landscapeHorizontalSpacing, + verticalSpacing = if (handheldLayout) 16.dp else compactVerticalSpacing, + contentPadding = PaddingValues(horizontal = landscapeContentHorizontalPadding, vertical = 4.dp), + squareCards = false, + ) + compact -> GameGridSpec( + columns = scaledGameCardColumnCount( + baseColumns = gameGridColumnCount(maxWidth, minimumPortraitColumns), + cardScale = settings.posterSizeScale, + minimumColumns = minimumPortraitColumns, + ), + cardHeight = 218.dp, + horizontalSpacing = compactHorizontalSpacing, + verticalSpacing = compactVerticalSpacing, + contentPadding = PaddingValues(4.dp), + squareCards = false, + ) + else -> GameGridSpec( + columns = scaledGameCardColumnCount( + baseColumns = gameGridColumnCount(maxWidth, minimumPortraitColumns), + cardScale = settings.posterSizeScale, + minimumColumns = minimumPortraitColumns, + ), + cardHeight = 246.dp, + horizontalSpacing = compactHorizontalSpacing, + verticalSpacing = compactVerticalSpacing, + contentPadding = PaddingValues(4.dp), + squareCards = false, + ) + } +} + +internal fun appContentEdgePaddingDp( + settings: AppSettings, + inStream: Boolean, + tvProfile: Boolean, +): Float = if (inStream || !tvProfile) 0f else settings.tvSafeAreaPaddingDp.coerceIn(0f, 120f) + +internal fun scaledGameCardColumnCount( + baseColumns: Int, + cardScale: Float, + minimumColumns: Int, +): Int = (baseColumns / cardScale.coerceIn(MIN_GAME_CARD_SCALE, MAX_GAME_CARD_SCALE)) + .roundToInt() + .coerceIn(minimumColumns, 12) + +internal fun storeRailVisibleCardCount( + availableWidthDp: Float, + baseCardWidthDp: Float, + spacingDp: Float, + cardScale: Float, +): Int { + val scaledCardWidth = baseCardWidthDp * cardScale.coerceIn(MIN_GAME_CARD_SCALE, MAX_GAME_CARD_SCALE) + return ((availableWidthDp + spacingDp) / (scaledCardWidth + spacingDp)) + .toInt() + .coerceAtLeast(1) +} + +private fun landscapePosterColumnCount( + maxWidth: androidx.compose.ui.unit.Dp, + horizontalSpacing: Dp, + horizontalContentPadding: Dp, + handheldLayout: Boolean, +): Int { + val minCardWidth = when { + handheldLayout -> 96.dp + else -> 150.dp + } + val preferredColumns = when { + handheldLayout && maxWidth >= 520.dp -> 5 + handheldLayout -> 5 + maxWidth >= 1440.dp -> 8 + maxWidth >= 1050.dp -> 7 + maxWidth >= 520.dp -> 6 + else -> 5 + } + val minimumColumns = if (handheldLayout) 4 else 3 + for (columns in preferredColumns downTo minimumColumns) { + if (landscapeCardWidth(maxWidth, columns, horizontalSpacing, horizontalContentPadding) >= minCardWidth) { + return columns + } + } + return minimumColumns +} + +private fun landscapeCardWidth( + maxWidth: androidx.compose.ui.unit.Dp, + columns: Int, + horizontalSpacing: Dp, + horizontalContentPadding: Dp, +): Dp { + val reservedWidth = horizontalContentPadding.value * 2f + horizontalSpacing.value * (columns - 1).coerceAtLeast(0) + return ((maxWidth.value - reservedWidth).coerceAtLeast(0f) / columns.coerceAtLeast(1)).dp +} + +private fun gameGridColumnCount(maxWidth: androidx.compose.ui.unit.Dp, minimumColumns: Int = 2): Int = + when { + maxWidth >= 1100.dp -> 5 + maxWidth >= 840.dp -> 4 + maxWidth >= 600.dp -> 3 + else -> minimumColumns + } + +@Composable +private fun GameCard( + game: GameInfo, + favorite: Boolean, + tvProfile: Boolean, + expressiveUi: Boolean, + controllerBackgroundAnimations: Boolean, + showGameStoreLabels: Boolean, + cardHeight: androidx.compose.ui.unit.Dp, + squareCard: Boolean, + thumbnailPlayOverlay: Boolean, + controllerActionMode: Boolean, + onSelect: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, +) { + var focused by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + val cardShape = RoundedCornerShape(if (expressiveUi) 12.dp else 8.dp) + val handheldPosterCard = !tvProfile + val launcherTile = handheldPosterCard && thumbnailPlayOverlay + val overlayActionSize = if (launcherTile) 34.dp else 44.dp + val overlayActionPadding = if (launcherTile) 6.dp else 8.dp + val enhancedControllerFocus = shouldShowEnhancedControllerFocus( + focused = focused, + tvProfile = tvProfile, + controllerActionMode = controllerActionMode, + ) + Card( + modifier = Modifier + .fillMaxWidth() + .then( + when { + handheldPosterCard -> Modifier.aspectRatio(GAME_BOX_ART_ASPECT_RATIO) + squareCard -> Modifier.aspectRatio(1f) + else -> Modifier.height(cardHeight) + }, + ) + .onFocusChanged { focused = it.isFocused || it.hasFocus } + .border( + width = if (focused) 3.dp else 1.dp, + color = when { + enhancedControllerFocus -> Color.Transparent + focused -> Color.White + else -> Color.Transparent + }, + shape = cardShape, + ) + .onPreviewKeyEvent { event -> + when { + !tvProfile && controllerActionMode && handleCatalogControllerAction( + event = event, + onFavorite = { onFavorite(game.id) }, + onPlay = { onPlay(game) }, + ) -> true + isTvActivateKey(event) -> { + onSelect(game) + true + } + else -> handleDpadFocusMove(event, focusManager) + } + } + .focusable(), + colors = CardDefaults.cardColors( + containerColor = if (expressiveUi) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f) else Panel, + ), + elevation = CardDefaults.cardElevation(defaultElevation = if (focused) 8.dp else 0.dp), + shape = cardShape, + ) { + Box( + Modifier + .weight(1f) + .clickable { onSelect(game) }, + ) { + UrlImage( + catalogCardImageUrl(game, tvProfile), + Modifier.fillMaxSize(), + contentScale = if (handheldPosterCard) ContentScale.Fit else ContentScale.Crop, + ) + if (shouldOverlayCatalogCardTitle(tvProfile)) { + GameCardTitleOverlay(game.title) + } + if (thumbnailPlayOverlay && shouldShowCatalogCardActions(tvProfile, controllerActionMode)) { + FavoriteIconButton( + favorite = favorite, + onClick = { onFavorite(game.id) }, + modifier = Modifier + .align(Alignment.TopStart) + .padding(overlayActionPadding), + size = overlayActionSize, + ) + ThumbnailPlayButton( + onClick = { onPlay(game) }, + onLongClick = { onChooseStore(game) }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(overlayActionPadding), + buttonSize = overlayActionSize, + ) + } + ControllerFocusFrame( + visible = enhancedControllerFocus, + animate = controllerBackgroundAnimations, + cornerRadius = if (expressiveUi) 12.dp else 8.dp, + ) + } + if (!thumbnailPlayOverlay && showGameStoreLabels) { + Column( + Modifier + .clickable { onSelect(game) } + .padding(9.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + if (showGameStoreLabels) { + Text(displayStoresForGame(game), color = TextMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelMedium) + } + } + } + } +} + +internal fun catalogCardImageUrl(game: GameInfo, tvProfile: Boolean): String? { + val source = if (tvProfile) { + game.tvCardImageUrl?.takeIf { it.isNotBlank() } + ?: game.imageUrl?.takeIf { it.isNotBlank() } + } else { + game.imageUrl + ?.takeIf { it.isNotBlank() } + ?.takeIf { !it.contains("img.nvidiagrid.net") || it.contains("/GAME_BOX_ART_") } + } ?: return null + return if (tvProfile) optimizedNvidiaImageUrl(source, 272) else source +} + +internal fun shouldOverlayCatalogCardTitle(tvProfile: Boolean): Boolean = tvProfile + +internal fun shouldShowCatalogCardActions(tvProfile: Boolean, controllerActionMode: Boolean): Boolean = + !tvProfile && !controllerActionMode + +internal fun shouldShowGameStoreLabels(tvProfile: Boolean, enabled: Boolean): Boolean = + enabled && !tvProfile + +@Composable +private fun GameCardTitleOverlay(title: String) { + Box( + Modifier + .fillMaxSize() + .background(GameCardOverlayGradient), + contentAlignment = Alignment.BottomStart, + ) { + Text( + text = title, + color = Color.White, + fontWeight = FontWeight.ExtraBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + ) + } +} + +private fun handleCatalogControllerAction( + event: androidx.compose.ui.input.key.KeyEvent, + onFavorite: () -> Unit, + onPlay: () -> Unit, +): Boolean { + if (event.type != KeyEventType.KeyUp) return false + return when (event.key) { + Key.ButtonX -> { + onFavorite() + true + } + Key.ButtonY -> { + onPlay() + true + } + else -> false + } +} + +@Composable +private fun ControllerCatalogRailActionHints(modifier: Modifier = Modifier) { + Surface( + modifier = modifier.padding(horizontal = 3.dp), + shape = RoundedCornerShape(8.dp), + color = Color.Black.copy(alpha = 0.8f), + tonalElevation = 2.dp, + shadowElevation = 2.dp, + ) { + Column( + Modifier.padding(horizontal = 4.dp, vertical = 5.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + ControllerCatalogActionHint( + button = "X", + label = stringResource(R.string.action_save), + buttonColor = Color(0xff4aa3ff), + ) + ControllerCatalogActionHint( + button = "Y", + label = stringResource(R.string.action_play), + buttonColor = Color(0xffffcf40), + ) + } + } +} + +@Composable +private fun ControllerCatalogActionHint( + button: String, + label: String, + buttonColor: Color, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Surface( + modifier = Modifier.size(18.dp), + shape = CircleShape, + color = buttonColor, + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + button, + color = Color.Black, + fontWeight = FontWeight.Black, + style = MaterialTheme.typography.labelSmall, + ) + } + } + Text( + label, + color = Color.White, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +internal fun launcherBadgeForStoreKey(storeKey: String?): LauncherBadge = + when (storeKey) { + "STEAM" -> LauncherBadge(R.drawable.ic_store_steam, "Steam", Color(0xff17324d)) + "EPIC", "EGS", "EPIC_GAMES_STORE" -> LauncherBadge(R.drawable.ic_store_epic, "Epic", Color(0xff111111)) + "HOYO", "HOYOVERSE", "HOYOPLAY", "HOYO_PLAY", "MIHOYO" -> LauncherBadge(R.drawable.ic_store_hoyo, "HoYo", Color(0xff2b62d9)) + "XBOX", "XBOX_GAME_PASS", "GAME_PASS" -> LauncherBadge(R.drawable.ic_store_xbox, "Xbox", Color(0xff107c10)) + "MICROSOFT", "MICROSOFT_STORE" -> LauncherBadge(R.drawable.ic_store_microsoft, "Microsoft Store", Color(0xff0067b8)) + "UBISOFT", "UBISOFT_CONNECT" -> LauncherBadge(R.drawable.ic_store_ubisoft, "Ubisoft Connect", Color(0xff006efc)) + "EA", "EA_APP", "ORIGIN" -> LauncherBadge(R.drawable.ic_store_ea, "EA app", Color(0xffff4747)) + "GOG", "GOG.COM", "GOG_COM" -> LauncherBadge(R.drawable.ic_store_gog, "GOG", Color(0xff6a35a8)) + "BATTLENET", "BATTLE.NET", "BATTLE_NET", "BLIZZARD" -> LauncherBadge(R.drawable.ic_store_battlenet, "Battle.net", Color(0xff148eff)) + "RIOT", "RIOT_CLIENT", "RIOT_GAMES" -> LauncherBadge(R.drawable.ic_store_riot, "Riot", Color(0xffd13639)) + "ROCKSTAR", "ROCKSTAR_GAMES", "ROCKSTAR_GAMES_LAUNCHER" -> LauncherBadge(R.drawable.ic_store_rockstar, "Rockstar", Color(0xffffc400), Color(0xff111111)) + "NCSOFT", "NC_SOFT", "PURPLE" -> LauncherBadge(R.drawable.ic_tab_store, "NCSOFT", Color(0xffb4822d), Color(0xff111111)) + "GOOGLE_PLAY", "PLAY_STORE", "ANDROID" -> LauncherBadge(R.drawable.ic_store_google_play, "Google Play", Color(0xff0f9d58)) + "AMAZON", "AMAZON_GAMES" -> LauncherBadge(R.drawable.ic_store_amazon, "Amazon Games", Color(0xffff9900), Color(0xff111111)) + else -> LauncherBadge(R.drawable.ic_tab_store, "GeForce NOW", Color.Black.copy(alpha = 0.72f)) + } + +private fun displayStoresForGame(game: GameInfo): String { + val stores = displayStoresForVariants(game.variants).ifEmpty { + game.availableStores.map(::gameStoreDisplayName) + }.distinctBy { normalizeGameStore(it) } + return stores.joinToString(", ").ifBlank { "GeForce NOW" } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ThumbnailPlayButton(onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, buttonSize: Dp = 44.dp) { + Surface( + modifier = modifier + .size(buttonSize) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + onLongClickLabel = stringResource(R.string.store_selector_play_long_press), + ), + shape = CircleShape, + color = Color.Black.copy(alpha = 0.35f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f)), + ) { + ZortosPlayMark( + modifier = Modifier.fillMaxSize().padding(buttonSize * 0.13f), + ringColor = Color.White, + ) + } +} + +@Composable +private fun ZortosPlayMark( + modifier: Modifier = Modifier, + ringColor: Color = MaterialTheme.colorScheme.primary, + playColor: Color = ringColor, +) { + Canvas(modifier) { + val play = Path().apply { + moveTo(size.width * 0.35f, size.height * 0.25f) + lineTo(size.width * 0.35f, size.height * 0.75f) + lineTo(size.width * 0.75f, size.height * 0.5f) + close() + } + drawPath(play, playColor) + } +} + +@Composable +private fun AnimatedLaunchOverlay(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + val visibleState = remember { + MutableTransitionState(false).apply { + targetState = true + } + } + AnimatedVisibility( + visibleState = visibleState, + enter = fadeIn() + slideInVertically(initialOffsetY = { it / 4 }) + scaleIn(initialScale = 0.94f), + exit = fadeOut() + slideOutVertically(targetOffsetY = { it / 4 }) + scaleOut(targetScale = 0.94f), + modifier = modifier, + ) { + content() + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun GameDetailsSheet( + game: GameInfo, + favorite: Boolean, + defaultVariantId: String?, + fullScreen: Boolean, + safeAreaPadding: Dp, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + connectedTvName: String?, + onPlayOnTv: (GameInfo) -> Unit, + onDismiss: () -> Unit, +) { + val gameFocusRequester = remember(game.id) { FocusRequester() } + val playFocusRequester = remember(game.id) { FocusRequester() } + LaunchedEffect(game.id, fullScreen) { + delay(80) + val initialRequester = if (shouldInitiallyFocusGameDetailsPlay(tvProfile = fullScreen)) { + playFocusRequester + } else { + gameFocusRequester + } + runCatching { initialRequester.requestFocus() } + } + BackHandler(onBack = onDismiss) + Box( + Modifier + .fillMaxSize() + .lockedFocusGroup() + .background(Color.Black.copy(alpha = 0.72f)) + .clickable(onClick = onDismiss), + contentAlignment = Alignment.BottomCenter, + ) { + Surface( + modifier = Modifier + .then( + if (fullScreen) { + Modifier.fillMaxSize() + } else { + Modifier + .fillMaxWidth() + .fillMaxHeight(0.92f) + }, + ) + .clickable(onClick = {}), + shape = if (fullScreen) RoundedCornerShape(0.dp) else RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + color = Panel, + tonalElevation = 8.dp, + ) { + BoxWithConstraints( + Modifier + .fillMaxSize() + .padding(if (fullScreen) safeAreaPadding else 0.dp), + ) { + val aspect = if (maxHeight.value > 0f) maxWidth.value / maxHeight.value else 1f + val landscapeTvLayout = maxWidth >= 720.dp && aspect >= 1.35f + val phoneLandscapeLayout = landscapeTvLayout && minOf(maxWidth, maxHeight) < PHONE_NAV_RAIL_MAX_SMALLEST_WIDTH + if (landscapeTvLayout) { + GameDetailsLandscapeContent( + game = game, + favorite = favorite, + defaultVariantId = defaultVariantId, + onPlay = onPlay, + onChooseStore = onChooseStore, + onFavorite = onFavorite, + connectedTvName = connectedTvName, + onPlayOnTv = onPlayOnTv, + onDismiss = onDismiss, + gameFocusRequester = gameFocusRequester, + playFocusRequester = playFocusRequester, + shortHeight = maxHeight <= 620.dp, + imageActionsOverlay = phoneLandscapeLayout, + ) + } else { + GameDetailsScrollableContent( + game = game, + favorite = favorite, + defaultVariantId = defaultVariantId, + onPlay = onPlay, + onChooseStore = onChooseStore, + onFavorite = onFavorite, + connectedTvName = connectedTvName, + onPlayOnTv = onPlayOnTv, + onDismiss = onDismiss, + gameFocusRequester = gameFocusRequester, + playFocusRequester = playFocusRequester, + ) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun GameDetailsLandscapeContent( + game: GameInfo, + favorite: Boolean, + defaultVariantId: String?, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + connectedTvName: String?, + onPlayOnTv: (GameInfo) -> Unit, + onDismiss: () -> Unit, + gameFocusRequester: FocusRequester, + playFocusRequester: FocusRequester, + shortHeight: Boolean, + imageActionsOverlay: Boolean, +) { + val description = gameDescriptionForDetails(game) + val context = LocalContext.current + val sideScrollState = rememberScrollState() + val detailsSpacing = if (shortHeight) 8.dp else 10.dp + var gameFocused by remember(game.id) { mutableStateOf(false) } + Row( + Modifier + .fillMaxSize() + .padding(horizontal = if (shortHeight) 18.dp else 24.dp, vertical = if (shortHeight) 16.dp else 22.dp), + horizontalArrangement = Arrangement.spacedBy(if (shortHeight) 16.dp else 22.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .weight(0.92f) + .fillMaxHeight() + .focusRequester(gameFocusRequester) + .focusProperties { right = playFocusRequester } + .onFocusChanged { gameFocused = it.isFocused } + .border( + width = if (gameFocused) 3.dp else 1.dp, + color = if (gameFocused) MaterialTheme.colorScheme.primary else Color.White.copy(alpha = 0.12f), + shape = RoundedCornerShape(20.dp), + ) + .clip(RoundedCornerShape(20.dp)) + .clickable { + onDismiss() + onPlay(game) + }, + ) { + UrlImage(gameHeroImageUrl(context, game), Modifier.fillMaxSize()) + GameImageTitleOverlay( + game = game, + compact = shortHeight, + reserveEndSpace = imageActionsOverlay, + modifier = Modifier.align(Alignment.BottomStart), + ) + if (imageActionsOverlay) { + ImageCloseButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.TopStart) + .padding(10.dp), + ) + } + if (imageActionsOverlay) { + Column( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(14.dp) + .width(150.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + connectedTvName?.let { tvName -> + OutlinedButton( + onClick = { + onDismiss() + onPlayOnTv(game) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Play on TV", maxLines = 1) + } + } + LongPressPlayButton( + onClick = { + onDismiss() + onPlay(game) + }, + onLongClick = { + onDismiss() + onChooseStore(game) + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(playFocusRequester), + ) + } + } + } + + Column( + Modifier + .weight(1.08f) + .fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(detailsSpacing), + ) { + if (imageActionsOverlay) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(sideScrollState), + verticalArrangement = Arrangement.spacedBy(detailsSpacing), + ) { + GameDetailsCompactInfoContent( + game = game, + defaultVariantId = defaultVariantId, + description = description, + ) + } + } else { + Column( + Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(sideScrollState), + verticalArrangement = Arrangement.spacedBy(detailsSpacing), + ) { + GameDetailsCompactInfoContent( + game = game, + defaultVariantId = defaultVariantId, + description = description, + ) + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + var dismissFocused by remember { mutableStateOf(false) } + val accent = MaterialTheme.colorScheme.primary + OutlinedButton( + onClick = onDismiss, + border = BorderStroke(1.dp, if (dismissFocused) accent else MaterialTheme.colorScheme.outline), + modifier = Modifier + .weight(1f) + .height(48.dp) + .onFocusChanged { dismissFocused = it.isFocused } + ) { + Text( + "Dismiss", + color = if (dismissFocused) accent else TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + LongPressPlayButton( + onClick = { + onDismiss() + onPlay(game) + }, + onLongClick = { + onDismiss() + onChooseStore(game) + }, + modifier = Modifier + .weight(1f) + .focusRequester(playFocusRequester), + ) + connectedTvName?.let { + OutlinedButton( + onClick = { + onDismiss() + onPlayOnTv(game) + }, + modifier = Modifier.weight(1f).height(48.dp), + ) { + Text("Play on TV", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + } + } +} + +@Composable +private fun GameDetailsCompactInfoContent( + game: GameInfo, + defaultVariantId: String?, + description: String?, +) { + OwnershipStatusRow(game = game, compact = true) + GameGenreChips(game = game, compact = true) + GameScreenshotGallery(game = game, compact = true) + GameDescriptionDisclosure(description = description, compact = true) + CompactDetailRows(game) + LaunchOptionsList( + game = game, + defaultVariantId = defaultVariantId, + compact = true, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun GameDetailsScrollableContent( + game: GameInfo, + favorite: Boolean, + defaultVariantId: String?, + onPlay: (GameInfo) -> Unit, + onChooseStore: (GameInfo) -> Unit, + onFavorite: (String) -> Unit, + connectedTvName: String?, + onPlayOnTv: (GameInfo) -> Unit, + onDismiss: () -> Unit, + gameFocusRequester: FocusRequester, + playFocusRequester: FocusRequester, +) { + val context = LocalContext.current + var gameFocused by remember(game.id) { mutableStateOf(false) } + Column(Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(bottom = 18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { + Box( + Modifier + .fillMaxWidth() + .height(220.dp) + .padding(horizontal = 10.dp, vertical = 6.dp) + .focusRequester(gameFocusRequester) + .focusProperties { down = playFocusRequester } + .onFocusChanged { gameFocused = it.isFocused } + .border( + width = if (gameFocused) 3.dp else 1.dp, + color = if (gameFocused) MaterialTheme.colorScheme.primary else Color.White.copy(alpha = 0.12f), + shape = RoundedCornerShape(18.dp), + ) + .clip(RoundedCornerShape(18.dp)) + .clickable { + onDismiss() + onPlay(game) + }, + ) { + UrlImage( + gameHeroImageUrl(context, game), + Modifier.fillMaxSize(), + ) + GameImageTitleOverlay( + game = game, + compact = false, + reserveEndSpace = false, + modifier = Modifier.align(Alignment.BottomStart), + ) + } + } + item { + Column(Modifier.padding(horizontal = 18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + val description = gameDescriptionForDetails(game) + OwnershipStatusRow(game = game, compact = false) + GameGenreChips(game = game, compact = false) + GameScreenshotGallery(game = game, compact = false) + GameDescriptionDisclosure(description = description, compact = false) + DetailRows(game) + LaunchOptionsList( + game = game, + defaultVariantId = defaultVariantId, + compact = false, + ) + } + } + } + Surface(color = Panel.copy(alpha = 0.98f), tonalElevation = 8.dp) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + var dismissFocused by remember { mutableStateOf(false) } + val accent = MaterialTheme.colorScheme.primary + OutlinedButton( + onClick = onDismiss, + border = BorderStroke(1.dp, if (dismissFocused) accent else MaterialTheme.colorScheme.outline), + modifier = Modifier + .weight(1f) + .height(48.dp) + .onFocusChanged { dismissFocused = it.isFocused } + ) { + Text( + "Dismiss", + color = if (dismissFocused) accent else TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + LongPressPlayButton( + onClick = { + onDismiss() + onPlay(game) + }, + onLongClick = { + onDismiss() + onChooseStore(game) + }, + modifier = Modifier + .weight(1f) + .focusRequester(playFocusRequester), + ) + connectedTvName?.let { + OutlinedButton( + onClick = { + onDismiss() + onPlayOnTv(game) + }, + modifier = Modifier.weight(1f).height(48.dp), + ) { + Text("Play on TV", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + } +} + +@Composable +private fun LaunchOptionsList( + game: GameInfo, + defaultVariantId: String?, + compact: Boolean, +) { + val variants = launchableGameVariants(game.variants) + if (variants.size <= 1) return + Column(verticalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 8.dp)) { + Text( + stringResource(R.string.store_selector_launchers), + color = TextMuted, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + variants.take(if (compact) 3 else variants.size).forEach { variant -> + val isDefault = variant.id == defaultVariantId + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(if (compact) 12.dp else 14.dp), + color = if (isDefault) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) else PanelAlt, + contentColor = TextPrimary, + ) { + Row( + Modifier.padding(horizontal = if (compact) 10.dp else 12.dp, vertical = if (compact) 8.dp else 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(Modifier.weight(1f)) { + Text(gameStoreDisplayName(variant.store), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + val details = variantDetailsText(variant) + Text( + if (isDefault) { + listOf(stringResource(R.string.store_selector_default), details).filter { it.isNotBlank() }.joinToString(" - ") + } else { + details.ifBlank { stringResource(R.string.store_selector_available_launcher) } + }, + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = if (compact) 1 else 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun LongPressPlayButton( + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val controllerFocusEnabled = LocalControllerFocusEnabled.current + var focused by remember { mutableStateOf(false) } + val controllerFocused = focused && controllerFocusEnabled + val shape = RoundedCornerShape(999.dp) + val accent = MaterialTheme.colorScheme.primary + val focusScale by animateFloatAsState( + targetValue = gameDetailsPlayFocusScale(controllerFocused), + animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing), + label = "game-details-play-focus-scale", + ) + val containerColor by animateColorAsState( + targetValue = if (controllerFocused) Color.White else accent, + animationSpec = tween(durationMillis = 120), + label = "game-details-play-focus-color", + ) + Surface( + modifier = modifier + .height(48.dp) + .onFocusChanged { focusState -> focused = focusState.isFocused } + .graphicsLayer { + scaleX = focusScale + scaleY = focusScale + } + .onPreviewKeyEvent { event -> + if (isTvActivateKey(event)) { + onClick() + true + } else { + false + } + } + .focusable() + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + onLongClickLabel = stringResource(R.string.store_selector_play_long_press), + ) + .then( + if (controllerFocused) { + Modifier.border( + width = gameDetailsPlayFocusBorderWidthDp(controllerFocused).dp, + color = accent, + shape = shape, + ) + } else { + Modifier + }, + ), + shape = shape, + color = containerColor, + tonalElevation = 0.dp, + shadowElevation = if (controllerFocused) 12.dp else 0.dp, + ) { + Row( + Modifier.fillMaxSize().padding(horizontal = 18.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + ZortosPlayMark( + modifier = Modifier.size(20.dp), + ringColor = Color.Black, + ) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.action_play), + color = Color.Black, + fontWeight = if (controllerFocused) FontWeight.ExtraBold else FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +internal fun gameDetailsPlayFocusScale(focused: Boolean): Float = if (focused) 1.06f else 1f + +internal fun gameDetailsPlayFocusBorderWidthDp(focused: Boolean): Float = if (focused) 4f else 0f + +private fun variantDetailsText(variant: GameVariant): String = + listOfNotNull( + variant.libraryStatus?.takeIf { it.isNotBlank() }?.let(::formatGameMetadataLabel), + variant.supportedControls.takeIf { it.isNotEmpty() }?.joinToString(", ") { formatGameMetadataLabel(it) }, + variant.lastPlayedDate?.takeIf { it.isNotBlank() }?.let { "Last played $it" }, + ).joinToString(" - ") + +@Composable +private fun ImageCloseButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + var focused by remember { mutableStateOf(false) } + val accent = MaterialTheme.colorScheme.primary + Surface( + modifier = modifier + .size(44.dp) + .onFocusChanged { focused = it.isFocused } + .border( + width = 2.dp, + color = if (focused) accent else Color.Transparent, + shape = CircleShape + ) + .clickable(onClick = onClick), + shape = CircleShape, + color = Color.Black.copy(alpha = 0.58f), + tonalElevation = 3.dp, + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(R.drawable.ic_clear), + contentDescription = stringResource(R.string.action_cancel), + tint = TextPrimary, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun FavoriteIconButton(favorite: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, size: Dp = 44.dp) { + val label = stringResource(if (favorite) R.string.action_saved else R.string.action_save) + var focused by remember { mutableStateOf(false) } + val accent = MaterialTheme.colorScheme.primary + Surface( + modifier = modifier + .size(size) + .onFocusChanged { focused = it.isFocused } + .semantics { contentDescription = label } + .clickable(onClick = onClick) + .focusable() + .then( + if (focused) Modifier.border(2.dp, accent, CircleShape) else Modifier + ), + shape = CircleShape, + color = Color.Black.copy(alpha = 0.35f), + tonalElevation = 0.dp, + border = BorderStroke(1.dp, if (focused) accent else Color.White.copy(alpha = 0.2f)), + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(if (favorite) R.drawable.ic_save_filled else R.drawable.ic_save), + contentDescription = null, + tint = if (favorite) MaterialTheme.colorScheme.primary else TextPrimary, + modifier = Modifier.size(size * 0.5f), + ) + } + } +} + +internal fun gameDescriptionForDetails(game: GameInfo): String? = + game.description?.takeIf { it.isNotBlank() } + ?: game.longDescription?.takeIf { it.isNotBlank() } + +private fun gameHeroImageUrl(context: Context, game: GameInfo?): String? { + val url = game?.screenshotUrl?.takeIf { it.isNotBlank() } + ?: game?.tvBannerUrl?.takeIf { it.isNotBlank() } + ?: game?.imageUrl?.takeIf { it.isNotBlank() } + ?: return null + return optimizedNvidiaImageUrl(url, wideImageRequestWidth(context)) +} + +private fun gameTvBannerImageUrl(context: Context, game: GameInfo?): String? { + val url = game?.tvBannerUrl?.takeIf { it.isNotBlank() } + ?: game?.screenshotUrl?.takeIf { it.isNotBlank() } + ?: game?.imageUrl?.takeIf { it.isNotBlank() } + ?: return null + return optimizedNvidiaImageUrl(url, wideImageRequestWidth(context)) +} + +private fun optimizedNvidiaImageUrl(url: String, width: Int): String { + if (!url.contains("img.nvidiagrid.net")) return url + val base = url + .substringBefore(";f=") + .substringBefore(";w=") + .substringBefore(";h=") + .substringBefore(";dpr=") + return "$base;f=webp;w=$width" +} + +private fun wideImageRequestWidth(context: Context): Int { + val connectivity = context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + val capabilities = connectivity?.getNetworkCapabilities(connectivity.activeNetwork) + val downstreamKbps = capabilities?.linkDownstreamBandwidthKbps ?: 0 + return when { + downstreamKbps >= 25_000 -> 1920 + downstreamKbps in 10_000 until 25_000 -> 1600 + downstreamKbps in 3_000 until 10_000 -> 1280 + downstreamKbps in 1 until 3_000 -> 960 + capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) == true -> 1600 + capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> 960 + else -> 1280 + } +} + +@Composable +private fun GameImageTitleOverlay( + game: GameInfo, + compact: Boolean, + reserveEndSpace: Boolean, + modifier: Modifier = Modifier, +) { + val textShadow = Shadow( + color = Color.Black, + offset = Offset(0f, 3f), + blurRadius = 14f, + ) + Column( + modifier + .fillMaxWidth() + .padding( + start = if (compact) 12.dp else 16.dp, + top = if (compact) 9.dp else 12.dp, + end = if (reserveEndSpace) 154.dp else if (compact) 12.dp else 16.dp, + bottom = if (compact) 10.dp else 14.dp, + ), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + game.title, + color = TextPrimary, + style = (if (compact) MaterialTheme.typography.titleLarge else MaterialTheme.typography.headlineSmall).copy( + shadow = textShadow, + ), + fontWeight = FontWeight.Bold, + maxLines = if (compact) 2 else 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + game.publisherName?.takeIf { it.isNotBlank() } ?: "Unknown publisher", + color = TextPrimary.copy(alpha = 0.88f), + style = MaterialTheme.typography.bodyMedium.copy(shadow = textShadow), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun GameTitleBlock(game: GameInfo, compact: Boolean) { + Column(verticalArrangement = Arrangement.spacedBy(if (compact) 3.dp else 5.dp)) { + Text( + game.title, + color = TextPrimary, + style = if (compact) MaterialTheme.typography.titleLarge else MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + maxLines = if (compact) 2 else 3, + overflow = TextOverflow.Ellipsis, + ) + Text( + game.publisherName?.takeIf { it.isNotBlank() } ?: "Unknown publisher", + color = TextMuted, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun OwnershipStatusRow(game: GameInfo, compact: Boolean) { + val ownedStores = ownedStoreLabels(game) + val shape = RoundedCornerShape(if (compact) 12.dp else 14.dp) + if (ownedStores.isEmpty()) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = shape, + color = Color(0xff4a1216), + tonalElevation = 0.dp, + ) { + Text( + "Not owned", + color = Color(0xffffb8bf), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 12.dp, vertical = if (compact) 8.dp else 10.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + return + } + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + ownedStores.forEach { store -> + val badge = launcherBadgeForStoreKey(normalizeGameStore(store)) + Surface( + shape = shape, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.16f), + tonalElevation = 0.dp, + ) { + Row( + Modifier.padding(horizontal = 10.dp, vertical = if (compact) 6.dp else 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ConnectorStoreIcon(badge) + Text( + "Owned on $store", + color = TextPrimary, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +private fun ownedStoreLabels(game: GameInfo): List = + libraryStoreDisplayNames(game).ifEmpty { + if (isGameInLibrary(game)) listOf("GeForce NOW") else emptyList() + } + +@Composable +private fun GameGenreChips(game: GameInfo, compact: Boolean) { + val genres = game.genres + .map { it.trim() } + .filter { it.isNotBlank() } + .map(::formatGameMetadataLabel) + .filterNot(::isNoisyGameTag) + .distinctBy { it.lowercase(Locale.US) } + .take(if (compact) 12 else 20) + if (genres.isEmpty()) return + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 7.dp), + contentPadding = PaddingValues(end = if (compact) 6.dp else 8.dp), + ) { + items(genres, key = { it }) { label -> + AssistChip(onClick = {}, label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) }) + } + } +} + +@Composable +private fun GameScreenshotGallery(game: GameInfo, compact: Boolean) { + val screenshots = game.screenshotUrls + .map(String::trim) + .filter(String::isNotBlank) + .distinct() + if (screenshots.isEmpty()) return + val context = LocalContext.current + val requestWidth = remember(context) { wideImageRequestWidth(context).coerceAtLeast(960) } + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(if (compact) 7.dp else 9.dp), + ) { + Text( + "Screenshots", + color = TextPrimary, + style = if (compact) MaterialTheme.typography.labelLarge else MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 10.dp), + contentPadding = PaddingValues(end = 8.dp), + ) { + items(screenshots, key = { it }) { screenshot -> + Surface( + modifier = Modifier + .width(if (compact) 224.dp else 288.dp) + .aspectRatio(16f / 9f), + shape = RoundedCornerShape(if (compact) 12.dp else 14.dp), + color = Color.Black, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)), + ) { + UrlImage( + url = optimizedNvidiaImageUrl(screenshot, requestWidth), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } + } + } + } +} + +@Composable +private fun GameDescriptionDisclosure(description: String?, compact: Boolean) { + var expanded by remember(description) { mutableStateOf(true) } + val text = description?.takeIf { it.isNotBlank() } ?: "No description is available for this game yet." + var focused by remember { mutableStateOf(false) } + val accent = MaterialTheme.colorScheme.primary + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(if (compact) 12.dp else 14.dp)) + .onFocusChanged { focused = it.isFocused } + .border( + width = 1.dp, + color = if (focused) accent else Color.Transparent, + shape = RoundedCornerShape(if (compact) 12.dp else 14.dp) + ) + .clickable { expanded = !expanded }, + shape = RoundedCornerShape(if (compact) 12.dp else 14.dp), + color = if (focused) PanelAlt.copy(alpha = 0.85f) else PanelAlt, + tonalElevation = 0.dp, + ) { + Column(Modifier.padding(horizontal = 12.dp, vertical = if (compact) 8.dp else 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Description", + color = TextPrimary, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { expanded = !expanded }, modifier = Modifier.size(36.dp)) { + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = if (expanded) "Hide description" else "Show description", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(20.dp) + .graphicsLayer(rotationZ = if (expanded) 90f else 0f), + ) + } + } + if (expanded) { + Text( + text, + color = if (description == null) TextMuted else TextPrimary, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (compact) 8 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +private fun formatGameMetadataLabel(raw: String): String { + val compact = raw.trim() + .removePrefix("GFN_") + .removePrefix("GAME_") + .replace(Regex("[_-]+"), " ") + .replace(Regex("\\s+"), " ") + .trim() + if (compact.isBlank()) return "" + val lower = compact.lowercase(Locale.US) + return when (lower) { + "full game" -> "Full game" + "single player" -> "Single-player" + "multi player", "multiplayer" -> "Multiplayer" + "controller", "gamepad" -> "Controller" + "keyboard mouse", "mouse keyboard" -> "Mouse and keyboard" + else -> compact.split(" ").joinToString(" ") { word -> + if (word.length <= 3 && word.all { it.isUpperCase() || it.isDigit() }) { + word + } else { + word.lowercase(Locale.US).replaceFirstChar { char -> char.titlecase(Locale.US) } + } + } + } +} + +private fun isNoisyGameTag(label: String): Boolean { + val normalized = label.trim().lowercase(Locale.US) + return normalized.isBlank() || + normalized == "unknown" || + normalized == "gfn" || + normalized == "nvidia" || + normalized.contains("sku based tag") || + normalized.contains("catalog") +} + +@Composable +private fun CompactDetailRows(game: GameInfo) { + val rows = gameDetailRows(game).take(4) + if (rows.isEmpty()) return + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + rows.forEach { row -> + DetailRow(row = row, compact = true) + } + } +} + +@Composable +private fun DetailRows(game: GameInfo) { + val rows = gameDetailRows(game) + if (rows.isEmpty()) return + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + rows.forEach { row -> + DetailRow(row = row, compact = false) + } + } +} + +private data class GameDetailRow( + val label: String, + val value: String, + val copyValue: String? = null, +) + +private fun gameDetailRows(game: GameInfo): List = + listOfNotNull( + game.playabilityState?.takeIf { it.isNotBlank() }?.let { GameDetailRow("Status", formatGameMetadataLabel(it)) }, + gameAppIdForDetails(game)?.let { GameDetailRow("App ID", it, copyValue = it) }, + game.contentRatings.takeIf { it.isNotEmpty() }?.joinToString(", ")?.let { GameDetailRow("Rating", it) }, + game.lastPlayed?.takeIf { it.isNotBlank() }?.let { GameDetailRow("Last played", it) }, + game.availableStores.takeIf { it.isNotEmpty() }?.map(::gameStoreDisplayName)?.distinct()?.joinToString(", ")?.let { GameDetailRow("Stores", it) }, + ) + +private fun gameAppIdForDetails(game: GameInfo): String? = + game.launchAppId?.takeIf { it.isNotBlank() } + ?: game.variants.firstNotNullOfOrNull { variant -> variant.id.takeIf { it.isNotBlank() && it.all(Char::isDigit) } } + ?: game.uuid?.takeIf { it.isNotBlank() } + ?: game.id.takeIf { it.isNotBlank() } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun DetailRow(row: GameDetailRow, compact: Boolean) { + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val shape = RoundedCornerShape(if (compact) 10.dp else 12.dp) + Row( + Modifier + .fillMaxWidth() + .clip(shape) + .background(PanelAlt) + .combinedClickable( + onClick = {}, + onLongClick = row.copyValue?.let { value -> + { + clipboard.setText(AnnotatedString(value)) + Toast.makeText(context, "App ID copied", Toast.LENGTH_SHORT).show() + } + }, + ) + .padding(horizontal = if (compact) 10.dp else 12.dp, vertical = if (compact) 7.dp else 10.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(if (compact) 10.dp else 12.dp), + ) { + Text( + row.label, + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.width(if (compact) 82.dp else 92.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + if (row.copyValue != null) "${row.value}" else row.value, + color = TextPrimary, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + maxLines = if (compact) 1 else 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private fun gameMatchesSearch(game: GameInfo, query: String): Boolean { + val normalized = query.trim().lowercase() + if (normalized.isBlank()) return true + val haystack = buildString { + append(game.title).append(' ') + append(game.description.orEmpty()).append(' ') + append(game.longDescription.orEmpty()).append(' ') + append(game.publisherName.orEmpty()).append(' ') + append(game.genres.joinToString(" ")).append(' ') + append(game.featureLabels.joinToString(" ")).append(' ') + append(displayStoresForGame(game)) + }.lowercase() + return normalized.split(Regex("\\s+")).all { it in haystack } +} + +private fun favoriteOrderedGames(games: List, favoriteIds: List): List { + val favorites = games.filter { it.id in favoriteIds } + return if (favorites.isNotEmpty()) favorites + games.filterNot { it.id in favoriteIds } else games +} + +@Composable +@OptIn(ExperimentalLayoutApi::class) +private fun StoreLaunchSelector( + game: GameInfo, + defaultVariantId: String?, + onLaunch: (GameInfo, GameVariant) -> Unit, + onSetDefaultStore: (String, String?) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val variants = remember(game) { launchableGameVariants(game.variants) } + val context = LocalContext.current + val initialVariantId = remember(game.id, defaultVariantId, variants) { + defaultVariantId?.takeIf { savedId -> variants.any { it.id == savedId } } + ?: variants.firstOrNull()?.id + } + var selectedVariantId by remember(game.id, initialVariantId) { mutableStateOf(initialVariantId) } + var rememberDefaultStore by remember(game.id, defaultVariantId) { mutableStateOf(defaultVariantId != null) } + val selectedVariant = variants.firstOrNull { it.id == selectedVariantId } + val continueFocusRequester = remember(game.id) { FocusRequester() } + BackHandler(onBack = onDismiss) + LaunchedEffect(game.id, variants.size) { + if (variants.isNotEmpty()) { + runCatching { continueFocusRequester.requestFocus() } + } + } + BoxWithConstraints( + Modifier + .fillMaxSize() + .lockedFocusGroup() + .background(Color.Black.copy(alpha = 0.72f)) + .clickable(enabled = false) {}, + ) { + val phoneLandscape = isPhoneLandscape(maxWidth, maxHeight) + val landscape = maxWidth > maxHeight + Box( + Modifier.fillMaxSize(), + contentAlignment = if (phoneLandscape) Alignment.CenterEnd else Alignment.Center, + ) { + Card( + modifier = modifier + .then( + if (phoneLandscape) { + Modifier + .padding(end = 12.dp) + .fillMaxWidth(0.9f) + .fillMaxHeight(0.9f) + } else { + Modifier + .fillMaxWidth(if (landscape) 0.78f else 0.92f) + .fillMaxHeight(if (landscape) 0.86f else 0.64f) + }, + ), + colors = CardDefaults.cardColors(containerColor = Panel, contentColor = TextPrimary), + shape = RoundedCornerShape(22.dp), + ) { + if (phoneLandscape) { + Row( + Modifier.fillMaxSize().padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LaunchGameSummary( + game = game, + subtitle = stringResource(R.string.store_selector_choose_launcher), + modifier = Modifier + .width(190.dp) + .fillMaxHeight(), + ) + StoreLaunchOptionsColumn( + variants = variants, + selectedVariantId = selectedVariantId, + defaultVariantId = defaultVariantId, + rememberDefaultStore = rememberDefaultStore, + selectedVariant = selectedVariant, + continueFocusRequester = continueFocusRequester, + onSelectVariant = { selectedVariantId = it }, + onRememberDefaultStoreChange = { rememberDefaultStore = it }, + onDismiss = onDismiss, + onContinue = { variant -> + if (rememberDefaultStore || defaultVariantId != null) { + onSetDefaultStore(game.id, if (rememberDefaultStore) variant.id else null) + } + if (rememberDefaultStore) { + Toast.makeText(context, context.getString(R.string.store_selector_long_press_tip), Toast.LENGTH_LONG).show() + } + onLaunch(game, variant) + }, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) + } + } else { + Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + UrlImage( + game.imageUrl, + Modifier + .width(58.dp) + .height(76.dp) + .clip(RoundedCornerShape(12.dp)), + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(game.title, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(stringResource(R.string.store_selector_choose_launcher), color = TextMuted, style = MaterialTheme.typography.bodySmall) + } + } + StoreLaunchOptionsColumn( + variants = variants, + selectedVariantId = selectedVariantId, + defaultVariantId = defaultVariantId, + rememberDefaultStore = rememberDefaultStore, + selectedVariant = selectedVariant, + continueFocusRequester = continueFocusRequester, + onSelectVariant = { selectedVariantId = it }, + onRememberDefaultStoreChange = { rememberDefaultStore = it }, + onDismiss = onDismiss, + onContinue = { variant -> + if (rememberDefaultStore || defaultVariantId != null) { + onSetDefaultStore(game.id, if (rememberDefaultStore) variant.id else null) + } + if (rememberDefaultStore) { + Toast.makeText(context, context.getString(R.string.store_selector_long_press_tip), Toast.LENGTH_LONG).show() + } + onLaunch(game, variant) + }, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } +} + +@Composable +private fun LaunchGameSummary(game: GameInfo, subtitle: String, modifier: Modifier = Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) { + UrlImage( + game.imageUrl, + Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(16.dp)), + ) + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(game.title, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(subtitle, color = TextMuted, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +private fun StoreLaunchOptionsColumn( + variants: List, + selectedVariantId: String?, + defaultVariantId: String?, + rememberDefaultStore: Boolean, + selectedVariant: GameVariant?, + continueFocusRequester: FocusRequester, + onSelectVariant: (String) -> Unit, + onRememberDefaultStoreChange: (Boolean) -> Unit, + onDismiss: () -> Unit, + onContinue: (GameVariant) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(variants, key = { it.id }) { variant -> + StoreLaunchVariantRow( + variant = variant, + selected = variant.id == selectedVariantId, + savedDefault = variant.id == defaultVariantId, + onClick = { onSelectVariant(variant.id) }, + ) + } + } + var checkFocused by remember { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .onFocusChanged { checkFocused = it.isFocused } + .background(if (checkFocused) Color.White.copy(alpha = 0.08f) else Color.Transparent) + .border( + width = 1.dp, + color = if (checkFocused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = RoundedCornerShape(14.dp) + ) + .clickable { onRememberDefaultStoreChange(!rememberDefaultStore) } + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Checkbox( + checked = rememberDefaultStore, + onCheckedChange = onRememberDefaultStoreChange, + ) + Text( + stringResource(R.string.store_selector_default_checkbox), + color = TextPrimary, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onDismiss, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.action_cancel)) + } + Button( + onClick = { + val variant = selectedVariant ?: return@Button + onContinue(variant) + }, + enabled = selectedVariant != null, + modifier = Modifier + .weight(1f) + .focusRequester(continueFocusRequester), + ) { + Text(stringResource(R.string.action_continue), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +@Composable +private fun StoreLaunchVariantRow( + variant: GameVariant, + selected: Boolean, + savedDefault: Boolean, + onClick: () -> Unit, +) { + val badge = launcherBadgeForStoreKey(splitGameStoreKeys(variant.store).firstOrNull()) + var focused by remember { mutableStateOf(false) } + Surface( + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focused = it.isFocused } + .border( + width = 2.dp, + color = if (focused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = RoundedCornerShape(14.dp) + ) + .clickable { onClick() }, + shape = RoundedCornerShape(14.dp), + color = if (focused) MaterialTheme.colorScheme.primary.copy(alpha = 0.28f) else if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) else PanelAlt, + contentColor = TextPrimary, + ) { + Row( + Modifier.padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + ConnectorStoreIcon(badge) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(gameStoreDisplayName(variant.store), fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + val details = listOf( + if (savedDefault) stringResource(R.string.store_selector_default) else "", + variantDetailsText(variant), + ).filter { it.isNotBlank() }.joinToString(" - ") + if (details.isNotBlank()) { + Text(details, color = TextMuted, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + if (selected) { + Text( + stringResource(R.string.store_selector_selected), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } + } + } +} + +@Composable +private fun StreamScreen(state: OpenNowUiState, viewModel: OpenNowViewModel) { + val context = LocalContext.current + val activity = context as? Activity + val view = LocalView.current + val audioController = remember(context) { AndroidNerdAudioController(context.applicationContext) } + val session = state.streamSession + val game = state.streamGame + var streamState by remember { mutableStateOf("Preparing") } + var initialVideoFrameRendered by remember(session?.sessionId) { mutableStateOf(false) } + val markInitialVideoFrameRendered by rememberUpdatedState<() -> Unit> { + initialVideoFrameRendered = true + } + var controlsOpen by remember { mutableStateOf(false) } + var exitConfirmOpen by remember { mutableStateOf(false) } + var keyboardOpen by remember { mutableStateOf(false) } + var keyboardText by remember { mutableStateOf("") } + var audioMuted by remember { mutableStateOf(false) } + var touchLayoutEditing by remember { mutableStateOf(false) } + var streamGuideOpen by remember(session?.sessionId) { mutableStateOf(false) } + var streamGuideStep by remember(session?.sessionId) { mutableStateOf(StreamGuideStep.OpenControls) } + var statsVisible by remember(state.settings.showStatsOnLaunch) { mutableStateOf(state.settings.showStatsOnLaunch) } + var streamStats by remember { mutableStateOf(StreamRuntimeStats()) } + var controllerMouseAssistEnabled by remember(session?.sessionId) { mutableStateOf(false) } + var controllerMouseEmulationEnabled by remember(session?.sessionId) { mutableStateOf(state.settings.controllerMouseEmulation) } + val streamReady = state.isNativeStreamReady() + val tvProfile = state.androidTvProfile + val physicalControllerConnected = rememberPhysicalControllerConnected(enabled = streamReady) + var showTouchControlsWithPhysicalController by remember(session?.sessionId) { mutableStateOf(false) } + var physicalControllerPromptOpen by remember(session?.sessionId) { mutableStateOf(false) } + var physicalControllerPromptHandled by remember(session?.sessionId) { mutableStateOf(false) } + var physicalControllerPromptDoNotShowAgain by remember(session?.sessionId) { mutableStateOf(false) } + val touchInputEnabled = !state.androidPictureInPictureActive + val touchControlsSuppressedByPhysicalController = + physicalControllerConnected && + state.settings.androidTouch.enabled && + !showTouchControlsWithPhysicalController + val touchControlsVisible = shouldShowAndroidTouchControls( + tvProfile = tvProfile, + touchInputEnabled = touchInputEnabled, + touchControlsEnabled = state.settings.androidTouch.enabled, + suppressedByPhysicalController = touchControlsSuppressedByPhysicalController, + ) + val fallbackSessionStartedAtMs = remember(session?.sessionId) { System.currentTimeMillis() } + val sessionStartedAtMs = session?.timerStartedAtMs ?: fallbackSessionStartedAtMs + var timerNowMs by remember(session?.sessionId) { mutableStateOf(System.currentTimeMillis()) } + val smartSessionLimit = smartSessionLimitFor(state.subscriptionInfo, state.authSession?.user?.membershipTier) + val buttonToneEnabled = state.settings.controllerUiSounds + val stretchToFit = state.settings.stretchStreamToFit + val playButtonTone = { + audioController.playButtonTone(buttonToneEnabled) + } + val launchStreamSettings = state.activeStreamSettings ?: state.settings.stream + // activeStreamSettings tracks the transport profile and can deliberately + // change during safe-codec recovery. Keep the original launch profile so + // requested, server-selected, decoded, and recovery modes remain distinct. + val requestedStreamSettings = remember(session?.sessionId) { launchStreamSettings } + val microphoneRequested = launchStreamSettings.microphoneMode != MicrophoneMode.Disabled + val initialMicrophonePermissionGranted = remember(session?.sessionId, microphoneRequested) { + !microphoneRequested || + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + } + var microphonePermissionGranted by remember(session?.sessionId, microphoneRequested) { + mutableStateOf(initialMicrophonePermissionGranted) + } + var microphonePermissionResolved by remember(session?.sessionId, microphoneRequested) { + mutableStateOf(!microphoneRequested || initialMicrophonePermissionGranted) + } + var microphoneEnabled by remember(session?.sessionId) { mutableStateOf(false) } + val microphonePermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + microphonePermissionGranted = granted + microphonePermissionResolved = true + if (!granted) { + Toast.makeText( + context, + context.getString(R.string.settings_microphone_permission_denied), + Toast.LENGTH_LONG, + ).show() + } + } + val streamSettings = launchStreamSettings.copy( + mouseSensitivity = state.settings.stream.mouseSensitivity, + mouseAcceleration = state.settings.stream.mouseAcceleration, + streamSharpeningEnabled = launchStreamSettings.streamSharpeningEnabled && state.settings.stream.streamSharpeningEnabled, + streamSharpeningAmount = state.settings.stream.streamSharpeningAmount, + ) + val statsAlignment = when (state.settings.streamStatsPosition) { + StreamStatsPosition.Left -> Alignment.TopStart + StreamStatsPosition.Center -> Alignment.TopCenter + StreamStatsPosition.Right -> Alignment.TopEnd + } + val dismissStreamGuide = { + streamGuideOpen = false + if (!state.settings.androidStreamGuideDismissed) { + viewModel.updateSettings(state.settings.copy(androidStreamGuideDismissed = true)) + } + } + val openControlsForGuide = { + keyboardOpen = false + exitConfirmOpen = false + physicalControllerPromptOpen = false + if (streamGuideOpen && streamGuideStep == StreamGuideStep.OpenControls) { + streamGuideStep = StreamGuideStep.PressDone + } + controlsOpen = true + } + LaunchedEffect(state.remoteStreamMenuRequestToken) { + if (state.remoteStreamMenuRequestToken > 0 && streamReady) { + openControlsForGuide() + } + } + LaunchedEffect(state.remoteStatsToggleRequestToken) { + if (state.remoteStatsToggleRequestToken > 0 && streamReady) { + statsVisible = !statsVisible + } + } + val streamOverlayOpen = controlsOpen || exitConfirmOpen || keyboardOpen || streamGuideOpen || physicalControllerPromptOpen || touchLayoutEditing + val externalMousePassthroughActive = streamReady && !streamOverlayOpen + val handleStreamBack = { + when { + streamGuideOpen && streamGuideStep == StreamGuideStep.OpenControls -> openControlsForGuide() + streamGuideOpen && streamGuideStep == StreamGuideStep.PressDone && controlsOpen -> { + controlsOpen = false + dismissStreamGuide() + } + streamGuideOpen -> dismissStreamGuide() + exitConfirmOpen -> exitConfirmOpen = false + keyboardOpen -> keyboardOpen = false + physicalControllerPromptOpen -> physicalControllerPromptOpen = false + controlsOpen -> controlsOpen = false + else -> controlsOpen = true + } + } + BackHandler(enabled = streamReady) { + handleStreamBack() + } + val client = remember { + NativeStreamClient( + context = context.applicationContext, + onState = { + streamState = it + viewModel.recordNativeStreamState(it) + if (it == "Streaming") viewModel.markStreamConnected() + }, + onError = { + streamState = it + viewModel.markStreamError(it) + }, + onSafeVideoFallbackApplied = { + streamState = it + viewModel.recordLocalSafeVideoFallback(it) + }, + onSessionRecoveryRequired = { + streamState = it + viewModel.recoverStreamSession(it) + }, + onFirstVideoFrameRendered = { + markInitialVideoFrameRendered() + }, + onStats = { + streamStats = it + viewModel.updateStreamRuntimeStats(it) + }, + onControllerMouseAssistChanged = { + controllerMouseAssistEnabled = it + }, + onStreamStopped = { + viewModel.stopStream() + }, + ) + } + + DisposableEffect(Unit) { + val decor = activity?.window?.decorView + NativeStreamInputRouter.attach(client) + NativeStreamInputRouter.setAndroidTvProfile(tvProfile) + onDispose { + if (Build.VERSION.SDK_INT >= 26) { + decor?.releasePointerCapture() + } + NativeStreamInputRouter.clearUiTouchPassthroughBounds() + NativeStreamInputRouter.clearStreamPanelTouchPassthroughBounds() + NativeStreamInputRouter.setSystemMenuHandler(null) + NativeStreamInputRouter.setSystemBackHandler(null) + NativeStreamInputRouter.setAndroidTvProfile(false) + NativeStreamInputRouter.setStreamUiActive(false) + NativeStreamInputRouter.detach(client) + client.release() + } + } + DisposableEffect(audioController) { + onDispose { + audioController.release() + } + } + + LaunchedEffect(streamReady, streamOverlayOpen, streamGuideOpen, streamGuideStep, touchLayoutEditing) { + NativeStreamInputRouter.setStreamUiActive(streamReady && streamOverlayOpen) + NativeStreamInputRouter.setSystemMenuHandler { + openControlsForGuide() + } + NativeStreamInputRouter.setSystemBackHandler { + handleStreamBack() + } + } + + LaunchedEffect(client, tvProfile) { + client.updateAndroidTvProfile(tvProfile) + client.updateControllerMouseAssistAutoArm(tvProfile) + } + + LaunchedEffect(streamReady, state.settings.androidStreamGuideDismissed, session?.sessionId) { + val shouldOpenGuide = streamReady && !state.settings.androidStreamGuideDismissed + streamGuideOpen = shouldOpenGuide + if (shouldOpenGuide) { + streamGuideStep = StreamGuideStep.OpenControls + } + } + + LaunchedEffect(controlsOpen, streamGuideOpen, streamGuideStep) { + if (controlsOpen && streamGuideOpen && streamGuideStep == StreamGuideStep.OpenControls) { + streamGuideStep = StreamGuideStep.PressDone + } + } + + LaunchedEffect( + physicalControllerConnected, + touchControlsSuppressedByPhysicalController, + streamGuideOpen, + controlsOpen, + exitConfirmOpen, + keyboardOpen, + ) { + if (!physicalControllerConnected) { + showTouchControlsWithPhysicalController = false + physicalControllerPromptOpen = false + return@LaunchedEffect + } + if ( + !tvProfile && + touchControlsSuppressedByPhysicalController && + !state.settings.androidPhysicalControllerPromptDismissed && + !physicalControllerPromptHandled && + !streamGuideOpen && + !controlsOpen && + !exitConfirmOpen && + !keyboardOpen + ) { + physicalControllerPromptOpen = true + } + } + + LaunchedEffect(streamReady, state.settings.sessionCounterEnabled, session?.sessionId, sessionStartedAtMs, smartSessionLimit) { + var previousRemainingSeconds: Int? = null + val sentSessionWarnings = mutableSetOf() + while (streamReady && state.settings.sessionCounterEnabled) { + val nowMs = System.currentTimeMillis() + timerNowMs = nowMs + val remainingSeconds = sessionRemainingSeconds(smartSessionLimit, sessionStartedAtMs, nowMs) + sessionWarningThresholdCrossed(previousRemainingSeconds, remainingSeconds)?.let { thresholdSeconds -> + if (sentSessionWarnings.add(thresholdSeconds)) { + Toast.makeText( + context, + "${formatSessionWarningThreshold(thresholdSeconds)} left in this session", + Toast.LENGTH_SHORT, + ).show() + } + } + previousRemainingSeconds = remainingSeconds + delay(1000L) + } + } + + LaunchedEffect(streamReady, touchInputEnabled, state.settings.androidTouch.mousePad) { + NativeStreamInputRouter.setTouchMouseEnabled(streamReady && touchInputEnabled && state.settings.androidTouch.mousePad) + } + LaunchedEffect(state.settings.androidTouch.mouseDirectClick) { + NativeStreamInputRouter.setMouseDirectClick(state.settings.androidTouch.mouseDirectClick) + } + + LaunchedEffect(streamReady, touchInputEnabled, state.settings.androidTouch.mousePad, controlsOpen, exitConfirmOpen, keyboardOpen, streamGuideOpen, touchControlsVisible) { + NativeStreamInputRouter.setCaptureAllTouch( + streamReady && + touchInputEnabled && + state.settings.androidTouch.mousePad && + !controlsOpen && + !exitConfirmOpen && + !keyboardOpen && + !streamGuideOpen, + ) + } + DisposableEffect(Unit) { + onDispose { + NativeStreamInputRouter.setCaptureAllTouch(false) + } + } + + LaunchedEffect(state.settings.phoneRumbleFallback) { + client.updateHapticsSettings(state.settings.phoneRumbleFallback) + } + LaunchedEffect(streamReady, microphoneRequested, microphonePermissionResolved, session?.sessionId) { + if (streamReady && microphoneRequested && !microphonePermissionResolved) { + microphonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + LaunchedEffect( + session, + streamReady, + microphonePermissionGranted, + microphonePermissionResolved, + ) { + if (session != null && streamReady && microphonePermissionResolved) { + val captureMicrophone = shouldCaptureMicrophone( + mode = launchStreamSettings.microphoneMode, + permissionGranted = microphonePermissionGranted, + ) + microphoneEnabled = captureMicrophone + client.setMicrophoneEnabled(captureMicrophone) + client.start( + session, + launchStreamSettings.copy( + microphoneMode = if (captureMicrophone) { + launchStreamSettings.microphoneMode + } else { + MicrophoneMode.Disabled + }, + ), + ) + } + } + LaunchedEffect(client, controllerMouseEmulationEnabled, streamReady) { + if (streamReady) { + client.setControllerMouseEmulationActive(controllerMouseEmulationEnabled) + } + } + val activeStreamMode = activeStreamModeStatus( + requestedSettings = requestedStreamSettings, + transportSettings = launchStreamSettings, + decodedResolution = streamStats.resolution, + serverNegotiatedResolution = session?.monitorSnapshot?.returnedResolution + ?: session?.negotiatedStreamProfile?.resolution, + serverFinalSelectedResolution = session?.monitorSnapshot?.finalSelectedResolution, + ) + LaunchedEffect( + session?.sessionId, + streamReady, + activeStreamMode, + ) { + if (streamReady && activeStreamMode != null) { + viewModel.recordActiveStreamMode(activeStreamMode) + } + } + + Box(Modifier.fillMaxSize().background(Color.Black)) { + if (state.activeSessionDecision != null) { + ActiveSessionDecisionScreen( + state = state, + onResumeSession = viewModel::resumeActiveSession, + onReplaceSession = viewModel::terminateActiveSessionAndStartNew, + onCancel = viewModel::dismissActiveSessionDecision, + ) + } else if (session == null && state.streamStatus != "idle") { + QueueLoadingScreen(state, viewModel) + } else if (session == null) { + NoActiveStreamScreen( + canResumeSession = state.activeSession != null, + canEndSession = state.authSession != null, + onBack = { viewModel.setPage(AppPage.Home) }, + onResumeSession = viewModel::resumeActiveSession, + onEndSession = viewModel::stopStream, + ) + } else if (!streamReady) { + QueueLoadingScreen(state, viewModel) + } else { + StreamVideoSurface( + client = client, + settings = streamSettings, + decodedResolution = streamStats.resolution, + serverNegotiatedResolution = session.negotiatedStreamProfile?.resolution, + hideExternalMousePointer = externalMousePassthroughActive, + touchMouseEnabled = touchInputEnabled && state.settings.androidTouch.mousePad, + pinchZoomEnabled = streamPinchZoomEnabled( + touchMouseEnabled = touchInputEnabled && state.settings.androidTouch.mousePad, + touchControllerVisible = touchControlsVisible, + ), + externalMouseRoot = activity?.window?.decorView, + onMouseCaptureInput = { (activity as? MainActivity)?.enforceStreamSystemUiFromInput() }, + stretchToFit = stretchToFit, + ) + if (statsVisible) { + StreamStatsPill( + streamStats = streamStats, + streamSettings = launchStreamSettings, + style = state.settings.streamStatsStyle, + metrics = state.settings.streamStatsMetrics, + serverLocation = session.zone, + modifier = Modifier.align(statsAlignment), + ) + } + if (activeStreamMode != null) { + ActiveStreamModePill( + status = activeStreamMode, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = if (statsVisible && statsAlignment == Alignment.TopCenter) 48.dp else 8.dp), + ) + } + if (touchControlsVisible) { + TouchOverlay( + client = client, + touch = state.settings.androidTouch.copy(enabled = true), + onButtonTone = { + if (state.settings.phoneRumbleFallback) { + view.performHapticFeedback( + android.view.HapticFeedbackConstants.KEYBOARD_TAP, + android.view.HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING + ) + } + }, + layoutEditing = touchLayoutEditing, + onSaveAllOffsets = { allOffsets -> + var touch = state.settings.androidTouch + allOffsets.forEach { (key, offset) -> + touch = touch.withOffset(key, offset.x, offset.y) + } + viewModel.updateSettings(state.settings.copy(androidTouch = touch)) + }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + AnimatedVisibility( + visible = !initialVideoFrameRendered, + enter = fadeIn(animationSpec = tween(180)) + scaleIn(initialScale = 0.96f), + exit = fadeOut(animationSpec = tween(180)) + scaleOut(targetScale = 0.98f), + modifier = Modifier.align(Alignment.Center), + ) { + InitialStreamConnectionOverlay( + gameTitle = game?.title, + status = initialStreamConnectionStatus(streamState), + ) + } + if (touchLayoutEditing) { + val doneButtonTone = playButtonTone + Box( + Modifier + .align(Alignment.Center), + contentAlignment = Alignment.Center, + ) { + Button( + onClick = { + doneButtonTone() + touchLayoutEditing = false + }, + shape = RoundedCornerShape(999.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = PaddingValues(horizontal = 28.dp, vertical = 14.dp), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 8.dp), + modifier = Modifier.pointerInteropFilter { event -> + if (event.action == MotionEvent.ACTION_UP || + event.action == MotionEvent.ACTION_DOWN + ) { + false // let Button's click handling still work + } else { + false + } + }, + ) { + Icon( + Icons.Rounded.Check, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + "Done", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + if (streamGuideOpen) { + AnimatedLaunchOverlay(Modifier.align(Alignment.Center)) { + StreamFirstLaunchGuide( + step = streamGuideStep, + controlsOpen = controlsOpen, + touchControlsEnabled = touchControlsVisible, + onOpenControls = { + playButtonTone() + openControlsForGuide() + }, + onSkip = { + playButtonTone() + controlsOpen = false + dismissStreamGuide() + }, + ) + } + } + if (physicalControllerPromptOpen) { + PhysicalControllerTouchControlsDialog( + doNotShowAgain = physicalControllerPromptDoNotShowAgain, + onDoNotShowAgainChange = { physicalControllerPromptDoNotShowAgain = it }, + onOk = { + physicalControllerPromptHandled = true + physicalControllerPromptOpen = false + showTouchControlsWithPhysicalController = false + if (physicalControllerPromptDoNotShowAgain) { + viewModel.updateSettings( + state.settings.copy(androidPhysicalControllerPromptDismissed = true), + ) + } + }, + onUndo = { + physicalControllerPromptHandled = true + physicalControllerPromptOpen = false + showTouchControlsWithPhysicalController = true + if (physicalControllerPromptDoNotShowAgain) { + viewModel.updateSettings( + state.settings.copy(androidPhysicalControllerPromptDismissed = true), + ) + } + }, + ) + } + AnimatedVisibility( + visible = controlsOpen, + enter = fadeIn() + slideInVertically(initialOffsetY = { it / 4 }) + scaleIn(initialScale = 0.96f), + exit = fadeOut() + slideOutVertically(targetOffsetY = { it / 4 }) + scaleOut(targetScale = 0.96f), + modifier = Modifier.align(Alignment.BottomEnd), + ) { + StreamControlsPanel( + gameTitle = game?.title ?: "Stream", + status = (state.queuePosition?.let { "Queue $it" } ?: streamState).takeUnless(::shouldHideStreamStatusText), + settings = state.settings, + tvProfile = tvProfile, + touchControlsVisible = touchControlsVisible, + controllerMouseAssistEnabled = controllerMouseAssistEnabled, + controllerMouseEmulationEnabled = controllerMouseEmulationEnabled, + showSessionTimer = state.settings.sessionCounterEnabled, + sessionTimerLimit = smartSessionLimit, + sessionStartedAtMs = sessionStartedAtMs, + sessionNowMs = timerNowMs, + audioMuted = audioMuted, + microphoneRequested = microphoneRequested, + microphonePermissionGranted = microphonePermissionGranted, + microphoneEnabled = microphoneEnabled, + statsVisible = statsVisible, + touchLayoutEditing = touchLayoutEditing, + bugReportSubmission = state.bugReportSubmission, + onAudioToggle = { + audioMuted = !audioMuted + client.setAudioMuted(audioMuted) + }, + onMicrophoneToggle = { + if (!microphonePermissionGranted) { + microphonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } else { + microphoneEnabled = !microphoneEnabled + client.setMicrophoneEnabled(microphoneEnabled) + } + }, + onStatsToggle = { + statsVisible = !statsVisible + viewModel.updateSettings(state.settings.copy(showStatsOnLaunch = statsVisible)) + }, + onStatsStyleCycle = { + viewModel.updateSettings(state.settings.copy(streamStatsStyle = state.settings.streamStatsStyle.next())) + }, + onStatsPositionCycle = { + viewModel.updateSettings(state.settings.copy(streamStatsPosition = state.settings.streamStatsPosition.next())) + }, + onStatsMetricsChange = { metrics -> + viewModel.updateSettings(state.settings.copy(streamStatsMetrics = metrics)) + }, + onPhoneRumbleFallbackToggle = { + viewModel.updateSettings(state.settings.copy(phoneRumbleFallback = !state.settings.phoneRumbleFallback)) + }, + onTouchLayoutEditingToggle = { + touchLayoutEditing = !touchLayoutEditing + }, + onKeyboardOpen = { + controlsOpen = false + keyboardOpen = true + }, + onEsc = { client.sendKeyCode(KeyEvent.KEYCODE_ESCAPE) }, + onEnter = { client.sendKeyCode(KeyEvent.KEYCODE_ENTER) }, + onBackspace = { client.sendKeyCode(KeyEvent.KEYCODE_DEL) }, + onSteamMenuOpen = { + controlsOpen = false + client.openSteamMenu() + }, + onControllerMouseAssistToggle = { + client.setControllerMouseAssistEnabled(!controllerMouseAssistEnabled) + }, + onControllerMouseEmulationToggle = { + val newState = !controllerMouseEmulationEnabled + controllerMouseEmulationEnabled = newState + client.setControllerMouseEmulationActive(newState) + }, + onExit = { + controlsOpen = false + exitConfirmOpen = true + }, + onTouchControlsToggle = { + if (physicalControllerConnected && !touchControlsVisible) { + showTouchControlsWithPhysicalController = true + if (!state.settings.androidTouch.enabled) { + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy(enabled = true), + ), + ) + } + } else { + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy(enabled = !state.settings.androidTouch.enabled), + ), + ) + } + }, + onMousePadToggle = { + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy(mousePad = !state.settings.androidTouch.mousePad), + ), + ) + }, + onMouseDirectClickToggle = { + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy(mouseDirectClick = !state.settings.androidTouch.mouseDirectClick), + ), + ) + }, + onToggleTouchControllerStyle = { + val nextStyle = if (state.settings.androidTouch.touchControllerStyle == TouchControllerStyle.V1) { + TouchControllerStyle.V2 + } else { + TouchControllerStyle.V1 + } + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy(touchControllerStyle = nextStyle), + ), + ) + }, + onJoystickModeToggle = { + val nextMode = if (state.settings.androidTouch.joystickMode == TouchJoystickMode.Fixed) { + TouchJoystickMode.Dynamic + } else { + TouchJoystickMode.Fixed + } + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy(joystickMode = nextMode), + ), + ) + }, + onJoystickDeadZoneChange = { value -> + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.copy(joystickDeadZone = value), + ), + ) + }, + onSharpeningToggle = { + viewModel.updateStreamSettings { settings -> + settings.copy(streamSharpeningEnabled = !settings.streamSharpeningEnabled) + } + }, + onSharpeningAmountChange = { value -> + viewModel.updateStreamSettings { settings -> + settings.copy(streamSharpeningAmount = value) + } + }, + onStretchToFitToggle = { + val next = !state.settings.stretchStreamToFit + viewModel.updateSettings( + state.settings.copy( + legacyCropStreamToFill = false, + stretchStreamToFit = next, + ), + ) + }, + onTouchScaleChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(scale = value))) + }, + onButtonScaleChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(buttonScale = value))) + }, + onStickScaleChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(stickScale = value))) + }, + onOpacityChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(opacity = value))) + }, + onTouchEdgePaddingChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(edgePaddingDp = value))) + }, + onTouchBottomPaddingChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(bottomPaddingDp = value))) + }, + onTouchLeftOffsetChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(leftOffsetYDp = value))) + }, + onTouchRightOffsetChange = { value -> + viewModel.updateSettings(state.settings.copy(androidTouch = state.settings.androidTouch.copy(rightOffsetYDp = value))) + }, + onTouchLayoutReset = { + viewModel.updateSettings( + state.settings.copy( + androidTouch = state.settings.androidTouch.withResetOffsets() + ) + ) + }, + onBugReportSubmit = viewModel::submitBugReport, + onBugReportReset = viewModel::resetBugReportSubmission, + onButtonTone = playButtonTone, + highlightDone = streamGuideOpen && streamGuideStep == StreamGuideStep.PressDone, + onClose = { + controlsOpen = false + if (streamGuideOpen && streamGuideStep == StreamGuideStep.PressDone) { + dismissStreamGuide() + } + }, + ) + } + if (keyboardOpen) { + AnimatedLaunchOverlay(Modifier.align(Alignment.BottomCenter)) { + StreamKeyboardBar( + text = keyboardText, + onTextChange = { keyboardText = it }, + onSend = { + val text = keyboardText + if (text.isNotBlank()) { + client.sendText(text) + keyboardText = "" + keyboardOpen = false + } + }, + onBackspace = { client.sendKeyCode(KeyEvent.KEYCODE_DEL) }, + onEnter = { client.sendKeyCode(KeyEvent.KEYCODE_ENTER) }, + onEsc = { client.sendKeyCode(KeyEvent.KEYCODE_ESCAPE) }, + onDone = { keyboardOpen = false }, + ) + } + } + if (exitConfirmOpen) { + AnimatedLaunchOverlay(Modifier.align(Alignment.Center)) { + StreamExitConfirmation( + gameTitle = game?.title ?: "this game", + onKeepPlaying = { exitConfirmOpen = false }, + onExit = { + exitConfirmOpen = false + viewModel.stopStream() + }, + ) + } + } + } + } +} + +internal fun shouldShowAndroidTouchControls( + tvProfile: Boolean, + touchInputEnabled: Boolean, + touchControlsEnabled: Boolean, + suppressedByPhysicalController: Boolean, +): Boolean = + !tvProfile && touchInputEnabled && touchControlsEnabled && !suppressedByPhysicalController + +private data class SessionTimerDisplay( + val label: String, + val value: String, + val detail: String, + val progress: Float, + val warning: Boolean, +) + +private enum class StreamGuideStep { + OpenControls, + PressDone, +} + +private fun sessionTimerDisplay(limit: SmartSessionLimit, startedAtMs: Long, nowMs: Long): SessionTimerDisplay { + val elapsedSeconds = sessionElapsedSeconds(startedAtMs, nowMs) + val limitSeconds = limit.limitHours * 60 * 60 + val remainingSeconds = sessionRemainingSeconds(limit, startedAtMs, nowMs) + val warning = remainingSeconds <= 10 * 60 + val progress = if (limitSeconds > 0) (elapsedSeconds.toFloat() / limitSeconds).coerceIn(0f, 1f) else 0f + return when (limit.mode) { + SessionTimerMode.Countdown -> SessionTimerDisplay( + label = "${limit.tierLabel} countdown", + value = formatSessionTimerDuration(remainingSeconds), + detail = "${limit.limitHours}h session limit", + progress = progress, + warning = warning, + ) + SessionTimerMode.Stopwatch -> SessionTimerDisplay( + label = "${limit.tierLabel} session", + value = "${formatSessionTimerDuration(elapsedSeconds)} / ${limit.limitHours}h", + detail = "Session stopwatch", + progress = progress, + warning = warning, + ) + } +} + +@Composable +private fun StreamSessionTimerMenuRow( + limit: SmartSessionLimit, + startedAtMs: Long, + nowMs: Long, + modifier: Modifier = Modifier, +) { + val display = sessionTimerDisplay(limit, startedAtMs, nowMs) + val progressColor = when { + display.warning -> Color(0xffffc266) + else -> MaterialTheme.colorScheme.primary + } + Column( + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.06f)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(7.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.weight(1f)) { + Text("Session timer", fontWeight = FontWeight.SemiBold) + Text(display.label, color = TextMuted, style = MaterialTheme.typography.labelSmall) + } + Text( + display.value, + color = if (display.warning) Color(0xffffc266) else TextPrimary, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Box( + Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(999.dp)) + .background(Color.White.copy(alpha = 0.12f)), + ) { + Box( + Modifier + .fillMaxWidth(display.progress) + .height(4.dp) + .background(progressColor), + ) + } + Text(display.detail, color = TextMuted, style = MaterialTheme.typography.labelSmall) + } +} + +private fun formatSessionTimerDuration(totalSeconds: Int): String { + val seconds = totalSeconds.coerceAtLeast(0) + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val remainingSeconds = seconds % 60 + return if (hours > 0) { + "%d:%02d:%02d".format(Locale.US, hours, minutes, remainingSeconds) + } else { + "%d:%02d".format(Locale.US, minutes, remainingSeconds) + } +} + +private fun formatSessionWarningThreshold(thresholdSeconds: Int): String { + val minutes = thresholdSeconds / 60 + return if (minutes == 1) "1 minute" else "$minutes minutes" +} + +@Composable +private fun StreamVideoSurface( + client: NativeStreamClient, + settings: StreamSettings, + decodedResolution: String?, + serverNegotiatedResolution: String?, + hideExternalMousePointer: Boolean, + touchMouseEnabled: Boolean, + pinchZoomEnabled: Boolean, + externalMouseRoot: android.view.View?, + onMouseCaptureInput: () -> Unit, + stretchToFit: Boolean, + modifier: Modifier = Modifier, +) { + val rootView = LocalView.current + val configuration = LocalConfiguration.current + val pointerRootView = externalMouseRoot ?: rootView + val currentOnMouseCaptureInput by rememberUpdatedState(onMouseCaptureInput) + var zoomScale by remember { mutableFloatStateOf(1f) } + var zoomOffset by remember { mutableStateOf(Offset.Zero) } + var viewportSize by remember { mutableStateOf(IntSize.Zero) } + val streamAspectRatio = remember(decodedResolution, serverNegotiatedResolution, settings.resolution, settings.aspectRatio) { + streamRendererAspectRatio(settings, decodedResolution, serverNegotiatedResolution) + } + val viewportAspectRatio = remember(viewportSize) { + if (viewportSize.width > 0 && viewportSize.height > 0) { + viewportSize.width.toFloat() / viewportSize.height.toFloat() + } else { + 0f + } + } + val rendererModifier = if (viewportAspectRatio <= 0f) { + Modifier.fillMaxSize() + } else if (viewportAspectRatio > streamAspectRatio) { + // Screen is wider than stream (e.g. 2400×1080 screen, 1920×1080 stream). + // Fit by height so the renderer has no black bars internally; horizontal + // stretch (if enabled) is applied later via View.scaleX. + Modifier + .fillMaxHeight() + .aspectRatio(streamAspectRatio) + } else { + // Screen is taller than stream — fit by width; vertical stretch via scaleY. + Modifier + .fillMaxWidth() + .aspectRatio(streamAspectRatio) + } + + // SCALE_ASPECT_FIT preserves every decoded pixel. Stretching the View on only + // the mismatching axis removes the bars without cropping HUD or edge content. + val stretchScale = remember(stretchToFit, viewportAspectRatio, streamAspectRatio) { + streamStretchScale( + enabled = stretchToFit, + viewportAspectRatio = viewportAspectRatio, + streamAspectRatio = streamAspectRatio, + ) + } + LaunchedEffect( + settings.resolution, + settings.aspectRatio, + settings.streamSharpeningEnabled, + touchMouseEnabled, + pinchZoomEnabled, + stretchToFit, + streamAspectRatio, + configuration.orientation, + configuration.screenWidthDp, + configuration.screenHeightDp, + ) { + zoomScale = 1f + zoomOffset = Offset.Zero + } + LaunchedEffect(stretchToFit) { + NativeStreamInputRouter.setStretchToFit(stretchToFit) + } + LaunchedEffect(streamAspectRatio) { + NativeStreamInputRouter.setRenderingAspectRatio(streamAspectRatio) + } + DisposableEffect(client, rootView, pointerRootView, hideExternalMousePointer) { + pointerRootView.configureAndroidMousePointerCapture(hideExternalMousePointer, { currentOnMouseCaptureInput() }) { event -> + client.dispatchMotion(event) + } + if (hideExternalMousePointer) { + pointerRootView.hideAndroidPointerTree() + } else { + pointerRootView.showAndroidPointerTree() + } + onDispose { + pointerRootView.clearAndroidMousePointerCapture() + pointerRootView.showAndroidPointerTree() + } + } + Box( + modifier + .fillMaxSize() + .background(Color.Black) + .onSizeChanged { + if (viewportSize != it) { + viewportSize = it + zoomScale = 1f + zoomOffset = Offset.Zero + } else { + zoomOffset = clampStreamZoomOffset(zoomOffset, zoomScale, it) + } + } + .clipToBounds(), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .matchParentSize() + .graphicsLayer { + scaleX = zoomScale + scaleY = zoomScale + translationX = zoomOffset.x + translationY = zoomOffset.y + }, + contentAlignment = Alignment.Center, + ) { + // AndroidView resizes the SurfaceView in place. Re-keying it as the viewport + // settles creates overlapping renderer surfaces during stream startup. + key(settings.streamSharpeningEnabled) { + AndroidView( + modifier = rendererModifier, + factory = { ctx -> + client.createRenderer(ctx, settings).apply { + isFocusable = false + isFocusableInTouchMode = false + hideAndroidPointerTree() + scaleX = stretchScale.first + scaleY = stretchScale.second + } + }, + update = { renderer -> + client.updateRendererSettings(settings) + renderer.scaleX = stretchScale.first + renderer.scaleY = stretchScale.second + renderer.isFocusable = false + renderer.isFocusableInTouchMode = false + pointerRootView.configureAndroidMousePointerCapture(hideExternalMousePointer, { currentOnMouseCaptureInput() }) { event -> + client.dispatchMotion(event) + } + if (hideExternalMousePointer) { + pointerRootView.hideAndroidPointerTree() + renderer.hideAndroidPointerTree() + } else { + pointerRootView.showAndroidPointerTree() + renderer.showAndroidPointerTree() + } + renderer.setOnKeyListener(null) + renderer.setOnGenericMotionListener { _, event -> + if (hideExternalMousePointer) pointerRootView.hideAndroidPointerTree() + client.dispatchMotion(event) + } + renderer.setOnTouchListener { view, event -> + NativeStreamInputRouter.dispatchTouch(event, view.width, view.height) + } + }, + onRelease = client::releaseRenderer, + ) + } + } + FingerMouseInputLayer( + enabled = touchMouseEnabled, + pinchZoomEnabled = pinchZoomEnabled, + onZoomGesture = { scaleChange, pan -> + val nextScale = (zoomScale * scaleChange).coerceIn(1f, 3f) + zoomScale = nextScale + zoomOffset = if (nextScale <= 1.001f) { + Offset.Zero + } else { + clampStreamZoomOffset(zoomOffset + pan, nextScale, viewportSize) + } + }, + modifier = Modifier.matchParentSize(), + ) + } +} + +internal fun streamRendererAspectRatio( + settings: StreamSettings, + decodedResolution: String?, + serverNegotiatedResolution: String? = null, +): Float { + val expectedPixels = streamResolutionPixels(settings) + val decodedPixels = parseResolutionPixelsOrNull(decodedResolution) + ?.takeIf(::isStableDecodedStreamResolution) + val negotiatedPixels = parseResolutionPixelsOrNull(serverNegotiatedResolution) + ?.takeIf(::isStableDecodedStreamResolution) + return streamAspectRatioForPixels(decodedPixels ?: negotiatedPixels ?: expectedPixels) +} + +internal fun streamStretchScale( + enabled: Boolean, + viewportAspectRatio: Float, + streamAspectRatio: Float, +): Pair { + if (!enabled || viewportAspectRatio <= 0f || streamAspectRatio <= 0f) return 1f to 1f + return when { + viewportAspectRatio > streamAspectRatio -> + (viewportAspectRatio / streamAspectRatio).coerceIn(1f, 3f) to 1f + viewportAspectRatio < streamAspectRatio -> + 1f to (streamAspectRatio / viewportAspectRatio).coerceIn(1f, 3f) + else -> 1f to 1f + } +} + +internal fun streamPinchZoomEnabled( + touchMouseEnabled: Boolean, + touchControllerVisible: Boolean, +): Boolean = touchMouseEnabled && !touchControllerVisible + +private fun streamAspectRatioForPixels(pixels: Pair): Float { + val (width, height) = pixels + if (width <= 0 || height <= 0) return 16f / 9f + return width.toFloat() / height.toFloat() +} + +private fun isStableDecodedStreamResolution(pixels: Pair): Boolean = + pixels.first >= MIN_STABLE_DECODED_STREAM_WIDTH_PX && + pixels.second >= MIN_STABLE_DECODED_STREAM_HEIGHT_PX + +private const val MIN_STABLE_DECODED_STREAM_WIDTH_PX = 320 +private const val MIN_STABLE_DECODED_STREAM_HEIGHT_PX = 180 + +private fun clampStreamZoomOffset(offset: Offset, zoomScale: Float, viewportSize: IntSize): Offset { + if (zoomScale <= 1.001f || viewportSize.width <= 0 || viewportSize.height <= 0) return Offset.Zero + val maxX = viewportSize.width * (zoomScale - 1f) / 2f + val maxY = viewportSize.height * (zoomScale - 1f) / 2f + return Offset( + x = offset.x.coerceIn(-maxX, maxX), + y = offset.y.coerceIn(-maxY, maxY), + ) +} + +private fun androidNullPointerIcon(view: android.view.View): PointerIcon? = + if (Build.VERSION.SDK_INT >= 24) { + runCatching { PointerIcon.getSystemIcon(view.context, PointerIcon.TYPE_NULL) } + .onFailure { error -> NativeInputDiagnostics.add("pointer icon unavailable error=${error.javaClass.simpleName}") } + .getOrNull() + } else { + null + } + +private fun View.configureAndroidMousePointerCapture(enabled: Boolean, onCaptureInput: () -> Unit = {}, onMotion: (MotionEvent) -> Boolean) { + if (Build.VERSION.SDK_INT < 26) return + if (!enabled) { + clearAndroidMousePointerCapture() + return + } + setOnCapturedPointerListener { _, event -> + onCaptureInput() + onMotion(event) + } + post { + if (isAttachedToWindow && hasWindowFocus() && !hasPointerCapture()) { + isFocusable = true + isFocusableInTouchMode = true + requestFocus() + onCaptureInput() + runCatching { requestPointerCapture() } + .onFailure { error -> NativeInputDiagnostics.add("pointer capture request failed error=${error.javaClass.simpleName}") } + } + } +} + +private fun View.clearAndroidMousePointerCapture() { + if (Build.VERSION.SDK_INT < 26) return + setOnCapturedPointerListener(null) + runCatching { releasePointerCapture() } + .onFailure { error -> NativeInputDiagnostics.add("pointer capture release failed error=${error.javaClass.simpleName}") } +} + +private fun android.view.View.hideAndroidPointerTree() { + if (Build.VERSION.SDK_INT < 24) return + val icon = androidNullPointerIcon(this) + applyAndroidPointerIconTree(icon) +} + +private fun android.view.View.showAndroidPointerTree() { + if (Build.VERSION.SDK_INT < 24) return + applyAndroidPointerIconTree(null) +} + +private fun android.view.View.applyAndroidPointerIconTree(icon: PointerIcon?) { + if (Build.VERSION.SDK_INT < 24) return + runCatching { pointerIcon = icon } + .onFailure { error -> NativeInputDiagnostics.add("pointer icon apply failed error=${error.javaClass.simpleName}") } + if (this is ViewGroup) { + for (index in 0 until childCount) { + getChildAt(index).applyAndroidPointerIconTree(icon) + } + } +} + +@Composable +private fun FingerMouseInputLayer( + enabled: Boolean, + pinchZoomEnabled: Boolean, + onZoomGesture: (scaleChange: Float, pan: Offset) -> Unit, + modifier: Modifier = Modifier, +) { + if (!enabled) return + var width by remember { mutableStateOf(0) } + var height by remember { mutableStateOf(0) } + var pinchActive by remember { mutableStateOf(false) } + var lastPinchDistance by remember { mutableFloatStateOf(0f) } + var lastPinchCentroid by remember { mutableStateOf(Offset.Zero) } + Box( + modifier + .onSizeChanged { + width = it.width + height = it.height + } + .pointerInteropFilter { event -> + if (NativeStreamInputRouter.isNativeUiTouchGestureActive()) { + pinchActive = false + lastPinchDistance = 0f + lastPinchCentroid = Offset.Zero + return@pointerInteropFilter true + } + if (event.pointerCount >= 2) { + // 3-finger touch is reserved for the Direct Click toggle gesture + // (handled in NativeStreamInputRouter.dispatchTouch). Do not + // interpret it as a pinch-zoom — reset pinch state and let it through. + if (event.pointerCount >= 3) { + pinchActive = false + lastPinchDistance = 0f + lastPinchCentroid = Offset.Zero + NativeStreamInputRouter.dispatchTouch(event, width, height) + return@pointerInteropFilter true + } + NativeStreamInputRouter.cancelTouchMouse() + if (!pinchZoomEnabled) { + // Multiple fingers while the touch controller is visible are + // controller input, not a request to crop the video surface. + pinchActive = true + lastPinchDistance = 0f + lastPinchCentroid = Offset.Zero + return@pointerInteropFilter true + } + val distance = event.firstTwoPointerDistance() + val centroid = event.firstTwoPointerCentroid() + if (pinchActive && lastPinchDistance > 0f && distance > 0f) { + onZoomGesture( + (distance / lastPinchDistance).coerceIn(0.82f, 1.22f), + centroid - lastPinchCentroid, + ) + } + pinchActive = true + lastPinchDistance = distance + lastPinchCentroid = centroid + return@pointerInteropFilter true + } + if (pinchActive) { + if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) { + pinchActive = false + lastPinchDistance = 0f + lastPinchCentroid = Offset.Zero + } + return@pointerInteropFilter true + } + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + NativeInputDiagnostics.add("compose finger layer down size=${width}x$height") + } + NativeStreamInputRouter.dispatchTouch(event, width, height) + }, + ) +} + +private fun MotionEvent.firstTwoPointerDistance(): Float { + if (pointerCount < 2) return 0f + val dx = getX(1) - getX(0) + val dy = getY(1) - getY(0) + return sqrt(dx * dx + dy * dy) +} + +private fun MotionEvent.firstTwoPointerCentroid(): Offset = + if (pointerCount >= 2) { + Offset((getX(0) + getX(1)) / 2f, (getY(0) + getY(1)) / 2f) + } else { + Offset.Zero + } + +@Composable +private fun ActiveSessionDecisionScreen( + state: OpenNowUiState, + onResumeSession: () -> Unit, + onReplaceSession: () -> Unit, + onCancel: () -> Unit, +) { + val decision = state.activeSessionDecision ?: return + val active = decision.activeSession + val activeGame = activeSessionGame(state, active) + Column( + Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Surface( + modifier = Modifier.fillMaxWidth().widthIn(max = 560.dp), + shape = RoundedCornerShape(18.dp), + color = PanelAlt.copy(alpha = 0.96f), + contentColor = TextPrimary, + tonalElevation = 4.dp, + ) { + Column( + Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + UrlImage( + activeGame?.imageUrl ?: state.streamGame?.imageUrl, + Modifier + .width(56.dp) + .height(74.dp) + .clip(RoundedCornerShape(10.dp)), + ) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Cloud session already active", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + activeGame?.title ?: "App ${active.appId}", + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + activeSessionSummary(active), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + "Resume the existing session, or terminate it and start ${decision.requestedGameTitle}.", + color = TextMuted, + style = MaterialTheme.typography.bodyMedium, + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton(onClick = onCancel) { Text("Cancel") } + OutlinedButton(onClick = onReplaceSession) { Text("Terminate and start new") } + Button(onClick = onResumeSession) { Text(stringResource(R.string.action_resume)) } + } + } + } + } +} + +@Composable +private fun NoActiveStreamScreen( + canResumeSession: Boolean, + canEndSession: Boolean, + onBack: () -> Unit, + onResumeSession: () -> Unit, + onEndSession: () -> Unit, +) { + Column( + Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("No active stream", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + Text( + "OpenNOW does not have a local stream attached right now.", + color = TextMuted, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(18.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedButton(onClick = onBack) { Text("Back to library") } + if (canResumeSession) { + Button(onClick = onResumeSession) { Text(stringResource(R.string.action_resume)) } + } + if (canEndSession) { + Button(onClick = onEndSession) { Text("End cloud session") } + } + } + } +} + +@Composable +private fun StreamControlLauncher( + controlsOpen: Boolean, + status: String?, + onToggle: () -> Unit, + onExit: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .padding(top = 10.dp, end = 10.dp) + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInRoot() + NativeStreamInputRouter.setUiTouchPassthroughBounds( + bounds.left.roundToInt(), + bounds.top.roundToInt(), + bounds.right.roundToInt(), + bounds.bottom.roundToInt(), + ) + }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (status != null) { + Surface( + shape = RoundedCornerShape(999.dp), + color = Panel.copy(alpha = 0.8f), + tonalElevation = 3.dp, + ) { + Text( + status, + color = TextMuted, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + ) + } + } + Button(onClick = onToggle, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) { + Text(if (controlsOpen) "Close" else "Controls") + } + OutlinedButton(onClick = onExit, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) { + Text("Exit") + } + } + + DisposableEffect(Unit) { + onDispose { + NativeStreamInputRouter.clearUiTouchPassthroughBounds() + } + } +} + +@Composable +private fun StreamFirstLaunchGuide( + step: StreamGuideStep, + controlsOpen: Boolean, + touchControlsEnabled: Boolean, + onOpenControls: () -> Unit, + onSkip: () -> Unit, +) { + val primaryFocusRequester = remember { FocusRequester() } + val overlayInteraction = remember { MutableInteractionSource() } + LaunchedEffect(step, controlsOpen) { + delay(80) + if (step == StreamGuideStep.OpenControls || !controlsOpen) { + runCatching { primaryFocusRequester.requestFocus() } + } + } + BoxWithConstraints( + if (step == StreamGuideStep.OpenControls) { + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.62f)) + .clickable( + interactionSource = overlayInteraction, + indication = null, + onClick = {}, + ) + } else { + Modifier.fillMaxSize() + }, + ) { + val landscape = maxWidth > maxHeight + if (step == StreamGuideStep.OpenControls) { + StreamGuideEdgeCue(Modifier.align(Alignment.CenterStart)) + StreamGuideCard( + stepLabel = "Step 1 of 2", + title = "Open the stream menu", + body = "Press Android Back, Menu, or swipe from the left edge. That opens the menu without exiting the stream.", + details = listOf( + "Back or the left-edge gesture opens controls.", + if (touchControlsEnabled) { + "Touch controls pause while this guide is up." + } else { + "You can turn touch controls on from the menu." + }, + "Use Skip tutorial if you already know this flow.", + ), + modifier = Modifier + .align(Alignment.Center) + .padding(18.dp) + .fillMaxWidth(if (landscape) 0.54f else 0.92f) + .then(if (landscape) Modifier.fillMaxHeight(0.82f) else Modifier), + primaryLabel = "Open controls", + primaryFocusRequester = primaryFocusRequester, + onPrimary = onOpenControls, + secondaryLabel = "Skip tutorial", + onSecondary = onSkip, + ) + } else { + StreamGuideDoneCallout( + controlsOpen = controlsOpen, + onOpenControls = onOpenControls, + onSkip = onSkip, + primaryFocusRequester = primaryFocusRequester, + modifier = Modifier + .align(if (landscape) Alignment.TopStart else Alignment.TopCenter) + .padding(18.dp) + .then(if (landscape) Modifier.fillMaxWidth(0.34f) else Modifier.fillMaxWidth(0.86f)) + .widthIn(max = 340.dp), + ) + } + } +} + +@Composable +private fun StreamGuideCard( + stepLabel: String, + title: String, + body: String, + details: List, + primaryLabel: String, + primaryFocusRequester: FocusRequester, + onPrimary: () -> Unit, + secondaryLabel: String, + onSecondary: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(20.dp), + color = Panel.copy(alpha = 0.96f), + contentColor = TextPrimary, + tonalElevation = 8.dp, + ) { + Column( + Modifier + .padding(18.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stepLabel, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(body, color = TextMuted, style = MaterialTheme.typography.bodyMedium) + } + details.forEachIndexed { index, detail -> + StreamGuidePoint(number = index + 1, body = detail) + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + OutlinedButton( + onClick = onSecondary, + modifier = Modifier.weight(1f), + ) { + Text(secondaryLabel, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Button( + onClick = onPrimary, + modifier = Modifier + .weight(1f) + .focusRequester(primaryFocusRequester), + ) { + Text(primaryLabel, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +private fun StreamGuideDoneCallout( + controlsOpen: Boolean, + onOpenControls: () -> Unit, + onSkip: () -> Unit, + primaryFocusRequester: FocusRequester, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(18.dp), + color = Panel.copy(alpha = 0.9f), + tonalElevation = 6.dp, + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text("Step 2 of 2", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold) + Text("Press Done", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + TextButton( + onClick = onSkip, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + ) { + Text("Skip", maxLines = 1) + } + if (!controlsOpen) { + Button( + onClick = onOpenControls, + modifier = Modifier.focusRequester(primaryFocusRequester), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + ) { + Text("Open", maxLines = 1) + } + } + } + } +} + +@Composable +private fun PhysicalControllerTouchControlsDialog( + doNotShowAgain: Boolean, + onDoNotShowAgainChange: (Boolean) -> Unit, + onOk: () -> Unit, + onUndo: () -> Unit, +) { + AlertDialog( + onDismissRequest = onOk, + title = { Text("Controller detected") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "The on-screen controller was hidden because a physical controller is connected.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { onDoNotShowAgainChange(!doNotShowAgain) } + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Checkbox( + checked = doNotShowAgain, + onCheckedChange = onDoNotShowAgainChange, + ) + Text("Don't show again", color = MaterialTheme.colorScheme.onSurface) + } + } + }, + confirmButton = { + TextButton(onClick = onOk) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = onUndo) { + Text("Undo") + } + }, + ) +} + +@Composable +private fun StreamGuideEdgeCue(modifier: Modifier = Modifier) { + Box( + modifier + .fillMaxHeight() + .width(112.dp) + .background( + Brush.horizontalGradient( + listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.28f), + Color.Transparent, + ), + ), + ), + ) { + Surface( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 14.dp), + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.92f), + tonalElevation = 6.dp, + ) { + Row( + Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(18.dp), + ) + Text("Back", color = MaterialTheme.colorScheme.onPrimary, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + } + } + } +} + +@Composable +private fun StreamGuidePoint(number: Int, body: String) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.06f)) + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.Top, + ) { + Surface( + modifier = Modifier.size(22.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + ) { + Box(contentAlignment = Alignment.Center) { + Text(number.toString(), color = MaterialTheme.colorScheme.onPrimary, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold) + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(body, color = TextMuted, style = MaterialTheme.typography.bodySmall) + } + } +} + +private enum class StreamControlsPage { + Main, + StatusBar, + Joysticks, +} + +@Composable +private fun StreamControlsPanel( + gameTitle: String, + status: String?, + settings: AppSettings, + tvProfile: Boolean, + touchControlsVisible: Boolean, + controllerMouseAssistEnabled: Boolean, + controllerMouseEmulationEnabled: Boolean, + showSessionTimer: Boolean, + sessionTimerLimit: SmartSessionLimit, + sessionStartedAtMs: Long, + sessionNowMs: Long, + audioMuted: Boolean, + microphoneRequested: Boolean, + microphonePermissionGranted: Boolean, + microphoneEnabled: Boolean, + statsVisible: Boolean, + touchLayoutEditing: Boolean, + bugReportSubmission: BugReportSubmissionState, + onAudioToggle: () -> Unit, + onMicrophoneToggle: () -> Unit, + onStatsToggle: () -> Unit, + onStatsStyleCycle: () -> Unit, + onStatsPositionCycle: () -> Unit, + onStatsMetricsChange: (StreamStatsMetrics) -> Unit, + onPhoneRumbleFallbackToggle: () -> Unit, + onTouchLayoutEditingToggle: () -> Unit, + onKeyboardOpen: () -> Unit, + onEsc: () -> Unit, + onEnter: () -> Unit, + onBackspace: () -> Unit, + onSteamMenuOpen: () -> Unit, + onControllerMouseAssistToggle: () -> Unit, + onControllerMouseEmulationToggle: () -> Unit, + onExit: () -> Unit, + onTouchControlsToggle: () -> Unit, + onMousePadToggle: () -> Unit, + onMouseDirectClickToggle: () -> Unit, + onToggleTouchControllerStyle: () -> Unit, + onJoystickModeToggle: () -> Unit, + onJoystickDeadZoneChange: (Float) -> Unit, + onSharpeningToggle: () -> Unit, + onSharpeningAmountChange: (Float) -> Unit, + onStretchToFitToggle: () -> Unit, + onTouchScaleChange: (Float) -> Unit, + onButtonScaleChange: (Float) -> Unit, + onStickScaleChange: (Float) -> Unit, + onOpacityChange: (Float) -> Unit, + onTouchEdgePaddingChange: (Float) -> Unit, + onTouchBottomPaddingChange: (Float) -> Unit, + onTouchLeftOffsetChange: (Float) -> Unit, + onTouchRightOffsetChange: (Float) -> Unit, + onTouchLayoutReset: () -> Unit, + onBugReportSubmit: (String, String) -> Unit, + onBugReportReset: () -> Unit, + onButtonTone: () -> Unit, + highlightDone: Boolean = false, + onClose: () -> Unit, +) { + val doneFocusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + var page by remember { mutableStateOf(StreamControlsPage.Main) } + var keyboardFocused by remember { mutableStateOf(false) } + var exitFocused by remember { mutableStateOf(false) } + var doneFocused by remember { mutableStateOf(false) } + BackHandler(enabled = page != StreamControlsPage.Main) { + page = StreamControlsPage.Main + } + LaunchedEffect(page) { + if (page == StreamControlsPage.Main) { + delay(120) + runCatching { doneFocusRequester.requestFocus() } + } + } + Surface( + modifier = Modifier + .padding(14.dp) + .fillMaxWidth(0.94f) + .fillMaxHeight(0.72f) + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInRoot() + NativeStreamInputRouter.setStreamPanelTouchPassthroughBounds( + bounds.left.roundToInt(), + bounds.top.roundToInt(), + bounds.right.roundToInt(), + bounds.bottom.roundToInt(), + ) + }, + shape = RoundedCornerShape(18.dp), + color = Panel.copy(alpha = 0.93f), + contentColor = TextPrimary, + tonalElevation = 6.dp, + ) { + LazyColumn( + modifier = Modifier.onPreviewKeyEvent { handleVerticalDpadFocusMove(it, focusManager) }, + contentPadding = PaddingValues(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (page == StreamControlsPage.StatusBar) { + item { + StatusBarSettingsPage( + settings = settings, + statsVisible = statsVisible, + onStatsToggle = onStatsToggle, + onStatsStyleCycle = onStatsStyleCycle, + onStatsPositionCycle = onStatsPositionCycle, + onStatsMetricsChange = onStatsMetricsChange, + onButtonTone = onButtonTone, + onBack = { page = StreamControlsPage.Main }, + ) + } + } else if (page == StreamControlsPage.Joysticks) { + item { + JoystickSettingsPage( + settings = settings.androidTouch, + onModeToggle = onJoystickModeToggle, + onDeadZoneChange = onJoystickDeadZoneChange, + onStickScaleChange = onStickScaleChange, + onButtonTone = onButtonTone, + onBack = { page = StreamControlsPage.Main }, + ) + } + } else { + item { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Column(Modifier.weight(1f)) { + Text("Stream Controls", fontWeight = FontWeight.Bold) + Text(gameTitle, color = TextMuted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (status != null) { + Text(status, color = TextMuted, style = MaterialTheme.typography.labelMedium) + } + OutlinedButton( + onClick = { + onButtonTone() + onKeyboardOpen() + }, + modifier = Modifier + .onFocusChanged { keyboardFocused = it.isFocused } + .border( + width = 1.dp, + color = if (keyboardFocused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = ButtonDefaults.outlinedShape + ), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_keyboard), + contentDescription = "Open keyboard sender", + tint = TextPrimary, + modifier = Modifier.size(20.dp), + ) + } + OutlinedButton( + onClick = { + onButtonTone() + onExit() + }, + modifier = Modifier + .onFocusChanged { exitFocused = it.isFocused } + .border( + width = 1.dp, + color = if (exitFocused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = ButtonDefaults.outlinedShape + ), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + ) { + Text("Exit") + } + val doneAction = { + onButtonTone() + onClose() + } + val doneModifier = Modifier + .focusRequester(doneFocusRequester) + .onFocusChanged { doneFocused = it.isFocused } + .border( + width = 1.dp, + color = if (doneFocused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = ButtonDefaults.outlinedShape + ) + if (highlightDone) { + Button( + onClick = doneAction, + modifier = doneModifier, + border = BorderStroke(2.dp, TextPrimary), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + ) { + Text("Done") + } + } else { + OutlinedButton( + onClick = doneAction, + modifier = doneModifier, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + ) { + Text("Done") + } + } + } + } + if (showSessionTimer) { + item { + StreamSessionTimerMenuRow( + limit = sessionTimerLimit, + startedAtMs = sessionStartedAtMs, + nowMs = sessionNowMs, + ) + } + } + item { + StreamPanelSection("Display") { + StreamControlSwitch("Audio", if (audioMuted) "Muted" else "On", !audioMuted) { + onButtonTone() + onAudioToggle() + } + StreamControlNavigation( + "Status bar", + if (!statsVisible) "Off" else "${settings.streamStatsStyle.label} · ${settings.streamStatsMetrics.enabledCount()} items", + ) { + onButtonTone() + page = StreamControlsPage.StatusBar + } + StreamControlSwitch("Stream sharpening", if (settings.stream.streamSharpeningEnabled) "On" else "Off", settings.stream.streamSharpeningEnabled) { + onButtonTone() + onSharpeningToggle() + } + if (settings.stream.streamSharpeningEnabled) { + CompactSlider("Sharpness amount", settings.stream.streamSharpeningAmount, 0f, 1f, onSharpeningAmountChange) + } + StreamControlSwitch("Stretch to fit", if (settings.stretchStreamToFit) "On" else "Off", settings.stretchStreamToFit) { + onButtonTone() + onStretchToFitToggle() + } + } + } + item { + StreamPanelSection("Input") { + if (microphoneRequested) { + StreamControlSwitch( + label = "Microphone", + value = when { + !microphonePermissionGranted -> "Permission required" + microphoneEnabled -> "On" + else -> "Muted" + }, + checked = microphoneEnabled && microphonePermissionGranted, + ) { + onButtonTone() + onMicrophoneToggle() + } + } + StreamControlAction( + label = "Steam Menu", + value = "Send Home to the streamed PC", + action = "Open", + ) { + onButtonTone() + onSteamMenuOpen() + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { + onButtonTone() + onEsc() + }, + modifier = Modifier.weight(1f), + ) { Text("Esc") } + OutlinedButton( + onClick = { + onButtonTone() + onEnter() + }, + modifier = Modifier.weight(1f), + ) { Text("Enter") } + OutlinedButton( + onClick = { + onButtonTone() + onBackspace() + }, + modifier = Modifier.weight(1f), + ) { Text("⌫") } + } + if (tvProfile) { + StreamControlSwitch( + "Controller mouse", + if (controllerMouseAssistEnabled) "Right stick · A click · B right-click" else "Off", + controllerMouseAssistEnabled, + ) { + onButtonTone() + onControllerMouseAssistToggle() + } + } else { + StreamControlSwitch("Finger mouse", if (settings.androidTouch.mousePad) "On" else "Off", settings.androidTouch.mousePad) { + onButtonTone() + onMousePadToggle() + } + if (settings.androidTouch.mousePad) { + Box(Modifier.padding(start = 24.dp)) { + StreamControlSwitch("Direct click", if (settings.androidTouch.mouseDirectClick) "On" else "Off", settings.androidTouch.mouseDirectClick) { + onButtonTone() + onMouseDirectClickToggle() + } + } + } + StreamControlSwitch("Touch controller", if (touchControlsVisible) "Visible" else "Hidden", touchControlsVisible) { + onButtonTone() + onTouchControlsToggle() + } + StreamControlNavigation( + "Joysticks", + when (settings.androidTouch.joystickMode) { + TouchJoystickMode.Fixed -> "Fixed" + TouchJoystickMode.Dynamic -> "Dynamic" + }, + ) { + onButtonTone() + page = StreamControlsPage.Joysticks + } + if (touchControlsVisible) { + StreamControlSwitch("Clean style", if (settings.androidTouch.touchControllerStyle == TouchControllerStyle.V2) "On" else "Off", settings.androidTouch.touchControllerStyle == TouchControllerStyle.V2) { + onButtonTone() + onToggleTouchControllerStyle() + } + } + StreamControlSwitch("Phone rumble fallback", if (settings.phoneRumbleFallback) "On" else "Off", settings.phoneRumbleFallback) { + onButtonTone() + onPhoneRumbleFallbackToggle() + } + } + // Mouse mode (Left stick): shown for all profiles — works with both physical + // gamepad and touch controller. + StreamControlSwitch( + "Mouse mode (Left stick)", + if (controllerMouseEmulationEnabled) "L stick moves · A clicks · B right-clicks" else "Off", + controllerMouseEmulationEnabled, + ) { + onButtonTone() + onControllerMouseEmulationToggle() + } + } + } + if (!tvProfile) item { + StreamPanelSection("Touch Layout") { + StreamControlSwitch("Drag edit mode", if (touchLayoutEditing) "On" else "Off", touchLayoutEditing) { + onButtonTone() + onTouchLayoutEditingToggle() + } + StreamControlAction("Reset touch layout", "Reset positions to default", "Reset") { + onButtonTone() + onTouchLayoutReset() + } + CompactSlider("Layout scale", settings.androidTouch.scale, 0.6f, 1.4f, onTouchScaleChange) + CompactSlider("Button size", settings.androidTouch.buttonScale, 0.65f, 1.5f, onButtonScaleChange) + CompactSlider("Opacity", settings.androidTouch.opacity, 0.15f, 1f, onOpacityChange) + CompactDpSlider("Edge padding", settings.androidTouch.edgePaddingDp, 0f, 72f, onTouchEdgePaddingChange) + CompactDpSlider("Bottom padding", settings.androidTouch.bottomPaddingDp, 0f, 120f, onTouchBottomPaddingChange) + CompactDpSlider("Left position", settings.androidTouch.leftOffsetYDp, -160f, 160f, onTouchLeftOffsetChange) + CompactDpSlider("Right position", settings.androidTouch.rightOffsetYDp, -160f, 160f, onTouchRightOffsetChange) + } + } + item { + StreamBugReporter( + submission = bugReportSubmission, + onSubmit = onBugReportSubmit, + onReset = onBugReportReset, + onButtonTone = onButtonTone, + ) + } + } + } + } + DisposableEffect(Unit) { + onDispose { + NativeStreamInputRouter.clearStreamPanelTouchPassthroughBounds() + } + } +} + +@Composable +private fun StreamBugReporter( + submission: BugReportSubmissionState, + onSubmit: (String, String) -> Unit, + onReset: () -> Unit, + onButtonTone: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + var title by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var consentChecked by remember { mutableStateOf(false) } + var confirmationOpen by remember { mutableStateOf(false) } + + StreamPanelSection("Bug reporter") { + if (!expanded) { + StreamControlAction( + label = "Report a stream bug", + value = "Send an issue and redacted diagnostics", + action = "Open", + ) { + onButtonTone() + expanded = true + } + return@StreamPanelSection + } + + if (submission.submitted) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = Green.copy(alpha = 0.12f), + contentColor = TextPrimary, + border = BorderStroke(1.dp, Green.copy(alpha = 0.45f)), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Rounded.Check, contentDescription = null, tint = Green) + Text("Bug report sent", fontWeight = FontWeight.Bold) + } + Text( + submission.reference?.let { "PrintedWaste reference: $it" } + ?: "PrintedWaste received your report.", + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { + onButtonTone() + title = "" + description = "" + consentChecked = false + confirmationOpen = false + onReset() + }, + ) { + Text("Send another") + } + TextButton( + onClick = { + onButtonTone() + expanded = false + }, + ) { + Text("Close") + } + } + } + } + return@StreamPanelSection + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Report a stream bug", fontWeight = FontWeight.Bold) + Text( + "Describe the problem without leaving your game.", + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + TextButton( + enabled = !submission.uploading, + onClick = { + onButtonTone() + expanded = false + }, + ) { + Text("Cancel") + } + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = Color(0xffffc266).copy(alpha = 0.10f), + contentColor = TextPrimary, + border = BorderStroke(1.dp, Color(0xffffc266).copy(alpha = 0.38f)), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(7.dp), + ) { + Text( + "Before you send", + color = Color(0xffffc266), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge, + ) + Text( + "This uploads to https://api.printedwaste.com/releases/opennow/bug-reports. PrintedWaste and OpenNOW maintainers may view your issue text, app version/build, device model, Android version, provider and membership category, current game, stream status/settings, and a redacted diagnostic log.", + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + Text( + "The automatic log removes account names, credentials, session IDs, and network addresses before upload. Your typed title and description are sent exactly as written, so do not include personal or sensitive information.", + color = TextPrimary, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Your data is not sold and is used only to investigate and fix bugs.", + color = TextPrimary, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + ) + Text( + "The same timestamped log available from Settings > Advanced > Debug Logs is attached automatically. No other files are added.", + color = TextMuted, + style = MaterialTheme.typography.labelSmall, + ) + } + } + + OutlinedTextField( + value = title, + onValueChange = { value -> + title = value + if (submission.error != null) onReset() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !submission.uploading, + singleLine = true, + label = { Text("Issue title") }, + placeholder = { Text("Stream froze after reconnecting") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + OutlinedTextField( + value = description, + onValueChange = { value -> + description = value + if (submission.error != null) onReset() + }, + modifier = Modifier + .fillMaxWidth() + .height(128.dp), + enabled = !submission.uploading, + minLines = 4, + maxLines = 7, + label = { Text("What happened?") }, + placeholder = { Text("What were you doing, what went wrong, and can you reproduce it?") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default), + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable(enabled = !submission.uploading) { + consentChecked = !consentChecked + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = consentChecked, + onCheckedChange = { consentChecked = it }, + enabled = !submission.uploading, + ) + Text( + "I understand what will be uploaded and consent to send it to PrintedWaste.", + modifier = Modifier.weight(1f), + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + + submission.error?.let { error -> + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.error.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.error, + ) { + Text( + error, + modifier = Modifier.padding(10.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + } + + Button( + onClick = { + onButtonTone() + confirmationOpen = true + }, + enabled = title.isNotBlank() && + description.isNotBlank() && + consentChecked && + !submission.uploading, + modifier = Modifier.fillMaxWidth(), + ) { + if (submission.uploading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text("Uploading report…") + } else { + Text("Send bug report") + } + } + } + } + + if (confirmationOpen) { + AlertDialog( + onDismissRequest = { confirmationOpen = false }, + title = { Text("Upload bug report?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + "Your report will be uploaded to the PrintedWaste API and may be viewed by PrintedWaste and OpenNOW maintainers.", + ) + Text( + "It includes your title and description exactly as written, the API's app/build fields, and the same redacted log file available from Settings > Advanced > Debug Logs. No other files are uploaded.", + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + Text( + "This data is not sold. It is used only to investigate and fix bugs.", + fontWeight = FontWeight.Bold, + ) + } + }, + confirmButton = { + Button( + onClick = { + onButtonTone() + confirmationOpen = false + onSubmit(title, description) + }, + ) { + Text("Upload to PrintedWaste") + } + }, + dismissButton = { + TextButton( + onClick = { + onButtonTone() + confirmationOpen = false + }, + ) { + Text("Go back") + } + }, + ) + } +} + +@Composable +private fun JoystickSettingsPage( + settings: AndroidTouchSettings, + onModeToggle: () -> Unit, + onDeadZoneChange: (Float) -> Unit, + onStickScaleChange: (Float) -> Unit, + onButtonTone: () -> Unit, + onBack: () -> Unit, +) { + val backFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + delay(120) + runCatching { backFocusRequester.requestFocus() } + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = { + onButtonTone() + onBack() + }, + modifier = Modifier.focusRequester(backFocusRequester), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(6.dp)) + Text("Back") + } + Column(Modifier.weight(1f)) { + Text("Joysticks", fontWeight = FontWeight.Bold) + Text("Tune the touch analog controls", color = TextMuted, style = MaterialTheme.typography.labelSmall) + } + } + StreamControlSwitch( + label = "Dynamic placement", + value = if (settings.joystickMode == TouchJoystickMode.Dynamic) { + "Starts centered beneath your thumb" + } else { + "Uses the saved fixed center" + }, + checked = settings.joystickMode == TouchJoystickMode.Dynamic, + ) { + onButtonTone() + onModeToggle() + } + CompactSlider("Stick size", settings.stickScale, 0.65f, 1.5f, onStickScaleChange) + CompactSlider("Dead zone", settings.joystickDeadZone, 0f, 0.3f, onDeadZoneChange) + Text( + "Dynamic mode keeps the saved stick area, but treats wherever your thumb first lands as neutral. This avoids sudden movement when you miss the exact center.", + color = TextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun StatusBarSettingsPage( + settings: AppSettings, + statsVisible: Boolean, + onStatsToggle: () -> Unit, + onStatsStyleCycle: () -> Unit, + onStatsPositionCycle: () -> Unit, + onStatsMetricsChange: (StreamStatsMetrics) -> Unit, + onButtonTone: () -> Unit, + onBack: () -> Unit, +) { + val backFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + delay(120) + runCatching { backFocusRequester.requestFocus() } + } + val metrics = settings.streamStatsMetrics + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = { + onButtonTone() + onBack() + }, + modifier = Modifier.focusRequester(backFocusRequester), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(6.dp)) + Text("Back") + } + Column(Modifier.weight(1f)) { + Text("Status bar", fontWeight = FontWeight.Bold) + Text("Choose its layout and information", color = TextMuted, style = MaterialTheme.typography.labelSmall) + } + } + StreamControlSwitch("Visible", if (statsVisible) "On" else "Off", statsVisible) { + onButtonTone() + onStatsToggle() + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatusBarOptionAction("Appearance", settings.streamStatsStyle.label, Modifier.weight(1f)) { + onButtonTone() + onStatsStyleCycle() + } + StatusBarOptionAction("Position", settings.streamStatsPosition.label, Modifier.weight(1f)) { + onButtonTone() + onStatsPositionCycle() + } + } + Text("Items", color = TextMuted, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + BoxWithConstraints(Modifier.fillMaxWidth()) { + val columns = when { + maxWidth >= 800.dp -> 5 + maxWidth >= 620.dp -> 4 + maxWidth >= 460.dp -> 3 + else -> 2 + } + val gap = 8.dp + val itemWidth = (maxWidth - gap * (columns - 1)) / columns.toFloat() + FlowRow( + modifier = Modifier.fillMaxWidth(), + maxItemsInEachRow = columns, + horizontalArrangement = Arrangement.spacedBy(gap), + verticalArrangement = Arrangement.spacedBy(gap), + ) { + StatusBarMetricSwitch("FPS", metrics.fps, Modifier.width(itemWidth)) { + onButtonTone() + onStatsMetricsChange(metrics.copy(fps = !metrics.fps)) + } + StatusBarMetricSwitch("Ping", metrics.ping, Modifier.width(itemWidth)) { + onButtonTone() + onStatsMetricsChange(metrics.copy(ping = !metrics.ping)) + } + StatusBarMetricSwitch("Bitrate", metrics.bitrate, Modifier.width(itemWidth)) { + onButtonTone() + onStatsMetricsChange(metrics.copy(bitrate = !metrics.bitrate)) + } + StatusBarMetricSwitch("Battery", metrics.battery, Modifier.width(itemWidth)) { + onButtonTone() + onStatsMetricsChange(metrics.copy(battery = !metrics.battery)) + } + StatusBarMetricSwitch("Connection", metrics.connection, Modifier.width(itemWidth)) { + onButtonTone() + onStatsMetricsChange(metrics.copy(connection = !metrics.connection)) + } + StatusBarMetricSwitch("Resolution", metrics.resolution, Modifier.width(itemWidth)) { + onButtonTone() + onStatsMetricsChange(metrics.copy(resolution = !metrics.resolution)) + } + StatusBarMetricSwitch("Codec", metrics.codec, Modifier.width(itemWidth)) { + onButtonTone() + onStatsMetricsChange(metrics.copy(codec = !metrics.codec)) + } + StatusBarMetricSwitch("Server", metrics.location, Modifier.width(itemWidth)) { + onButtonTone() + onStatsMetricsChange(metrics.copy(location = !metrics.location)) + } + StatusBarMetricSwitch("Dec / Jit", metrics.latency, Modifier.width(itemWidth)) { + onButtonTone() + onStatsMetricsChange(metrics.copy(latency = !metrics.latency)) + } + StatusBarMetricSwitch("Loss", metrics.packetLoss, Modifier.width(itemWidth)) { + onButtonTone() + onStatsMetricsChange(metrics.copy(packetLoss = !metrics.packetLoss)) + } + } + } + } +} + +@Composable +private fun StatusBarOptionAction(label: String, value: String, modifier: Modifier = Modifier, onClick: () -> Unit) { + var focused by remember { mutableStateOf(false) } + Column( + modifier + .clip(RoundedCornerShape(12.dp)) + .onFocusChanged { focused = it.isFocused } + .background(if (focused) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.06f)) + .border( + width = 1.dp, + color = if (focused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = RoundedCornerShape(12.dp) + ) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 9.dp), + ) { + Text(label, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold) + Text(value, color = TextMuted, style = MaterialTheme.typography.labelSmall, maxLines = 1) + } +} + +@Composable +private fun StatusBarMetricSwitch(label: String, checked: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { + var focused by remember { mutableStateOf(false) } + Row( + modifier + .clip(RoundedCornerShape(12.dp)) + .onFocusChanged { focused = it.isFocused } + .background(if (focused) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.06f)) + .border( + width = 1.dp, + color = if (focused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = RoundedCornerShape(12.dp) + ) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, Modifier.weight(1f), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold) + Switch(checked = checked, onCheckedChange = { onClick() }) + } +} + +@Composable +private fun StreamPanelSection(title: String, content: @Composable ColumnScope.() -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, color = TextMuted, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + content() + } +} + +@Composable +private fun StreamControlSwitch(label: String, value: String, checked: Boolean, onClick: () -> Unit) { + var focused by remember { mutableStateOf(false) } + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .onFocusChanged { focused = it.isFocused } + .background(if (focused) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.06f)) + .border( + width = 1.dp, + color = if (focused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = RoundedCornerShape(12.dp) + ) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(label, fontWeight = FontWeight.SemiBold) + Text(value, color = TextMuted, style = MaterialTheme.typography.labelSmall) + } + Switch(checked = checked, onCheckedChange = { onClick() }) + } +} + +@Composable +private fun StreamControlNavigation(label: String, value: String, onClick: () -> Unit) { + var focused by remember { mutableStateOf(false) } + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .onFocusChanged { focused = it.isFocused } + .background(if (focused) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.06f)) + .border( + width = 1.dp, + color = if (focused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = RoundedCornerShape(12.dp) + ) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(label, fontWeight = FontWeight.SemiBold) + Text(value, color = TextMuted, style = MaterialTheme.typography.labelSmall) + } + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = "Open $label options", + tint = TextPrimary, + modifier = Modifier.size(22.dp), + ) + } +} + +@Composable +private fun StreamControlAction(label: String, value: String, action: String = "Change", onClick: () -> Unit) { + var focused by remember { mutableStateOf(false) } + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .onFocusChanged { focused = it.isFocused } + .background(if (focused) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.06f)) + .border( + width = 1.dp, + color = if (focused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = RoundedCornerShape(12.dp) + ) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(label, fontWeight = FontWeight.SemiBold) + Text(value, color = TextMuted, style = MaterialTheme.typography.labelSmall) + } + Text(action, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelMedium) + } +} + +@Composable +private fun CompactSlider(label: String, value: Float, min: Float, max: Float, onChange: (Float) -> Unit) { + var local by remember(value) { mutableFloatStateOf(value) } + val focusManager = LocalFocusManager.current + var focused by remember { mutableStateOf(false) } + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(if (focused) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.06f)) + .border( + width = if (focused) 2.dp else 1.dp, + color = if (focused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(label, Modifier.weight(1f)) + Text("${(local * 100).roundToInt()}%", color = TextMuted) + } + Slider( + modifier = Modifier + .onFocusChanged { focused = it.isFocused } + .onPreviewKeyEvent { + handleSliderDpadInput(it, local, min, max, 0.05f, focusManager) { newVal -> + local = newVal + onChange(newVal) + } + }, + value = local, + onValueChange = { + local = it.coerceIn(min, max) + onChange(local) + }, + valueRange = min..max, + ) + } +} + +@Composable +private fun CompactDpSlider(label: String, value: Float, min: Float, max: Float, onChange: (Float) -> Unit) { + var local by remember(value) { mutableFloatStateOf(value) } + val focusManager = LocalFocusManager.current + var focused by remember { mutableStateOf(false) } + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(if (focused) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.06f)) + .border( + width = if (focused) 2.dp else 1.dp, + color = if (focused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(label, Modifier.weight(1f)) + Text("${local.roundToInt()} dp", color = TextMuted) + } + Slider( + modifier = Modifier + .onFocusChanged { focused = it.isFocused } + .onPreviewKeyEvent { + handleSliderDpadInput(it, local, min, max, 2f, focusManager) { newVal -> + local = newVal + onChange(newVal) + } + }, + value = local, + onValueChange = { + local = it.coerceIn(min, max) + onChange(local) + }, + valueRange = min..max, + ) + } +} + +@Composable +private fun StreamKeyboardBar( + text: String, + onTextChange: (String) -> Unit, + onSend: () -> Unit, + onBackspace: () -> Unit, + onEnter: () -> Unit, + onEsc: () -> Unit, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + val inputFocusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val sendIfReady = { + if (text.isNotBlank()) { + onSend() + } + } + LaunchedEffect(Unit) { + delay(80) + runCatching { inputFocusRequester.requestFocus() } + keyboardController?.show() + } + Surface( + modifier = modifier.fillMaxWidth(), + color = Panel.copy(alpha = 0.95f), + tonalElevation = 8.dp, + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = text, + onValueChange = onTextChange, + modifier = Modifier + .fillMaxWidth() + .focusRequester(inputFocusRequester), + singleLine = true, + placeholder = { Text("Type into stream", color = TextMuted) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { sendIfReady() }), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Button(onClick = sendIfReady, enabled = text.isNotBlank(), modifier = Modifier.weight(1f)) { Text("Send") } + OutlinedButton(onClick = onBackspace, modifier = Modifier.weight(1f)) { Text("⌫") } + OutlinedButton(onClick = onEnter, modifier = Modifier.weight(1f)) { Text("Enter") } + OutlinedButton(onClick = onEsc, modifier = Modifier.weight(1f)) { Text("Esc") } + TextButton( + onClick = { + keyboardController?.hide() + onDone() + }, + ) { Text("Done") } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun StreamStatsPill( + streamStats: StreamRuntimeStats, + streamSettings: StreamSettings, + style: StreamStatsStyle, + metrics: StreamStatsMetrics, + serverLocation: String?, + modifier: Modifier = Modifier, +) { + if (metrics.enabledCount() == 0) return + val deviceStatus = rememberCompactStreamDeviceStatus() + Surface( + modifier = modifier.padding(8.dp).widthIn(max = if (style == StreamStatsStyle.Compact) 720.dp else 300.dp), + shape = RoundedCornerShape(if (style == StreamStatsStyle.Compact) 999.dp else 16.dp), + color = Panel.copy(alpha = 0.52f), + tonalElevation = 0.dp, + ) { + if (style == StreamStatsStyle.Compact) { + Row( + Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + StreamStatsMetricItems(streamStats, streamSettings, metrics, deviceStatus, serverLocation) + } + } else { + FlowRow( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp), + maxItemsInEachRow = 2, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + StreamStatsMetricItems(streamStats, streamSettings, metrics, deviceStatus, serverLocation) + } + } + } +} + +@Composable +private fun ActiveStreamModePill( + status: ActiveStreamModeStatus, + modifier: Modifier = Modifier, +) { + val modeLabel = when (status.resolutionSource) { + StreamResolutionChangeSource.ServerNegotiatedFallback -> + "Server ${formatRuntimeResolution(status.displayedResolution)}" + StreamResolutionChangeSource.ProviderOrGameModeChange -> + "Stream ${formatRuntimeResolution(status.displayedResolution)}" + null -> null + } + val recoveryLabel = if (status.safeVideoRecoveryActive) { + "Recovery ${status.transportCodec.name}" + } else { + null + } + val text = buildList { + modeLabel?.let(::add) + add("Requested ${formatRuntimeResolution(status.requestedResolution)}") + recoveryLabel?.let(::add) + }.joinToString(" • ") + Surface( + modifier = modifier.padding(horizontal = 8.dp), + shape = RoundedCornerShape(999.dp), + color = Color(0xff4a2f0b).copy(alpha = 0.88f), + tonalElevation = 0.dp, + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + color = Color(0xffffd38a), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun StreamStatsMetricItems( + streamStats: StreamRuntimeStats, + streamSettings: StreamSettings, + metrics: StreamStatsMetrics, + deviceStatus: CompactStreamDeviceStatus, + serverLocation: String?, +) { + if (metrics.fps) { + StreamStatsText("FPS ${streamStats.fps?.toString() ?: streamSettings.fps}") + } + if (metrics.ping) { + val ping = streamStats.pingMs + val color = when { + ping == null -> TextPrimary + ping >= 100 -> Color(0xffff4f4f) // Bright red + ping >= 50 -> Color(0xffffa500) // Orange + else -> TextPrimary + } + StreamStatsText("Ping ${ping?.let { "${it}ms" } ?: "--"}", color = color) + } + if (metrics.latency) { + streamStats.decodeMs?.let { + StreamStatsText("Dec %.1fms".format(java.util.Locale.US, it)) + } + streamStats.jitterMs?.let { + StreamStatsText("Jit %.1fms".format(java.util.Locale.US, it)) + } + } + if (metrics.packetLoss) { + streamStats.packetLossPct?.let { loss -> + val color = if (loss > 1.0) Color(0xffff4f4f) else TextPrimary + StreamStatsText("Loss %.1f%%".format(java.util.Locale.US, loss), color = color) + } + } + if (metrics.bitrate) { + StreamStatsText(formatRuntimeBitrate(streamStats.bitrateKbps)) + } + if (metrics.battery) { + StreamBatteryIndicator(deviceStatus) + } + if (metrics.connection) { + StreamNetworkIndicator(deviceStatus) + } + if (metrics.resolution) { + StreamStatsText( + streamStats.resolution?.let(::formatRuntimeResolution) + ?: formatRuntimeResolution(normalizeStreamResolutionForAspect(streamSettings.resolution, streamSettings.aspectRatio)), + ) + } + if (metrics.codec) { + StreamStatsText(streamStats.codec?.takeIf { it.isNotBlank() } ?: streamSettings.codec.name) + } + if (metrics.location && !serverLocation.isNullOrBlank()) { + val displayName = serverLocation.removePrefix("NPA-").removePrefix("NP-").uppercase() + StreamStatsText(displayName) + } +} + +@Composable +private fun StreamStatsText(value: String, color: Color = TextPrimary) { + Text( + value, + color = color, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) +} + +private data class CompactStreamDeviceStatus( + val batteryPercent: Int? = null, + val batteryCharging: Boolean = false, + val networkKind: AndroidNetworkKind = AndroidNetworkKind.Unknown, + val networkBars: Int? = null, + val cellularGeneration: String? = null, +) + +@Composable +private fun rememberCompactStreamDeviceStatus(): CompactStreamDeviceStatus { + val context = LocalContext.current + val appContext = remember(context) { context.applicationContext } + var status by remember(appContext) { mutableStateOf(readCompactStreamDeviceStatus(appContext)) } + LaunchedEffect(appContext) { + while (true) { + status = readCompactStreamDeviceStatus(appContext) + delay(COMPACT_STREAM_DEVICE_STATUS_REFRESH_MS) + } + } + return status +} + +private fun readCompactStreamDeviceStatus(context: Context): CompactStreamDeviceStatus { + val diagnostics = AndroidRuntimeDiagnostics.snapshot(context) + return CompactStreamDeviceStatus( + batteryPercent = diagnostics.batteryPercent, + batteryCharging = diagnostics.batteryCharging, + networkKind = diagnostics.networkKind, + networkBars = diagnostics.networkSignalBars, + cellularGeneration = diagnostics.cellularGeneration, + ) +} + +@Composable +private fun StreamBatteryIndicator(status: CompactStreamDeviceStatus) { + val description = status.batteryPercent?.let { percent -> + "Battery $percent percent${if (status.batteryCharging) ", charging" else ""}" + } ?: "Battery unknown" + Row( + modifier = Modifier.semantics { contentDescription = description }, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.BatteryFull, + contentDescription = null, + tint = TextPrimary, + modifier = Modifier.size(18.dp).graphicsLayer { rotationZ = 90f }, + ) + Text( + status.batteryPercent?.let { "$it%" } ?: "--%", + color = TextPrimary, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + ) + } +} + +@Composable +private fun StreamNetworkIndicator(status: CompactStreamDeviceStatus) { + val bars = status.networkBars?.coerceIn(0, 4) + val label = when (status.networkKind) { + AndroidNetworkKind.Cellular -> status.cellularGeneration ?: status.networkKind.label + AndroidNetworkKind.Ethernet, + AndroidNetworkKind.Other, + AndroidNetworkKind.None, + AndroidNetworkKind.Unknown, + -> status.networkKind.label + AndroidNetworkKind.Wifi -> null + } + val description = "${label ?: status.networkKind.label} signal ${bars?.toString() ?: "unknown"} bars" + Row( + modifier = Modifier.semantics { contentDescription = description }, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (label != null) { + Text( + label, + color = TextPrimary, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + } + if (status.networkKind == AndroidNetworkKind.Wifi) { + Icon( + imageVector = when (bars) { + 4 -> Icons.Rounded.Wifi + 3 -> Icons.Rounded.Wifi + 2 -> Icons.Rounded.Wifi2Bar + 1 -> Icons.Rounded.Wifi1Bar + 0 -> Icons.Rounded.SignalWifi0Bar + else -> Icons.Rounded.WifiOff + }, + contentDescription = null, + tint = TextPrimary, + modifier = Modifier.size(20.dp), + ) + } else if (status.networkKind == AndroidNetworkKind.Cellular || status.networkKind == AndroidNetworkKind.Other || status.networkKind == AndroidNetworkKind.Unknown) { + Icon( + imageVector = when (bars) { + 4 -> Icons.Rounded.SignalCellular4Bar + 3 -> Icons.Rounded.SignalCellularAlt + 2 -> Icons.Rounded.SignalCellularAlt2Bar + 1 -> Icons.Rounded.SignalCellularAlt1Bar + else -> Icons.Rounded.SignalCellular0Bar + }, + contentDescription = null, + tint = TextPrimary, + modifier = Modifier.size(20.dp), + ) + } + } +} + +private fun formatRuntimeResolution(resolution: String): String { + val parts = resolution.lowercase(Locale.US).split("x", limit = 2) + return if (parts.size == 2 && parts.all { it.trim().isNotBlank() }) { + "${parts[0].trim()}x${parts[1].trim()}" + } else { + resolution + } +} + +private fun formatRuntimeBitrate(bitrateKbps: Int?): String { + val kbps = bitrateKbps ?: return "--" + return if (kbps >= 1000) { + "${(kbps / 1000.0).let { kotlin.math.round(it * 10.0) / 10.0 }} Mbps" + } else { + "$kbps Kbps" + } +} + +private fun shouldHideStreamStatusText(status: String): Boolean = + status.trim().replace('_', ' ').let { + it.equals("Streaming", ignoreCase = true) || + it.equals("ICE CONNECTED", ignoreCase = true) || + it.equals("ICE COMPLETED", ignoreCase = true) + } + +internal data class InitialStreamConnectionStatus( + val phase: String, + val title: String, + val detail: String, +) + +internal fun initialStreamConnectionStatus(nativeState: String): InitialStreamConnectionStatus { + val normalized = nativeState.trim().replace('_', ' ') + return when { + normalized.equals("Preparing", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Preparing", + title = "Preparing your stream", + detail = "Getting the secure video connection ready.", + ) + normalized.startsWith("Connecting signaling", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Connecting", + title = "Connecting to your game", + detail = "Opening a secure connection to the streaming server.", + ) + normalized.startsWith("Waiting for offer", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Waiting for video", + title = "Starting the video stream", + detail = "The server is preparing the first video frame.", + ) + normalized.equals("ICE CHECKING", ignoreCase = true) || + normalized.equals("ICE NEW", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Securing connection", + title = "Almost ready", + detail = "Checking the best route for the live video stream.", + ) + normalized.equals("ICE DISCONNECTED", ignoreCase = true) || + normalized.equals("ICE FAILED", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Retrying", + title = "Connection interrupted", + detail = "OpenNOW is retrying the initial stream connection.", + ) + normalized.startsWith("Recovering video", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Recovering video", + title = "Waiting for a clear frame", + detail = "Requesting a fresh video frame before showing the stream.", + ) + normalized.contains("safe H264 profile", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Optimizing video", + title = "Trying a compatible video mode", + detail = "Restarting the initial video connection with safer settings.", + ) + normalized.startsWith("Reconnecting", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Retrying connection", + title = "Connecting again", + detail = "The initial connection did not finish, so OpenNOW is retrying it.", + ) + normalized.startsWith("Recovering cloud session", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Checking session", + title = "Restoring your game session", + detail = "Checking the existing cloud session before continuing.", + ) + normalized.equals("Streaming", ignoreCase = true) -> InitialStreamConnectionStatus( + phase = "Starting video", + title = "Connection established", + detail = "Waiting for the first video frame to appear.", + ) + else -> InitialStreamConnectionStatus( + phase = "Starting stream", + title = "Preparing your game", + detail = "OpenNOW is waiting for the live video to begin.", + ) + } +} + +@Composable +private fun InitialStreamConnectionOverlay( + gameTitle: String?, + status: InitialStreamConnectionStatus, + modifier: Modifier = Modifier, +) { + BoxWithConstraints( + modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.18f)) + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + val cardWidthFraction = if (maxWidth > maxHeight) 0.54f else 0.9f + Surface( + modifier = Modifier + .fillMaxWidth(cardWidthFraction) + .widthIn(max = 560.dp), + shape = RoundedCornerShape(22.dp), + color = Panel.copy(alpha = 0.96f), + contentColor = TextPrimary, + tonalElevation = 10.dp, + ) { + Row( + Modifier.padding(horizontal = 22.dp, vertical = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(18.dp), + ) { + CircularProgressIndicator( + modifier = Modifier + .size(38.dp) + .semantics { contentDescription = status.phase }, + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.primary, + ) + Column( + Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + Text( + gameTitle?.takeIf { it.isNotBlank() } ?: "OpenNOW stream", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + status.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + status.detail, + color = TextMuted, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + status.phase, + color = TextMuted.copy(alpha = 0.78f), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } +} + +@Composable +private fun StreamExitConfirmation( + gameTitle: String, + onKeepPlaying: () -> Unit, + onExit: () -> Unit, + modifier: Modifier = Modifier, +) { + val keepPlayingFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + delay(80) + runCatching { keepPlayingFocusRequester.requestFocus() } + } + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.58f)) + .clickable(onClick = onKeepPlaying), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = modifier + .padding(24.dp) + .fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = Panel.copy(alpha = 0.95f), + contentColor = TextPrimary, + tonalElevation = 8.dp, + ) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Session Control", color = TextMuted, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + Text("Exit Stream?", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text("Do you really want to exit $gameTitle?", color = TextMuted) + Text("Your current cloud gaming session will be closed.", color = TextMuted, style = MaterialTheme.typography.bodySmall) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = onKeepPlaying, + modifier = Modifier + .weight(1f) + .focusRequester(keepPlayingFocusRequester), + ) { Text("Keep Playing") } + Button(onClick = onExit, modifier = Modifier.weight(1f)) { Text("Exit Stream") } + } + } + } + } +} + +@Composable +private fun QueueLoadingScreen(state: OpenNowUiState, viewModel: OpenNowViewModel) { + val session = state.streamSession + val game = state.streamGame + val ads = sessionAdItems(session?.adState) + val ad = ads.firstOrNull { it.adId == state.queueAdActiveId } ?: ads.firstOrNull() + val mediaUrl = ad?.adMediaFiles?.firstOrNull { !it.mediaFileUrl.isNullOrBlank() }?.mediaFileUrl + ?: ad?.adUrl + ?: ad?.mediaUrl + val queuePosition = activeQueuePosition(state) + val visibleQueuePosition = rememberStableQueuePosition(queuePosition) + val queueCopy = queueLaunchStatusText(state, visibleQueuePosition) + val hasPlayableAd = ad != null && mediaUrl != null + + BoxWithConstraints( + Modifier + .fillMaxSize() + .clipToBounds(), + contentAlignment = Alignment.Center, + ) { + QueueAmbientBackdrop( + accent = state.settings.uiAccent.color, + queuePosition = visibleQueuePosition, + ) + val useLandscapeAdLayout = hasPlayableAd && maxWidth > maxHeight + + Box( + Modifier + .fillMaxSize() + .padding(18.dp), + contentAlignment = Alignment.Center, + ) { + if (ad != null && mediaUrl != null) { + QueueAdPanel( + ad = ad, + mediaUrl = mediaUrl, + viewModel = viewModel, + game = game, + queueCopy = queueCopy, + queuePosition = visibleQueuePosition, + error = state.error, + playbackKey = session?.sessionId.orEmpty(), + compact = useLandscapeAdLayout, + onMinimize = viewModel::minimizeStreamLaunch, + onCancel = viewModel::stopStream, + modifier = Modifier + .fillMaxWidth(if (useLandscapeAdLayout) 0.72f else 1f) + .widthIn(max = if (useLandscapeAdLayout) 900.dp else 620.dp), + ) + } else { + QueueStatusPanel( + game = game, + queueCopy = queueCopy, + queuePosition = visibleQueuePosition, + error = state.error, + compact = false, + onMinimize = viewModel::minimizeStreamLaunch, + onCancel = viewModel::stopStream, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun QueueAmbientBackdrop( + accent: Color, + queuePosition: Int?, + modifier: Modifier = Modifier, +) { + val transition = rememberInfiniteTransition(label = "queue-ambient") + val driftA by transition.animateFloat( + initialValue = -1f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 11000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "queue-ambient-drift-a", + ) + val driftB by transition.animateFloat( + initialValue = 1f, + targetValue = -1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 14000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "queue-ambient-drift-b", + ) + val phase by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 16000, easing = LinearEasing), + ), + label = "queue-ambient-phase", + ) + val shimmer by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 5200, easing = LinearEasing), + ), + label = "queue-ambient-shimmer", + ) + val orbADim by transition.animateFloat( + initialValue = 0.35f, + targetValue = 0.72f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 8200, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "queue-ambient-orb-a-dim", + ) + val orbBDim by transition.animateFloat( + initialValue = 0.26f, + targetValue = 0.56f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 9800, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "queue-ambient-orb-b-dim", + ) + + BoxWithConstraints( + modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + listOf( + Color(0xff010203), + Color(0xff05080a), + Color(0xff020304), + ), + ), + ), + ) { + val baseSize = minOf(maxWidth, maxHeight) + QueueAmbientOrb( + color = accent, + size = baseSize * 0.92f, + alpha = orbADim, + modifier = Modifier + .align(Alignment.TopStart) + .offset( + x = maxWidth * (-0.22f + 0.10f * driftA), + y = maxHeight * (0.02f + 0.08f * driftB), + ), + ) + QueueAmbientOrb( + color = Color(0xff2bdcff), + size = baseSize * 0.7f, + alpha = orbBDim, + modifier = Modifier + .align(Alignment.BottomEnd) + .offset( + x = maxWidth * (0.15f + 0.08f * driftB), + y = maxHeight * (0.10f + 0.07f * driftA), + ), + ) + QueueSignalField( + accent = accent, + queuePosition = queuePosition, + phase = phase, + shimmer = shimmer, + modifier = Modifier.matchParentSize(), + ) + Box( + Modifier + .matchParentSize() + .background(Color.Black.copy(alpha = 0.34f)), + ) + } +} + +@Composable +private fun QueueAmbientOrb( + color: Color, + size: Dp, + alpha: Float, + modifier: Modifier = Modifier, +) { + Box( + modifier + .size(size) + .blur(64.dp) + .graphicsLayer(alpha = alpha.coerceIn(0f, 1f)) + .background( + Brush.radialGradient( + listOf( + color.copy(alpha = 0.58f), + color.copy(alpha = 0.16f), + Color.Transparent, + ), + ), + CircleShape, + ), + ) +} + +@Composable +private fun QueueSignalField( + accent: Color, + queuePosition: Int?, + phase: Float, + shimmer: Float, + modifier: Modifier = Modifier, +) { + val heat = queueUrgency(queuePosition) + Canvas(modifier) { + val lineCount = 9 + val spacing = size.height / lineCount + val offset = shimmer * spacing + for (index in -1..lineCount) { + val y = index * spacing + offset + drawLine( + color = accent.copy(alpha = 0.035f + heat * 0.035f), + start = Offset(-size.width * 0.12f, y), + end = Offset(size.width * 1.08f, y - size.height * 0.10f), + strokeWidth = 1.dp.toPx(), + ) + } + repeat(12) { index -> + val lane = index + 1 + val x = ((lane * 0.173f + phase * (0.08f + lane * 0.006f)) % 1f) * size.width + val y = ((lane * 0.291f + shimmer * (0.12f + lane * 0.004f)) % 1f) * size.height + drawCircle( + color = accent.copy(alpha = 0.05f + heat * 0.04f), + radius = (1.5f + (index % 4)) * density, + center = Offset(x, y), + ) + } + } +} + +@Composable +private fun AnimatedQueueStatusText( + queueCopy: String, + queuePosition: Int?, + compact: Boolean, + modifier: Modifier = Modifier, +) { + if (queuePosition == null) { + Text( + queueCopy, + modifier = modifier, + color = queueIdleStatusColor(queueCopy), + style = (if (compact) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.titleMedium) + .copy(fontWeight = FontWeight.Normal), + textAlign = TextAlign.Center, + ) + return + } + + var previousQueuePosition by remember { mutableStateOf(null) } + val numberProgress = remember { Animatable(1f) } + var numberTrigger by remember { mutableStateOf(0) } + var numberFrom by remember { mutableStateOf(queuePosition.toString()) } + var numberTo by remember { mutableStateOf(queuePosition.toString()) } + val heat = queueUrgency(queuePosition) + val hotQueue = queuePosition < 10 + val transition = rememberInfiniteTransition(label = "queue-status-glow") + val glow by transition.animateFloat( + initialValue = 0.55f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = if (hotQueue) 520 else 1100, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "queue-status-glow-alpha", + ) + val moleculePhase by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = (190 - heat * 95).roundToInt().coerceIn(92, 190), + easing = LinearEasing, + ), + ), + label = "queue-status-molecule-phase", + ) + val statusColor by animateColorAsState( + targetValue = queueUrgencyColor(queuePosition), + animationSpec = tween(durationMillis = 240), + label = "queue-status-color", + ) + + LaunchedEffect(queuePosition) { + val current = queuePosition + val previous = previousQueuePosition + if (previous != null && current < previous) { + numberFrom = previous.toString() + numberTo = current.toString() + numberTrigger += 1 + } else { + numberFrom = current.toString() + numberTo = current.toString() + } + previousQueuePosition = current + } + + LaunchedEffect(numberTrigger) { + if (numberTrigger == 0) return@LaunchedEffect + numberProgress.snapTo(0f) + numberProgress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = if (hotQueue) 320 else 420), + ) + } + + val moleculeCagePx = with(LocalDensity.current) { + (if (hotQueue) (0.45f + heat * 1.45f).dp else 0.dp).toPx() + } + val shakeX = if (hotQueue) { + (sin(moleculePhase * 31.415928f) * 0.64f + sin(moleculePhase * 106.81416f) * 0.36f) * moleculeCagePx + } else { + 0f + } + val shakeY = if (hotQueue) { + (sin(moleculePhase * 43.982296f) * 0.55f + sin(moleculePhase * 81.68141f) * 0.45f) * moleculeCagePx * 0.55f + } else { + 0f + } + val parts = queueStatusParts(queueCopy, queuePosition) + val textStyle = (if (compact) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.titleMedium) + .copy(fontWeight = FontWeight.Normal) + val numberPhase = numberProgress.value + val numberAnimating = numberPhase < 1f + val numberTravelPx = with(LocalDensity.current) { (if (compact) 18.dp else 22.dp).toPx() } + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + parts.prefix, + color = TextMuted, + style = textStyle, + textAlign = TextAlign.Center, + ) + AnimatedQueueNumber( + currentNumber = parts.number, + previousNumber = numberFrom, + targetNumber = numberTo, + animating = numberAnimating, + phase = numberPhase, + travelPx = numberTravelPx, + color = statusColor, + style = textStyle.copy( + shadow = Shadow( + color = statusColor.copy(alpha = heat * (0.38f + 0.42f * glow)), + offset = Offset(0f, 0f), + blurRadius = 18f + heat * 18f, + ), + ), + shakeX = shakeX, + shakeY = shakeY, + ) + Text( + parts.suffix, + color = TextMuted, + style = textStyle, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun AnimatedQueueNumber( + currentNumber: String, + previousNumber: String, + targetNumber: String, + animating: Boolean, + phase: Float, + travelPx: Float, + color: Color, + style: TextStyle, + shakeX: Float, + shakeY: Float, +) { + val fromNumber = if (animating) previousNumber else currentNumber + val toNumber = if (animating) targetNumber else currentNumber + val slotCount = toNumber.length + + Row( + modifier = Modifier + .clipToBounds() + .graphicsLayer( + translationX = shakeX, + translationY = shakeY, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(slotCount) { slotIndex -> + val fromDigit = fromNumber.rightAlignedCharAt(slotIndex, slotCount) + val toDigit = toNumber.rightAlignedCharAt(slotIndex, slotCount) + QueueNumberDigitSlot( + fromDigit = fromDigit, + toDigit = toDigit, + digitChanged = animating && fromDigit != toDigit, + phase = phase, + travelPx = travelPx, + color = color, + style = style, + ) + } + } +} + +@Composable +private fun QueueNumberDigitSlot( + fromDigit: Char?, + toDigit: Char?, + digitChanged: Boolean, + phase: Float, + travelPx: Float, + color: Color, + style: TextStyle, +) { + val from = fromDigit?.toString().orEmpty() + val to = toDigit?.toString().orEmpty() + Box( + modifier = Modifier.clipToBounds(), + contentAlignment = Alignment.Center, + ) { + if (from.isNotEmpty()) { + Text( + from, + modifier = Modifier.graphicsLayer(alpha = 0f), + color = color, + style = style, + textAlign = TextAlign.Center, + ) + } + if (to.isNotEmpty() && to != from) { + Text( + to, + modifier = Modifier.graphicsLayer(alpha = 0f), + color = color, + style = style, + textAlign = TextAlign.Center, + ) + } + if (digitChanged) { + if (from.isNotEmpty()) { + Text( + from, + modifier = Modifier.graphicsLayer( + translationY = -travelPx * phase, + scaleX = 1f - phase * 0.03f, + scaleY = 1f - phase * 0.03f, + alpha = 1f - phase, + ), + color = color, + style = style, + textAlign = TextAlign.Center, + ) + } + if (to.isNotEmpty()) { + Text( + to, + modifier = Modifier.graphicsLayer( + translationY = travelPx * (1f - phase), + scaleX = 0.97f + phase * 0.03f, + scaleY = 0.97f + phase * 0.03f, + alpha = phase, + ), + color = color, + style = style, + textAlign = TextAlign.Center, + ) + } + } else if (to.isNotEmpty()) { + Text( + to, + color = color, + style = style, + textAlign = TextAlign.Center, + ) + } + } +} + +private fun String.rightAlignedCharAt(slotIndex: Int, slotCount: Int): Char? = + getOrNull(length - slotCount + slotIndex) + +private data class QueueStatusParts( + val prefix: String, + val number: String, + val suffix: String, +) + +private fun queueStatusParts(queueCopy: String, queuePosition: Int): QueueStatusParts { + val number = queuePosition.toString() + val index = queueCopy.indexOf(number) + if (index < 0) { + return QueueStatusParts(prefix = "$queueCopy ", number = number, suffix = "") + } + return QueueStatusParts( + prefix = queueCopy.substring(0, index), + number = number, + suffix = queueCopy.substring(index + number.length), + ) +} + +private fun queueUrgency(queuePosition: Int?): Float { + val position = queuePosition ?: return 0f + if (position >= 10) return 0f + return ((10 - position).toFloat() / 9f).coerceIn(0f, 1f) +} + +private fun activeQueuePosition(state: OpenNowUiState): Int? = + queueDisplayPosition(state) + +@Composable +private fun rememberStableQueuePosition(queuePosition: Int?): Int? { + var stableQueuePosition by remember { mutableStateOf(queuePosition) } + LaunchedEffect(queuePosition) { + if (queuePosition == stableQueuePosition) return@LaunchedEffect + if (queuePosition == null || stableQueuePosition == null) { + stableQueuePosition = queuePosition + return@LaunchedEffect + } + delay(QUEUE_POSITION_VISUAL_SETTLE_MS) + stableQueuePosition = queuePosition + } + return stableQueuePosition +} + +private fun queueLaunchStatusText(state: OpenNowUiState, queuePosition: Int?): String = + queuePosition?.let { "Queue position $it" } ?: queueLaunchStatusText(state) + +private fun queueIdleStatusColor(queueCopy: String): Color = + if (queueCopy.equals("Starting session", ignoreCase = true)) Green else TextMuted + +private fun queueUrgencyColor(queuePosition: Int?): Color { + val heat = queueUrgency(queuePosition) + if (heat <= 0f) return TextMuted + val green = (0.57f - 0.49f * heat).coerceIn(0.06f, 0.57f) + val blue = (0.25f - 0.17f * heat).coerceIn(0.08f, 0.25f) + return Color(red = 1f, green = green, blue = blue, alpha = 1f) +} + +@Composable +private fun QueueStatusPanel( + game: GameInfo?, + queueCopy: String, + queuePosition: Int?, + error: String?, + compact: Boolean, + onMinimize: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + Column( + modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + val imageWidth = if (compact) 154.dp else 220.dp + UrlImage( + gameTvBannerImageUrl(context, game), + Modifier + .width(imageWidth) + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(14.dp)), + ) + Spacer(Modifier.height(if (compact) 12.dp else 16.dp)) + Text( + game?.title ?: "Starting stream", + color = TextPrimary, + style = if (compact) MaterialTheme.typography.titleLarge else MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + AnimatedQueueStatusText( + queueCopy = queueCopy, + queuePosition = queuePosition, + compact = compact, + ) + Spacer(Modifier.height(if (compact) 14.dp else 18.dp)) + LinearProgressIndicator(Modifier.fillMaxWidth(if (compact) 0.9f else 0.7f)) + Spacer(Modifier.height(12.dp)) + Row( + Modifier.fillMaxWidth(if (compact) 0.92f else 0.7f), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + OutlinedButton(onClick = onMinimize, modifier = Modifier.weight(1f)) { + Text("Minimize", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { + Text("Cancel", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + if (compact && queuePosition != null) { + Spacer(Modifier.height(14.dp)) + LandscapeQueuePositionDock(queuePosition = queuePosition) + } + error?.let { + Spacer(Modifier.height(12.dp)) + Text(it, color = Color(0xffff9f9f), textAlign = TextAlign.Center) + } + } +} + +@Composable +private fun LandscapeQueuePositionDock(queuePosition: Int, modifier: Modifier = Modifier) { + val accent = queueUrgencyColor(queuePosition) + val heat = queueUrgency(queuePosition) + val shape = RoundedCornerShape(16.dp) + Box( + modifier + .fillMaxWidth(0.92f) + .clip(shape) + .background( + Brush.horizontalGradient( + listOf( + accent.copy(alpha = 0.18f + heat * 0.16f), + PanelAlt.copy(alpha = 0.94f), + Color.Black.copy(alpha = 0.36f), + ), + ), + ) + .border(1.dp, accent.copy(alpha = 0.32f + heat * 0.36f), shape) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + "Queue", + color = TextMuted, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + "Live position", + color = TextMuted.copy(alpha = 0.78f), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + queuePosition.toString(), + color = accent, + style = MaterialTheme.typography.displaySmall.copy( + fontWeight = FontWeight.Black, + shadow = Shadow( + color = accent.copy(alpha = 0.24f + heat * 0.42f), + offset = Offset(0f, 0f), + blurRadius = 18f + heat * 14f, + ), + ), + maxLines = 1, + textAlign = TextAlign.End, + ) + } + } +} + +@Composable +private fun QueueAdPanel( + ad: SessionAdInfo, + mediaUrl: String, + viewModel: OpenNowViewModel, + game: GameInfo?, + queueCopy: String, + queuePosition: Int?, + error: String?, + playbackKey: String, + compact: Boolean, + onMinimize: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(18.dp), + color = Panel.copy(alpha = 0.95f), + tonalElevation = 8.dp, + ) { + if (compact) { + Row( + Modifier.padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + QueueAdPlayback( + ad = ad, + mediaUrl = mediaUrl, + playbackKey = playbackKey, + viewModel = viewModel, + modifier = Modifier + .weight(1.55f) + .aspectRatio(16f / 9f), + ) + Column( + Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + QueueAdHeading(game = game, compact = true) + QueueStatusAndActions( + queueCopy = queueCopy, + queuePosition = queuePosition, + compact = true, + stackActions = true, + onMinimize = onMinimize, + onCancel = onCancel, + ) + error?.let { + Text(it, color = Color(0xffff9f9f), textAlign = TextAlign.Center) + } + } + } + } else { + Column( + Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + QueueAdHeading(game = game, compact = false) + QueueAdPlayback( + ad = ad, + mediaUrl = mediaUrl, + playbackKey = playbackKey, + viewModel = viewModel, + modifier = Modifier + .fillMaxWidth() + .height(220.dp), + ) + QueueStatusAndActions( + queueCopy = queueCopy, + queuePosition = queuePosition, + compact = false, + stackActions = false, + onMinimize = onMinimize, + onCancel = onCancel, + ) + error?.let { + Text(it, color = Color(0xffff9f9f), textAlign = TextAlign.Center) + } + } + } + } +} + +@Composable +private fun QueueAdPlayback( + ad: SessionAdInfo, + mediaUrl: String, + playbackKey: String, + viewModel: OpenNowViewModel, + modifier: Modifier = Modifier, +) { + QueueAdPlayer( + adId = ad.adId, + url = mediaUrl, + playbackKey = playbackKey, + modifier = modifier, + onStarted = { viewModel.reportQueueAd(ad.adId, "start") }, + onPaused = { viewModel.reportQueueAd(ad.adId, "pause") }, + onResumed = { viewModel.reportQueueAd(ad.adId, "resume") }, + onFinished = { watchedTimeInMs -> + viewModel.reportQueueAd(ad.adId, "finish", watchedTimeInMs = watchedTimeInMs) + }, + onError = { watchedTimeInMs -> + viewModel.reportQueueAd( + ad.adId, + "cancel", + watchedTimeInMs = watchedTimeInMs, + cancelReason = "error", + errorInfo = "Error loading url", + ) + }, + ) +} + +@Composable +private fun QueueAdHeading(game: GameInfo?, compact: Boolean) { + Column(Modifier.fillMaxWidth()) { + Text( + "Advertisement", + color = TextMuted, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + Text( + game?.title ?: "Starting stream", + color = TextPrimary, + style = if (compact) MaterialTheme.typography.titleMedium else MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun QueueStatusAndActions( + queueCopy: String, + queuePosition: Int?, + compact: Boolean, + stackActions: Boolean, + onMinimize: () -> Unit, + onCancel: () -> Unit, +) { + Column( + Modifier.fillMaxWidth(if (compact) 1f else 0.7f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 10.dp), + ) { + AnimatedQueueStatusText( + queueCopy = queueCopy, + queuePosition = queuePosition, + compact = compact, + ) + LinearProgressIndicator(Modifier.fillMaxWidth()) + if (stackActions) { + OutlinedButton(onClick = onMinimize, modifier = Modifier.fillMaxWidth()) { + Text("Minimize", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { + Text("Cancel", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } else { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + OutlinedButton(onClick = onMinimize, modifier = Modifier.weight(1f)) { + Text("Minimize", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { + Text("Cancel", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +private fun MinimizedQueueDock( + state: OpenNowUiState, + onRestore: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + val queuePosition = activeQueuePosition(state) + val visibleQueuePosition = rememberStableQueuePosition(queuePosition) + val queueCopy = queueLaunchStatusText(state, visibleQueuePosition) + Surface( + modifier = modifier + .fillMaxWidth(), + shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp), + color = Panel.copy(alpha = 0.98f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary) + Column(Modifier.weight(1f)) { + Text( + state.streamGame?.title ?: "Starting stream", + color = TextPrimary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + MinimizedQueueStatusText( + queueCopy = queueCopy, + queuePosition = visibleQueuePosition, + ) + } + TextButton(onClick = onRestore) { Text("View") } + OutlinedButton(onClick = onCancel, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp)) { + Text("Cancel") + } + } + } +} + +@Composable +private fun MinimizedQueueStatusText( + queueCopy: String, + queuePosition: Int?, +) { + if (queuePosition == null) { + Text(queueCopy, color = queueIdleStatusColor(queueCopy), style = MaterialTheme.typography.bodySmall) + return + } + val parts = queueStatusParts(queueCopy, queuePosition) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(parts.prefix, color = TextMuted, style = MaterialTheme.typography.bodySmall) + Text( + parts.number, + color = queueUrgencyColor(queuePosition), + style = MaterialTheme.typography.bodySmall, + ) + Text(parts.suffix, color = TextMuted, style = MaterialTheme.typography.bodySmall) + } +} + +@Composable +private fun QueueAdPlayer( + adId: String, + url: String, + playbackKey: String, + modifier: Modifier = Modifier, + onStarted: () -> Unit, + onPaused: () -> Unit, + onResumed: () -> Unit, + onFinished: (watchedTimeInMs: Long) -> Unit, + onError: (watchedTimeInMs: Long) -> Unit, +) { + val context = LocalContext.current + var muted by remember { mutableStateOf(false) } + val player = remember(adId, url, playbackKey) { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(url)) + volume = if (muted) 0f else 1f + prepare() + playWhenReady = true + } + } + var reportedStart by remember(adId, url, playbackKey) { mutableStateOf(false) } + var reportedFinish by remember(adId, url, playbackKey) { mutableStateOf(false) } + var reportedPause by remember(adId, url, playbackKey) { mutableStateOf(false) } + var playing by remember(adId, url, playbackKey) { mutableStateOf(player.playWhenReady) } + var controlsVisible by remember(adId, url, playbackKey) { mutableStateOf(false) } + LaunchedEffect(controlsVisible, playing) { + if (controlsVisible && playing) { + delay(2400L) + controlsVisible = false + } + } + DisposableEffect(player) { + val listener = object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + playing = isPlaying + if (!isPlaying) controlsVisible = true + } + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + if (!reportedStart || reportedFinish) return + if (playWhenReady && reportedPause) { + reportedPause = false + onResumed() + } else if (!playWhenReady && player.playbackState != Player.STATE_ENDED && !reportedPause) { + reportedPause = true + onPaused() + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY && player.playWhenReady && !reportedStart) { + reportedStart = true + onStarted() + } + if (playbackState == Player.STATE_ENDED && !reportedFinish) { + reportedFinish = true + onFinished(player.currentPosition.coerceAtLeast(0L)) + } + } + + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + if (!reportedFinish) { + reportedFinish = true + onError(player.currentPosition.coerceAtLeast(0L)) + } + } + } + player.addListener(listener) + listener.onIsPlayingChanged(player.isPlaying) + listener.onPlaybackStateChanged(player.playbackState) + onDispose { + player.removeListener(listener) + player.release() + } + } + Box( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { controlsVisible = true }, + ) { + AndroidView( + modifier = Modifier + .fillMaxSize(), + factory = { ctx -> PlayerView(ctx).apply { this.player = player; useController = false } }, + update = { it.player = player; it.useController = false }, + ) + AnimatedVisibility( + visible = controlsVisible || !playing, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + Row( + modifier = Modifier + .padding(bottom = 12.dp) + .clip(RoundedCornerShape(999.dp)) + .background(Color.Black.copy(alpha = 0.58f)) + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + QueueAdIconButton( + label = if (playing) "Pause ad" else "Play ad", + icon = if (playing) QueueAdControlIcon.Pause else QueueAdControlIcon.Play, + onClick = { + controlsVisible = true + if (playing) { + player.pause() + playing = false + } else { + player.play() + playing = true + } + }, + ) + QueueAdIconButton( + label = if (muted) "Unmute ad" else "Mute ad", + icon = if (muted) QueueAdControlIcon.Muted else QueueAdControlIcon.Volume, + onClick = { + controlsVisible = true + muted = !muted + player.volume = if (muted) 0f else 1f + }, + ) + } + } + } +} + +private enum class QueueAdControlIcon { Play, Pause, Volume, Muted } + +@Composable +private fun QueueAdIconButton(label: String, icon: QueueAdControlIcon, onClick: () -> Unit) { + IconButton( + onClick = onClick, + modifier = Modifier + .size(42.dp) + .semantics { contentDescription = label }, + ) { + QueueAdControlIconView(icon = icon, modifier = Modifier.size(22.dp)) + } +} + +@Composable +private fun QueueAdControlIconView(icon: QueueAdControlIcon, modifier: Modifier = Modifier) { + Canvas(modifier) { + val w = size.width + val h = size.height + when (icon) { + QueueAdControlIcon.Play -> { + val path = Path().apply { + moveTo(w * 0.35f, h * 0.24f) + lineTo(w * 0.35f, h * 0.76f) + lineTo(w * 0.76f, h * 0.5f) + close() + } + drawPath(path, Color.White) + } + QueueAdControlIcon.Pause -> { + drawRoundRect(Color.White, Offset(w * 0.28f, h * 0.24f), Size(w * 0.14f, h * 0.52f), CornerRadius(w * 0.04f, w * 0.04f)) + drawRoundRect(Color.White, Offset(w * 0.58f, h * 0.24f), Size(w * 0.14f, h * 0.52f), CornerRadius(w * 0.04f, w * 0.04f)) + } + QueueAdControlIcon.Volume, QueueAdControlIcon.Muted -> { + val body = Path().apply { + moveTo(w * 0.18f, h * 0.42f) + lineTo(w * 0.34f, h * 0.42f) + lineTo(w * 0.52f, h * 0.26f) + lineTo(w * 0.52f, h * 0.74f) + lineTo(w * 0.34f, h * 0.58f) + lineTo(w * 0.18f, h * 0.58f) + close() + } + drawPath(body, Color.White) + if (icon == QueueAdControlIcon.Volume) { + drawLine(Color.White, Offset(w * 0.62f, h * 0.38f), Offset(w * 0.72f, h * 0.5f), strokeWidth = w * 0.08f) + drawLine(Color.White, Offset(w * 0.72f, h * 0.5f), Offset(w * 0.62f, h * 0.62f), strokeWidth = w * 0.08f) + } else { + drawLine(Color.White, Offset(w * 0.64f, h * 0.36f), Offset(w * 0.84f, h * 0.64f), strokeWidth = w * 0.08f) + drawLine(Color.White, Offset(w * 0.84f, h * 0.36f), Offset(w * 0.64f, h * 0.64f), strokeWidth = w * 0.08f) + } + } + } + } +} + +@Composable +private fun TouchOverlay( + client: NativeStreamClient, + touch: AndroidTouchSettings, + onButtonTone: () -> Unit, + layoutEditing: Boolean, + onSaveAllOffsets: (Map) -> Unit, + modifier: Modifier = Modifier, +) { + val opacity = touch.opacity + val layoutScale = touch.scale + val buttonScale = touch.buttonScale + val stickScale = touch.stickScale + + val localOffsets = remember(touch.offsets) { + androidx.compose.runtime.mutableStateMapOf().apply { + putAll(touch.offsets) + } + } + + fun getLocalOffset(key: String): TouchOffset { + val saved = localOffsets[key] + if (saved != null) return saved + val baseKey = key.substringBeforeLast("_") + return when (baseKey) { + "lt", "lb", "lstick", "dpad", "l3" -> TouchOffset(touch.leftOffsetXDp, touch.leftOffsetYDp) + "rt", "rb", "rstick", "face", "r3" -> TouchOffset(touch.rightOffsetXDp, touch.rightOffsetYDp) + else -> TouchOffset() + } + } + + val onLocalOffsetChange = { key: String, x: Float, y: Float -> + localOffsets[key] = TouchOffset(x, y) + } + + val currentLocalOffsets by rememberUpdatedState(localOffsets.toMap()) + val currentOnSaveAllOffsets by rememberUpdatedState(onSaveAllOffsets) + DisposableEffect(layoutEditing) { + onDispose { + if (layoutEditing) { + currentOnSaveAllOffsets(currentLocalOffsets) + } + } + } + + LaunchedEffect(client, touch.enabled) { + client.setVirtualControllerVisible(touch.enabled) + NativeStreamInputRouter.setTouchControllerVisible(touch.enabled) + } + DisposableEffect(client) { + onDispose { + client.setVirtualControllerVisible(false) + NativeStreamInputRouter.setTouchControllerVisible(false) + NativeStreamInputRouter.clearTouchControllerPassthroughBounds() + } + } + + CompositionLocalProvider(LocalTouchControllerStyle provides touch.touchControllerStyle) { + BoxWithConstraints( + modifier + .fillMaxSize() + .padding( + start = touch.edgePaddingDp.dp, + top = 10.dp, + end = touch.edgePaddingDp.dp, + bottom = touch.bottomPaddingDp.dp, + ), + ) { + if (touch.enabled) { + val landscape = maxWidth > maxHeight + val suffix = if (landscape) "_landscape" else "_portrait" + val getOrientationLocalOffset = { key: String -> getLocalOffset(key + suffix) } + val onOrientationLocalOffsetChange = { key: String, x: Float, y: Float -> + onLocalOffsetChange(key + suffix, x, y) + } + + if (landscape) { + LandscapeTouchControls( + client = client, + opacity = opacity, + layoutScale = layoutScale, + buttonScale = buttonScale, + stickScale = stickScale, + joystickMode = touch.joystickMode, + joystickDeadZone = touch.joystickDeadZone, + viewportHeight = maxHeight, + layoutEditing = layoutEditing, + getLocalOffset = getOrientationLocalOffset, + onLocalOffsetChange = onOrientationLocalOffsetChange, + onButtonTone = onButtonTone, + ) + } else { + PortraitTouchControls( + client = client, + opacity = opacity, + layoutScale = layoutScale, + buttonScale = buttonScale, + stickScale = stickScale, + joystickMode = touch.joystickMode, + joystickDeadZone = touch.joystickDeadZone, + layoutEditing = layoutEditing, + getLocalOffset = getOrientationLocalOffset, + onLocalOffsetChange = onOrientationLocalOffsetChange, + onButtonTone = onButtonTone, + ) + } + } + } + } +} + +@Composable +private fun PortraitTouchControls( + client: NativeStreamClient, + opacity: Float, + layoutScale: Float, + buttonScale: Float, + stickScale: Float, + joystickMode: TouchJoystickMode, + joystickDeadZone: Float, + layoutEditing: Boolean, + getLocalOffset: (String) -> TouchOffset, + onLocalOffsetChange: (String, Float, Float) -> Unit, + onButtonTone: () -> Unit, +) { + val leftStickDiameter = 116.dp * stickScale * layoutScale + val rightStickDiameter = 104.dp * stickScale * layoutScale + val buttonSize48 = 48.dp * buttonScale * layoutScale + val buttonSize44 = 44.dp * buttonScale * layoutScale + val faceWidth = buttonSize48 * 2.44f + + Box( + Modifier.fillMaxSize().padding(horizontal = 32.dp, vertical = 24.dp) + ) { + val scale = buttonScale * layoutScale + val triggerWidth = 64.dp * scale + val bumperHeight = 32.dp * scale + + TouchControlGroup( + id = "portrait-lt", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("lt").x.dp, + offsetY = getLocalOffset("lt").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("lt", x, y) }, + modifier = Modifier.align(Alignment.TopStart), + ) { + GamepadTriggerButton( + label = "LT", + left = true, + client = client, + opacity = opacity, + width = triggerWidth, + height = bumperHeight, + shape = RoundedCornerShape(50), + onPressTone = onButtonTone, + ) + } + + TouchControlGroup( + id = "portrait-lb", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("lb").x.dp, + offsetY = getLocalOffset("lb").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("lb", x, y) }, + modifier = Modifier.align(Alignment.TopStart).padding(top = bumperHeight + 6.dp), + ) { + GamepadBumperButton( + label = "LB", + mask = 0x0100, + client = client, + opacity = opacity, + width = triggerWidth, + height = bumperHeight, + onPressTone = onButtonTone, + ) + } + + TouchControlGroup( + id = "portrait-lstick", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("lstick").x.dp, + offsetY = getLocalOffset("lstick").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("lstick", x, y) }, + modifier = Modifier.align(Alignment.BottomStart), + ) { + VirtualStick( + label = "L", + client = client, + opacity = opacity, + diameter = leftStickDiameter, + mode = joystickMode, + deadZone = joystickDeadZone, + onChange = client::setVirtualLeftStick, + ) + } + + TouchControlGroup( + id = "portrait-l3", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("l3").x.dp, + offsetY = getLocalOffset("l3").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("l3", x, y) }, + modifier = Modifier.align(Alignment.BottomStart).padding( + start = (leftStickDiameter - buttonSize48) / 2, + bottom = leftStickDiameter + 6.dp + ), + ) { + GamepadButton("LS", GamepadButtonMapping.LEFT_THUMB, client, opacity, buttonSize48, onButtonTone) + } + + TouchControlGroup( + id = "portrait-dpad", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("dpad").x.dp, + offsetY = getLocalOffset("dpad").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("dpad", x, y) }, + modifier = Modifier.align(Alignment.BottomStart).padding(start = leftStickDiameter + 12.dp), + ) { + DpadCluster(client, opacity, buttonScale * layoutScale, onButtonTone) + } + + TouchControlGroup( + id = "portrait-rt", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("rt").x.dp, + offsetY = getLocalOffset("rt").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("rt", x, y) }, + modifier = Modifier.align(Alignment.TopEnd), + ) { + GamepadTriggerButton( + label = "RT", + left = false, + client = client, + opacity = opacity, + width = triggerWidth, + height = bumperHeight, + shape = RoundedCornerShape(50), + onPressTone = onButtonTone, + ) + } + + TouchControlGroup( + id = "portrait-rb", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("rb").x.dp, + offsetY = getLocalOffset("rb").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("rb", x, y) }, + modifier = Modifier.align(Alignment.TopEnd).padding(top = bumperHeight + 6.dp), + ) { + GamepadBumperButton( + label = "RB", + mask = 0x0200, + client = client, + opacity = opacity, + width = triggerWidth, + height = bumperHeight, + onPressTone = onButtonTone, + ) + } + + TouchControlGroup( + id = "portrait-select", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("select").x.dp, + offsetY = getLocalOffset("select").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("select", x, y) }, + modifier = Modifier.align(Alignment.TopEnd).padding(top = buttonSize48 + 8.dp, end = buttonSize44 + 8.dp), + ) { + GamepadButton("◀", 0x0020, client, opacity, buttonSize44, onButtonTone) + } + + TouchControlGroup( + id = "portrait-start", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("start").x.dp, + offsetY = getLocalOffset("start").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("start", x, y) }, + modifier = Modifier.align(Alignment.TopEnd).padding(top = buttonSize48 + 8.dp), + ) { + GamepadButton("▶", 0x0010, client, opacity, buttonSize44, onButtonTone) + } + + TouchControlGroup( + id = "portrait-rstick", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("rstick").x.dp, + offsetY = getLocalOffset("rstick").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("rstick", x, y) }, + modifier = Modifier.align(Alignment.BottomEnd).padding(end = faceWidth + 12.dp), + ) { + VirtualStick( + label = "R", + client = client, + opacity = opacity, + diameter = rightStickDiameter, + mode = joystickMode, + deadZone = joystickDeadZone, + onChange = client::setVirtualRightStick, + ) + } + + TouchControlGroup( + id = "portrait-r3", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("r3").x.dp, + offsetY = getLocalOffset("r3").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("r3", x, y) }, + modifier = Modifier.align(Alignment.BottomEnd).padding( + end = faceWidth + 12.dp + (rightStickDiameter - buttonSize48) / 2, + bottom = rightStickDiameter + 6.dp + ), + ) { + GamepadButton("RS", GamepadButtonMapping.RIGHT_THUMB, client, opacity, buttonSize48, onButtonTone) + } + + TouchControlGroup( + id = "portrait-face", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("face").x.dp, + offsetY = getLocalOffset("face").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("face", x, y) }, + modifier = Modifier.align(Alignment.BottomEnd), + ) { + FaceButtonCluster(client, opacity, buttonScale * layoutScale, onButtonTone) + } + } +} + +@Composable +private fun BoxScope.LandscapeTouchControls( + client: NativeStreamClient, + opacity: Float, + layoutScale: Float, + buttonScale: Float, + stickScale: Float, + joystickMode: TouchJoystickMode, + joystickDeadZone: Float, + viewportHeight: Dp, + layoutEditing: Boolean, + getLocalOffset: (String) -> TouchOffset, + onLocalOffsetChange: (String, Float, Float) -> Unit, + onButtonTone: () -> Unit, +) { + val controlScale = buttonScale * layoutScale + val topControlClearance = landscapeTouchTopControlClearanceDp(viewportHeight.value, controlScale).dp + Box(Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 24.dp)) { + val triggerWidth = 76.dp * controlScale + val bumperHeight = 36.dp * controlScale + + TouchControlGroup( + id = "landscape-lt", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("lt").x.dp, + offsetY = getLocalOffset("lt").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("lt", x, y) }, + modifier = Modifier.align(Alignment.TopStart).padding(top = topControlClearance), + ) { + GamepadTriggerButton( + label = "LT", + left = true, + client = client, + opacity = opacity, + width = triggerWidth, + height = bumperHeight, + shape = RoundedCornerShape(50), + onPressTone = onButtonTone, + ) + } + + TouchControlGroup( + id = "landscape-lb", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("lb").x.dp, + offsetY = getLocalOffset("lb").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("lb", x, y) }, + modifier = Modifier.align(Alignment.TopStart).padding(top = topControlClearance + bumperHeight + 6.dp), + ) { + GamepadBumperButton( + label = "LB", + mask = 0x0100, + client = client, + opacity = opacity, + width = triggerWidth, + height = bumperHeight, + onPressTone = onButtonTone, + ) + } + + val selectSize = 42.dp * controlScale + TouchControlGroup( + id = "landscape-select", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("select").x.dp, + offsetY = getLocalOffset("select").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("select", x, y) }, + modifier = Modifier.align(Alignment.BottomCenter).padding(end = selectSize / 2 + 27.dp), + ) { + GamepadButton("◀", 0x0020, client, opacity, selectSize, onButtonTone) + } + + TouchControlGroup( + id = "landscape-start", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("start").x.dp, + offsetY = getLocalOffset("start").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("start", x, y) }, + modifier = Modifier.align(Alignment.BottomCenter).padding(start = selectSize / 2 + 27.dp), + ) { + GamepadButton("▶", 0x0010, client, opacity, selectSize, onButtonTone) + } + + TouchControlGroup( + id = "landscape-rb", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("rb").x.dp, + offsetY = getLocalOffset("rb").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("rb", x, y) }, + modifier = Modifier.align(Alignment.TopEnd).padding(top = topControlClearance + bumperHeight + 6.dp), + ) { + GamepadBumperButton( + label = "RB", + mask = 0x0200, + client = client, + opacity = opacity, + width = triggerWidth, + height = bumperHeight, + onPressTone = onButtonTone, + ) + } + + TouchControlGroup( + id = "landscape-rt", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("rt").x.dp, + offsetY = getLocalOffset("rt").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("rt", x, y) }, + modifier = Modifier.align(Alignment.TopEnd).padding(top = topControlClearance), + ) { + GamepadTriggerButton( + label = "RT", + left = false, + client = client, + opacity = opacity, + width = triggerWidth, + height = bumperHeight, + shape = RoundedCornerShape(50), + onPressTone = onButtonTone, + ) + } + + val dpadScale = controlScale * 0.88f + val dpadButtonSize = 54.dp * dpadScale + val dpadWidth = dpadButtonSize * 2.44f + TouchControlGroup( + id = "landscape-dpad", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("dpad").x.dp, + offsetY = getLocalOffset("dpad").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("dpad", x, y) }, + modifier = Modifier.align(Alignment.BottomStart), + ) { + DpadCluster(client, opacity, dpadScale, onButtonTone) + } + + val leftStickDiameter = 112.dp * stickScale * layoutScale + TouchControlGroup( + id = "landscape-lstick", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("lstick").x.dp, + offsetY = getLocalOffset("lstick").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("lstick", x, y) }, + modifier = Modifier.align(Alignment.BottomStart).padding(start = dpadWidth + 14.dp), + ) { + VirtualStick( + label = "L", + client = client, + opacity = opacity, + diameter = leftStickDiameter, + mode = joystickMode, + deadZone = joystickDeadZone, + onChange = client::setVirtualLeftStick, + ) + } + + val l3Size = 54.dp * controlScale + TouchControlGroup( + id = "landscape-l3", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("l3").x.dp, + offsetY = getLocalOffset("l3").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("l3", x, y) }, + modifier = Modifier.align(Alignment.BottomStart).padding( + start = dpadWidth + 14.dp + (leftStickDiameter - l3Size) / 2, + bottom = leftStickDiameter + 6.dp + ), + ) { + GamepadButton("LS", GamepadButtonMapping.LEFT_THUMB, client, opacity, l3Size, onButtonTone) + } + + val faceScale = controlScale * 0.9f + val faceButtonSize = 54.dp * faceScale + val faceWidth = faceButtonSize * 2.44f + val rightStickDiameter = 112.dp * stickScale * layoutScale + TouchControlGroup( + id = "landscape-rstick", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("rstick").x.dp, + offsetY = getLocalOffset("rstick").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("rstick", x, y) }, + modifier = Modifier.align(Alignment.BottomEnd).padding(end = faceWidth + 14.dp), + ) { + VirtualStick( + label = "R", + client = client, + opacity = opacity, + diameter = rightStickDiameter, + mode = joystickMode, + deadZone = joystickDeadZone, + onChange = client::setVirtualRightStick, + ) + } + + val r3Size = 54.dp * controlScale + TouchControlGroup( + id = "landscape-r3", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("r3").x.dp, + offsetY = getLocalOffset("r3").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("r3", x, y) }, + modifier = Modifier.align(Alignment.BottomEnd).padding( + end = faceWidth + 14.dp + (rightStickDiameter - r3Size) / 2, + bottom = rightStickDiameter + 6.dp + ), + ) { + GamepadButton("RS", GamepadButtonMapping.RIGHT_THUMB, client, opacity, r3Size, onButtonTone) + } + + TouchControlGroup( + id = "landscape-face", + layoutEditing = layoutEditing, + offsetX = getLocalOffset("face").x.dp, + offsetY = getLocalOffset("face").y.dp, + onOffsetChange = { x, y -> onLocalOffsetChange("face", x, y) }, + modifier = Modifier.align(Alignment.BottomEnd), + ) { + FaceButtonCluster(client, opacity, faceScale, onButtonTone) + } + } +} + +internal fun landscapeTouchTopControlClearanceDp(viewportHeightDp: Float, controlScale: Float): Float { + val viewportBand = (viewportHeightDp * 0.11f).coerceIn(34f, 58f) + val scaledBand = viewportBand * controlScale.coerceIn(0.75f, 1.35f) + return scaledBand.coerceIn(30f, 76f) +} + +@Composable +private fun TouchControlGroup( + id: String, + layoutEditing: Boolean, + offsetX: Dp, + offsetY: Dp, + onOffsetChange: (Float, Float) -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + val density = LocalDensity.current + val currentOffsetX by rememberUpdatedState(offsetX) + val currentOffsetY by rememberUpdatedState(offsetY) + val currentOnOffsetChange by rememberUpdatedState(onOffsetChange) + Box( + modifier + .offset(x = offsetX, y = offsetY) + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInRoot() + NativeStreamInputRouter.setTouchControllerPassthroughBound( + id, + bounds.left.roundToInt(), + bounds.top.roundToInt(), + bounds.right.roundToInt(), + bounds.bottom.roundToInt(), + ) + }, + contentAlignment = Alignment.Center, + ) { + content() + if (layoutEditing) { + Box( + Modifier + .matchParentSize() + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.16f)) + .border(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.72f), RoundedCornerShape(18.dp)) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + val deltaXDp = with(density) { dragAmount.x.toDp().value } + val deltaYDp = with(density) { dragAmount.y.toDp().value } + currentOnOffsetChange( + (currentOffsetX.value + deltaXDp).coerceIn(-280f, 280f), + (currentOffsetY.value + deltaYDp).coerceIn(-280f, 280f), + ) + } + }, + contentAlignment = Alignment.TopCenter, + ) { + Surface( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f), + shape = RoundedCornerShape(999.dp), + modifier = Modifier.padding(top = 4.dp), + ) { + Text( + "Drag", + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + ) + } + } + } + } + DisposableEffect(id) { + onDispose { + NativeStreamInputRouter.clearTouchControllerPassthroughBound(id) + } + } +} + +private fun clampStickOffset(offset: Offset, maxRadius: Float): Offset { + val distance = sqrt(offset.x * offset.x + offset.y * offset.y) + if (distance <= maxRadius || distance == 0f) return offset + val scale = maxRadius / distance + return Offset(offset.x * scale, offset.y * scale) +} + +internal fun applyTouchJoystickDeadZone(value: Float, deadZone: Float): Float { + val clampedValue = value.coerceIn(-1f, 1f) + val clampedDeadZone = deadZone.coerceIn(0f, 0.95f) + val magnitude = kotlin.math.abs(clampedValue) + if (magnitude <= clampedDeadZone) return 0f + val adjusted = (magnitude - clampedDeadZone) / (1f - clampedDeadZone) + return if (clampedValue < 0f) -adjusted else adjusted +} + +@Composable +private fun StickWithThumbButton( + stickLabel: String, + thumbLabel: String, + thumbMask: Int, + client: NativeStreamClient, + opacity: Float, + diameter: Dp, + buttonScale: Float, + mode: TouchJoystickMode = TouchJoystickMode.Fixed, + deadZone: Float = 0f, + onButtonTone: () -> Unit, + onChange: (Float, Float) -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + GamepadPillButton( + label = thumbLabel, + mask = thumbMask, + client = client, + opacity = opacity, + width = 56.dp * buttonScale, + height = 34.dp * buttonScale, + onPressTone = onButtonTone, + ) + VirtualStick( + label = stickLabel, + client = client, + opacity = opacity, + diameter = diameter, + mode = mode, + deadZone = deadZone, + onChange = onChange, + ) + } +} + +@Composable +private fun VirtualStick( + label: String, + client: NativeStreamClient, + opacity: Float, + diameter: androidx.compose.ui.unit.Dp, + mode: TouchJoystickMode, + deadZone: Float, + onChange: (Float, Float) -> Unit, +) { + val currentOnChange by rememberUpdatedState(onChange) + var knobOffset by remember { mutableStateOf(Offset.Zero) } + var baseOffset by remember { mutableStateOf(Offset.Zero) } + val style = LocalTouchControllerStyle.current + + DisposableEffect(client) { + onDispose { + currentOnChange(0f, 0f) + } + } + + Box( + Modifier + .size(diameter) + .pointerInput(client, mode, deadZone) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + val fixedCenter = Offset(size.width / 2f, size.height / 2f) + val gestureCenter = if (mode == TouchJoystickMode.Dynamic) down.position else fixedCenter + val maxRadius = min(size.width, size.height) * 0.34f + baseOffset = gestureCenter - fixedCenter + + fun updateStick(position: Offset) { + val clamped = clampStickOffset(position - gestureCenter, maxRadius) + val rawX = (clamped.x / maxRadius).coerceIn(-1f, 1f) + val rawY = (clamped.y / maxRadius).coerceIn(-1f, 1f) + val magnitude = sqrt(rawX * rawX + rawY * rawY).coerceIn(0f, 1f) + val adjustedMagnitude = applyTouchJoystickDeadZone(magnitude, deadZone) + val adjustment = if (magnitude > 0f) adjustedMagnitude / magnitude else 0f + currentOnChange(rawX * adjustment, rawY * adjustment) + knobOffset = clamped + } + + try { + updateStick(down.position) + down.consume() + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + change.consume() + break + } + updateStick(change.position) + change.consume() + } + } finally { + currentOnChange(0f, 0f) + knobOffset = Offset.Zero + baseOffset = Offset.Zero + } + } + }, + contentAlignment = Alignment.Center, + ) { + val knobBackground = if (style == TouchControllerStyle.V2) { + Color.White.copy(alpha = opacity * 0.2f) + } else { + Color.LightGray.copy(alpha = opacity * 0.8f) + } + val knobBorderModifier = if (style == TouchControllerStyle.V2) { + Modifier.border(1.dp, Color.White.copy(alpha = opacity * 0.5f), CircleShape) + } else { + Modifier + } + Box( + Modifier + .size(diameter) + .graphicsLayer { + translationX = baseOffset.x + translationY = baseOffset.y + } + .clip(CircleShape) + .background(Color.Transparent) + .border(1.dp, Color.White.copy(alpha = opacity * 0.3f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(diameter * 0.44f) + .graphicsLayer { + translationX = knobOffset.x + translationY = knobOffset.y + } + .clip(CircleShape) + .background(knobBackground) + .then(knobBorderModifier) + ) + } + } +} + +@Composable +private fun FaceButtonCluster(client: NativeStreamClient, opacity: Float, scale: Float, onButtonTone: () -> Unit) { + val buttonSize = 54.dp * scale + val distance = buttonSize * 1.05f + val boxSize = distance * 2 + buttonSize + Box(Modifier.size(boxSize)) { + Box(Modifier.align(Alignment.Center).offset(y = -distance)) { + GamepadButton("Y", 0x8000, client, opacity, buttonSize, onButtonTone) + } + Box(Modifier.align(Alignment.Center).offset(y = distance)) { + GamepadButton("A", 0x1000, client, opacity, buttonSize, onButtonTone) + } + Box(Modifier.align(Alignment.Center).offset(x = -distance)) { + GamepadButton("X", 0x4000, client, opacity, buttonSize, onButtonTone) + } + Box(Modifier.align(Alignment.Center).offset(x = distance)) { + GamepadButton("B", 0x2000, client, opacity, buttonSize, onButtonTone) + } + } +} + +@Composable +private fun DpadArrowhead( + label: String, + pressed: Boolean, + opacity: Float, +) { + val arrowColor = if (pressed) { + Color.White + } else { + Color.White.copy(alpha = opacity * 0.8f) + } + Text( + text = label, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + color = arrowColor + ) +} + +@Composable +private fun DpadCluster(client: NativeStreamClient, opacity: Float, scale: Float, onButtonTone: () -> Unit) { + val currentOnButtonTone by rememberUpdatedState(onButtonTone) + val buttonSize = 54.dp * scale + val distance = buttonSize * 1.05f + val boxSize = distance * 2 + buttonSize + + var upPressed by remember { mutableStateOf(false) } + var downPressed by remember { mutableStateOf(false) } + var leftPressed by remember { mutableStateOf(false) } + var rightPressed by remember { mutableStateOf(false) } + + val style = LocalTouchControllerStyle.current + val crossColor = if (style == TouchControllerStyle.V2) Color.Transparent else Color.Black.copy(alpha = opacity * 0.6f) + val crossBorderColor = if (style == TouchControllerStyle.V2) Color.White.copy(alpha = opacity * 0.5f) else Color.White.copy(alpha = opacity * 0.4f) + val crossBorderWidth = 1.dp + + DisposableEffect(client) { + onDispose { + client.setVirtualButton(0x0001, false) + client.setVirtualButton(0x0002, false) + client.setVirtualButton(0x0004, false) + client.setVirtualButton(0x0008, false) + } + } + + Box( + Modifier + .size(boxSize) + .pointerInput(client) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + + fun updateDirection(position: Offset) { + val w = size.width + val h = size.height + val cx = w / 2f + val cy = h / 2f + val px = position.x + val py = position.y + val dx = px - cx + val dy = py - cy + val touchDist = Math.sqrt((dx * dx + dy * dy).toDouble()).toFloat() + val deadzone = 12.dp.toPx() + var newUp = false + var newDown = false + var newLeft = false + var newRight = false + if (touchDist > deadzone) { + val absDx = Math.abs(dx) + val absDy = Math.abs(dy) + if (dy < 0 && absDy > absDx * 0.414f) newUp = true + if (dy > 0 && absDy > absDx * 0.414f) newDown = true + if (dx < 0 && absDx > absDy * 0.414f) newLeft = true + if (dx > 0 && absDx > absDy * 0.414f) newRight = true + } + + val playTone = (!upPressed && newUp) || (!downPressed && newDown) || + (!leftPressed && newLeft) || (!rightPressed && newRight) + if (upPressed != newUp) { client.setVirtualButton(0x0001, newUp); upPressed = newUp } + if (downPressed != newDown) { client.setVirtualButton(0x0002, newDown); downPressed = newDown } + if (leftPressed != newLeft) { client.setVirtualButton(0x0004, newLeft); leftPressed = newLeft } + if (rightPressed != newRight) { client.setVirtualButton(0x0008, newRight); rightPressed = newRight } + if (playTone) currentOnButtonTone() + } + + try { + updateDirection(down.position) + down.consume() + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + change.consume() + break + } + updateDirection(change.position) + change.consume() + } + } finally { + if (upPressed) { client.setVirtualButton(0x0001, false); upPressed = false } + if (downPressed) { client.setVirtualButton(0x0002, false); downPressed = false } + if (leftPressed) { client.setVirtualButton(0x0004, false); leftPressed = false } + if (rightPressed) { client.setVirtualButton(0x0008, false); rightPressed = false } + } + } + } + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val w = size.width + val h = size.height + val armSize = buttonSize.toPx() + val cornerRadius = androidx.compose.ui.geometry.CornerRadius(8.dp.toPx(), 8.dp.toPx()) + + val crossPath = Path().apply { + addRoundRect( + androidx.compose.ui.geometry.RoundRect( + left = (w - armSize) / 2f, + top = 0f, + right = (w + armSize) / 2f, + bottom = h, + cornerRadius = cornerRadius + ) + ) + addRoundRect( + androidx.compose.ui.geometry.RoundRect( + left = 0f, + top = (h - armSize) / 2f, + right = w, + bottom = (h + armSize) / 2f, + cornerRadius = cornerRadius + ) + ) + } + + if (style != TouchControllerStyle.V2) { + drawPath(crossPath, crossColor) + } + + val pressedColor = if (style == TouchControllerStyle.V2) { + Color.White.copy(alpha = opacity * 0.15f) + } else { + Color.White.copy(alpha = opacity * 0.2f) + } + + val pressedPath = Path() + if (upPressed) { + pressedPath.addRoundRect( + androidx.compose.ui.geometry.RoundRect( + left = (w - armSize) / 2f, + top = 0f, + right = (w + armSize) / 2f, + bottom = h / 2f, + topLeftCornerRadius = cornerRadius, + topRightCornerRadius = cornerRadius + ) + ) + } + if (downPressed) { + pressedPath.addRoundRect( + androidx.compose.ui.geometry.RoundRect( + left = (w - armSize) / 2f, + top = h / 2f, + right = (w + armSize) / 2f, + bottom = h, + bottomLeftCornerRadius = cornerRadius, + bottomRightCornerRadius = cornerRadius + ) + ) + } + if (leftPressed) { + pressedPath.addRoundRect( + androidx.compose.ui.geometry.RoundRect( + left = 0f, + top = (h - armSize) / 2f, + right = w / 2f, + bottom = (h + armSize) / 2f, + topLeftCornerRadius = cornerRadius, + bottomLeftCornerRadius = cornerRadius + ) + ) + } + if (rightPressed) { + pressedPath.addRoundRect( + androidx.compose.ui.geometry.RoundRect( + left = w / 2f, + top = (h - armSize) / 2f, + right = w, + bottom = (h + armSize) / 2f, + topRightCornerRadius = cornerRadius, + bottomRightCornerRadius = cornerRadius + ) + ) + } + drawPath(pressedPath, pressedColor) + + drawPath( + path = crossPath, + color = crossBorderColor, + style = Stroke(width = crossBorderWidth.toPx()) + ) + } + + Box(Modifier.align(Alignment.Center).offset(y = -distance)) { + DpadArrowhead("▲", upPressed, opacity) + } + Box(Modifier.align(Alignment.Center).offset(y = distance)) { + DpadArrowhead("▼", downPressed, opacity) + } + Box(Modifier.align(Alignment.Center).offset(x = -distance)) { + DpadArrowhead("◀", leftPressed, opacity) + } + Box(Modifier.align(Alignment.Center).offset(x = distance)) { + DpadArrowhead("▶", rightPressed, opacity) + } + } +} + +private fun Modifier.virtualPressInput( + client: NativeStreamClient, + controlKey: Any, + onPressedChange: State<(Boolean) -> Unit>, +): Modifier = pointerInput(client, controlKey) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + onPressedChange.value(true) + try { + down.consume() + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + change.consume() + break + } + change.consume() + } + } finally { + onPressedChange.value(false) + } + } +} + +@Composable +private fun GamepadTriggerButton( + label: String, + left: Boolean, + client: NativeStreamClient, + opacity: Float, + width: androidx.compose.ui.unit.Dp, + height: androidx.compose.ui.unit.Dp, + shape: androidx.compose.ui.graphics.Shape, + onPressTone: () -> Unit = {}, +) { + var pressed by remember { mutableStateOf(false) } + val currentOnPressedChange = rememberUpdatedState<(Boolean) -> Unit> { down -> + if (down != pressed) { + client.setVirtualTrigger(left, down) + pressed = down + if (down) onPressTone() + } + } + val style = LocalTouchControllerStyle.current + val buttonColor = if (style == TouchControllerStyle.V2) { + Color.Transparent + } else { + Color.Black.copy(alpha = opacity * 0.6f) + } + val pressedColor = if (style == TouchControllerStyle.V2) { + Color.White.copy(alpha = opacity * 0.15f) + } else { + Color.White.copy(alpha = opacity * 0.2f) + } + val borderColor = if (style == TouchControllerStyle.V2) { + if (pressed) Color.White.copy(alpha = opacity * 0.9f) else Color.White.copy(alpha = opacity * 0.5f) + } else { + Color.White.copy(alpha = opacity * 0.4f) + } + val borderWidth = if (style == TouchControllerStyle.V2 && pressed) 2.dp else 1.dp + Box( + Modifier + .width(width) + .height(height) + .clip(shape) + .background(if (pressed) pressedColor else buttonColor) + .border(borderWidth, borderColor, shape) + .virtualPressInput(client, left, currentOnPressedChange), + contentAlignment = Alignment.Center, + ) { + Text(label, fontWeight = FontWeight.SemiBold, color = Color.White.copy(alpha = opacity * 0.9f)) + } + DisposableEffect(client, left) { + onDispose { + client.setVirtualTrigger(left, false) + } + } +} + +@Composable +private fun GamepadBumperButton( + label: String, + mask: Int, + client: NativeStreamClient, + opacity: Float, + width: androidx.compose.ui.unit.Dp, + height: androidx.compose.ui.unit.Dp, + onPressTone: () -> Unit = {}, +) { + var pressed by remember { mutableStateOf(false) } + val currentOnPressedChange = rememberUpdatedState<(Boolean) -> Unit> { down -> + if (down != pressed) { + client.setVirtualButton(mask, down) + pressed = down + if (down) onPressTone() + } + } + val style = LocalTouchControllerStyle.current + val buttonColor = if (style == TouchControllerStyle.V2) { + Color.Transparent + } else { + Color.Black.copy(alpha = opacity * 0.6f) + } + val pressedColor = if (style == TouchControllerStyle.V2) { + Color.White.copy(alpha = opacity * 0.15f) + } else { + Color.White.copy(alpha = opacity * 0.2f) + } + val borderColor = if (style == TouchControllerStyle.V2) { + if (pressed) Color.White.copy(alpha = opacity * 0.9f) else Color.White.copy(alpha = opacity * 0.5f) + } else { + Color.White.copy(alpha = opacity * 0.4f) + } + val borderWidth = if (style == TouchControllerStyle.V2 && pressed) 2.dp else 1.dp + val shape = RoundedCornerShape(50) + Box( + Modifier + .width(width) + .height(height) + .clip(shape) + .background(if (pressed) pressedColor else buttonColor) + .border(borderWidth, borderColor, shape) + .virtualPressInput(client, mask, currentOnPressedChange), + contentAlignment = Alignment.Center, + ) { + Text(label, fontWeight = FontWeight.SemiBold, color = Color.White.copy(alpha = opacity * 0.9f)) + } + DisposableEffect(client, mask) { + onDispose { + client.setVirtualButton(mask, false) + } + } +} + +@Composable +private fun GamepadButton( + label: String, + mask: Int, + client: NativeStreamClient, + opacity: Float, + size: androidx.compose.ui.unit.Dp, + onPressTone: () -> Unit = {}, +) { + val currentOnPressTone by rememberUpdatedState(onPressTone) + var pressed by remember { mutableStateOf(false) } + val currentOnPressedChange = rememberUpdatedState<(Boolean) -> Unit> { down -> + if (down != pressed) { + client.setVirtualButton(mask, down) + pressed = down + if (down) currentOnPressTone() + } + } + val style = LocalTouchControllerStyle.current + val buttonColor = if (style == TouchControllerStyle.V2) { + Color.Transparent + } else { + Color.Black.copy(alpha = opacity * 0.6f) + } + val pressedColor = if (style == TouchControllerStyle.V2) { + Color.White.copy(alpha = opacity * 0.15f) + } else { + Color.White.copy(alpha = opacity * 0.2f) + } + val borderColor = if (style == TouchControllerStyle.V2) { + if (pressed) Color.White.copy(alpha = opacity * 0.9f) else Color.White.copy(alpha = opacity * 0.5f) + } else { + Color.White.copy(alpha = opacity * 0.4f) + } + val borderWidth = if (style == TouchControllerStyle.V2 && pressed) 2.dp else 1.dp + Box( + Modifier + .size(size) + .clip(CircleShape) + .background(if (pressed) pressedColor else buttonColor) + .border(borderWidth, borderColor, CircleShape) + .virtualPressInput(client, mask, currentOnPressedChange), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + fontWeight = FontWeight.SemiBold, + color = Color.White.copy(alpha = opacity * 0.9f), + ) + } + DisposableEffect(client, mask) { + onDispose { + client.setVirtualButton(mask, false) + } + } +} + +@Composable +private fun GamepadPillButton( + label: String, + mask: Int, + client: NativeStreamClient, + opacity: Float, + width: androidx.compose.ui.unit.Dp, + height: androidx.compose.ui.unit.Dp, + onPressTone: () -> Unit = {}, +) { + val currentOnPressTone by rememberUpdatedState(onPressTone) + var pressed by remember { mutableStateOf(false) } + val currentOnPressedChange = rememberUpdatedState<(Boolean) -> Unit> { down -> + if (down != pressed) { + client.setVirtualButton(mask, down) + pressed = down + if (down) currentOnPressTone() + } + } + val style = LocalTouchControllerStyle.current + val buttonColor = if (style == TouchControllerStyle.V2) { + Color.Transparent + } else { + Color.Black.copy(alpha = opacity * 0.6f) + } + val pressedColor = if (style == TouchControllerStyle.V2) { + Color.White.copy(alpha = opacity * 0.15f) + } else { + Color.White.copy(alpha = opacity * 0.2f) + } + val borderColor = if (style == TouchControllerStyle.V2) { + if (pressed) Color.White.copy(alpha = opacity * 0.9f) else Color.White.copy(alpha = opacity * 0.5f) + } else { + Color.White.copy(alpha = opacity * 0.4f) + } + val borderWidth = if (style == TouchControllerStyle.V2 && pressed) 2.dp else 1.dp + Box( + Modifier + .width(width) + .height(height) + .clip(RoundedCornerShape(999.dp)) + .background(if (pressed) pressedColor else buttonColor) + .border(borderWidth, borderColor, RoundedCornerShape(999.dp)) + .virtualPressInput(client, mask, currentOnPressedChange), + contentAlignment = Alignment.Center, + ) { + Text( + label, + fontWeight = FontWeight.SemiBold, + color = Color.White.copy(alpha = opacity * 0.9f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + DisposableEffect(client, mask) { + onDispose { + client.setVirtualButton(mask, false) + } + } +} + +@Composable +private fun SortPicker( + options: List, + selected: String, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, + compact: Boolean = false, +) { + val labels = options.ifEmpty { listOf(CatalogSortOption("relevance", "Relevance", "")) } + val selectedLabel = labels.firstOrNull { it.id == selected }?.label ?: labels.first().label + var expanded by remember { mutableStateOf(false) } + val controlShape = RoundedCornerShape(999.dp) + val controlColor = Color.White.copy(alpha = 0.1f) + Box(modifier) { + OutlinedButton( + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth().height(if (compact) TopBarCompactControlHeight else 40.dp), + shape = controlShape, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f)), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = controlColor, + contentColor = TextPrimary, + ), + contentPadding = PaddingValues(horizontal = if (compact) 8.dp else 12.dp), + ) { + Text( + "Sort: $selectedLabel", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = if (compact) MaterialTheme.typography.labelMedium else MaterialTheme.typography.labelLarge, + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + labels.forEach { option -> + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(if (option.id == selected) "✓" else "", modifier = Modifier.width(24.dp)) + Text(option.label) + } + }, + onClick = { + expanded = false + onSelect(option.id) + }, + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SelectedFilterChips(options: List, selectedIds: List, onToggle: (String) -> Unit) { + val selectedOptions = options.filter { it.id in selectedIds } + if (selectedOptions.isEmpty()) return + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + selectedOptions.take(4).forEach { option -> + AssistChip(onClick = { onToggle(option.id) }, label = { Text(option.label, maxLines = 1, overflow = TextOverflow.Ellipsis) }) + } + if (selectedOptions.size > 4) { + AssistChip(onClick = {}, label = { Text("+${selectedOptions.size - 4}") }) + } + } +} + +private fun catalogVisibleFilterGroups(groups: List): List = + groups.filter { it.id in setOf("digital_store", "genre", "subscriptions") } + +private fun catalogFilterOptions(groups: List): List = + groups.flatMap { group -> group.options.take(if (group.id == "genre") 10 else group.options.size) } + +@Composable +private fun FilterMenu( + options: List, + selectedIds: List, + onToggle: (String) -> Unit, + compact: Boolean = false, +) { + var expanded by remember { mutableStateOf(false) } + val filterControlShape = RoundedCornerShape(999.dp) + val filterControlColor = Color.White.copy(alpha = 0.1f) + Box { + OutlinedButton( + onClick = { expanded = true }, + modifier = Modifier.height(if (compact) TopBarCompactControlHeight else 36.dp), + shape = filterControlShape, + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f)), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = filterControlColor, + contentColor = TextPrimary, + ), + contentPadding = PaddingValues(horizontal = 10.dp), + ) { + Text(if (selectedIds.isEmpty()) "Filters" else "Filters ${selectedIds.size}", maxLines = 1, style = MaterialTheme.typography.labelMedium) + } + if (expanded) { + AlertDialog( + onDismissRequest = { expanded = false }, + title = { + Text( + "Filters", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + color = TextPrimary, + ) + }, + text = { + LazyColumn( + modifier = Modifier.fillMaxHeight(0.6f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(options) { option -> + val isSelected = option.id in selectedIds + var rowFocused by remember { mutableStateOf(false) } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .onFocusChanged { rowFocused = it.isFocused } + .background(if (rowFocused) Color.White.copy(alpha = 0.08f) else Color.Transparent) + .border( + width = 1.dp, + color = if (rowFocused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = RoundedCornerShape(8.dp) + ) + .clickable { onToggle(option.id) } + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = isSelected, + onCheckedChange = null + ) + Spacer(Modifier.width(12.dp)) + Text( + option.label, + style = MaterialTheme.typography.bodyLarge, + color = TextPrimary + ) + } + } + } + }, + confirmButton = { + Button(onClick = { expanded = false }) { + Text("Done") + } + } + ) + } + } +} + +@Composable +private fun PrintedWasteSelector( + state: OpenNowUiState, + game: GameInfo, + viewModel: OpenNowViewModel, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = viewModel::dismissPrintedWasteSelector) + val zones = remember(state.printedWasteQueue, state.printedWasteMapping, state.printedWastePings) { + state.printedWasteQueue + .filter { (zoneId, _) -> isStandardPrintedWasteZone(zoneId) && state.printedWasteMapping[zoneId]?.nuked != true } + .map { (zoneId, zone) -> + val routingUrl = printedWasteZoneUrl(zoneId) + PrintedWasteZoneOption( + zoneId = zoneId, + zone = zone, + routingUrl = routingUrl, + pingMs = state.printedWastePings[routingUrl], + ) + } + } + val autoZone = remember(zones) { recommendedPrintedWasteZone(zones) } + val sortedZones = remember(zones, autoZone) { + val maxPing = zones.mapNotNull { it.pingMs }.maxOrNull()?.coerceAtLeast(1) ?: 1 + val maxQueue = zones.maxOfOrNull { it.zone.QueuePosition }?.coerceAtLeast(1) ?: 1 + zones.sortedWith( + compareByDescending { it.zoneId == autoZone?.zoneId } + .thenBy { printedWasteScore(it, maxPing, maxQueue) } + .thenBy { it.zoneId }, + ) + } + var selectedZoneId by remember(game.id, sortedZones) { mutableStateOf(autoZone?.zoneId) } + val selectedZone = sortedZones.firstOrNull { it.zoneId == selectedZoneId } ?: autoZone + val context = LocalContext.current + + BoxWithConstraints( + Modifier + .fillMaxSize() + .lockedFocusGroup() + .background(Color.Black.copy(alpha = 0.72f)) + .clickable(enabled = false) {}, + ) { + val phoneLandscape = isPhoneLandscape(maxWidth, maxHeight) + Box( + Modifier.fillMaxSize(), + contentAlignment = if (phoneLandscape) Alignment.CenterEnd else Alignment.Center, + ) { + Card( + modifier = modifier + .then( + if (phoneLandscape) { + Modifier + .padding(end = 12.dp) + .fillMaxWidth(0.9f) + .fillMaxHeight(0.9f) + } else { + Modifier + .fillMaxWidth(0.94f) + .fillMaxHeight(0.82f) + }, + ), + colors = CardDefaults.cardColors(containerColor = Panel), + shape = RoundedCornerShape(22.dp), + ) { + if (phoneLandscape) { + Row( + Modifier.fillMaxSize().padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PrintedWasteGameSummary( + game = game, + modifier = Modifier + .width(190.dp) + .fillMaxHeight(), + ) + PrintedWasteOptionsColumn( + state = state, + zones = sortedZones, + selectedZoneId = selectedZoneId, + selectedZone = selectedZone, + autoZone = autoZone, + showRecommendedCard = true, + onSelectZone = { selectedZoneId = it }, + onRetry = viewModel::refreshPrintedWasteQueues, + onDismiss = viewModel::dismissPrintedWasteSelector, + onDefault = { viewModel.launchWithPrintedWaste(null) }, + onLaunch = { viewModel.launchWithPrintedWaste(selectedZone?.routingUrl) }, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + ) + } + } else { + Column(Modifier.fillMaxSize().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + UrlImage( + gameTvBannerImageUrl(context, game), + Modifier + .width(98.dp) + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(12.dp)), + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(game.title, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("Free tier queue routing", color = TextMuted, style = MaterialTheme.typography.bodySmall) + } + } + PrintedWasteOptionsColumn( + state = state, + zones = sortedZones, + selectedZoneId = selectedZoneId, + selectedZone = selectedZone, + autoZone = autoZone, + showRecommendedCard = true, + onSelectZone = { selectedZoneId = it }, + onRetry = viewModel::refreshPrintedWasteQueues, + onDismiss = viewModel::dismissPrintedWasteSelector, + onDefault = { viewModel.launchWithPrintedWaste(null) }, + onLaunch = { viewModel.launchWithPrintedWaste(selectedZone?.routingUrl) }, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } +} + +@Composable +private fun PrintedWasteGameSummary( + game: GameInfo, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) { + UrlImage( + gameTvBannerImageUrl(context, game), + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(16.dp)), + ) + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(game.title, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text("Free tier queue routing", color = TextMuted, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } + } +} + +@Composable +private fun PrintedWasteOptionsColumn( + state: OpenNowUiState, + zones: List, + selectedZoneId: String?, + selectedZone: PrintedWasteZoneOption?, + autoZone: PrintedWasteZoneOption?, + showRecommendedCard: Boolean, + onSelectZone: (String) -> Unit, + onRetry: () -> Unit, + onDismiss: () -> Unit, + onDefault: () -> Unit, + onLaunch: () -> Unit, + modifier: Modifier = Modifier, +) { + val zoneListState = rememberLazyListState() + val zoneListFocusRequester = remember { FocusRequester() } + val defaultFocusRequester = remember { FocusRequester() } + val launchFocusRequester = remember { FocusRequester() } + var zoneListFocused by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + fun selectZoneAt(index: Int) { + val next = zones.getOrNull(index) ?: return + onSelectZone(next.zoneId) + scope.launch { + zoneListState.animateScrollToItem(index) + } + } + LaunchedEffect(state.printedWasteLoading, state.printedWasteError, zones.size) { + delay(80) + if (!state.printedWasteLoading && state.printedWasteError == null && zones.isNotEmpty()) { + runCatching { launchFocusRequester.requestFocus() } + } else { + runCatching { defaultFocusRequester.requestFocus() } + } + } + Column(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (state.printedWasteLoading) { + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + Text("Checking PrintedWaste queues and latency", color = TextMuted) + } + } + } else if (state.printedWasteError != null) { + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(state.printedWasteError, color = Color(0xffff9f9f)) + OutlinedButton(onClick = onRetry) { Text("Retry") } + } + } + } else { + if (showRecommendedCard) { + autoZone?.let { + RecommendedPrintedWasteCard(it) + } + } + var listFocused by remember { mutableStateOf(false) } + LazyColumn( + state = zoneListState, + modifier = Modifier + .weight(1f) + .focusRequester(zoneListFocusRequester) + .onFocusChanged { listFocused = it.isFocused } + .onPreviewKeyEvent { event -> + if (isTvActivateKey(event)) { + if (selectedZone != null) { + onLaunch() + true + } else { + false + } + } else if (event.type == KeyEventType.KeyDown) { + val selectedIndex = zones.indexOfFirst { it.zoneId == selectedZoneId }.let { if (it >= 0) it else 0 } + when (event.key) { + Key.DirectionUp -> { + if (selectedIndex > 0) { + selectZoneAt(selectedIndex - 1) + true + } else { + false + } + } + Key.DirectionDown -> { + if (selectedIndex < zones.lastIndex) { + selectZoneAt(selectedIndex + 1) + true + } else { + runCatching { launchFocusRequester.requestFocus() }.isSuccess + } + } + else -> false + } + } else { + false + } + } + .focusable(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(zones, key = { it.zoneId }) { zoneOption -> + val isCurrent = zoneOption.zoneId == selectedZoneId + PrintedWasteZoneRow( + zoneOption = zoneOption, + selected = isCurrent, + focused = isCurrent && listFocused, + listFocused = listFocused, + onClick = { onSelectZone(zoneOption.zoneId) }, + ) + } + } + } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onDismiss) { Text("Cancel") } + OutlinedButton( + onClick = onDefault, + modifier = Modifier + .weight(1f) + .focusRequester(defaultFocusRequester), + ) { + Text("Default", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Button( + onClick = onLaunch, + enabled = !state.printedWasteLoading && selectedZone != null, + modifier = Modifier + .weight(1f) + .focusRequester(launchFocusRequester) + .focusProperties { up = zoneListFocusRequester }, + ) { + Text("Launch", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +@Composable +private fun RecommendedPrintedWasteCard(zoneOption: PrintedWasteZoneOption) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + ) { + Row( + Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Best available route", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + Text( + "${zoneOption.zoneId} · ${regionLabel(zoneOption.zone.Region)}", + color = TextMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + QueueMetricPill("Ping", zoneOption.pingMs?.let { "$it ms" } ?: "Checking") + QueueMetricPill("Ahead", zoneOption.zone.QueuePosition.toString(), queueColor(zoneOption.zone.QueuePosition)) + } + } +} + +@Composable +private fun PrintedWasteZoneRow( + zoneOption: PrintedWasteZoneOption, + selected: Boolean, + focused: Boolean, + listFocused: Boolean, + onClick: () -> Unit, +) { + val zone = zoneOption.zone + Surface( + modifier = Modifier + .fillMaxWidth() + .focusProperties { canFocus = false } + .clip(RoundedCornerShape(12.dp)) + .border( + width = 2.dp, + color = if (focused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = RoundedCornerShape(12.dp) + ) + .clickable { onClick() }, + shape = RoundedCornerShape(12.dp), + color = if (focused) MaterialTheme.colorScheme.primary.copy(alpha = 0.28f) else if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.16f) else PanelAlt, + tonalElevation = if (selected) 2.dp else 0.dp, + border = if (selected && listFocused) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, + ) { + BoxWithConstraints(Modifier.fillMaxWidth()) { + val compact = maxWidth < 520.dp + if (compact) { + Column( + Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(zoneOption.zoneId, fontWeight = FontWeight.Bold, color = if (selected) MaterialTheme.colorScheme.primary else TextPrimary) + Text(regionLabel(zone.Region), color = TextMuted, style = MaterialTheme.typography.bodySmall) + } + if (selected) { + Text("Selected", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelMedium) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + QueueMetricPill("Ping", zoneOption.pingMs?.let { "$it ms" } ?: "--", zoneOption.pingMs?.let(::pingColor) ?: TextMuted) + QueueMetricPill("Ahead", zone.QueuePosition.toString(), queueColor(zone.QueuePosition)) + zone.eta?.let { QueueMetricPill("Wait", formatPrintedWasteWait(it)) } + } + } + } else { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(Modifier.weight(1f)) { + Text(zoneOption.zoneId, fontWeight = FontWeight.Bold, color = if (selected) MaterialTheme.colorScheme.primary else TextPrimary) + Text(regionLabel(zone.Region), color = TextMuted, style = MaterialTheme.typography.bodySmall) + } + QueueMetricPill("Ping", zoneOption.pingMs?.let { "$it ms" } ?: "--", zoneOption.pingMs?.let(::pingColor) ?: TextMuted) + QueueMetricPill("Ahead", zone.QueuePosition.toString(), queueColor(zone.QueuePosition)) + zone.eta?.let { QueueMetricPill("Wait", formatPrintedWasteWait(it)) } + } + } + } + } +} + +@Composable +private fun QueueMetricPill( + label: String, + value: String, + valueColor: Color = TextPrimary, +) { + Surface( + shape = RoundedCornerShape(10.dp), + color = Color.Black.copy(alpha = 0.22f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)), + ) { + Column( + Modifier.padding(horizontal = 9.dp, vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(label, color = TextMuted, style = MaterialTheme.typography.labelSmall) + Text(value, color = valueColor, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelMedium) + } + } +} + +private fun isStandardPrintedWasteZone(zoneId: String): Boolean = + zoneId.startsWith("NP-") && !zoneId.startsWith("NPA-") + +private data class PrintedWasteZoneOption( + val zoneId: String, + val zone: PrintedWasteZone, + val routingUrl: String, + val pingMs: Long?, +) + +private fun recommendedPrintedWasteZone(zones: List): PrintedWasteZoneOption? { + if (zones.isEmpty()) return null + val pool = zones.filter { it.pingMs != null }.ifEmpty { zones } + val maxPing = pool.mapNotNull { it.pingMs }.maxOrNull()?.coerceAtLeast(1) ?: 1 + val maxQueue = pool.maxOfOrNull { it.zone.QueuePosition }?.coerceAtLeast(1) ?: 1 + return pool.minWithOrNull( + compareBy { printedWasteScore(it, maxPing, maxQueue) } + .thenBy { it.pingMs ?: Long.MAX_VALUE } + .thenBy { it.zone.QueuePosition }, + ) +} + +private fun printedWasteScore(zone: PrintedWasteZoneOption, maxPing: Long, maxQueue: Int): Double { + val pingScore = ((zone.pingMs ?: maxPing).toDouble() / maxPing.toDouble()) * 0.75 + val queueScore = (zone.zone.QueuePosition.toDouble() / maxQueue.toDouble()) * 0.25 + return pingScore + queueScore +} + +private fun printedWasteZoneUrl(zoneId: String): String = + "https://${zoneId.lowercase()}.cloudmatchbeta.nvidiagrid.net/" + +private fun formatPrintedWasteWait(etaMs: Long): String { + val minutes = ((etaMs + 59_999L) / 60_000L).coerceAtLeast(1L) + return if (minutes < 60L) "${minutes}m" else "${minutes / 60L}h ${minutes % 60L}m" +} + +private fun queueColor(queue: Int): Color = when { + queue <= 5 -> Green + queue <= 20 -> Color(0xffc7ef6b) + queue <= 45 -> Color(0xffffc95a) + else -> Color(0xffff8d8d) +} + +private fun pingColor(pingMs: Long): Color = when { + pingMs <= 60L -> Green + pingMs <= 120L -> Color(0xffc7ef6b) + pingMs <= 180L -> Color(0xffffc95a) + else -> Color(0xffff8d8d) +} + +private fun regionLabel(region: String): String = when (region) { + "US" -> "North America" + "CA" -> "Canada" + "EU" -> "Europe" + "JP" -> "Japan" + "KR" -> "South Korea" + "THAI" -> "Southeast Asia" + "MY" -> "Malaysia" + else -> region +} + +@Composable +private fun ProviderPicker(providers: List, selected: LoginProvider, onSelect: (LoginProvider) -> Unit) { + var expanded by remember { mutableStateOf(false) } + Box { + OutlinedButton(onClick = { expanded = true }) { Text(selected.displayName) } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + providers.forEach { provider -> + DropdownMenuItem( + text = { Text(provider.displayName) }, + onClick = { + expanded = false + onSelect(provider) + }, + ) + } + } + } +} + +private sealed interface UrlImageState { + data object Empty : UrlImageState + data object Loading : UrlImageState + data object Failed : UrlImageState + data object Loaded : UrlImageState +} + +private fun imageDataForSource(source: String): Any? { + val key = source.trim() + if (key.isBlank()) return null + val uri = runCatching { Uri.parse(key) }.getOrNull() ?: return null + val scheme = uri.scheme.orEmpty().lowercase(Locale.US) + return when { + scheme == "http" || scheme == "https" -> key + scheme == "content" || scheme == "android.resource" || scheme == "file" -> uri + scheme.isBlank() && key.startsWith("/") -> File(key) + else -> uri + } +} + +@Composable +internal fun UrlImage( + url: String?, + modifier: Modifier = Modifier, + fallbackUrl: String? = null, + contentScale: ContentScale = ContentScale.Crop, +) { + val source = url?.trim().orEmpty() + val fallbackSource = fallbackUrl?.trim()?.takeIf { it.isNotBlank() && it != source } + var activeSource by remember(source, fallbackSource) { + mutableStateOf(source.takeIf { it.isNotBlank() } ?: fallbackSource) + } + var imageState by remember(source, fallbackSource) { + mutableStateOf(if (activeSource == null) UrlImageState.Empty else UrlImageState.Loading) + } + val imageData = remember(activeSource) { activeSource?.let(::imageDataForSource) } + LaunchedEffect(activeSource, imageData, fallbackSource, source) { + if (activeSource == null) { + imageState = UrlImageState.Empty + } else if (imageData == null) { + if (activeSource == source && fallbackSource != null) { + activeSource = fallbackSource + imageState = UrlImageState.Loading + } else { + imageState = UrlImageState.Failed + } + } + } + Box(modifier.background(Color(0xff102015)), contentAlignment = Alignment.Center) { + if (imageData != null) { + key(activeSource) { + AsyncImage( + model = imageData, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = contentScale, + onLoading = { imageState = UrlImageState.Loading }, + onSuccess = { imageState = UrlImageState.Loaded }, + onError = { + if (activeSource == source && fallbackSource != null) { + activeSource = fallbackSource + imageState = UrlImageState.Loading + } else { + imageState = UrlImageState.Failed + } + }, + ) + } + } + when (imageState) { + UrlImageState.Loading -> LoadingShimmer(Modifier.fillMaxSize()) + UrlImageState.Loaded -> Unit + UrlImageState.Empty, + UrlImageState.Failed, + -> OpenNowMark(42.dp) + } + } +} + +@Composable +private fun LoadingShimmer(modifier: Modifier = Modifier) { + // Use the shared shimmer offset from GameGridSkeleton if available; fall back to a + // local animation only when LoadingShimmer is used outside a GameGridSkeleton context. + // Using nullable avoids treating 0f (a valid animation start value) as "not provided". + val sharedPulse = LocalTvLoadingPulse.current + val localPulse = if (LocalTvLoadingProfile.current && sharedPulse == null) { + val transition = rememberInfiniteTransition(label = "loading-pulse-local") + val pulse = transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "loading-pulse-local", + ) + pulse + } else { + null + } + val pulse = sharedPulse ?: localPulse + val shimmer = LocalShimmerOffset.current ?: if (pulse == null) run { + val transition = rememberInfiniteTransition(label = "shimmer-local") + val localOffset = transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = SHIMMER_CYCLE_DURATION_MS, easing = LinearEasing), + ), + label = "shimmer-offset-local", + ) + localOffset + } else null + val baseColor = Color(0xff0d1216) + val highlightColor1 = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.32f) + val highlightColor2 = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) + + Spacer( + modifier = modifier + .background(baseColor) + .drawBehind { + if (pulse != null) { + drawRect(highlightColor1.copy(alpha = 0.08f + pulse.value * 0.18f)) + } else { + val width = size.width + val height = size.height + // Keep the highlight narrow and move it fully beyond both edges. The + // repeat seam then joins two identical base-color frames instead of + // visibly snapping a full-card gradient back to the beginning. + val bandWidth = (width * 0.52f).coerceAtLeast(1f) + val bandCenter = -2f * bandWidth + (shimmer?.value ?: 0f) * (width + 4f * bandWidth) + val brush = Brush.linearGradient( + colors = listOf( + Color.Transparent, + highlightColor1, + highlightColor2, + highlightColor1, + Color.Transparent, + ), + start = Offset(bandCenter - bandWidth, -height), + end = Offset(bandCenter + bandWidth, height * 2f), + ) + drawRect(brush) + } + } + ) +} + +@Composable +private fun OpenNowMark(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) { + Image( + painter = painterResource(R.drawable.opennow_logo_mark), + contentDescription = "OpenNOW", + modifier = modifier + .width(size * 1.85f) + .height(size), + contentScale = ContentScale.Fit, + ) +} + +@Composable +private fun OpenNowAppIcon(size: androidx.compose.ui.unit.Dp) { + Image( + painter = painterResource(R.drawable.opennow_icon), + contentDescription = "OpenNOW", + modifier = Modifier.size(size), + contentScale = ContentScale.Fit, + ) +} + +internal val ColorQuality.label: String + get() = when (this) { + ColorQuality.EightBit420 -> "8-bit 4:2:0" + ColorQuality.EightBit444 -> "8-bit 4:4:4" + ColorQuality.TenBit420 -> "10-bit 4:2:0" + ColorQuality.TenBit444 -> "10-bit 4:4:4" + } + +private val GameCardOverlayGradient = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Transparent, Color.Black.copy(alpha = 0.95f)) +) diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsControls.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsControls.kt new file mode 100644 index 000000000..b76e3e0bd --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsControls.kt @@ -0,0 +1,349 @@ +package com.opencloudgaming.opennow + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import java.util.Locale +import kotlin.math.roundToInt + +@Composable +internal fun SearchableSettingsSection( + searchQuery: String, + title: String, + vararg keywords: String, + content: @Composable ColumnScope.() -> Unit, +) { + if (settingsSearchMatches(searchQuery, title, *keywords)) { + SettingsSection(title, content) + } +} + +private fun settingsSearchMatches(searchQuery: String, vararg terms: String): Boolean { + val tokens = searchQuery.trim().lowercase(Locale.US).split(Regex("\\s+")).filter { it.isNotBlank() } + if (tokens.isEmpty()) return true + val haystack = terms.joinToString(" ").lowercase(Locale.US) + return tokens.all { token -> token in haystack } +} + +@Composable +private fun SettingsSection(title: String, content: @Composable ColumnScope.() -> Unit) { + val sectionShape = RoundedCornerShape(14.dp) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + shape = sectionShape, + ) { + Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(title, color = SettingsText, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + content() + } + } +} + +@Composable +internal fun SettingSwitch( + label: String, + checked: Boolean, + enabled: Boolean = true, + description: String? = null, + onCheckedChange: (Boolean) -> Unit, +) { + val focusManager = LocalFocusManager.current + val controllerNavigationEnabled = LocalSettingsControllerNavigationEnabled.current + var focused by remember { mutableStateOf(false) } + val showFocus = controllerNavigationEnabled && focused + var descriptionExpanded by remember(label) { mutableStateOf(false) } + val shape = RoundedCornerShape(14.dp) + val toggle = { + if (enabled) { + onCheckedChange(!checked) + } + } + Row( + Modifier + .fillMaxWidth() + .onFocusChanged { focused = controllerNavigationEnabled && (it.isFocused || it.hasFocus) } + .border( + width = if (showFocus) 2.dp else 1.dp, + color = if (showFocus) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = shape, + ) + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f)) + .clickable(enabled = enabled, onClick = toggle) + .onPreviewKeyEvent { event -> + when { + controllerNavigationEnabled && enabled && isTvActivateKey(event) -> { + toggle() + true + } + controllerNavigationEnabled -> handleVerticalDpadFocusMove(event, focusManager) + else -> false + } + } + .focusable(enabled = controllerNavigationEnabled) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.45f) + Column( + Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text(label, color = contentColor, maxLines = 2, overflow = TextOverflow.Ellipsis) + if (!description.isNullOrBlank() && descriptionExpanded) { + Text( + description, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (enabled) 0.86f else 0.45f), + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (!description.isNullOrBlank()) { + IconButton( + onClick = { descriptionExpanded = !descriptionExpanded }, + modifier = Modifier.width(40.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_help), + contentDescription = if (descriptionExpanded) "Hide description" else "Show description", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + Switch(checked = checked, enabled = enabled, onCheckedChange = onCheckedChange) + } +} + +@Composable +internal fun SessionProxyWarningDialog(onCancel: () -> Unit, onEnable: () -> Unit) { + AlertDialog( + onDismissRequest = onCancel, + title = { Text(stringResource(R.string.settings_session_proxy_warning_title), fontWeight = FontWeight.Bold) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.settings_session_proxy_warning_traffic), style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.settings_session_proxy_warning_breakage), style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.settings_session_proxy_warning_trust), style = MaterialTheme.typography.bodySmall) + } + }, + confirmButton = { + Button(onClick = onEnable) { + Text(stringResource(R.string.settings_session_proxy_warning_enable)) + } + }, + dismissButton = { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.action_cancel)) + } + }, + containerColor = SettingsPanel, + titleContentColor = SettingsText, + textContentColor = SettingsTextMuted, + ) +} + +@Composable +internal fun NumberSlider( + label: String, + value: Float, + min: Float, + max: Float, + step: Float, + descriptionProvider: ((Float) -> String?)? = null, + onChange: (Float) -> Unit +) { + var local by remember(value) { mutableFloatStateOf(value) } + val focusManager = LocalFocusManager.current + val controllerNavigationEnabled = LocalSettingsControllerNavigationEnabled.current + var focused by remember { mutableStateOf(false) } + val showFocus = controllerNavigationEnabled && focused + val shape = RoundedCornerShape(14.dp) + Column( + Modifier + .fillMaxWidth() + .border( + width = if (showFocus) 2.dp else 1.dp, + color = if (showFocus) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = shape, + ) + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f)) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(label, Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(if (step < 1f) "%.2f".format(local) else local.roundToInt().toString(), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Slider( + modifier = Modifier + .onFocusChanged { focused = it.isFocused } + .onPreviewKeyEvent { + handleSliderDpadInput(it, local, min, max, step, focusManager) { newVal -> + local = ((newVal / step).roundToInt() * step).coerceIn(min, max) + onChange(local) + } + }, + value = local, + onValueChange = { local = ((it / step).roundToInt() * step).coerceIn(min, max) }, + onValueChangeFinished = { onChange(local) }, + valueRange = min..max, + ) + val description = descriptionProvider?.invoke(local) + if (description != null) { + Spacer(Modifier.height(2.dp)) + Text( + description, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + style = MaterialTheme.typography.labelSmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +internal fun ChoiceRow(label: String, options: List, selected: String, onSelect: (String) -> Unit) { + ChoiceMenuRow( + label = label, + options = options.map { ChoiceMenuOption(value = it, label = it) }, + selectedLabel = selected, + onSelect = onSelect, + ) +} + +@Composable +internal fun ChoiceMenuRow( + label: String, + options: List, + selectedLabel: String, + onSelect: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + var focused by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + val controllerNavigationEnabled = LocalSettingsControllerNavigationEnabled.current + val showFocus = controllerNavigationEnabled && focused + val autoLabel = stringResource(R.string.option_auto) + val shape = RoundedCornerShape(14.dp) + Row( + Modifier + .fillMaxWidth() + .onFocusChanged { focused = controllerNavigationEnabled && (it.isFocused || it.hasFocus) } + .border( + width = if (showFocus) 2.dp else 1.dp, + color = if (showFocus) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = shape, + ) + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f)) + .clickable { expanded = true } + .onPreviewKeyEvent { event -> + when { + controllerNavigationEnabled && isTvActivateKey(event) -> { + expanded = true + true + } + controllerNavigationEnabled -> handleVerticalDpadFocusMove(event, focusManager) + else -> false + } + } + .focusable(enabled = controllerNavigationEnabled) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis) + Box { + OutlinedButton(onClick = { expanded = true }) { Text(selectedLabel.ifBlank { autoLabel }, maxLines = 1, overflow = TextOverflow.Ellipsis) } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { option -> + DropdownMenuItem( + text = { + val disabledAlpha = if (option.enabled) 1f else 0.48f + val badgeAlpha = if (option.enabled) 0.7f else 0.48f + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + option.label, + color = if (option.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = disabledAlpha), + ) + option.badge?.let { badge -> + Text( + badge, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = badgeAlpha), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier + .border(1.dp, MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (option.enabled) 0.3f else 0.2f), RoundedCornerShape(3.dp)) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) + } + } + }, + enabled = option.enabled, + onClick = { + expanded = false + onSelect(option.value) + }, + ) + } + } + } + } +} + +@Composable +internal fun ChoiceOptionRow(label: String, options: List, selectedValue: String, onSelect: (String) -> Unit) { + val selectedLabel = options.firstOrNull { it.value == selectedValue }?.label ?: selectedValue + ChoiceRow(label, options.map { it.label }, selectedLabel) { selected -> + options.firstOrNull { it.label == selected }?.value?.let(onSelect) + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsPanels.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsPanels.kt new file mode 100644 index 000000000..beead8738 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsPanels.kt @@ -0,0 +1,1583 @@ +package com.opencloudgaming.opennow + +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import java.text.DateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.roundToInt +import android.os.PowerManager +import android.os.BatteryManager +import android.os.Build +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.provider.Settings +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.foundation.layout.Spacer +import kotlinx.coroutines.delay + +@Composable +internal fun AppDataSettingsPanel(viewModel: OpenNowViewModel) { + var clearCacheConfirmOpen by remember { mutableStateOf(false) } + var resetSettingsConfirmOpen by remember { mutableStateOf(false) } + if (clearCacheConfirmOpen) { + AlertDialog( + onDismissRequest = { clearCacheConfirmOpen = false }, + title = { Text("Clear game cache?") }, + text = { Text("Cached store, library, and search results will be removed. Your account and settings stay unchanged.") }, + confirmButton = { + Button( + onClick = { + clearCacheConfirmOpen = false + viewModel.clearCatalogCache() + }, + ) { + Text("Clear cache") + } + }, + dismissButton = { + TextButton(onClick = { clearCacheConfirmOpen = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + if (resetSettingsConfirmOpen) { + AlertDialog( + onDismissRequest = { resetSettingsConfirmOpen = false }, + title = { Text("Reset settings and app data?") }, + text = { Text("Accounts, settings, cached games, tutorial state, and local app files will be removed. OpenNOW will relaunch like a fresh install.") }, + confirmButton = { + Button( + onClick = { + resetSettingsConfirmOpen = false + viewModel.resetSettings() + }, + ) { + Text("Reset and relaunch") + } + }, + dismissButton = { + TextButton(onClick = { resetSettingsConfirmOpen = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + "Reset tutorial only makes the stream guide appear again. Reset settings is destructive: it clears local app data and relaunches OpenNOW.", + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedButton(onClick = { clearCacheConfirmOpen = true }, modifier = Modifier.weight(1f)) { + Text("Clear cache", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(onClick = viewModel::resetStreamTutorial, modifier = Modifier.weight(1f)) { + Text("Reset tutorial", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + OutlinedButton(onClick = { resetSettingsConfirmOpen = true }, modifier = Modifier.fillMaxWidth()) { + Text("Reset settings", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +@Composable +internal fun AndroidUpdatePanel(state: OpenNowUiState, viewModel: OpenNowViewModel) { + val update = state.androidUpdate + if (!update.apkUpdatesAllowed) { + AndroidUpdateUnavailablePanel(update) + return + } + val updateCheckingDisabled = !state.settings.autoCheckForUpdates + val checkBlockedByStream = state.isAndroidUpdateCheckBlockedByStream() + val showCheckPauseMessage = checkBlockedByStream && when (update.status) { + AndroidUpdateStatus.Available, + AndroidUpdateStatus.Downloading, + AndroidUpdateStatus.Downloaded -> false + else -> true + } + val statusMessage = when { + updateCheckingDisabled -> "Automatic checks are off." + showCheckPauseMessage -> "Checks pause while streaming." + else -> updateStatusSubtitle(update) + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(22.dp), + color = if (update.status in updateAvailableStatuses) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f) + }, + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + updateStatusTitle(update), + color = SettingsText, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + statusMessage, + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + UpdateStatusBadge(update.status) + } + UpdateVersionSummary(update) + if (update.status == AndroidUpdateStatus.Downloading) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + update.progress?.let { progress -> + Text( + formatAndroidUpdateProgress(progress), + color = SettingsTextMuted, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + UpdateReleaseNotes(update.releaseNotes) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = viewModel::checkAndroidUpdate, + enabled = update.canCheck && !checkBlockedByStream && !updateCheckingDisabled, + modifier = Modifier.weight(1f), + ) { + Text(if (update.status == AndroidUpdateStatus.Checking) "Checking..." else "Check", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + when { + update.status == AndroidUpdateStatus.Available -> { + Button( + onClick = viewModel::downloadAndroidUpdate, + enabled = update.canDownload, + modifier = Modifier.weight(1f), + ) { + Text("Download", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + update.status == AndroidUpdateStatus.Downloaded -> { + Button( + onClick = viewModel::installAndroidUpdate, + enabled = update.canInstall, + modifier = Modifier.weight(1f), + ) { + Text("Install", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + } + } +} + +@Composable +private fun AndroidUpdateUnavailablePanel(update: AndroidUpdateState) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + if (update.installSource.isGooglePlay) "Updates managed by Google Play" else "APK updates unavailable", + color = SettingsText, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + update.message, + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + Surface( + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.16f), + ) { + Text( + if (update.installSource.isGooglePlay) "PLAY" else "LOCKED", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.52f), + ) { + Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + UpdateInfoValue("Current", update.currentVersionName.ifBlank { "Installed" }, Modifier.weight(1f)) + UpdateInfoValue("Source", update.installSource.displayName, Modifier.weight(1f)) + } + } + } + } +} + +@Composable +private fun UpdateStatusBadge(status: AndroidUpdateStatus) { + Surface( + shape = RoundedCornerShape(999.dp), + color = updateMessageColor(status).copy(alpha = 0.16f), + ) { + Text( + updateStatusBadgeText(status), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + color = updateMessageColor(status), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } +} + +private val updateAvailableStatuses = setOf( + AndroidUpdateStatus.Available, + AndroidUpdateStatus.Downloading, + AndroidUpdateStatus.Downloaded, +) + +@Composable +private fun UpdateVersionSummary(update: AndroidUpdateState) { + val checked = update.lastCheckedAt?.let { checkedAt -> + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(checkedAt)) + } + val availableVersion = formatAvailableUpdateVersion(update) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.52f), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + UpdateInfoValue("Current", update.currentVersionName.ifBlank { "Installed" }, Modifier.weight(1f)) + availableVersion?.let { + UpdateInfoValue("Available", it, Modifier.weight(1f)) + } + } + checked?.let { + Text( + "Last checked $it", + color = SettingsTextMuted, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun UpdateInfoValue(label: String, value: String, modifier: Modifier = Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + label, + color = SettingsTextMuted, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + value, + color = SettingsText, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun UpdateReleaseNotes(notes: String?) { + val releaseNotes = notes?.trim()?.takeIf { it.isNotBlank() } ?: return + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.52f), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + "Release notes", + color = SettingsText, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + Text( + releaseNotes, + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 8, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private fun formatAvailableUpdateVersion(update: AndroidUpdateState): String? { + val pieces = listOfNotNull( + update.availableVersionName?.let { "v$it" }, + update.availableVersionCode?.let { "build $it" }, + ) + return pieces.takeIf { it.isNotEmpty() }?.joinToString(" ") +} + +private fun updateStatusTitle(update: AndroidUpdateState): String = + when (update.status) { + AndroidUpdateStatus.Available -> "Update available" + AndroidUpdateStatus.Downloading -> "Downloading update" + AndroidUpdateStatus.Downloaded -> "Ready to install" + AndroidUpdateStatus.NotAvailable -> "OpenNOW is up to date" + AndroidUpdateStatus.Checking -> "Checking for updates" + AndroidUpdateStatus.Error -> "Update check failed" + AndroidUpdateStatus.Idle -> "App updates" + } + +private fun updateStatusSubtitle(update: AndroidUpdateState): String = + when (update.status) { + AndroidUpdateStatus.Available -> update.availableVersionName?.let { "Version $it is available." } ?: "A new build is available." + AndroidUpdateStatus.Downloading -> "Keep OpenNOW open while the APK downloads." + AndroidUpdateStatus.Downloaded -> update.availableVersionName?.let { "Version $it has been downloaded." } ?: "The update has been downloaded." + AndroidUpdateStatus.NotAvailable -> update.message + AndroidUpdateStatus.Checking -> "Contacting the update source." + AndroidUpdateStatus.Error -> update.message + AndroidUpdateStatus.Idle -> update.message + } + +private fun updateStatusBadgeText(status: AndroidUpdateStatus): String = + when (status) { + AndroidUpdateStatus.Available -> "NEW" + AndroidUpdateStatus.Downloading -> "DOWNLOADING" + AndroidUpdateStatus.Downloaded -> "READY" + AndroidUpdateStatus.NotAvailable -> "CURRENT" + AndroidUpdateStatus.Checking -> "CHECKING" + AndroidUpdateStatus.Error -> "ERROR" + AndroidUpdateStatus.Idle -> "IDLE" + } + +@Composable +private fun updateMessageColor(status: AndroidUpdateStatus): Color = + when (status) { + AndroidUpdateStatus.Available, + AndroidUpdateStatus.Downloaded, + AndroidUpdateStatus.NotAvailable -> MaterialTheme.colorScheme.primary + AndroidUpdateStatus.Error -> Color(0xffff9f9f) + else -> SettingsTextMuted + } + +private fun formatAndroidUpdateProgress(progress: AndroidUpdateProgress): String { + val bytes = progress.totalBytes?.let { total -> + "${formatUpdateBytes(progress.transferredBytes)} / ${formatUpdateBytes(total)}" + } ?: formatUpdateBytes(progress.transferredBytes) + return progress.percent?.let { "$it% - $bytes" } ?: bytes +} + +private fun formatUpdateBytes(bytes: Long): String { + if (bytes < 1024L) return "$bytes B" + val units = listOf("KB", "MB", "GB") + var value = bytes.toDouble() / 1024.0 + var unit = units.first() + for (index in 1 until units.size) { + if (value < 1024.0) break + value /= 1024.0 + unit = units[index] + } + return "%.1f %s".format(Locale.US, value, unit) +} + +@Composable +internal fun AccountSettingsPanel(state: OpenNowUiState, viewModel: OpenNowViewModel) { + val currentSession = state.authSession + val currentUserId = currentSession?.user?.userId + val context = LocalContext.current + var addAccountPromptOpen by remember { mutableStateOf(false) } + val addAccountProviders = remember(state.providers, state.selectedProvider) { + accountProviderOptions(state.providers, state.selectedProvider) + } + if (addAccountPromptOpen) { + AddAccountProviderDialog( + providers = addAccountProviders, + selectedProvider = state.selectedProvider, + onProviderSelected = { provider -> + addAccountPromptOpen = false + viewModel.selectProvider(provider) + viewModel.login(provider) + }, + onDismiss = { addAccountPromptOpen = false }, + ) + } + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + state.savedAccounts.ifEmpty { + state.authSession?.toSavedAccount()?.let { listOf(it) } ?: emptyList() + }.forEach { account -> + val selected = account.userId == currentUserId + val membershipTier = if (selected) { + state.subscriptionInfo?.membershipTier?.takeIf { it.isNotBlank() } + ?: currentSession.user.membershipTier.takeIf { it.isNotBlank() } + ?: account.membershipTier + } else { + account.membershipTier + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.16f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f), + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(Modifier.weight(1f)) { + Text(account.displayName.ifBlank { "NVIDIA Account" }, color = SettingsText, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + listOfNotNull(account.email?.takeIf { it.isNotBlank() }, account.providerCode, membershipTier).joinToString(" - "), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (selected) { + Text("Active", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + } else { + OutlinedButton(onClick = { viewModel.switchAccount(account.userId) }, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp)) { + Text("Switch") + } + } + } + } + } + AndroidUpdateNoticeRow( + update = state.androidUpdate, + dismissedKey = state.dismissedAndroidUpdateNoticeKey, + onOpenUpdates = viewModel::openAndroidUpdateSettings, + onDismiss = viewModel::dismissAndroidUpdateNotice, + ) + state.deviceLoginPrompt?.let { prompt -> + DeviceLoginPanel( + prompt = prompt, + phase = state.launchPhase, + onCancel = viewModel::cancelLogin, + modifier = Modifier.fillMaxWidth(), + qrMaxSize = 240.dp, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Button(onClick = { addAccountPromptOpen = true }, modifier = Modifier.weight(1f)) { Text("Add account") } + OutlinedButton(onClick = viewModel::logout, modifier = Modifier.weight(1f)) { Text("Sign out") } + } + OutlinedButton(onClick = viewModel::logoutAll, modifier = Modifier.fillMaxWidth()) { Text("Sign out all accounts") } + AccountPlayTimeStatsPanel( + subscriptionInfo = state.subscriptionInfo, + fallbackMembershipTier = state.authSession?.user?.membershipTier, + ) + StorageAddonPanel( + storageAddon = state.subscriptionInfo?.storageAddon, + openExternal = { url -> + if (!openExternalUrl(context, url)) { + Toast.makeText(context, "No browser available", Toast.LENGTH_SHORT).show() + } + }, + ) + AccountConnectorsPanel( + connectors = state.accountConnectors, + loading = state.loadingAccountConnectors, + actionStore = state.connectorActionStore, + onRefresh = viewModel::refreshAccountConnectors, + onConnect = { connector -> + viewModel.connectAccountConnector(connector.store) { url -> + if (!openExternalUrl(context, url)) { + Toast.makeText(context, "No browser available", Toast.LENGTH_SHORT).show() + } + } + }, + onDisconnect = { connector -> + viewModel.disconnectAccountConnector(connector.store) + }, + openExternal = { url -> + if (!openExternalUrl(context, url)) { + Toast.makeText(context, "No browser available", Toast.LENGTH_SHORT).show() + } + }, + ) + } +} + +@Composable +private fun AddAccountProviderDialog( + providers: List, + selectedProvider: LoginProvider, + onProviderSelected: (LoginProvider) -> Unit, + onDismiss: () -> Unit, +) { + var providerChoice by remember(providers, selectedProvider) { + mutableStateOf(providers.preferredProvider(selectedProvider)) + } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Choose provider") }, + text = { + Column( + Modifier + .heightIn(max = 360.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Select the GeForce NOW provider to use for the new account.", + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + providers.forEach { provider -> + ProviderChoiceRow( + provider = provider, + selected = provider.sameProvider(providerChoice), + onClick = { providerChoice = provider }, + ) + } + } + }, + confirmButton = { + Button(onClick = { onProviderSelected(providerChoice) }) { + Text("Continue") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} + +@Composable +private fun ProviderChoiceRow(provider: LoginProvider, selected: Boolean, onClick: () -> Unit) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.16f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f) + }, + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(Modifier.weight(1f)) { + Text( + provider.displayName, + color = SettingsText, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + provider.code, + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (selected) { + Text("Selected", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + } + } + } +} + +@Composable +private fun AccountPlayTimeStatsPanel(subscriptionInfo: SubscriptionInfo?, fallbackMembershipTier: String?) { + val sessionLimit = smartSessionLimitFor(subscriptionInfo, fallbackMembershipTier) + val monthlyLimit = monthlyHourLimitFor(subscriptionInfo, fallbackMembershipTier) + val monthlyRemaining = monthlyHoursRemainingFor(subscriptionInfo, fallbackMembershipTier) + val usedHours = subscriptionInfo?.usedHours?.takeIf { it > 0.0 } + val progressFraction = if (monthlyLimit != null && monthlyLimit > 0.0) { + ((usedHours ?: 0.0) / monthlyLimit).toFloat().coerceIn(0f, 1f) + } else { + null + } + val freePlan = sessionLimit.mode == SessionTimerMode.Countdown + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Play time stats", color = SettingsText, fontWeight = FontWeight.SemiBold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + UsageMetricTile( + label = "Session", + value = "${sessionLimit.limitHours}h", + detail = when (sessionLimit.mode) { + SessionTimerMode.Countdown -> "countdown" + SessionTimerMode.Stopwatch -> "stopwatch" + }, + modifier = Modifier.weight(1f), + ) + UsageMetricTile( + label = "Monthly left", + value = monthlyRemaining?.let(::formatPlayTimeHours) ?: "--", + detail = monthlyLimit?.let { "of ${formatPlayTimeHours(it)}" } ?: if (freePlan) "paid plans" else "refresh account", + modifier = Modifier.weight(1f), + ) + } + if (progressFraction != null && monthlyLimit != null) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + "${formatPlayTimeHours(usedHours ?: 0.0)} used", + color = SettingsTextMuted, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.weight(1f), + ) + Text( + "${formatPlayTimePercent(progressFraction)} of ${formatPlayTimeHours(monthlyLimit)}", + color = SettingsTextMuted, + style = MaterialTheme.typography.labelSmall, + ) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(999.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)) + .semantics { + contentDescription = "Monthly play time ${formatPlayTimePercent(progressFraction)} used" + progressBarRangeInfo = ProgressBarRangeInfo(progressFraction, 0f..1f) + }, + ) { + Box( + Modifier + .fillMaxWidth(progressFraction) + .height(4.dp) + .background( + when { + progressFraction >= 0.9f -> Color(0xffff8a65) + progressFraction >= 0.75f -> Color(0xffffc266) + else -> MaterialTheme.colorScheme.primary + }, + ), + ) + } + } + } else { + Text( + if (freePlan) { + "Paid plans show monthly play-time usage here when NVIDIA reports it." + } else { + "Refresh Account settings after sign-in to load monthly play-time usage." + }, + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + +@Composable +private fun UsageMetricTile(label: String, value: String, detail: String, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.52f), + ) { + Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label, color = SettingsTextMuted, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(value, color = SettingsText, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(detail, color = SettingsTextMuted, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +internal fun AndroidUpdateNoticeRow( + update: AndroidUpdateState, + dismissedKey: String?, + onOpenUpdates: () -> Unit, + onDismiss: () -> Unit, +) { + val noticeKey = update.visibleNoticeKey(dismissedKey) ?: return + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onOpenUpdates), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), + ) { + Row( + Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + UpdateStatusBadge(update.status) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(accountUpdateTitle(update), color = SettingsText, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(accountUpdateSubtitle(update), color = SettingsTextMuted, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + if (update.status == AndroidUpdateStatus.Downloading) { + CircularUpdateProgress(update.progress) + } + IconButton( + onClick = onDismiss, + modifier = Modifier.semantics { contentDescription = "Dismiss update ${noticeKey.takeLast(12)}" }, + ) { + Icon( + painter = painterResource(R.drawable.ic_clear), + contentDescription = null, + tint = SettingsTextMuted, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +@Composable +private fun CircularUpdateProgress(progress: AndroidUpdateProgress?) { + val label = progress?.let(::formatAndroidUpdateProgress) ?: "Downloading" + Text( + label, + color = SettingsTextMuted, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +private fun accountUpdateTitle(update: AndroidUpdateState): String = + when (update.status) { + AndroidUpdateStatus.Downloaded -> "Update ready" + AndroidUpdateStatus.Downloading -> "Downloading OpenNOW" + else -> "OpenNOW update available" + } + +private fun accountUpdateSubtitle(update: AndroidUpdateState): String = + update.availableVersionName?.let { "Version $it is ready for this device." } + ?: update.message + +@Composable +private fun StorageAddonPanel(storageAddon: StorageAddon?, openExternal: (String) -> Unit) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Cloud storage", color = SettingsText, fontWeight = FontWeight.SemiBold) + if (storageAddon == null) { + Text("No persistent storage add-on is active for this account.", color = SettingsTextMuted, style = MaterialTheme.typography.bodySmall) + OutlinedButton(onClick = { openExternal(GFN_ADD_STORAGE_URL) }, modifier = Modifier.fillMaxWidth()) { + Text("Add storage", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } else { + val used = storageAddon.usedGb + val total = storageAddon.sizeGb + val usageFraction = storageUsageFraction(used, total) + Text( + listOfNotNull( + total?.let { "Total ${formatStorageGb(it)}" }, + used?.let { "Used ${formatStorageGb(it)}" }, + if (used != null && total != null) "Available ${formatStorageGb((total - used).coerceAtLeast(0.0))}" else null, + ).joinToString(" - "), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + if (usageFraction != null) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + "Storage usage", + color = SettingsText, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Text( + "${formatStoragePercent(usageFraction)} used", + color = SettingsTextMuted, + style = MaterialTheme.typography.labelSmall, + ) + } + val usageColor = when { + usageFraction >= 0.9f -> Color(0xffff8a65) + usageFraction >= 0.75f -> Color(0xffffc266) + else -> MaterialTheme.colorScheme.primary + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(999.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)) + .semantics { + contentDescription = "Cloud storage ${formatStoragePercent(usageFraction)} used" + progressBarRangeInfo = ProgressBarRangeInfo(usageFraction, 0f..1f) + }, + ) { + Box( + Modifier + .fillMaxWidth(usageFraction.coerceIn(0f, 1f)) + .height(4.dp) + .background(usageColor), + ) + } + } + } + storageAddon.regionName?.takeIf { it.isNotBlank() }?.let { region -> + Text("Location: $region", color = SettingsTextMuted, style = MaterialTheme.typography.bodySmall) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Button(onClick = { openExternal(GFN_STORAGE_MANAGEMENT_URL) }, modifier = Modifier.weight(1f)) { + Text("Manage", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(onClick = { openExternal(GFN_STORAGE_RESET_URL) }, modifier = Modifier.weight(1f)) { + Text("Reset", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + OutlinedButton(onClick = { openExternal(GFN_STORAGE_MANAGEMENT_URL) }, modifier = Modifier.fillMaxWidth()) { + Text("Change storage location", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +private fun AccountConnectorsPanel( + connectors: List, + loading: Boolean, + actionStore: String?, + onRefresh: () -> Unit, + onConnect: (AccountConnector) -> Unit, + onDisconnect: (AccountConnector) -> Unit, + openExternal: (String) -> Unit, +) { + var disconnecting by remember { mutableStateOf(null) } + disconnecting?.let { connector -> + AlertDialog( + onDismissRequest = { disconnecting = null }, + title = { Text("Disconnect ${connector.label}?") }, + text = { Text("This removes the linked ${connector.label} account from GeForce NOW. You can connect it again later.") }, + confirmButton = { + Button( + onClick = { + disconnecting = null + onDisconnect(connector) + }, + ) { + Text("Disconnect") + } + }, + dismissButton = { + TextButton(onClick = { disconnecting = null }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Game store connections", color = SettingsText, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + TextButton(onClick = onRefresh, enabled = !loading) { + Text(if (loading) "Refreshing..." else "Refresh") + } + } + if (connectors.isEmpty()) { + Text( + if (loading) "Loading connected stores..." else "Connect Steam, Epic, Xbox, and other supported stores to sync your GeForce NOW library.", + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } else { + connectors.take(6).forEach { connector -> + ConnectorRow( + connector = connector, + busy = actionStore == connector.store, + onConnect = { onConnect(connector) }, + onDisconnect = { disconnecting = connector }, + ) + } + } + OutlinedButton(onClick = { openExternal(GFN_ACCOUNT_HELP_URL) }, modifier = Modifier.fillMaxWidth()) { + Text("Connection help", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } +} + +@Composable +private fun ConnectorRow( + connector: AccountConnector, + busy: Boolean, + onConnect: () -> Unit, + onDisconnect: () -> Unit, +) { + val actionEnabled = !busy && (connector.isLinked || connector.supported) + val badge = launcherBadgeForStoreKey(splitGameStoreKeys(connector.store).firstOrNull()) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = actionEnabled) { + if (connector.isLinked) onDisconnect() else onConnect() + }, + ) { + ConnectorStoreIcon(badge) + Column(Modifier.weight(1f)) { + Text(connector.label, color = SettingsText, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(connectorStatusText(connector), color = SettingsTextMuted, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (connector.isLinked) { + OutlinedButton(onClick = onDisconnect, enabled = !busy, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp)) { + Text(if (busy) "Removing..." else "Disconnect") + } + } else { + Button(onClick = onConnect, enabled = connector.supported && !busy, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp)) { + Text(if (busy) "Opening..." else "Connect") + } + } + } +} + +@Composable +internal fun ConnectorStoreIcon(badge: LauncherBadge) { + Surface( + modifier = Modifier + .size(34.dp) + .semantics { contentDescription = "${badge.name} store" }, + shape = RoundedCornerShape(10.dp), + color = badge.background.copy(alpha = 0.88f), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Icon( + painter = painterResource(badge.iconRes), + contentDescription = null, + tint = badge.foreground, + modifier = Modifier.size(19.dp), + ) + } + } +} + +private fun AuthSession.toSavedAccount(): SavedAccount = + SavedAccount( + userId = user.userId, + displayName = user.displayName, + email = user.email, + avatarUrl = user.avatarUrl, + membershipTier = user.membershipTier, + providerCode = provider.code, + ) + +private fun accountProviderOptions(providers: List, selectedProvider: LoginProvider): List = + (providers + selectedProvider) + .distinctBy { it.providerIdentityKey() } + .ifEmpty { listOf(selectedProvider) } + +private fun List.preferredProvider(provider: LoginProvider): LoginProvider = + firstOrNull { it.sameProvider(provider) } + ?: firstOrNull() + ?: provider + +private fun LoginProvider.sameProvider(other: LoginProvider): Boolean = + providerIdentityKey() == other.providerIdentityKey() + +private fun LoginProvider.providerIdentityKey(): String = + idpId.ifBlank { code }.lowercase(Locale.US) + +private const val GFN_STORAGE_MANAGEMENT_URL = "https://gfn.link/cloudstorage" +private const val GFN_STORAGE_RESET_URL = "https://gfn.link/resetstorage" +private const val GFN_ADD_STORAGE_URL = "https://gfn.link/addstorage" +private const val GFN_ACCOUNT_HELP_URL = "https://gfn.link/5399" +private const val OPENNOW_GITHUB_URL = "https://github.com/OpenCloudGaming/OpenNOW" + +private data class DeveloperCredit( + val name: String, + val githubUrl: String, +) + +private val DEVELOPER_CREDITS = listOf( + DeveloperCredit("Kiefer", "https://github.com/Kief5555"), + DeveloperCredit("Zortos", "https://github.com/zortos293"), +) + +private fun formatStorageGb(value: Double): String = + if (value % 1.0 == 0.0) "${value.toInt()} GB" else "%.1f GB".format(Locale.US, value) + +private fun storageUsageFraction(usedGb: Double?, totalGb: Double?): Float? { + if (usedGb == null || totalGb == null || totalGb <= 0.0) return null + return (usedGb / totalGb).coerceIn(0.0, 1.0).toFloat() +} + +private fun formatStoragePercent(fraction: Float): String = + "${(fraction * 100).roundToInt().coerceIn(0, 100)}%" + +private fun formatPlayTimeHours(value: Double): String = + if (value >= 10.0 || value % 1.0 == 0.0) { + "${value.roundToInt()}h" + } else { + "%.1fh".format(Locale.US, value) + } + +private fun formatPlayTimePercent(fraction: Float): String = + "${(fraction * 100).roundToInt().coerceIn(0, 100)}%" + +private fun connectorStatusText(connector: AccountConnector): String { + if (!connector.isLinked) return if (connector.required) "Required for some games" else "Available to connect" + val identity = connector.userDisplayName?.takeIf { it.isNotBlank() } + ?: connector.userIdentifier?.takeIf { it.isNotBlank() } + val sync = when { + connector.syncedGameCount != null -> "${connector.syncedGameCount} synced games" + !connector.syncState.isNullOrBlank() -> connector.syncState.replace('_', ' ').lowercase(Locale.US) + .replaceFirstChar { it.titlecase(Locale.US) } + else -> null + } + return listOfNotNull(identity, sync).joinToString(" - ").ifBlank { "Connected" } +} + +@Composable +internal fun CodecDiagnosticsPanel(report: RuntimeCodecReport?) { + if (report == null) { + Text(stringResource(R.string.settings_codec_diagnostics_unavailable), color = SettingsTextMuted) + return + } + val clipboard = LocalClipboardManager.current + var copied by remember(report) { mutableStateOf(false) } + val safeDecoders = report.capabilities.count { it.streamingRealtimeSafe() } + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = { + clipboard.setText(AnnotatedString(formatCodecDiagnosticReport(report))) + copied = true + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (copied) { + stringResource(R.string.settings_codec_diagnostics_copied) + } else { + stringResource(R.string.settings_codec_diagnostics_copy) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + CodecSummaryChip("${safeDecoders}/${report.capabilities.size}", "real-time decoders") + CodecSummaryChip(if (report.lowPowerGpuProfile) "Low power" else "Standard", "device profile") + CodecSummaryChip(if (report.androidTvProfile) "TV" else "Mobile", "shell") + } + report.capabilities.forEach { capability -> + CodecCapabilityRow(capability) + } + Text( + report.nativeRuntimeSummary.replace("{", "").replace("}", "").replace("\"", ""), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private fun formatCodecDiagnosticReport(report: RuntimeCodecReport): String = buildString { + appendLine("OpenNOW Android codec diagnostics") + appendLine("nativeRuntimeSummary=${report.nativeRuntimeSummary}") + appendLine("androidTvProfile=${report.androidTvProfile}") + appendLine("lowPowerGpuProfile=${report.lowPowerGpuProfile}") + appendLine("constrainedRuntimeProfile=${report.constrainedRuntimeProfile}") + report.capabilities.forEach { capability -> + appendLine() + appendLine("codec=${capability.codec}") + appendLine("decoderAvailable=${capability.decoderAvailable}") + appendLine("decoderName=${capability.decoderName ?: "none"}") + appendLine("hardwareDecoder=${capability.hardwareDecoder}") + appendLine("realtimeSafe=${capability.realtimeSafe}") + appendLine("nativeDecoderAvailable=${capability.nativeDecoderAvailable ?: "unknown"}") + appendLine("webRtcDecoderAvailable=${capability.webRtcDecoderAvailable ?: "unknown"}") + appendLine("webRtcDecoderName=${capability.webRtcDecoderName ?: "none"}") + appendLine("webRtcHardwareDecoderAvailable=${capability.webRtcHardwareDecoderAvailable ?: "unknown"}") + appendLine("webRtcProfiles=${capability.webRtcCodecProfiles.joinToString(", ").ifBlank { "none" }}") + appendLine("encoderAvailable=${capability.encoderAvailable}") + appendLine("encoderName=${capability.encoderName ?: "none"}") + appendLine("hardwareEncoder=${capability.hardwareEncoder}") + } +} + +@Composable +private fun RowScope.CodecSummaryChip(value: String, label: String) { + Surface( + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f), + ) { + Column(Modifier.padding(horizontal = 10.dp, vertical = 8.dp)) { + Text(value, color = SettingsText, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(label, color = SettingsTextMuted, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +private fun CodecCapabilityRow(capability: CodecCapability) { + val streamingReady = capability.streamingDecoderAvailable() + val healthy = capability.streamingRealtimeSafe() + val status = when { + healthy -> "Ready" + streamingReady -> "WebRTC ready" + capability.decoderAvailable -> "Platform only" + else -> "Unavailable" + } + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f), + ) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(capability.codec.name, color = SettingsText, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + Text( + status, + color = if (healthy) MaterialTheme.colorScheme.primary else Color(0xffffc266), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + } + Text( + "WebRTC: ${capability.streamingDecoderName() ?: "none"}", + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + "Hardware decode ${yesNo(capability.streamingHardwareDecoderAvailable())} - native ${capability.nativeDecoderAvailable ?: "unknown"} - platform ${capability.decoderName ?: "none"}", + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private fun yesNo(value: Boolean): String = if (value) "yes" else "no" + +internal val StreamStatsStyle.label: String + get() = when (this) { + StreamStatsStyle.Compact -> "Single line" + StreamStatsStyle.Detailed -> "Multiline" + } + +internal fun StreamStatsStyle.next(): StreamStatsStyle = + when (this) { + StreamStatsStyle.Compact -> StreamStatsStyle.Detailed + StreamStatsStyle.Detailed -> StreamStatsStyle.Compact + } + +internal val StreamStatsPosition.label: String + get() = when (this) { + StreamStatsPosition.Left -> "Left" + StreamStatsPosition.Center -> "Center" + StreamStatsPosition.Right -> "Right" + } + +internal fun StreamStatsPosition.next(): StreamStatsPosition = + when (this) { + StreamStatsPosition.Left -> StreamStatsPosition.Center + StreamStatsPosition.Center -> StreamStatsPosition.Right + StreamStatsPosition.Right -> StreamStatsPosition.Left + } + +@Composable +internal fun AppVersionPanel() { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(SettingsPanelAlt) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("OpenNOW Android", color = SettingsText, fontWeight = FontWeight.SemiBold) + Text("Version ${BuildConfig.VERSION_NAME}", color = SettingsTextMuted, style = MaterialTheme.typography.bodySmall) + } + Text("Build ${BuildConfig.VERSION_CODE}", color = SettingsTextMuted, style = MaterialTheme.typography.labelMedium) + } +} + +@Composable +internal fun OpenNowGitHubPanel() { + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(SettingsPanelAlt) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("OpenNOW Repository", color = SettingsText, fontWeight = FontWeight.SemiBold) + Text("OpenCloudGaming/OpenNOW", color = SettingsTextMuted, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(onClick = { openExternalUrlOrCopy(context, clipboard, OPENNOW_GITHUB_URL, "GitHub link copied") }) { + Text("GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +internal fun DeveloperPanel() { + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + DEVELOPER_CREDITS.forEach { developer -> + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(SettingsPanelAlt) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + modifier = Modifier.size(52.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + UrlImage("${developer.githubUrl}.png?size=160", Modifier.fillMaxSize().clip(CircleShape)) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(developer.name, color = SettingsText, fontWeight = FontWeight.SemiBold) + Text("Developer", color = SettingsTextMuted, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(onClick = { openExternalUrlOrCopy(context, clipboard, developer.githubUrl, "GitHub link copied") }) { + Text("GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +@Composable +internal fun ThanksPanel() { + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + Text( + stringResource(R.string.settings_thanks_body), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodyMedium, + ) + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(SettingsPanelAlt) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(stringResource(R.string.settings_thanks_darkevilpt), color = SettingsText, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.settings_thanks_darkevilpt_note), color = SettingsTextMuted, style = MaterialTheme.typography.bodySmall) + } + } + Button( + onClick = { + openExternalUrlOrCopy(context, clipboard, DONATE_URL, context.getString(R.string.settings_donate_link_copied)) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.settings_donate), maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +private fun openExternalUrlOrCopy( + context: android.content.Context, + clipboard: androidx.compose.ui.platform.ClipboardManager, + url: String, + copiedMessage: String, +) { + if (!openExternalUrl(context, url)) { + clipboard.setText(AnnotatedString(url)) + Toast.makeText(context, copiedMessage, Toast.LENGTH_SHORT).show() + } +} + +@Composable +internal fun DebugLogsPanel(state: OpenNowUiState, viewModel: OpenNowViewModel) { + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + var copied by remember { mutableStateOf(false) } + var saved by remember { mutableStateOf(false) } + var saveError by remember { mutableStateOf(null) } + var pendingLogText by remember { mutableStateOf("") } + val saveLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + runCatching { + context.contentResolver.openOutputStream(uri)?.use { output -> + output.write(pendingLogText.toByteArray(Charsets.UTF_8)) + } ?: error("Could not open log file") + }.onSuccess { + saved = true + saveError = null + }.onFailure { error -> + saveError = error.message ?: "Could not save logs" + } + } + Text( + "Exports launch state, queue state, stream updates, recovery events, settings, codec capabilities, and recent sanitized CloudMatch JSON responses.", + color = SettingsTextMuted, + ) + if (state.androidTvProfile) { + Button( + onClick = viewModel::uploadDiagnosticShare, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Upload logs and show QR", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text( + "Sensitive values are removed before an unlisted, temporary paste is created. Scan the QR code with your phone to share it.", + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Button( + onClick = { + clipboard.setText(AnnotatedString(viewModel.sanitizedDebugLogText())) + copied = true + }, + modifier = Modifier.weight(1f), + ) { + Text(if (copied) "Copied logs" else "Copy logs", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton( + onClick = { + pendingLogText = viewModel.sanitizedDebugLogText() + saved = false + saveError = null + saveLauncher.launch(viewModel.debugLogFileName()) + }, + modifier = Modifier.weight(1f), + ) { + Text(if (saved) "Exported" else "Export logs", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + state.error?.let { error -> + OutlinedButton( + onClick = { + clipboard.setText(AnnotatedString(error)) + copied = true + }, + ) { + Text("Copy error") + } + } + saveError?.let { + Text(it, color = Color(0xffff9f9f), style = MaterialTheme.typography.bodySmall) + } +} + +@Composable +internal fun rememberDeviceHasBattery(): Boolean { + val appContext = LocalContext.current.applicationContext + return remember(appContext) { deviceHasBattery(appContext) } +} + +internal fun shouldShowBatteryOptimization(explicitBatteryPresent: Boolean?): Boolean = + explicitBatteryPresent != false + +private fun deviceHasBattery(context: Context): Boolean { + val batteryStatus = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver( + null, + IntentFilter(Intent.ACTION_BATTERY_CHANGED), + Context.RECEIVER_NOT_EXPORTED, + ) + } else { + @Suppress("DEPRECATION") + context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + } + val explicitBatteryPresent = batteryStatus + ?.takeIf { it.hasExtra(BatteryManager.EXTRA_PRESENT) } + ?.getBooleanExtra(BatteryManager.EXTRA_PRESENT, true) + return shouldShowBatteryOptimization(explicitBatteryPresent) +} + +@Composable +internal fun BatteryOptimizationPanel() { + val context = LocalContext.current + var isIgnoring by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager + while (true) { + isIgnoring = pm?.isIgnoringBatteryOptimizations(context.packageName) == true + delay(1000L) + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Android battery optimization restricts the app's background activity, which can cause connection timeouts or pause GFN queue progress when the app is minimized.", + style = MaterialTheme.typography.bodyMedium, + color = SettingsTextMuted + ) + Spacer(Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Background activity", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = if (isIgnoring) "Unlimited (Allowed in background)" else "Optimized (May timeout in background)", + color = if (isIgnoring) Color(0xff81c784) else Color(0xffffb74d), + style = MaterialTheme.typography.bodySmall + ) + } + if (!isIgnoring) { + Button( + onClick = { + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${context.packageName}") + } + try { + context.startActivity(intent) + } catch (e: Exception) { + try { + context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + } catch (_: Exception) {} + } + } + ) { + Text("Allow") + } + } else { + OutlinedButton( + onClick = { + try { + context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + } catch (_: Exception) {} + } + ) { + Text("Settings") + } + } + } + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsScreens.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsScreens.kt new file mode 100644 index 000000000..98b180ff6 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowSettingsScreens.kt @@ -0,0 +1,1442 @@ +package com.opencloudgaming.opennow + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.Alignment +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID +import kotlin.math.roundToInt + +internal val SettingsBackground = Color(0xff090b0d) +internal val SettingsPanel = Color(0xff11161a) +internal val SettingsPanelAlt = Color(0xff171d22) +internal val SettingsText = Color(0xffeef3f5) +internal val SettingsTextMuted = Color(0xff98a4aa) +internal const val DONATE_URL = "https://printedwaste.com/donate" +internal val PHONE_NAV_RAIL_MAX_SMALLEST_WIDTH = 600.dp +internal val APP_NAV_RAIL_WIDTH = 80.dp +internal const val PHONE_ULTRAWIDE_MIN_STREAM_ASPECT = 2.2f +internal const val PHONE_ULTRAWIDE_MIN_VIEWPORT_ASPECT = 2.0f +internal const val CATALOG_BACKGROUND_IMAGE_FILE_PREFIX = "catalog_background_image" +internal val LocalSettingsControllerNavigationEnabled = androidx.compose.runtime.staticCompositionLocalOf { false } + +internal data class SettingsChoiceOption(val value: String, val label: String) +internal data class ChoiceMenuOption( + val value: String, + val label: String, + val enabled: Boolean = true, + val badge: String? = null, +) + +internal enum class SearchTarget { + Store, + Library, + Settings, +} + +private enum class SettingsCategory( + val title: String, + val summary: String, + val iconRes: Int, +) { + General("General", "Updates, privacy, advanced options, reset", R.drawable.ic_tab_settings), + Stream("Stream", "Resolution, FPS, codec, HDR, proxy", R.drawable.ic_tab_stream), + Input("Input", "Microphone, mouse, keyboard, touch controls, rumble", R.drawable.ic_tab_library), + Interface("Interface", "Color, cards, stats, controller UI", R.drawable.ic_tab_store), + Account("Account", "Sign-in, storage, connected stores", R.drawable.ic_tab_store), + Advanced("Advanced", "Diagnostics, debug logs, advanced tools", R.drawable.ic_search), + About("About", "Version, credits, and support", R.drawable.ic_tab_settings), +} + +internal data class LauncherBadge( + val iconRes: Int, + val name: String, + val background: Color, + val foreground: Color = SettingsText, +) + +private val keyboardLayoutOptions = listOf( + SettingsChoiceOption("en-US", "English (US)"), + SettingsChoiceOption("en-GB", "English (UK)"), + SettingsChoiceOption("tr-TR", "Turkish Q"), + SettingsChoiceOption("de-DE", "German"), + SettingsChoiceOption("fr-FR", "French"), + SettingsChoiceOption("es-ES", "Spanish"), + SettingsChoiceOption("es-MX", "Spanish (Latin America)"), + SettingsChoiceOption("it-IT", "Italian"), + SettingsChoiceOption("pt-PT", "Portuguese (Portugal)"), + SettingsChoiceOption("pt-BR", "Portuguese (Brazil)"), + SettingsChoiceOption("pl-PL", "Polish"), + SettingsChoiceOption("ru-RU", "Russian"), + SettingsChoiceOption("ja-JP", "Japanese"), + SettingsChoiceOption("ko-KR", "Korean"), + SettingsChoiceOption("zh-CN", "Chinese (Simplified)"), + SettingsChoiceOption("zh-TW", "Chinese (Traditional)"), +) + +private val gameLanguageOptions = listOf( + SettingsChoiceOption("en_US", "English (US)"), + SettingsChoiceOption("en_GB", "English (UK)"), + SettingsChoiceOption("de_DE", "Deutsch"), + SettingsChoiceOption("fr_FR", "Francais"), + SettingsChoiceOption("es_ES", "Espanol (ES)"), + SettingsChoiceOption("es_MX", "Espanol (MX)"), + SettingsChoiceOption("it_IT", "Italiano"), + SettingsChoiceOption("pt_PT", "Portugues (PT)"), + SettingsChoiceOption("pt_BR", "Portugues (BR)"), + SettingsChoiceOption("ru_RU", "Russian"), + SettingsChoiceOption("pl_PL", "Polish"), + SettingsChoiceOption("tr_TR", "Turkish"), + SettingsChoiceOption("ar_SA", "Arabic"), + SettingsChoiceOption("ja_JP", "Japanese"), + SettingsChoiceOption("ko_KR", "Korean"), + SettingsChoiceOption("zh_CN", "Chinese (Simplified)"), + SettingsChoiceOption("zh_TW", "Chinese (Traditional)"), + SettingsChoiceOption("th_TH", "Thai"), + SettingsChoiceOption("vi_VN", "Vietnamese"), + SettingsChoiceOption("id_ID", "Indonesian"), + SettingsChoiceOption("cs_CZ", "Czech"), + SettingsChoiceOption("el_GR", "Greek"), + SettingsChoiceOption("hu_HU", "Hungarian"), + SettingsChoiceOption("ro_RO", "Romanian"), + SettingsChoiceOption("uk_UA", "Ukrainian"), + SettingsChoiceOption("nl_NL", "Dutch"), + SettingsChoiceOption("sv_SE", "Swedish"), + SettingsChoiceOption("da_DK", "Danish"), + SettingsChoiceOption("fi_FI", "Finnish"), + SettingsChoiceOption("no_NO", "Norwegian"), +) + +@Composable +internal fun SettingsScreen( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + tvProfile: Boolean, + searchRequested: Boolean, + searchQuery: String, + backRequestToken: Int, + onSearchQueryChange: (String) -> Unit, + onDetailRouteChange: (Boolean) -> Unit, +) { + var showSessionProxyWarning by remember { mutableStateOf(false) } + var selectedCategory by remember { mutableStateOf(null) } + val scrollState = rememberScrollState() + val searchFocusRequester = remember { FocusRequester() } + val detailFocusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val physicalControllerConnected = rememberPhysicalControllerConnected(enabled = !tvProfile) + val controllerNavigationEnabled = tvProfile || physicalControllerConnected + val showSearch = searchRequested || searchQuery.isNotBlank() + val categories = remember { settingsCategories() } + LaunchedEffect(searchRequested) { + if (searchRequested) { + delay(90) + runCatching { searchFocusRequester.requestFocus() } + keyboardController?.show() + } + } + LaunchedEffect(categories) { + if (selectedCategory != null && selectedCategory !in settingsDetailCategories()) { + selectedCategory = null + } + } + LaunchedEffect(state.settingsRouteTarget) { + val routeTarget = state.settingsRouteTarget ?: return@LaunchedEffect + val routeCategory = when (routeTarget) { + SettingsRouteTarget.General -> SettingsCategory.General + SettingsRouteTarget.Stream -> SettingsCategory.Stream + } + if (selectedCategory != routeCategory || searchQuery.isNotBlank()) { + onSearchQueryChange("") + selectedCategory = routeCategory + } + viewModel.consumeSettingsRouteTarget(routeTarget) + } + BackHandler(enabled = selectedCategory != null) { + selectedCategory = null + } + LaunchedEffect(selectedCategory, controllerNavigationEnabled) { + val detailOpen = selectedCategory != null + onDetailRouteChange(detailOpen) + if (detailOpen && controllerNavigationEnabled) { + delay(90) + runCatching { detailFocusRequester.requestFocus() } + } + if (tvProfile) { + // Focus can scroll the first control into view while AnimatedContent is + // still measuring the new route. Reset afterward so every TV detail + // page opens with its header and remote Back hint fully visible. + delay(30) + scrollState.scrollTo(0) + } + } + LaunchedEffect(backRequestToken) { + if (backRequestToken > 0 && selectedCategory != null) { + selectedCategory = null + } + } + DisposableEffect(Unit) { + onDispose { onDetailRouteChange(false) } + } + CompositionLocalProvider(LocalSettingsControllerNavigationEnabled provides controllerNavigationEnabled) { + if (showSessionProxyWarning) { + SessionProxyWarningDialog( + onCancel = { showSessionProxyWarning = false }, + onEnable = { + viewModel.updateStreamSettings { s -> s.copy(sessionProxyEnabled = true) } + showSessionProxyWarning = false + }, + ) + } + if (tvProfile) { + SwipeToRefreshContainer( + refreshing = state.settingsRefreshing, + onRefresh = viewModel::refreshSettings, + modifier = Modifier.fillMaxSize(), + enabled = false, + ) { + Column( + Modifier + .fillMaxSize() + .background(SettingsBackground) + .onPreviewKeyEvent { handleVerticalDpadFocusMove(it, focusManager) } + .verticalScroll(scrollState) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + AnimatedVisibility(visible = showSearch) { + NativeSearchField( + query = searchQuery, + onQueryChange = onSearchQueryChange, + placeholder = stringResource(R.string.search_settings), + focusRequester = searchFocusRequester, + modifier = Modifier.fillMaxWidth(), + ) + } + AnimatedContent(targetState = selectedCategory, label = "settings-route") { category -> + SettingsBody( + state = state, + viewModel = viewModel, + tvProfile = tvProfile, + physicalControllerConnected = physicalControllerConnected, + searchQuery = searchQuery, + selectedCategory = category, + categories = categories, + detailFocusRequester = detailFocusRequester, + onSelectCategory = { selectedCategory = it }, + onBack = { selectedCategory = null }, + showSessionProxyWarning = { showSessionProxyWarning = true }, + ) + } + } + } + } else { + SwipeToRefreshContainer( + refreshing = state.settingsRefreshing, + onRefresh = viewModel::refreshSettings, + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn( + Modifier + .fillMaxSize() + .background(SettingsBackground), + contentPadding = PaddingValues(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + AnimatedVisibility(visible = showSearch) { + NativeSearchField( + query = searchQuery, + onQueryChange = onSearchQueryChange, + placeholder = stringResource(R.string.search_settings), + focusRequester = searchFocusRequester, + modifier = Modifier.fillMaxWidth(), + ) + } + } + item { + AnimatedContent(targetState = selectedCategory, label = "settings-route") { category -> + SettingsBody( + state = state, + viewModel = viewModel, + tvProfile = tvProfile, + physicalControllerConnected = physicalControllerConnected, + searchQuery = searchQuery, + selectedCategory = category, + categories = categories, + detailFocusRequester = detailFocusRequester, + onSelectCategory = { selectedCategory = it }, + onBack = { selectedCategory = null }, + showSessionProxyWarning = { showSessionProxyWarning = true }, + ) + } + } + } + } + } + } +} + +@Composable +private fun SettingsBody( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + tvProfile: Boolean, + physicalControllerConnected: Boolean, + searchQuery: String, + selectedCategory: SettingsCategory?, + categories: List, + detailFocusRequester: FocusRequester, + onSelectCategory: (SettingsCategory) -> Unit, + onBack: () -> Unit, + showSessionProxyWarning: () -> Unit, +) { + when { + searchQuery.isNotBlank() -> { + SettingsContent( + state = state, + viewModel = viewModel, + searchQuery = searchQuery, + selectedCategory = null, + showSessionProxyWarning = showSessionProxyWarning, + ) + } + selectedCategory == null -> { + SettingsCategoryLanding( + state = state, + viewModel = viewModel, + categories = categories, + onSelectCategory = onSelectCategory, + ) + } + else -> { + Column( + Modifier + .fillMaxWidth() + .lockedFocusGroup(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SettingsDetailHeader( + category = selectedCategory, + tvProfile = tvProfile, + physicalControllerConnected = physicalControllerConnected, + onBack = onBack, + ) + Box( + Modifier + .fillMaxWidth() + .focusRequester(detailFocusRequester) + .focusGroup(), + ) { + SettingsContent( + state = state, + viewModel = viewModel, + searchQuery = searchQuery, + selectedCategory = selectedCategory, + showSessionProxyWarning = showSessionProxyWarning, + ) + } + } + } + } +} + +@Composable +private fun SettingsContent( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + searchQuery: String, + selectedCategory: SettingsCategory?, + showSessionProxyWarning: () -> Unit, +) { + val settings = state.settings + val context = LocalContext.current + val deviceHasBattery = rememberDeviceHasBattery() + var pendingMicrophoneMode by remember { mutableStateOf(null) } + val microphonePermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + val requestedMode = pendingMicrophoneMode + pendingMicrophoneMode = null + if (granted && requestedMode != null) { + viewModel.updateSettings( + settings.copy( + stream = settings.stream.copy(microphoneMode = requestedMode), + ), + ) + } else if (!granted) { + Toast.makeText( + context, + context.getString(R.string.settings_microphone_permission_denied), + Toast.LENGTH_LONG, + ).show() + } + } + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + CategorySettingsSection(selectedCategory, SettingsCategory.General, searchQuery, "App updates", "update", "updates", "disable update checking", "checking", "check", "download", "install", "apk") { + if (state.androidUpdate.apkUpdatesAllowed) { + SettingSwitch(stringResource(R.string.settings_disable_update_checking), !settings.autoCheckForUpdates) { disabled -> + viewModel.updateSettings(settings.copy(autoCheckForUpdates = !disabled)) + } + } + AndroidUpdatePanel(state = state, viewModel = viewModel) + } + CategorySettingsSection(selectedCategory, SettingsCategory.General, searchQuery, stringResource(R.string.settings_nerd_mode), "advanced", "advanced options", "nerd", "experimental", "diagnostics", "catalog", "cave", "background", "wallpaper", "image", "custom") { + AdvancedOptionsSettings(settings = settings, viewModel = viewModel) + } + CategorySettingsSection(selectedCategory, SettingsCategory.General, searchQuery, "Privacy", "privacy", "analytics", "telemetry", "posthog", "usage", "tracking", "opt out") { + SettingSwitch("Share usage analytics", settings.analyticsSharingEnabled) { enabled -> + viewModel.updateSettings( + settings.copy( + analyticsConsentAsked = true, + analyticsOptOut = !enabled, + ), + ) + } + } + CategorySettingsSection(selectedCategory, SettingsCategory.Stream, searchQuery, stringResource(R.string.settings_section_stream), "stream", "preset", "data saver", "low", "medium", "high", "custom", "resolution", "aspect ratio", "fps", "bitrate", "codec", "color", "hdr", "sharpening", "region", "session proxy", "proxy", "l4s", "cloud g-sync", "vrr", "native streamer", "low latency", "native decoder", "decoder") { + val fallbackMembershipTier = state.authSession?.user?.membershipTier + ChoiceMenuRow( + label = stringResource(R.string.settings_stream_preset), + options = StreamPreset.entries.map { preset -> + ChoiceMenuOption( + value = preset.name, + label = streamPresetLabel(preset), + ) + }, + selectedLabel = streamPresetLabel(settings.streamPreset), + ) { value -> + viewModel.applyStreamPreset(StreamPreset.valueOf(value)) + } + val performanceWarningReasons = settings.stream.lowPowerPerformanceWarningReasons(state.codecReport) + if (performanceWarningReasons.isNotEmpty()) { + LowPowerStreamWarning(performanceWarningReasons) + } + val resolutionChoices = streamResolutionChoicesForAspect(settings.stream.aspectRatio).ifEmpty { + streamResolutionChoicesForAspect("16:9") + } + val selectedResolution = normalizeStreamResolutionForAspectAndPlan( + settings.stream.resolution, + settings.stream.aspectRatio, + state.subscriptionInfo, + fallbackMembershipTier, + ) + ChoiceMenuRow( + label = stringResource(R.string.settings_resolution), + options = resolutionChoices.map { choice -> + val available = choice.isAvailableFor(state.subscriptionInfo, fallbackMembershipTier) + ChoiceMenuOption( + value = choice.value, + label = choice.label, + enabled = available, + badge = if (available) null else choice.requiredPlanLabel, + ) + }, + selectedLabel = resolutionChoices.firstOrNull { it.value == selectedResolution }?.label ?: selectedResolution, + ) { + viewModel.updateStreamSettings { s -> s.copy(resolution = it) } + } + ChoiceMenuRow( + label = stringResource(R.string.settings_aspect_ratio), + options = streamAspectRatioOptions().map { aspectRatio -> + val choices = streamResolutionChoicesForAspect(aspectRatio) + val available = choices.any { it.isAvailableFor(state.subscriptionInfo, fallbackMembershipTier) } + ChoiceMenuOption( + value = aspectRatio, + label = aspectRatio, + enabled = available, + badge = if (available) null else choices.firstNotNullOfOrNull { it.requiredPlanLabel }, + ) + }, + selectedLabel = settings.stream.aspectRatio, + ) { + viewModel.updateStreamSettings { s -> + s.copy( + aspectRatio = it, + resolution = normalizeStreamResolutionForAspectAndPlan( + s.resolution, + it, + state.subscriptionInfo, + fallbackMembershipTier, + ), + ) + } + } + SettingSwitch(stringResource(R.string.settings_stretch_stream_to_fit), settings.stretchStreamToFit) { enabled -> + viewModel.updateSettings( + settings.copy( + legacyCropStreamToFill = false, + stretchStreamToFit = enabled, + ), + ) + } + val maxFps = maxStreamFpsFor(state.subscriptionInfo, fallbackMembershipTier) + NumberSlider(stringResource(R.string.settings_fps), settings.stream.fps.coerceAtMost(maxFps).toFloat(), 30f, maxFps.toFloat(), 30f) { + val fps = it.roundToInt().coerceIn(30, maxFps) + viewModel.updateStreamSettings { s -> s.copy(fps = fps) } + } + NumberSlider( + label = stringResource(R.string.settings_bitrate), + value = settings.stream.maxBitrateMbps.toFloat(), + min = 1f, + max = 150f, + step = 1f, + descriptionProvider = { mbps -> + "Est. data usage: %.1f GB/hour".format((mbps * 3600f) / (8f * 1000f)) + } + ) { + viewModel.updateStreamSettings { s -> s.copy(maxBitrateMbps = it.roundToInt()) } + } + val comingSoonLabel = stringResource(R.string.option_coming_soon) + val unavailableLabel = "Unavailable" + val settingsAvailableStream = settings.stream.withAndroidSettingsAvailability() + val effectiveCodec = settingsAvailableStream.adjustedForDevice(state.codecReport).codec + ChoiceMenuRow( + label = stringResource(R.string.settings_codec), + options = VideoCodec.entries.map { codec -> + val launchUsable = state.codecReport + ?.capabilities + ?.firstOrNull { it.codec == codec } + ?.streamingDecoderUsableForLaunch() + ?: true + val settingsAvailable = codec.availableForAndroidSettings() + val available = settingsAvailable && launchUsable + ChoiceMenuOption( + value = codec.name, + label = codec.name, + enabled = available, + badge = when { + available -> null + !settingsAvailable -> comingSoonLabel + else -> unavailableLabel + }, + ) + }, + selectedLabel = if (effectiveCodec == settings.stream.codec) { + settings.stream.codec.name + } else { + "${settings.stream.codec.name} -> ${effectiveCodec.name}" + }, + ) { + viewModel.updateStreamSettings { s -> + s.copy(codec = VideoCodec.valueOf(it)).withCodecColorCompatibility() + } + } + val effectiveColorQuality = settingsAvailableStream.withCodecColorCompatibility().colorQuality + ChoiceMenuRow( + label = stringResource(R.string.settings_color), + options = ColorQuality.entries.map { quality -> + val available = quality.availableForCodec(settingsAvailableStream.codec) + ChoiceMenuOption( + value = quality.name, + label = quality.label, + enabled = available, + badge = if (available) null else comingSoonLabel, + ) + }, + selectedLabel = if (effectiveColorQuality == settings.stream.colorQuality) { + settings.stream.colorQuality.label + } else { + "${settings.stream.colorQuality.label} -> ${effectiveColorQuality.label}" + }, + ) { value -> + viewModel.updateStreamSettings { s -> + s.copy(colorQuality = ColorQuality.valueOf(value)).withCodecColorCompatibility() + } + } + val hdrAvailable = hasHdrStreamingPlan(state.subscriptionInfo, fallbackMembershipTier) + SettingSwitch( + stringResource(R.string.settings_hdr), + settings.stream.hdrEnabled && hdrAvailable, + enabled = hdrAvailable, + ) { enabled -> + viewModel.updateStreamSettings { s -> + s.copy( + hdrEnabled = enabled, + colorQuality = if (enabled && !s.colorQuality.name.startsWith("TenBit")) ColorQuality.TenBit420 else s.colorQuality, + ).withCodecColorCompatibility() + } + } + SettingSwitch("Stream sharpening", settings.stream.streamSharpeningEnabled) { + viewModel.updateStreamSettings { s -> s.copy(streamSharpeningEnabled = it) } + } + if (settings.stream.streamSharpeningEnabled) { + NumberSlider("Sharpness amount", settings.stream.streamSharpeningAmount, 0f, 1f, 0.05f) { + viewModel.updateStreamSettings { s -> s.copy(streamSharpeningAmount = it) } + } + } + ChoiceRow(stringResource(R.string.settings_region), listOf(stringResource(R.string.option_auto)) + state.regions.map { it.name }, state.regions.firstOrNull { it.url == settings.stream.region }?.name ?: stringResource(R.string.option_auto)) { label -> + val url = state.regions.firstOrNull { it.name == label }?.url.orEmpty() + viewModel.updateStreamSettings { s -> s.copy(region = url) } + } + SettingSwitch(stringResource(R.string.settings_session_proxy), settings.stream.sessionProxyEnabled) { enabled -> + if (enabled) { + showSessionProxyWarning() + } else { + viewModel.updateStreamSettings { s -> s.copy(sessionProxyEnabled = false) } + } + } + Text( + stringResource(R.string.settings_session_proxy_hint), + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + if (settings.stream.sessionProxyEnabled) { + OutlinedTextField( + value = settings.stream.sessionProxyUrl, + onValueChange = { value -> viewModel.updateStreamSettings { s -> s.copy(sessionProxyUrl = value) } }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(stringResource(R.string.settings_session_proxy_url)) }, + placeholder = { Text("http://127.0.0.1:8080") }, + ) + } + SettingSwitch( + label = stringResource(R.string.settings_l4s), + checked = settings.stream.enableL4S, + description = stringResource(R.string.settings_l4s_desc), + ) { + viewModel.updateStreamSettings { s -> s.copy(enableL4S = it) } + } + SettingSwitch( + label = stringResource(R.string.settings_cloud_gsync), + checked = settings.stream.enableCloudGsync, + description = stringResource(R.string.settings_cloud_gsync_desc), + ) { + viewModel.updateStreamSettings { s -> s.copy(enableCloudGsync = it) } + } + SettingSwitch( + label = stringResource(R.string.settings_native_streamer), + checked = settings.nativeLowLatencyDecoder, + description = stringResource(R.string.settings_native_streamer_desc), + ) { enabled -> + viewModel.updateSettings(settings.copy(nativeLowLatencyDecoder = enabled)) + } + } + CategorySettingsSection(selectedCategory, SettingsCategory.Input, searchQuery, "Input", "input", "microphone", "mic", "voice", "audio", "mouse", "sensitivity", "acceleration", "keyboard", "layout", "language", "clipboard", "paste", "rumble", "touch", "finger", "opacity", "edge", "padding", "offset", "controls", "stick", "joystick", "analog", "dynamic", "dead zone", "button") { + SettingSwitch( + label = stringResource(R.string.settings_microphone), + checked = settings.stream.microphoneMode != MicrophoneMode.Disabled, + description = stringResource(R.string.settings_microphone_desc), + ) { enabled -> + if (!enabled) { + viewModel.updateSettings( + settings.copy( + stream = settings.stream.copy(microphoneMode = MicrophoneMode.Disabled), + ), + ) + } else if ( + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) { + viewModel.updateSettings( + settings.copy( + stream = settings.stream.copy(microphoneMode = MicrophoneMode.VoiceActivity), + ), + ) + } else { + pendingMicrophoneMode = MicrophoneMode.VoiceActivity + microphonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + NumberSlider("Mouse sensitivity", settings.stream.mouseSensitivity, 0.25f, 3f, 0.05f) { + viewModel.updateStreamSettings { s -> s.copy(mouseSensitivity = it) } + } + NumberSlider("Mouse acceleration", settings.stream.mouseAcceleration.toFloat(), 1f, 150f, 1f) { + viewModel.updateStreamSettings { s -> s.copy(mouseAcceleration = it.roundToInt()) } + } + ChoiceOptionRow("Keyboard layout", keyboardLayoutOptions, settings.stream.keyboardLayout) { + viewModel.updateStreamSettings { s -> s.copy(keyboardLayout = it) } + } + ChoiceOptionRow("Game language", gameLanguageOptions, settings.stream.gameLanguage) { + viewModel.updateStreamSettings { s -> s.copy(gameLanguage = it) } + } + SettingSwitch("Clipboard paste", settings.clipboardPaste) { enabled -> viewModel.updateSettings(settings.copy(clipboardPaste = enabled)) } + SettingSwitch("Phone rumble fallback", settings.phoneRumbleFallback) { enabled -> viewModel.updateSettings(settings.copy(phoneRumbleFallback = enabled)) } + SettingSwitch( + label = "Mouse mode (Left stick)", + checked = settings.controllerMouseEmulation, + description = "Toggle in Stream Controls per session. Left stick moves the cursor, A button clicks, B button right-clicks.", + ) { enabled -> + viewModel.updateSettings(settings.copy(controllerMouseEmulation = enabled)) + } + SettingSwitch("Touch controls", settings.androidTouch.enabled) { enabled -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(enabled = enabled))) } + val touchStyleOptions = listOf( + SettingsChoiceOption(TouchControllerStyle.V1.name, "V1 (Solid)"), + SettingsChoiceOption(TouchControllerStyle.V2.name, "V2 (Clean Outline)") + ) + ChoiceOptionRow("Touch controller style", touchStyleOptions, settings.androidTouch.touchControllerStyle.name) { styleName -> + val style = TouchControllerStyle.valueOf(styleName) + viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(touchControllerStyle = style))) + } + val joystickModeOptions = listOf( + SettingsChoiceOption(TouchJoystickMode.Fixed.name, "Fixed"), + SettingsChoiceOption(TouchJoystickMode.Dynamic.name, "Dynamic"), + ) + ChoiceOptionRow("Touch joystick", joystickModeOptions, settings.androidTouch.joystickMode.name) { modeName -> + val mode = TouchJoystickMode.valueOf(modeName) + viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(joystickMode = mode))) + } + NumberSlider("Joystick dead zone", settings.androidTouch.joystickDeadZone, 0f, 0.3f, 0.01f) { value -> + viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(joystickDeadZone = value))) + } + SettingSwitch("Finger mouse", settings.androidTouch.mousePad) { enabled -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(mousePad = enabled))) } + if (settings.androidTouch.mousePad) { + Box(Modifier.padding(start = 24.dp)) { + SettingSwitch("Direct click", settings.androidTouch.mouseDirectClick) { enabled -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(mouseDirectClick = enabled))) } + } + } + NumberSlider("Touch layout scale", settings.androidTouch.scale, 0.6f, 1.4f, 0.05f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(scale = value))) } + NumberSlider("Touch button size", settings.androidTouch.buttonScale, 0.65f, 1.5f, 0.05f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(buttonScale = value))) } + NumberSlider("Touch stick size", settings.androidTouch.stickScale, 0.65f, 1.5f, 0.05f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(stickScale = value))) } + NumberSlider("Touch opacity", settings.androidTouch.opacity, 0.15f, 1f, 0.05f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(opacity = value))) } + NumberSlider("Touch edge padding", settings.androidTouch.edgePaddingDp, 0f, 72f, 1f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(edgePaddingDp = value))) } + NumberSlider("Touch bottom padding", settings.androidTouch.bottomPaddingDp, 0f, 120f, 1f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(bottomPaddingDp = value))) } + NumberSlider("Left controls horizontal offset", settings.androidTouch.leftOffsetXDp, -220f, 220f, 2f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(leftOffsetXDp = value))) } + NumberSlider("Left controls vertical offset", settings.androidTouch.leftOffsetYDp, -160f, 160f, 2f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(leftOffsetYDp = value))) } + NumberSlider("Right controls horizontal offset", settings.androidTouch.rightOffsetXDp, -220f, 220f, 2f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(rightOffsetXDp = value))) } + NumberSlider("Right controls vertical offset", settings.androidTouch.rightOffsetYDp, -160f, 160f, 2f) { value -> viewModel.updateSettings(settings.copy(androidTouch = settings.androidTouch.copy(rightOffsetYDp = value))) } + } + CategorySettingsSection(selectedCategory, SettingsCategory.Interface, searchQuery, stringResource(R.string.settings_section_interface), "interface", "ui", "system colors", "accent", "launch page", "default page", "store", "library", "nerd", "expressive", "compact", "cards", "store labels", "game card size", "stats", "position", "server selector", "controller", "sounds", "button", "tone", "tv", "safe area", "screen padding", "overscan", "session counter", "intro", "music", "queue", "stretch", "fill") { + val accentOptions = UiAccent.entries.map { it to uiAccentLabel(it) } + SettingSwitch(stringResource(R.string.settings_dynamic_color), settings.dynamicColor) { viewModel.updateSettings(settings.copy(dynamicColor = it)) } + ChoiceRow(stringResource(R.string.settings_accent), accentOptions.map { it.second }, accentOptions.firstOrNull { it.first == settings.uiAccent }?.second ?: accentOptions.first().second) { label -> + accentOptions.firstOrNull { it.second == label }?.first?.let { accent -> + viewModel.updateSettings(settings.copy(uiAccent = accent)) + } + } + val launchPageOptions = AppLaunchPage.entries.map { page -> page to appLaunchPageLabel(page) } + ChoiceRow( + stringResource(R.string.settings_launch_page), + launchPageOptions.map { it.second }, + launchPageOptions.firstOrNull { it.first == settings.launchPage }?.second + ?: launchPageOptions.first().second, + ) { label -> + launchPageOptions.firstOrNull { it.second == label }?.first?.let { page -> + viewModel.updateSettings(settings.copy(launchPage = page)) + } + } + SettingSwitch( + label = stringResource(R.string.settings_expressive_ui), + checked = settings.expressiveUi, + description = stringResource(R.string.settings_expressive_ui_desc), + ) { + viewModel.updateSettings(settings.copy(expressiveUi = it)) + } + SettingSwitch(stringResource(R.string.settings_compact_cards), settings.compactGameCards) { viewModel.updateSettings(settings.copy(compactGameCards = it)) } + SettingSwitch(stringResource(R.string.settings_show_store_labels), settings.showGameStoreLabels) { viewModel.updateSettings(settings.copy(showGameStoreLabels = it)) } + NumberSlider(stringResource(R.string.settings_card_size), settings.posterSizeScale, MIN_GAME_CARD_SCALE, MAX_GAME_CARD_SCALE, 0.05f) { value -> + viewModel.updateSettings(settings.copy(posterSizeScale = value)) + } + NumberSlider(stringResource(R.string.settings_tv_safe_area), settings.tvSafeAreaPaddingDp, 0f, 72f, 2f) { value -> + viewModel.updateSettings(settings.copy(tvSafeAreaPaddingDp = value)) + } + SettingSwitch(stringResource(R.string.settings_show_stats), settings.showStatsOnLaunch) { viewModel.updateSettings(settings.copy(showStatsOnLaunch = it)) } + ChoiceRow("Status bar appearance", StreamStatsStyle.entries.map { it.label }, settings.streamStatsStyle.label) { label -> + StreamStatsStyle.entries.firstOrNull { it.label == label }?.let { style -> + viewModel.updateSettings(settings.copy(streamStatsStyle = style)) + } + } + ChoiceRow(stringResource(R.string.settings_stats_position), StreamStatsPosition.entries.map { it.label }, settings.streamStatsPosition.label) { label -> + StreamStatsPosition.entries.firstOrNull { it.label == label }?.let { position -> + viewModel.updateSettings(settings.copy(streamStatsPosition = position)) + } + } + SettingSwitch(stringResource(R.string.settings_hide_server_selector), settings.hideServerSelector) { viewModel.updateSettings(settings.copy(hideServerSelector = it)) } + SettingSwitch( + label = stringResource(R.string.settings_button_press_tones), + checked = settings.controllerUiSounds, + description = stringResource(R.string.settings_button_press_tones_desc), + ) { enabled -> + viewModel.updateSettings(settings.copy(controllerUiSounds = enabled)) + } + SettingSwitch(stringResource(R.string.settings_session_counter), settings.sessionCounterEnabled) { viewModel.updateSettings(settings.copy(sessionCounterEnabled = it)) } + SettingSwitch(stringResource(R.string.settings_stream_intro_music), settings.streamIntroMusic) { enabled -> + viewModel.updateSettings(settings.copy(streamIntroMusic = enabled)) + } + if (settings.streamIntroMusic) { + val introStartOptions = IntroMusicStartMode.entries.map { mode -> + mode to introMusicStartModeLabel(mode) + } + ChoiceRow( + stringResource(R.string.settings_stream_intro_music_start), + introStartOptions.map { it.second }, + introStartOptions.firstOrNull { it.first == settings.streamIntroStartMode }?.second + ?: introStartOptions.first().second, + ) { label -> + introStartOptions.firstOrNull { it.second == label }?.first?.let { mode -> + viewModel.updateSettings(settings.copy(streamIntroStartMode = mode)) + } + } + } + SettingSwitch(stringResource(R.string.settings_queue_ready_music), settings.queueReadyMusic) { enabled -> + viewModel.updateSettings(settings.copy(queueReadyMusic = enabled)) + } + } + CategorySettingsSection(selectedCategory, SettingsCategory.General, searchQuery, "App Data", "app data", "data", "cache", "clear", "reset", "settings", "tutorial", "guide", "wipe", "relaunch", "fresh install") { + AppDataSettingsPanel(viewModel = viewModel) + } + CategorySettingsSection(selectedCategory, SettingsCategory.Account, searchQuery, "Account", "account", "login", "logout", "sign in", "saved", "provider", "membership", "subscription") { + AccountSettingsPanel(state = state, viewModel = viewModel) + } + CategorySettingsSection(selectedCategory, SettingsCategory.Advanced, searchQuery, "Codec Diagnostics", "codec", "diagnostics", "probe", "av1", "h264", "h265", "hevc", "decode") { + CodecDiagnosticsPanel(state.codecReport) + } + CategorySettingsSection(selectedCategory, SettingsCategory.Advanced, searchQuery, "Debug Logs", "debug", "logs", "logcat", "events", "export", "json", "cloudmatch", "queue", "stream") { + DebugLogsPanel(state = state, viewModel = viewModel) + } + if (deviceHasBattery) { + CategorySettingsSection(selectedCategory, SettingsCategory.Advanced, searchQuery, "Battery Optimization", "battery", "optimization", "background", "activity", "ignore", "allow", "run") { + BatteryOptimizationPanel() + } + } + CategorySettingsSection(selectedCategory, SettingsCategory.About, searchQuery, "About", "about", "version", "build", "app", "github", "developer", "kiefer", "zortos", "opennow", "repository") { + AppVersionPanel() + OpenNowGitHubPanel() + DeveloperPanel() + } + CategorySettingsSection(selectedCategory, SettingsCategory.About, searchQuery, stringResource(R.string.settings_section_thanks), "thanks", "credits", "contributors", "darkevilpt", "donate", "paypal", "printedwaste") { + ThanksPanel() + } + } +} + +@Composable +private fun LowPowerStreamWarning(reasons: List) { + val warningColor = Color(0xffffc266) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = warningColor.copy(alpha = 0.10f), + contentColor = SettingsText, + border = BorderStroke(1.dp, warningColor.copy(alpha = 0.38f)), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + "This device may struggle with these settings", + color = warningColor, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge, + ) + Text( + reasons.joinToString(", "), + color = SettingsText, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + "OpenNOW won't lower these settings just because this device is low-powered. The Recommended preset is the safer option.", + color = SettingsTextMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun SettingsCategoryLanding( + state: OpenNowUiState, + viewModel: OpenNowViewModel, + categories: List, + onSelectCategory: (SettingsCategory) -> Unit, +) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) { + SettingsAccountCard( + state = state, + onClick = { onSelectCategory(SettingsCategory.Account) }, + ) + AndroidUpdateNoticeRow( + update = state.androidUpdate, + dismissedKey = state.dismissedAndroidUpdateNoticeKey, + onOpenUpdates = { onSelectCategory(SettingsCategory.General) }, + onDismiss = viewModel::dismissAndroidUpdateNotice, + ) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) { + categories.forEach { category -> + SettingsCategoryRow( + category = category, + onClick = { onSelectCategory(category) }, + ) + } + } + } + SearchableSettingsSection("", "About", "about", "version", "build", "app", "github", "developer", "kiefer", "zortos", "opennow", "repository") { + AppVersionPanel() + OpenNowGitHubPanel() + DeveloperPanel() + } + SearchableSettingsSection("", stringResource(R.string.settings_section_thanks), "thanks", "credits", "contributors", "darkevilpt", "donate", "paypal", "printedwaste") { + ThanksPanel() + } + } +} + +@Composable +private fun AdvancedOptionsSettings(settings: AppSettings, viewModel: OpenNowViewModel) { + SettingSwitch( + label = stringResource(R.string.settings_nerd_mode), + checked = settings.nerdMode, + description = stringResource(R.string.settings_nerd_mode_desc), + ) { + viewModel.updateSettings(settings.copy(nerdMode = it)) + } + SettingSwitch( + label = stringResource(R.string.settings_nerd_catalog_background), + checked = settings.nerdCatalogBackground, + description = stringResource(R.string.settings_nerd_catalog_background_desc), + ) { + viewModel.updateSettings(settings.copy(nerdCatalogBackground = it)) + } + if (settings.nerdCatalogBackground) { + CatalogBackgroundImageSetting(settings = settings, viewModel = viewModel) + } +} + +@Composable +private fun CatalogBackgroundImageSetting(settings: AppSettings, viewModel: OpenNowViewModel) { + val context = LocalContext.current + val appContext = context.applicationContext + val currentSettings by rememberUpdatedState(settings) + val scope = rememberCoroutineScope() + val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + takePersistableImageReadPermission(context, uri) + scope.launch { + val newUri = withContext(Dispatchers.IO) { + persistCatalogBackgroundImage(appContext, uri) + } + val previousUri = currentSettings.nerdCatalogBackgroundUri + if (newUri != uri.toString()) { + releasePersistableImageReadPermission(context, uri.toString()) + } + viewModel.updateSettings( + currentSettings.copy( + nerdCatalogBackground = true, + nerdCatalogBackgroundUri = newUri, + ), + ) + if (!previousUri.isNullOrBlank() && previousUri != newUri) { + releasePersistableImageReadPermission(context, previousUri) + } + pruneStoredCatalogBackgroundImages(appContext, keepUri = newUri) + } + } + val customBackgroundUri = settings.nerdCatalogBackgroundUri?.takeIf { it.isNotBlank() } + val hasCustomBackground = customBackgroundUri != null + val presetOptions = listOf( + CatalogBackgroundPreset.ColorfulAbstract to stringResource(R.string.catalog_background_colorful_abstract), + CatalogBackgroundPreset.Original to stringResource(R.string.catalog_background_original), + ) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.76f), + ) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + stringResource(R.string.settings_catalog_background_image), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + if (hasCustomBackground) { + stringResource(R.string.settings_catalog_background_image_custom) + } else { + presetOptions.first { it.first == settings.catalogBackgroundPreset }.second + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + ChoiceRow( + label = stringResource(R.string.settings_catalog_background_built_in), + options = presetOptions.map { it.second }, + selected = presetOptions.first { it.first == settings.catalogBackgroundPreset }.second, + ) { selectedLabel -> + val selectedPreset = presetOptions.firstOrNull { it.second == selectedLabel }?.first + ?: return@ChoiceRow + viewModel.updateSettings( + settings.copy( + catalogBackgroundPreset = selectedPreset, + nerdCatalogBackgroundUri = null, + ), + ) + customBackgroundUri?.let { releasePersistableImageReadPermission(context, it) } + pruneStoredCatalogBackgroundImages(appContext) + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { picker.launch(arrayOf("image/*")) }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.action_choose_image), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (hasCustomBackground) { + OutlinedButton( + onClick = { + viewModel.updateSettings(settings.copy(nerdCatalogBackgroundUri = null)) + releasePersistableImageReadPermission(context, customBackgroundUri) + pruneStoredCatalogBackgroundImages(appContext) + }, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.action_use_default), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + } +} + +private fun takePersistableImageReadPermission(context: android.content.Context, uri: Uri) { + runCatching { + context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } +} + +private fun persistCatalogBackgroundImage(context: android.content.Context, uri: Uri): String { + val uniqueId = UUID.randomUUID().toString() + val target = File(context.filesDir, "$CATALOG_BACKGROUND_IMAGE_FILE_PREFIX-$uniqueId") + val temp = File(context.cacheDir, "$CATALOG_BACKGROUND_IMAGE_FILE_PREFIX-$uniqueId.tmp") + return try { + val input = context.contentResolver.openInputStream(uri) ?: return uri.toString() + input.use { + temp.outputStream().use { output -> + it.copyTo(output) + } + } + if (!temp.renameTo(target)) { + temp.copyTo(target, overwrite = true) + } + Uri.fromFile(target).toString() + } catch (_: Exception) { + target.delete() + uri.toString() + } finally { + temp.delete() + } +} + +internal fun isManagedCatalogBackgroundImageFile(filesDir: File, candidate: File): Boolean { + val normalizedFilesDir = runCatching { filesDir.canonicalFile }.getOrElse { filesDir.absoluteFile } + val normalizedCandidate = runCatching { candidate.canonicalFile }.getOrElse { candidate.absoluteFile } + val managedName = normalizedCandidate.name == CATALOG_BACKGROUND_IMAGE_FILE_PREFIX || + normalizedCandidate.name.startsWith("$CATALOG_BACKGROUND_IMAGE_FILE_PREFIX-") + return normalizedCandidate.parentFile == normalizedFilesDir && managedName +} + +private fun pruneStoredCatalogBackgroundImages(context: android.content.Context, keepUri: String? = null) { + val keepFile = keepUri + ?.let { runCatching { Uri.parse(it) }.getOrNull() } + ?.takeIf { it.scheme.equals("file", ignoreCase = true) } + ?.path + ?.let(::File) + ?.let { file -> runCatching { file.canonicalFile }.getOrElse { file.absoluteFile } } + runCatching { + context.filesDir.listFiles() + .orEmpty() + .asSequence() + .filter { isManagedCatalogBackgroundImageFile(context.filesDir, it) } + .filterNot { candidate -> + val normalized = runCatching { candidate.canonicalFile }.getOrElse { candidate.absoluteFile } + normalized == keepFile + } + .forEach(File::delete) + context.cacheDir.listFiles() + .orEmpty() + .asSequence() + .filter { + it.name == "$CATALOG_BACKGROUND_IMAGE_FILE_PREFIX.tmp" || + (it.name.startsWith("$CATALOG_BACKGROUND_IMAGE_FILE_PREFIX-") && it.name.endsWith(".tmp")) + } + .forEach(File::delete) + } +} + +private fun releasePersistableImageReadPermission(context: android.content.Context, uriString: String) { + val uri = runCatching { Uri.parse(uriString) }.getOrNull() ?: return + runCatching { + context.contentResolver.releasePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } +} + +@Composable +private fun streamPresetLabel(preset: StreamPreset): String = + when (preset) { + StreamPreset.Recommended -> stringResource(R.string.stream_preset_recommended) + StreamPreset.Custom -> stringResource(R.string.stream_preset_custom) + StreamPreset.LowDataSaver -> stringResource(R.string.stream_preset_low_data_saver) + StreamPreset.Medium -> stringResource(R.string.stream_preset_medium) + StreamPreset.High -> stringResource(R.string.stream_preset_high) + } + +@Composable +private fun introMusicStartModeLabel(mode: IntroMusicStartMode): String = + when (mode) { + IntroMusicStartMode.Muted -> stringResource(R.string.intro_music_start_muted) + IntroMusicStartMode.Playing -> stringResource(R.string.intro_music_start_playing) + } + +@Composable +private fun appLaunchPageLabel(page: AppLaunchPage): String = + when (page) { + AppLaunchPage.Store -> stringResource(R.string.launch_page_store) + AppLaunchPage.Library -> stringResource(R.string.launch_page_library) + } + +@Composable +private fun SettingsAccountCard(state: OpenNowUiState, onClick: () -> Unit) { + val account = state.savedAccounts.firstOrNull { it.userId == state.authSession?.user?.userId } + ?: state.savedAccounts.firstOrNull() + val displayName = account?.displayName?.takeIf { it.isNotBlank() } + ?: state.authSession?.user?.displayName?.takeIf { it.isNotBlank() } + ?: "NVIDIA Account" + val email = account?.email?.takeIf { it.isNotBlank() } + ?: state.authSession?.user?.email?.takeIf { it.isNotBlank() } + val tier = state.subscriptionInfo?.membershipTier?.takeIf { it.isNotBlank() } + ?: state.authSession?.user?.membershipTier?.takeIf { it.isNotBlank() } + ?: account?.membershipTier?.takeIf { it.isNotBlank() } + val detail = listOfNotNull(email, tier).joinToString(" - ").ifBlank { + if (state.authSession == null && account == null) "Sign in to sync your GeForce NOW account" else "Manage account" + } + var focused by remember { mutableStateOf(false) } + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(28.dp)) + .onFocusChanged { focused = it.isFocused } + .border( + width = 2.dp, + color = if (focused) MaterialTheme.colorScheme.primary else Color.Transparent, + shape = RoundedCornerShape(28.dp) + ) + .clickable(onClick = onClick), + shape = RoundedCornerShape(28.dp), + color = if (focused) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.surface, + ) { + Row( + modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Surface( + modifier = Modifier.size(52.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Text( + displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "N", + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + displayName, + color = SettingsText, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + detail, + color = SettingsTextMuted, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = SettingsTextMuted, + modifier = Modifier.size(22.dp), + ) + } + } +} + +@Composable +private fun SettingsCategoryRow(category: SettingsCategory, onClick: () -> Unit) { + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(14.dp) + val accent = MaterialTheme.colorScheme.primary + Row( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .onFocusChanged { focused = it.isFocused || it.hasFocus } + .background(if (focused) accent.copy(alpha = 0.22f) else Color.Transparent) + .border( + width = if (focused) 3.dp else 1.dp, + color = if (focused) accent else Color.Transparent, + shape = shape, + ) + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(18.dp), + ) { + Surface( + modifier = Modifier.size(42.dp), + shape = RoundedCornerShape(14.dp), + color = if (focused) accent else accent.copy(alpha = 0.16f), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + Icon( + painter = painterResource(category.iconRes), + contentDescription = null, + tint = if (focused) MaterialTheme.colorScheme.onPrimary else accent, + modifier = Modifier.size(22.dp), + ) + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + category.title, + color = if (focused) Color.White else SettingsText, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (focused) FontWeight.ExtraBold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + category.summary, + color = if (focused) Color.White.copy(alpha = 0.86f) else SettingsTextMuted, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = if (focused) accent else SettingsTextMuted, + modifier = Modifier.size(22.dp), + ) + } +} + +@Composable +private fun SettingsDetailHeader( + category: SettingsCategory, + tvProfile: Boolean, + physicalControllerConnected: Boolean, + onBack: () -> Unit, +) { + val controllerNavigationEnabled = LocalSettingsControllerNavigationEnabled.current + val showHardwareBackHint = tvProfile || controllerNavigationEnabled + var backFocused by remember { mutableStateOf(false) } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (showHardwareBackHint) { + Surface( + modifier = Modifier + .onFocusChanged { backFocused = it.isFocused || it.hasFocus } + .border( + width = if (backFocused) 3.dp else 1.dp, + color = if (backFocused) Color.White else Color.Transparent, + shape = RoundedCornerShape(999.dp), + ) + .clickable(onClick = onBack), + shape = RoundedCornerShape(999.dp), + color = if (backFocused) MaterialTheme.colorScheme.primary.copy(alpha = 0.24f) else Color.Transparent, + ) { + Row( + modifier = Modifier.padding(horizontal = 5.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + Surface( + modifier = Modifier + .size(42.dp) + .border(2.dp, Color.White.copy(alpha = 0.9f), CircleShape), + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (tvProfile || !physicalControllerConnected) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = "Remote Back button", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(21.dp), + ) + } else { + Text( + "B", + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Black, + style = MaterialTheme.typography.titleMedium, + ) + } + } + } + Text( + "BACK", + color = SettingsText, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(end = 7.dp), + ) + } + } + } else { + Surface( + modifier = Modifier + .size(42.dp) + .border(1.dp, SettingsTextMuted.copy(alpha = 0.5f), CircleShape) + .clickable(onClick = onBack), + shape = CircleShape, + color = Color.Transparent, + ) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = "Back", + tint = SettingsText, + modifier = Modifier.size(21.dp), + ) + } + } + } + Column(Modifier.fillMaxWidth()) { + Text( + category.title, + color = SettingsText, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + category.summary, + color = SettingsTextMuted, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun CategorySettingsSection( + selectedCategory: SettingsCategory?, + category: SettingsCategory, + searchQuery: String, + title: String, + vararg keywords: String, + content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit, +) { + if (searchQuery.isNotBlank() || selectedCategory == null || selectedCategory == category) { + SearchableSettingsSection(searchQuery, title, *keywords, content = content) + } +} + +private fun settingsCategories(): List = + buildList { + add(SettingsCategory.General) + add(SettingsCategory.Stream) + add(SettingsCategory.Input) + add(SettingsCategory.Interface) + add(SettingsCategory.Advanced) + } + +private fun settingsDetailCategories(): List = + settingsCategories() + SettingsCategory.Account diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowViewModel.kt b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowViewModel.kt new file mode 100644 index 000000000..ae807df93 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/OpenNowViewModel.kt @@ -0,0 +1,3408 @@ +package com.opencloudgaming.opennow + +import android.app.Application +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.SystemClock +import android.util.Log +import android.widget.Toast +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import androidx.compose.runtime.Immutable +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.OkHttpClient +import java.text.SimpleDateFormat +import java.text.DateFormat +import java.util.Date +import java.util.Locale + +enum class AppPage { + Home, + Library, + Settings, + Stream, +} + +enum class SettingsRouteTarget { + General, + Stream, +} + +private const val ANDROID_UPDATE_LAUNCH_CHECK_DELAY_MS = 5_000L +internal const val ANDROID_UPDATE_PERIODIC_CHECK_INTERVAL_MS = 6L * 60L * 60L * 1000L +private const val ANDROID_UPDATE_STREAMING_RETRY_DELAY_MS = 30_000L +private const val DEBUG_EVENT_LIMIT = 140 +private const val DEBUG_EVENT_MESSAGE_LIMIT = 640 +private const val DEBUG_PAYLOAD_LIMIT = 12 +private const val DEBUG_PAYLOAD_BODY_LIMIT = 8_000 +private const val LOGIN_PHASE_GETTING_TOKENS = "Getting sign-in tokens" +private const val STREAM_RUNTIME_STATS_EVENT_INTERVAL_MS = 30_000L +private const val SESSION_REPORT_NETWORK_SAMPLE_INTERVAL_MS = 5_000L + +private data class DebugLogEvent( + val timestampMs: Long, + val category: String, + val message: String, +) + +private data class DebugPayloadEvent( + val timestampMs: Long, + val operation: String, + val method: String, + val url: String, + val statusCode: Int, + val requestBody: String, + val body: String, +) + +private data class TimedStreamRuntimeStats( + val capturedAtMs: Long, + val sessionId: String?, + val stats: StreamRuntimeStats, +) + +data class ActiveSessionDecision( + val activeSession: ActiveSessionInfo, + val requestedGameTitle: String, +) + +@Immutable +data class DiagnosticShareState( + val awaitingConsent: Boolean = false, + val uploading: Boolean = false, + val pasteUrl: String? = null, + val clipboardSummary: String? = null, + val error: String? = null, +) + +@Immutable +data class BugReportSubmissionState( + val uploading: Boolean = false, + val submitted: Boolean = false, + val reference: String? = null, + val error: String? = null, +) + +private data class PendingActiveSessionLaunch( + val game: GameInfo, + val launchAppId: String, + val baseUrl: String, + val settings: StreamSettings, + val accountLinked: Boolean, + val activeSession: ActiveSessionInfo, + val returnPage: AppPage, +) + +@Immutable +data class OpenNowUiState( + val initializing: Boolean = false, + val page: AppPage = AppPage.Home, + val authSession: AuthSession? = null, + val providers: List = listOf(defaultProvider()), + val selectedProvider: LoginProvider = defaultProvider(), + val savedAccounts: List = emptyList(), + val subscriptionInfo: SubscriptionInfo? = null, + val accountConnectors: List = emptyList(), + val loadingAccountConnectors: Boolean = false, + val connectorActionStore: String? = null, + val regions: List = emptyList(), + val games: List = emptyList(), + val libraryGames: List = emptyList(), + val queuedGameKeys: List = emptyList(), + val catalogResult: CatalogBrowseResult = CatalogBrowseResult(emptyList()), + val catalogSearch: String = "", + val librarySearch: String = "", + val catalogSortId: String = "relevance", + val catalogFilterIds: List = emptyList(), + val libraryFilterIds: List = emptyList(), + val loadingGames: Boolean = false, + val settingsRefreshing: Boolean = false, + val settingsRouteTarget: SettingsRouteTarget? = null, + val settings: AppSettings = AppSettings(), + val androidTvProfile: Boolean = false, + val codecReport: RuntimeCodecReport? = null, + val selectedGame: GameInfo? = null, + val activeSession: ActiveSessionInfo? = null, + val activeSessionDecision: ActiveSessionDecision? = null, + val streamSession: SessionInfo? = null, + val activeStreamSettings: StreamSettings? = null, + val streamGame: GameInfo? = null, + val streamLaunchMinimized: Boolean = false, + val streamReturnPage: AppPage? = null, + val launchPhase: String = "", + val queuePosition: Int? = null, + val queueAdActiveId: String? = null, + val streamStatus: String = "idle", + val error: String? = null, + val deviceLoginPrompt: DeviceLoginPrompt? = null, + val pendingStoreChoiceGame: GameInfo? = null, + val pendingPrintedWasteGame: GameInfo? = null, + val printedWasteQueue: Map = emptyMap(), + val printedWasteMapping: Map = emptyMap(), + val printedWastePings: Map = emptyMap(), + val printedWasteLoading: Boolean = false, + val printedWasteError: String? = null, + val androidUpdate: AndroidUpdateState = AndroidUpdateState(), + val dismissedAndroidUpdateNoticeKey: String? = null, + val androidPictureInPictureActive: Boolean = false, + val diagnosticShare: DiagnosticShareState = DiagnosticShareState(), + val bugReportSubmission: BugReportSubmissionState = BugReportSubmissionState(), + val loginToolsVisible: Boolean = false, + val localTvConnector: LocalTvConnectorState = LocalTvConnectorState(), + val remoteStreamMenuRequestToken: Int = 0, + val remoteStatsToggleRequestToken: Int = 0, + val sessionReport: SessionReport? = null, +) + +internal fun OpenNowUiState.isAndroidUpdateCheckBlockedByStream(): Boolean = + streamStatus != "idle" || streamSession != null || activeStreamSettings != null + +internal fun OpenNowUiState.isNativeStreamReady(): Boolean = + streamStatus in setOf("connecting", "streaming") && + streamSession?.isReadyForStream() == true + +class OpenNowViewModel(application: Application) : AndroidViewModel(application) { + private val openNowApplication = application as OpenNowApplication + private val http: OkHttpClient = openNowApplication.httpClient + private val settingsStore = SettingsStore(application) + private val sessionTimerAnchorStore = SessionTimerAnchorStore(application) + private val authStore = openNowApplication.authStore + private val authRepository = openNowApplication.authRepository + private val catalogRepository = GfnCatalogRepository(http) + private val catalogCacheStore = CatalogCacheStore(application) + private val queuedGameStore = QueuedGameStore(application) + private val subscriptionRepository = GfnSubscriptionRepository(http) + private val accountConnectorRepository = GfnAccountConnectorRepository(http) + private val printedWasteRepository = PrintedWasteRepository(http) + private val sessionRepository = GfnSessionRepository( + authStore = authStore, + http = http, + physicalDisplayResolutionProvider = { application.physicalStreamDisplayResolution() }, + diagnosticsSink = { response -> recordSessionDiagnosticResponse(response) }, + ) + private val appUpdater = AndroidAppUpdater(application, http) + private val androidUpdateNoticeStore = AndroidUpdateNoticeStore(application) + private val localTvConnector = openNowApplication.localTvConnector + private val queueAdReportMutex = Mutex() + private val accountConnectorRefreshMutex = Mutex() + private val runtimeResolutionNoticeKeys = mutableSetOf() + private val debugEventsLock = Any() + private val debugEvents = ArrayDeque() + private val debugPayloadsLock = Any() + private val debugPayloads = ArrayDeque() + private val authRestoreMutex = Mutex() + @Volatile + private var latestStreamRuntimeStats: TimedStreamRuntimeStats? = null + private var lastRuntimeStatsEventAtMs: Long = 0L + private var streamReportLaunchProfile: StreamReportLaunchProfile? = null + private var streamSessionReportAccumulator: StreamSessionReportAccumulator? = null + private var lastSessionReportNetworkSampleAtMs: Long = 0L + private var sessionReportFinalizedForStop: Boolean = false + private var deviceRecommendation: AndroidDeviceRecommendation? = null + private val settingsDiagnosticTapTracker = RapidTapTracker() + private val loginIconTapTracker = RapidTapTracker() + + private val initialAuthSession = authStore.activeSession() + private val androidTvProfile = isAndroidTvProfile(application) + private val initialSettings = settingsStore.settings.value.let { current -> + if (androidTvProfile && current.tvLayoutProfileVersion < TV_LAYOUT_PROFILE_VERSION) { + settingsStore.update { saved -> + saved.copy( + // 36dp on every edge consumed 144 physical pixels per axis at the + // common TV density. Migrate only the legacy default; preserve custom values. + tvSafeAreaPaddingDp = if (saved.tvSafeAreaPaddingDp == 36f) 16f else saved.tvSafeAreaPaddingDp, + tvLayoutProfileVersion = TV_LAYOUT_PROFILE_VERSION, + ) + } + settingsStore.settings.value + } else { + current + } + } + private val _state = MutableStateFlow( + OpenNowUiState( + page = defaultLaunchAppPage(initialSettings), + authSession = initialAuthSession, + providers = initialProviders(initialAuthSession), + selectedProvider = authStore.state.value.selectedProvider ?: initialAuthSession?.provider ?: defaultProvider(), + savedAccounts = authStore.state.value.sessions.map { session -> session.toSavedAccount() }, + loadingGames = initialAuthSession != null, + settings = initialSettings, + androidTvProfile = androidTvProfile, + androidUpdate = appUpdater.state.value, + dismissedAndroidUpdateNoticeKey = androidUpdateNoticeStore.dismissedKey(), + queuedGameKeys = queuedGameStore.load(), + ), + ) + val state: StateFlow = _state.asStateFlow() + + private var gamesJob: Job? = null + private var launchJob: Job? = null + private var activeSubscriptionJob: Job? = null + private var pendingActiveSessionLaunch: PendingActiveSessionLaunch? = null + private var loginJob: Job? = null + private var androidUpdateJob: Job? = null + private var androidUpdateAutoJob: Job? = null + private var settingsRefreshJob: Job? = null + private var authRefreshJob: Job? = null + + init { + viewModelScope.launch { + settingsStore.settings.collect { next -> + OpenNowAnalytics.applyOptOut(!next.analyticsSharingEnabled) + _state.update { it.copy(settings = next) } + } + } + if (androidTvProfile) { + viewModelScope.launch { + settingsStore.settings + .map { it.localTvRemoteEnabled } + .distinctUntilChanged() + .collect { enabled -> + if (enabled) localTvConnector.startHosting() else localTvConnector.stopHosting() + } + } + } + viewModelScope.launch { + localTvConnector.state.collect { next -> + _state.update { it.copy(localTvConnector = next) } + } + } + viewModelScope.launch { + localTvConnector.launchRequests.collect { request -> + if (!state.value.androidTvProfile) return@collect + val allGames = state.value.games + state.value.libraryGames + val game = allGames.firstOrNull { game -> + game.id == request.gameId || + game.uuid == request.gameId || + game.launchAppId == request.gameId || + game.variants.any { it.id == request.gameId } + } ?: GameInfo( + id = request.gameId, + uuid = request.gameId, + launchAppId = request.gameId.takeIf { it.all(Char::isDigit) }, + title = request.title ?: "Game ${request.gameId}", + variants = listOf(GameVariant(id = request.gameId, store = "Unknown")), + ) + recordDebugEvent("tv-connector", "Accepted encrypted local launch game=${game.title}") + play(game) + } + } + viewModelScope.launch { + localTvConnector.signInRequests.collect { transferredSession -> + if (!state.value.androidTvProfile) return@collect + acceptLocalTvSignIn(transferredSession) + } + } + viewModelScope.launch { + localTvConnector.remoteRequests.collect { request -> + if (!state.value.androidTvProfile) return@collect + handleLocalTvRemoteRequest(request) + } + } + viewModelScope.launch { + appUpdater.state.collect { next -> + _state.update { it.copy(androidUpdate = next) } + } + } + viewModelScope.launch { + state + .map { it.isAndroidUpdateCheckBlockedByStream() to it.androidUpdate.status } + .distinctUntilChanged() + .collect { (blocked, updateStatus) -> + if (blocked && updateStatus == AndroidUpdateStatus.Checking) { + cancelAndroidUpdateCheckForStreaming() + } + } + } + if (appUpdater.state.value.apkUpdatesAllowed) { + startAndroidUpdateAutoChecks() + } + initialize() + } + + private fun recordDebugEvent(category: String, message: String) { + val oneLineMessage = message + .lineSequence() + .joinToString(" ") { it.trim() } + .take(DEBUG_EVENT_MESSAGE_LIMIT) + val event = DebugLogEvent( + timestampMs = System.currentTimeMillis(), + category = category, + message = oneLineMessage, + ) + synchronized(debugEventsLock) { + debugEvents.addLast(event) + while (debugEvents.size > DEBUG_EVENT_LIMIT) { + debugEvents.removeFirst() + } + } + Log.d(OPENNOW_DEBUG_LOG_TAG, "${event.category}: ${event.message}") + } + + private fun debugEventSnapshot(): List = + synchronized(debugEventsLock) { debugEvents.toList() } + + private fun recordSessionDiagnosticResponse(response: GfnSessionDiagnosticResponse) { + val sanitizedBody = sanitizeDiagnosticLogPayload(response.responseBody, DEBUG_PAYLOAD_BODY_LIMIT) + val event = DebugPayloadEvent( + timestampMs = System.currentTimeMillis(), + operation = response.operation, + method = response.method, + url = response.url, + statusCode = response.statusCode, + requestBody = response.requestBody + .takeIf { it.isNotBlank() } + ?.let { sanitizeDiagnosticLogPayload(it, DEBUG_PAYLOAD_BODY_LIMIT) } + .orEmpty(), + body = sanitizedBody, + ) + synchronized(debugPayloadsLock) { + debugPayloads.addLast(event) + while (debugPayloads.size > DEBUG_PAYLOAD_LIMIT) { + debugPayloads.removeFirst() + } + } + Log.d( + OPENNOW_DEBUG_LOG_TAG, + "gfn-json: ${response.operation} ${response.method} http=${response.statusCode} requestBytes=${response.requestBody.length} responseBytes=${response.responseBody.length} captured=${sanitizedBody.length} host=${hostForDebug(response.url)}", + ) + } + + private fun debugPayloadSnapshot(): List = + synchronized(debugPayloadsLock) { debugPayloads.toList() } + + private fun defaultLaunchAppPage(settings: AppSettings = settingsStore.settings.value): AppPage = + when (settings.launchPage) { + AppLaunchPage.Store -> AppPage.Home + AppLaunchPage.Library -> AppPage.Library + } + + fun initialize() { + viewModelScope.launch { + val codecReport = withContext(Dispatchers.Default) { + CodecProbe.report(getApplication()) + } + val recommendation = recommendedAndroidStreamProfile(getApplication(), codecReport) + deviceRecommendation = recommendation + val currentSettings = settingsStore.settings.value + val recommendedStream = recommendation.stream.withMicrophoneSettingsFrom(currentSettings.stream) + if ( + currentSettings.streamPreset == StreamPreset.Recommended && + currentSettings.stream != recommendedStream + ) { + settingsStore.update { settings -> + if (settings.streamPreset == StreamPreset.Recommended) { + settings.copy(stream = recommendedStream) + } else { + settings + } + } + } + _state.update { + it.copy( + codecReport = codecReport, + settings = settingsStore.settings.value, + initializing = false, + ) + } + val restoreResult = restoreAuthSession() + val providers = runCatching { authRepository.loginProviders() }.getOrDefault(listOf(defaultProvider())) + val restored = restoreResult.getOrNull() + val activeSession = restored ?: authStore.activeSession() + val selected = activeSession?.provider ?: authStore.state.value.selectedProvider ?: providers.firstOrNull() ?: defaultProvider() + val restoreError = restoreResult.exceptionOrNull()?.message?.takeIf { activeSession == null } + _state.update { + it.copy( + providers = providers, + selectedProvider = selected, + authSession = activeSession, + savedAccounts = authStore.state.value.sessions.map { session -> session.toSavedAccount() }, + initializing = false, + launchPhase = "", + loadingGames = if (activeSession == null) false else it.loadingGames, + games = if (activeSession == null) emptyList() else it.games, + libraryGames = if (activeSession == null) emptyList() else it.libraryGames, + catalogResult = if (activeSession == null) CatalogBrowseResult(emptyList()) else it.catalogResult, + error = restoreError ?: it.error, + ) + } + if (activeSession != null) { + refreshAfterAuth(activeSession) + } + } + } + + private fun initialProviders(activeSession: AuthSession?): List = + listOfNotNull(authStore.state.value.selectedProvider, activeSession?.provider, defaultProvider()) + .distinctBy { provider -> provider.code.uppercase(Locale.US) } + + private suspend fun restoreAuthSession(throwOnRefreshFailure: Boolean = false): Result = + authRestoreMutex.withLock { + runCatching { + authRepository.restore( + throwOnRefreshFailure = throwOnRefreshFailure, + removeExpiredSessionOnFailure = !throwOnRefreshFailure, + ) + } + } + + fun refreshAuthSessionIfNeeded() { + if (authRefreshJob?.isActive == true) return + val expectedUserId = state.value.authSession?.user?.userId ?: return + authRefreshJob = viewModelScope.launch { + try { + val result = restoreAuthSession(throwOnRefreshFailure = true) + val refreshed = result.getOrNull() + if (refreshed != null) { + val tokenChanged = refreshed.tokens != state.value.authSession?.tokens + _state.update { current -> + if (current.authSession?.user?.userId != expectedUserId) { + current + } else { + current.copy( + authSession = refreshed, + selectedProvider = refreshed.provider, + savedAccounts = authStore.state.value.sessions.map { session -> session.toSavedAccount() }, + ) + } + } + if (tokenChanged) { + recordDebugEvent("auth", "Refreshed saved sign-in tokens in the background") + } + } + result.exceptionOrNull()?.let { error -> + recordDebugEvent("auth", "Background sign-in refresh failed error=${error.debugMessage()}") + } + } finally { + authRefreshJob = null + } + } + } + + fun setPage(page: AppPage) { + _state.update { it.copy(page = page, selectedGame = null) } + } + + fun recordSettingsIconTap() { + if (settingsDiagnosticTapTracker.recordTap(SystemClock.elapsedRealtime())) requestDiagnosticShare() + } + + fun recordLoginIconTap() { + if (!loginIconTapTracker.recordTap(SystemClock.elapsedRealtime())) return + _state.update { it.copy(loginToolsVisible = true) } + } + + fun dismissLoginTools() { + _state.update { it.copy(loginToolsVisible = false) } + } + + fun dismissDiagnosticShare() { + _state.update { it.copy(diagnosticShare = DiagnosticShareState()) } + } + + fun dismissSessionReport() { + _state.update { it.copy(sessionReport = null) } + } + + fun requestDiagnosticShare() { + _state.update { + it.copy(diagnosticShare = DiagnosticShareState(awaitingConsent = true)) + } + } + + fun resetBugReportSubmission() { + if (state.value.bugReportSubmission.uploading) return + _state.update { it.copy(bugReportSubmission = BugReportSubmissionState()) } + } + + fun submitBugReport(title: String, description: String) { + if (state.value.bugReportSubmission.uploading) return + _state.update { + it.copy(bugReportSubmission = BugReportSubmissionState(uploading = true)) + } + viewModelScope.launch { + try { + val logFileName = debugLogFileName() + val metadata = buildAndroidBugReportMetadata(logFileName) + val logBytes = withContext(Dispatchers.Default) { + sanitizedDebugLogText().toByteArray(Charsets.UTF_8) + } + val receipt = uploadAndroidBugReport( + http = http, + report = AndroidBugReport( + title = title, + description = description, + versionName = BuildConfig.VERSION_NAME, + versionCode = BuildConfig.VERSION_CODE.toString(), + metadata = metadata, + files = listOf( + AndroidBugReportAttachment( + fileName = logFileName, + contentType = "text/plain; charset=utf-8", + bytes = logBytes, + ), + ), + ), + ) + recordDebugEvent("bug-report", "PrintedWaste bug report submitted") + _state.update { + it.copy( + bugReportSubmission = BugReportSubmissionState( + submitted = true, + reference = receipt.reference, + ), + ) + } + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + recordDebugEvent( + "bug-report", + "PrintedWaste bug report failed error=${error.debugMessage()}", + ) + _state.update { + it.copy( + bugReportSubmission = BugReportSubmissionState( + error = error.message ?: "Could not send the bug report", + ), + ) + } + } + } + } + + fun startLocalTvConnector() { + if (!state.value.androidTvProfile) return + settingsStore.update { it.copy(localTvRemoteEnabled = true) } + localTvConnector.startHosting() + } + + fun stopLocalTvConnector() { + if (state.value.androidTvProfile) { + settingsStore.update { it.copy(localTvRemoteEnabled = false) } + } + localTvConnector.stopHosting() + } + + fun refreshLocalTvPairingCode() { + if (!state.value.androidTvProfile || !state.value.settings.localTvRemoteEnabled) return + localTvConnector.refreshPairingCode() + } + + fun setLocalTvDeviceTrusted(trusted: Boolean) { + if (!state.value.androidTvProfile) return + localTvConnector.setPairedDeviceTrusted(trusted) + } + + fun setLocalTvTrustRequested(requested: Boolean) { + if (state.value.androidTvProfile) return + localTvConnector.setPhoneTrustRequest(requested) + } + + fun forgetLocalTvConnector() { + localTvConnector.forgetPhoneTarget() + } + + fun playOnLocalTv(game: GameInfo) { + if (state.value.androidTvProfile) return + localTvConnector.sendLaunch(gameTrackingKey(game), game.title) + } + + fun signInLocalTv() { + if (state.value.androidTvProfile) return + val session = state.value.authSession ?: run { + _state.update { it.copy(error = "Sign in on the phone first") } + return + } + localTvConnector.sendSignIn(session) + } + + fun switchLocalTvAccount(userId: String) { + if (state.value.androidTvProfile) return + val session = authStore.state.value.sessions.firstOrNull { it.user.userId == userId } ?: run { + _state.update { it.copy(error = "That account is no longer available on this phone") } + return + } + localTvConnector.sendSignIn(session) + } + + fun sendLocalTvRemoteAction(action: String, value: String? = null) { + if (state.value.androidTvProfile) return + localTvConnector.sendRemoteAction(action, value) + } + + private fun handleLocalTvRemoteRequest(request: LocalTvRemoteRequest) { + recordDebugEvent("tv-remote", "Accepted encrypted action=${request.action}") + when (request.action) { + "open_stream_menu" -> _state.update { + it.copy(remoteStreamMenuRequestToken = it.remoteStreamMenuRequestToken + 1) + } + "toggle_stream_stats" -> _state.update { + it.copy(remoteStatsToggleRequestToken = it.remoteStatsToggleRequestToken + 1) + } + "stop_stream" -> stopStream() + "apply_recommended" -> applyStreamPreset(StreamPreset.Recommended) + "set_codec" -> request.value + ?.let { value -> runCatching { VideoCodec.valueOf(value) }.getOrNull() } + ?.let { codec -> updateStreamSettings { it.copy(codec = codec) } } + "set_resolution" -> request.value + ?.takeIf { streamAspectRatioForResolution(it) != null } + ?.let { resolution -> + updateStreamSettings { + it.copy( + resolution = resolution, + aspectRatio = streamAspectRatioForResolution(resolution) ?: it.aspectRatio, + ) + } + } + "set_fps" -> request.value?.toIntOrNull() + ?.takeIf { it in setOf(30, 60, 120) } + ?.let { fps -> updateStreamSettings { it.copy(fps = fps) } } + "set_background" -> request.value?.toBooleanStrictOrNull()?.let { enabled -> + settingsStore.update { it.copy(nerdCatalogBackground = enabled) } + } + "set_ui_sounds" -> request.value?.toBooleanStrictOrNull()?.let { enabled -> + settingsStore.update { it.copy(controllerUiSounds = enabled) } + } + "set_safe_area" -> request.value?.toFloatOrNull()?.coerceIn(0f, 120f)?.let { padding -> + settingsStore.update { it.copy(tvSafeAreaPaddingDp = padding) } + } + "set_hide_server_selector" -> request.value?.toBooleanStrictOrNull()?.let { hidden -> + settingsStore.update { it.copy(hideServerSelector = hidden) } + } + } + } + + private fun acceptLocalTvSignIn(transferredSession: AuthSession) { + viewModelScope.launch { + authStore.upsertSession(transferredSession) + val restored = restoreAuthSession(throwOnRefreshFailure = true).getOrElse { error -> + authStore.removeSession(transferredSession.user.userId) + _state.update { it.copy(error = "Phone sign-in could not be verified: ${error.message.orEmpty()}") } + return@launch + } ?: run { + authStore.removeSession(transferredSession.user.userId) + _state.update { it.copy(error = "Phone sign-in could not be verified") } + return@launch + } + _state.update { + it.copy( + authSession = restored, + selectedProvider = restored.provider, + savedAccounts = authStore.state.value.sessions.map { saved -> saved.toSavedAccount() }, + error = null, + loadingGames = true, + ) + } + Toast.makeText(getApplication(), "Signed in securely from phone", Toast.LENGTH_SHORT).show() + recordDebugEvent("tv-connector", "Accepted encrypted local sign-in provider=${restored.provider.code}") + refreshAfterAuth(restored) + } + } + + fun uploadDiagnosticShare() { + if (state.value.diagnosticShare.uploading) return + _state.update { + it.copy(diagnosticShare = DiagnosticShareState(uploading = true)) + } + viewModelScope.launch { + val snapshot = state.value + val summaryHeader = diagnosticSummaryHeader(snapshot) + val sanitizedLog = sanitizedDebugLogText() + val payload = sanitizeDiagnosticExport( + buildString { + appendLine(summaryHeader) + appendLine() + append(sanitizedLog) + }, + ) + runCatching { uploadAndroidDiagnosticPaste(http, payload) } + .onSuccess { pasteUrl -> + _state.update { + it.copy( + diagnosticShare = DiagnosticShareState( + pasteUrl = pasteUrl, + clipboardSummary = "$summaryHeader\nPaste: $pasteUrl", + ), + ) + } + } + .onFailure { error -> + if (error is CancellationException) return@onFailure + _state.update { + it.copy( + diagnosticShare = DiagnosticShareState( + awaitingConsent = true, + error = error.message ?: "Could not upload diagnostics", + ), + ) + } + } + } + } + + private fun diagnosticSummaryHeader(snapshot: OpenNowUiState): String { + val recommendation = deviceRecommendation + val model = listOf(Build.MANUFACTURER, Build.MODEL) + .map(String::trim) + .filter(String::isNotBlank) + .distinct() + .joinToString(" ") + val accountType = snapshot.subscriptionInfo?.membershipTier + ?: snapshot.authSession?.user?.membershipTier + ?: "Unknown" + val provider = snapshot.authSession?.provider?.displayName?.takeIf { it.isNotBlank() } ?: "Unknown" + return buildString { + appendLine("OpenNOW Android ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + appendLine("Client: ${if (snapshot.androidTvProfile) "Android TV" else "Android mobile"}") + appendLine("Hardware: $model · Android ${Build.VERSION.RELEASE}") + appendLine("Screen: ${recommendation?.displayWidth ?: "?"}x${recommendation?.displayHeight ?: "?"} · processors ${recommendation?.processorCount ?: "?"} · memory ${recommendation?.totalMemoryMiB?.let { "$it MiB" } ?: "unknown"}") + appendLine("Membership: $provider · $accountType") + appendLine("Profile: ${snapshot.settings.streamPreset} · ${snapshot.settings.stream.resolution}@${snapshot.settings.stream.fps} · ${snapshot.settings.stream.codec} · ${snapshot.settings.stream.maxBitrateMbps} Mbps") + append("Status: ${snapshot.streamStatus} · ${snapshot.error?.take(160)?.let(::sanitizeDiagnosticExport) ?: "no current error"}") + } + } + + fun openAndroidUpdateSettings() { + _state.update { + it.copy( + page = AppPage.Settings, + selectedGame = null, + settingsRouteTarget = SettingsRouteTarget.General, + ) + } + } + + fun openStreamSettings() { + _state.update { + it.copy( + page = AppPage.Settings, + selectedGame = null, + settingsRouteTarget = SettingsRouteTarget.Stream, + ) + } + } + + fun consumeSettingsRouteTarget(target: SettingsRouteTarget) { + _state.update { current -> + if (current.settingsRouteTarget == target) { + current.copy(settingsRouteTarget = null) + } else { + current + } + } + } + + fun handleControllerBackNavigation() { + _state.update { current -> + when { + current.pendingStoreChoiceGame != null -> current.copy(pendingStoreChoiceGame = null) + current.pendingPrintedWasteGame != null -> current.copy( + pendingPrintedWasteGame = null, + printedWasteLoading = false, + printedWasteError = null, + ) + current.selectedGame != null -> current.copy(selectedGame = null) + current.page != AppPage.Home -> current.copy(page = AppPage.Home, selectedGame = null) + else -> current + } + } + } + + fun selectProvider(provider: LoginProvider) { + if (!provider.supportsDeviceCodeLogin && state.value.deviceLoginPrompt != null) { + loginJob?.cancel() + loginJob = null + } + _state.update { + it.copy( + selectedProvider = provider, + deviceLoginPrompt = if (provider.supportsDeviceCodeLogin) it.deviceLoginPrompt else null, + launchPhase = if (provider.supportsDeviceCodeLogin) it.launchPhase else "", + ) + } + } + + fun login(provider: LoginProvider = state.value.selectedProvider) { + loginJob?.cancel() + loginJob = viewModelScope.launch { + val useDeviceCode = state.value.androidTvProfile && provider.supportsDeviceCodeLogin + _state.update { + it.copy( + error = null, + launchPhase = if (useDeviceCode) "Requesting TV sign-in code" else "Opening ${provider.displayName} login", + deviceLoginPrompt = null, + ) + } + runCatching { + loginWithBestAvailableMethod(provider, useDeviceCode) + } + .onSuccess { session -> + completeLogin(session) + } + .onFailure { error -> + if (error is CancellationException) return@onFailure + _state.update { it.copy(error = error.message ?: "Login failed", launchPhase = "", deviceLoginPrompt = null) } + } + } + } + + fun loginWithCode(provider: LoginProvider = state.value.selectedProvider) { + if (!provider.supportsDeviceCodeLogin) { + login(provider) + return + } + loginJob?.cancel() + loginJob = viewModelScope.launch { + _state.update { + it.copy( + error = null, + launchPhase = "Requesting sign-in code", + deviceLoginPrompt = null, + ) + } + runCatching { + loginWithDeviceCode(provider) + } + .onSuccess { session -> + completeLogin(session, loginMethod = "device_code") + } + .onFailure { error -> + if (error is CancellationException) return@onFailure + _state.update { it.copy(error = error.message ?: "Code sign-in failed", launchPhase = "", deviceLoginPrompt = null) } + } + } + } + + fun loginWithToken(tokenInput: String, provider: LoginProvider = state.value.selectedProvider) { + loginJob?.cancel() + loginJob = viewModelScope.launch { + _state.update { + it.copy( + error = null, + launchPhase = "Checking sign-in token", + deviceLoginPrompt = null, + loginToolsVisible = false, + ) + } + runCatching { authRepository.loginWithToken(provider, tokenInput) } + .onSuccess { session -> completeLogin(session, loginMethod = "token") } + .onFailure { error -> + if (error is CancellationException) return@onFailure + _state.update { + it.copy( + error = error.message ?: "Token sign-in failed", + launchPhase = "", + deviceLoginPrompt = null, + ) + } + } + } + } + + private suspend fun completeLogin(session: AuthSession, loginMethod: String? = null) { + _state.update { + it.copy( + authSession = session, + selectedProvider = session.provider, + savedAccounts = authStore.state.value.sessions.map { saved -> saved.toSavedAccount() }, + launchPhase = "", + deviceLoginPrompt = null, + error = null, + page = defaultLaunchAppPage(), + loginToolsVisible = false, + ) + } + OpenNowAnalytics.capture( + event = "user_logged_in", + properties = buildMap { + put("provider", session.provider.code) + put("membership_tier", session.user.membershipTier) + loginMethod?.let { put("login_method", it) } + }, + ) + refreshAfterAuth(session) + } + + private suspend fun loginWithBestAvailableMethod(provider: LoginProvider, useDeviceCode: Boolean): AuthSession { + if (useDeviceCode) { + return loginWithDeviceCode(provider) + } + + return try { + authRepository.login(provider) { + _state.update { it.copy(launchPhase = LOGIN_PHASE_GETTING_TOKENS, error = null) } + } + } catch (error: Throwable) { + if (error is CancellationException || !isLoopbackLoginFailure(error)) { + throw error + } + if (!provider.supportsDeviceCodeLogin) { + throw error + } + _state.update { + it.copy( + launchPhase = "Requesting sign-in code", + error = "Browser sign-in could not reach the local callback. Use this code to finish sign-in.", + ) + } + loginWithDeviceCode(provider, clearErrorOnPrompt = false) + } + } + + private suspend fun loginWithDeviceCode(provider: LoginProvider, clearErrorOnPrompt: Boolean = true): AuthSession = + authRepository.loginWithDeviceCode(provider) { prompt -> + _state.update { + it.copy( + deviceLoginPrompt = prompt, + launchPhase = "Waiting for sign-in", + error = if (clearErrorOnPrompt) null else it.error, + ) + } + } + + private fun isLoopbackLoginFailure(error: Throwable): Boolean { + val message = generateSequence(error) { it.cause } + .mapNotNull { it.message } + .joinToString(" ") + .lowercase() + return "oauth callback" in message || + "callback ports" in message || + "local callback" in message || + "localhost" in message || + "127.0.0.1" in message + } + + fun cancelLogin() { + loginJob?.cancel() + loginJob = null + _state.update { it.copy(launchPhase = "", deviceLoginPrompt = null) } + } + + fun logout() { + viewModelScope.launch { + pendingActiveSessionLaunch = null + OpenNowAnalytics.capture(event = "user_logged_out") + OpenNowAnalytics.reset() + authRepository.logout() + val nextSession = authStore.activeSession() + _state.update { + it.copy( + authSession = nextSession, + selectedProvider = nextSession?.provider ?: it.selectedProvider, + savedAccounts = authStore.state.value.sessions.map { saved -> saved.toSavedAccount() }, + subscriptionInfo = null, + accountConnectors = emptyList(), + loadingAccountConnectors = false, + connectorActionStore = null, + games = emptyList(), + libraryGames = emptyList(), + libraryFilterIds = emptyList(), + streamSession = null, + activeStreamSettings = null, + activeSession = null, + activeSessionDecision = null, + deviceLoginPrompt = null, + pendingStoreChoiceGame = null, + page = AppPage.Home, + ) + } + if (nextSession != null) { + refreshAfterAuth(nextSession) + } + } + } + + fun switchAccount(userId: String) { + viewModelScope.launch { + pendingActiveSessionLaunch = null + authStore.setActiveSession(userId) + val session = authRepository.restore(forceRefresh = false) ?: return@launch + gamesJob?.cancel() + _state.update { + it.copy( + authSession = session, + selectedProvider = session.provider, + savedAccounts = authStore.state.value.sessions.map { saved -> saved.toSavedAccount() }, + subscriptionInfo = null, + accountConnectors = emptyList(), + loadingAccountConnectors = false, + connectorActionStore = null, + games = emptyList(), + libraryGames = emptyList(), + catalogResult = CatalogBrowseResult(emptyList()), + libraryFilterIds = emptyList(), + selectedGame = null, + activeSession = null, + activeSessionDecision = null, + error = null, + page = AppPage.Home, + ) + } + OpenNowAnalytics.capture( + event = "account_switched", + properties = mapOf( + "provider" to session.provider.code, + "membership_tier" to session.user.membershipTier, + ), + ) + refreshAfterAuth(session) + } + } + + fun logoutAll() { + pendingActiveSessionLaunch = null + authRepository.logoutAll() + _state.update { + it.copy( + authSession = null, + savedAccounts = emptyList(), + subscriptionInfo = null, + accountConnectors = emptyList(), + loadingAccountConnectors = false, + connectorActionStore = null, + games = emptyList(), + libraryGames = emptyList(), + libraryFilterIds = emptyList(), + streamSession = null, + activeStreamSettings = null, + activeSession = null, + activeSessionDecision = null, + deviceLoginPrompt = null, + pendingStoreChoiceGame = null, + page = AppPage.Home, + ) + } + } + + fun refreshGames() { + val session = state.value.authSession ?: return + viewModelScope.launch { + refreshAfterAuth(session, keepRefreshVisibleWithCache = true) + } + } + + fun setCatalogSearch(query: String) { + _state.update { it.copy(catalogSearch = query) } + if (query.isNotBlank()) { + OpenNowAnalytics.capture( + event = "catalog_searched", + properties = mapOf("query" to query), + ) + } + refreshCatalogDebounced() + } + + fun setLibrarySearch(query: String) { + _state.update { it.copy(librarySearch = query) } + } + + fun toggleLibraryFilter(filterId: String) { + _state.update { + val filters = if (filterId in it.libraryFilterIds) it.libraryFilterIds - filterId else it.libraryFilterIds + filterId + it.copy(libraryFilterIds = filters) + } + } + + fun clearLibraryFilters() { + _state.update { it.copy(libraryFilterIds = emptyList()) } + } + + fun setCatalogSort(sortId: String) { + _state.update { it.copy(catalogSortId = sortId) } + refreshCatalogDebounced() + } + + fun toggleCatalogFilter(filterId: String) { + val adding = filterId !in state.value.catalogFilterIds + _state.update { + val filters = if (filterId in it.catalogFilterIds) it.catalogFilterIds - filterId else it.catalogFilterIds + filterId + it.copy(catalogFilterIds = filters) + } + OpenNowAnalytics.capture( + event = "catalog_filter_applied", + properties = mapOf( + "filter_id" to filterId, + "action" to if (adding) "add" else "remove", + ), + ) + refreshCatalogDebounced() + } + + fun clearCatalogFilters() { + _state.update { it.copy(catalogFilterIds = emptyList()) } + refreshCatalogDebounced() + } + + fun selectGame(game: GameInfo) { + _state.update { it.copy(selectedGame = game) } + OpenNowAnalytics.capture( + event = "game_selected", + properties = mapOf( + "game_id" to game.id, + "game_title" to game.title, + ), + ) + } + + fun clearSelectedGame() { + _state.update { it.copy(selectedGame = null) } + } + + fun updateSettings(next: AppSettings) { + settingsStore.replace(next) + } + + fun checkAndroidUpdate() { + startAndroidUpdateCheck(automatic = false) + } + + fun dismissAndroidUpdateNotice() { + val key = androidUpdateNoticeKey(state.value.androidUpdate) ?: return + androidUpdateNoticeStore.dismiss(key) + _state.update { it.copy(dismissedAndroidUpdateNoticeKey = key) } + } + + fun downloadAndroidUpdate() { + if (androidUpdateJob?.isActive == true || !state.value.androidUpdate.canDownload) return + OpenNowAnalytics.capture(event = "app_update_downloaded") + androidUpdateJob = viewModelScope.launch { + appUpdater.downloadUpdate() + } + } + + fun installAndroidUpdate() { + if (!state.value.androidUpdate.canInstall) return + appUpdater.installDownloadedUpdate() + } + + private fun startAndroidUpdateAutoChecks() { + if (androidUpdateAutoJob?.isActive == true) return + if (!state.value.androidUpdate.apkUpdatesAllowed) return + androidUpdateAutoJob = viewModelScope.launch { + delay(ANDROID_UPDATE_LAUNCH_CHECK_DELAY_MS) + while (true) { + runAutomaticAndroidUpdateCheck() + delay(ANDROID_UPDATE_PERIODIC_CHECK_INTERVAL_MS) + } + } + } + + private suspend fun runAutomaticAndroidUpdateCheck() { + if (!state.value.androidUpdate.apkUpdatesAllowed) return + if (!state.value.settings.autoCheckForUpdates) return + waitForAndroidUpdateCheckWindow() + val snapshot = state.value + if (!snapshot.settings.autoCheckForUpdates || !snapshot.androidUpdate.shouldRunAutomaticCheck()) return + startAndroidUpdateCheck(automatic = true)?.join() + } + + private suspend fun waitForAndroidUpdateCheckWindow() { + while (state.value.isAndroidUpdateCheckBlockedByStream()) { + delay(ANDROID_UPDATE_STREAMING_RETRY_DELAY_MS) + } + } + + private fun startAndroidUpdateCheck(automatic: Boolean): Job? { + if (androidUpdateJob?.isActive == true) return null + if (!state.value.androidUpdate.apkUpdatesAllowed) return null + if (state.value.isAndroidUpdateCheckBlockedByStream()) { + if (!automatic) { + appUpdater.markCheckDeferredForStreaming() + } + return null + } + return viewModelScope.launch { + if (state.value.isAndroidUpdateCheckBlockedByStream()) { + if (!automatic) { + appUpdater.markCheckDeferredForStreaming() + } + return@launch + } + appUpdater.checkForUpdate() + }.also { job -> + androidUpdateJob = job + } + } + + private fun cancelAndroidUpdateCheckForStreaming() { + if (state.value.androidUpdate.status != AndroidUpdateStatus.Checking) return + androidUpdateJob?.cancel() + androidUpdateJob = null + appUpdater.markCheckDeferredForStreaming() + } + + fun refreshSettings() { + if (settingsRefreshJob?.isActive == true) return + settingsRefreshJob = viewModelScope.launch { + _state.update { it.copy(settingsRefreshing = true, error = null) } + try { + val updateJob = startAndroidUpdateCheck(automatic = false) + val session = state.value.authSession + val accountJob = session?.let { activeSession -> + launch { refreshSettingsAccountData(activeSession) } + } + val accountConnectorsJob = session?.let { activeSession -> + launch { refreshAccountConnectors(activeSession) } + } + updateJob?.join() + accountJob?.join() + accountConnectorsJob?.join() + } finally { + _state.update { it.copy(settingsRefreshing = false) } + } + } + } + + fun resetSettings() { + Toast.makeText(getApplication(), "Clearing app data and relaunching OpenNOW", Toast.LENGTH_SHORT).show() + wipeAppDataAndRelaunch(getApplication()) + } + + fun resetStreamTutorial() { + settingsStore.update { it.copy(androidStreamGuideDismissed = false) } + Toast.makeText(getApplication(), "Tutorial will show on the next stream", Toast.LENGTH_SHORT).show() + } + + fun clearCatalogCache() { + viewModelScope.launch { + val removed = withContext(Dispatchers.IO) { catalogCacheStore.clear() } + Toast.makeText( + getApplication(), + if (removed == 0) "Game cache was already clear" else "Cleared game cache", + Toast.LENGTH_SHORT, + ).show() + state.value.authSession?.let { refreshAfterAuth(it) } + } + } + + fun refreshAccountConnectors() { + val session = state.value.authSession ?: return + viewModelScope.launch { + refreshAccountConnectors(session) + } + } + + private suspend fun refreshAccountConnectors(session: AuthSession) { + accountConnectorRefreshMutex.withLock { + val token = accountConnectorAuthToken(session) + _state.update { it.copy(loadingAccountConnectors = true) } + runCatching { accountConnectorRepository.fetchConnectors(token) } + .onSuccess { connectors -> + _state.update { it.copy(accountConnectors = connectors, loadingAccountConnectors = false) } + } + .onFailure { error -> + if (error is CancellationException) return@onFailure + _state.update { it.copy(loadingAccountConnectors = false, error = error.message ?: "Failed to load account connections") } + } + } + } + + private suspend fun refreshSettingsAccountData(session: AuthSession) { + val token = accountConnectorAuthToken(session) + coroutineScope { + val subscription = async { + runCatching { + val vpcId = catalogRepository.getVpcId(token, session.provider.streamingServiceUrl) + subscriptionRepository.fetchSubscription(token, session.user.userId, vpcId) + }.getOrNull() + } + val regions = async { + runCatching { fetchDynamicRegions(http, token, session.provider.streamingServiceUrl).first } + .getOrDefault(emptyList()) + } + val fetchedSubscription = subscription.await() + val enrichedSession = persistSubscriptionTier(session, fetchedSubscription) + val fetchedRegions = regions.await() + _state.update { current -> + current.copy( + authSession = current.authSession + ?.takeIf { it.user.userId == enrichedSession.user.userId } + ?.let { enrichedSession } + ?: current.authSession, + savedAccounts = savedAccountsSnapshot(), + subscriptionInfo = fetchedSubscription ?: current.subscriptionInfo, + regions = fetchedRegions.ifEmpty { current.regions }, + ) + } + } + } + + fun connectAccountConnector(store: String, openUrl: (String) -> Unit) { + val session = state.value.authSession ?: return + viewModelScope.launch { + val token = accountConnectorAuthToken(session) + _state.update { it.copy(connectorActionStore = store, error = null) } + runCatching { withTimeout(20_000L) { accountConnectorRepository.loginUrl(store, token) } } + .onSuccess { url -> + _state.update { it.copy(connectorActionStore = null) } + openUrl(url) + refreshAccountConnectorsAfterLinking() + } + .onFailure { error -> + if (error is CancellationException) return@onFailure + val message = error.message ?: "Failed to start store connection" + Toast.makeText(getApplication(), message, Toast.LENGTH_SHORT).show() + _state.update { it.copy(connectorActionStore = null, error = message) } + } + } + } + + private suspend fun accountConnectorAuthToken(session: AuthSession): String = + authRepository.restore(forceRefresh = false)?.tokens?.let { it.idToken ?: it.accessToken } + ?: (session.tokens.idToken ?: session.tokens.accessToken) + + private fun refreshAccountConnectorsAfterLinking() { + viewModelScope.launch { + repeat(6) { attempt -> + delay(if (attempt == 0) 5_000L else 10_000L) + val session = state.value.authSession ?: return@launch + refreshAccountConnectors(session) + } + } + } + + fun disconnectAccountConnector(store: String) { + val session = state.value.authSession ?: return + viewModelScope.launch { + val token = accountConnectorAuthToken(session) + _state.update { it.copy(connectorActionStore = store, error = null) } + runCatching { accountConnectorRepository.disconnect(store, token) } + .onSuccess { + Toast.makeText(getApplication(), "Store disconnected", Toast.LENGTH_SHORT).show() + _state.update { it.copy(connectorActionStore = null) } + refreshAccountConnectors() + } + .onFailure { error -> + if (error is CancellationException) return@onFailure + val message = error.message ?: "Failed to disconnect store" + Toast.makeText(getApplication(), message, Toast.LENGTH_SHORT).show() + _state.update { it.copy(connectorActionStore = null, error = message) } + } + } + } + + fun updateStreamSettings(transform: (StreamSettings) -> StreamSettings) { + val snapshot = state.value + settingsStore.update { + it.copy( + stream = transform(it.stream) + .withAndroidSettingsAvailability() + .withFpsAllowed(snapshot.subscriptionInfo, snapshot.authSession?.user?.membershipTier), + streamPreset = StreamPreset.Custom, + ) + } + } + + fun applyStreamPreset(preset: StreamPreset) { + val snapshot = state.value + settingsStore.update { settings -> + val presetStream = (if (preset == StreamPreset.Recommended) { + (deviceRecommendation ?: recommendedAndroidStreamProfile(getApplication(), snapshot.codecReport)).stream + } else { + settings.stream.applyingStreamPreset(preset) + }).withMicrophoneSettingsFrom(settings.stream) + settings.copy( + streamPreset = preset, + stream = presetStream + .withAndroidSettingsAvailability() + .withResolutionAllowed(snapshot.subscriptionInfo, snapshot.authSession?.user?.membershipTier) + .withFpsAllowed(snapshot.subscriptionInfo, snapshot.authSession?.user?.membershipTier), + ) + } + } + + fun updateFavorites(gameId: String) { + val adding = gameId !in settingsStore.settings.value.favoriteGameIds + settingsStore.update { + val next = if (gameId in it.favoriteGameIds) it.favoriteGameIds - gameId else it.favoriteGameIds + gameId + it.copy(favoriteGameIds = next) + } + OpenNowAnalytics.capture( + event = "favorite_toggled", + properties = mapOf( + "game_id" to gameId, + "action" to if (adding) "add" else "remove", + ), + ) + } + + fun setDefaultGameVariant(gameId: String, variantId: String?) { + settingsStore.update { + val next = it.defaultGameVariantIds.toMutableMap() + if (variantId.isNullOrBlank()) { + next.remove(gameId) + } else { + next[gameId] = variantId + } + it.copy(defaultGameVariantIds = next) + } + } + + fun dismissStoreChoice() { + _state.update { it.copy(pendingStoreChoiceGame = null) } + } + + fun chooseStore(game: GameInfo) { + val launchVariants = launchableGameVariants(game.variants) + if (launchVariants.size > 1) { + _state.update { it.copy(pendingStoreChoiceGame = game, selectedGame = null, error = null) } + } else { + play(game, skipStoreChoice = true) + } + } + + fun playVariant(game: GameInfo, variant: GameVariant) { + _state.update { it.copy(pendingStoreChoiceGame = null) } + play(game.withSelectedVariant(variant.id), skipStoreChoice = true) + } + + fun play(game: GameInfo, streamingBaseUrlOverride: String? = null, skipPrintedWaste: Boolean = false, skipStoreChoice: Boolean = false) { + if (launchJob?.isActive == true) { + recordDebugEvent("launch", "Ignored play request while another launch is active game=${game.title}") + return + } + if (!skipStoreChoice) { + val launchVariants = launchableGameVariants(game.variants) + val defaultVariantId = state.value.settings.defaultGameVariantIds[game.id] + val defaultVariant = launchVariants.firstOrNull { it.id == defaultVariantId } + if (defaultVariant != null) { + recordDebugEvent("launch", "Using default launcher ${gameStoreDisplayName(defaultVariant.store)} for ${game.title}") + Toast.makeText( + getApplication(), + getApplication().getString( + R.string.store_selector_default_launch_notice, + gameStoreDisplayName(defaultVariant.store), + ), + Toast.LENGTH_SHORT, + ).show() + play(game.withSelectedVariant(defaultVariant.id), streamingBaseUrlOverride, skipPrintedWaste, skipStoreChoice = true) + return + } + if (launchVariants.size > 1) { + recordDebugEvent("launch", "Waiting for launcher choice game=${game.title} variants=${launchVariants.size}") + _state.update { it.copy(pendingStoreChoiceGame = game, selectedGame = null, error = null) } + return + } + } + launchJob = viewModelScope.launch { + val auth = state.value.authSession ?: run { + recordDebugEvent("launch", "Play request ignored without an auth session game=${game.title}") + return@launch + } + // Wait for active subscription fetch to finish so we have accurate membership info to allow resolutions + activeSubscriptionJob?.join() + val returnPage = state.value.page.takeUnless { it == AppPage.Stream } ?: state.value.streamReturnPage ?: AppPage.Home + if (!skipPrintedWaste && streamingBaseUrlOverride == null && shouldUsePrintedWasteQueue(auth)) { + recordDebugEvent("queue", "Opening PrintedWaste selector game=${game.title}") + showPrintedWasteSelector(game) + return@launch + } + val requestedSettings = streamSettingsBeforeDeviceAdjustment() + val settings = requestedSettings.adjustedForDevice(state.value.codecReport) + prepareSessionReport( + gameTitle = game.title, + selectedSettings = state.value.settings.stream, + eligibleSettings = requestedSettings, + initialSettings = settings, + ) + if (settings != requestedSettings) { + recordDebugEvent( + "launch", + "Adjusted stream settings requested=${requestedSettings.debugSummary()} effective=${settings.debugSummary()}", + ) + } + val token = auth.tokens.idToken ?: auth.tokens.accessToken + val baseUrl = streamingBaseUrlOverride ?: effectiveStreamingBaseUrl() + pendingActiveSessionLaunch = null + recordDebugEvent( + "launch", + "Starting launch game=${game.title} base=${hostForDebug(baseUrl)} settings=${settings.debugSummary()} override=${streamingBaseUrlOverride != null}", + ) + recordQueuedGame(game) + OpenNowAnalytics.capture( + event = "stream_started", + properties = mapOf( + "game_id" to game.id, + "game_title" to game.title, + "resolution" to settings.resolution, + "fps" to settings.fps, + "codec" to settings.codec.name, + ), + ) + _state.update { + it.copy( + streamStatus = "queue", + launchPhase = "Resolving game", + streamGame = game, + activeStreamSettings = settings, + selectedGame = null, + page = AppPage.Stream, + streamReturnPage = returnPage, + streamLaunchMinimized = false, + error = null, + queuePosition = null, + queueAdActiveId = null, + pendingStoreChoiceGame = null, + pendingPrintedWasteGame = null, + activeSessionDecision = null, + printedWasteError = null, + printedWastePings = emptyMap(), + sessionReport = null, + ) + } + runCatching { + val selectedVariant = game.variants.getOrNull(game.selectedVariantIndex) ?: game.variants.firstOrNull() + val candidateId = selectedVariant?.id ?: game.launchAppId ?: game.uuid ?: game.id + val launchAppId = candidateId.takeIf { it.all(Char::isDigit) } + ?: game.launchAppId?.takeIf { it.all(Char::isDigit) } + ?: catalogRepository.resolveLaunchAppId(token, candidateId, baseUrl) + ?: error("Could not resolve numeric appId for ${game.title}") + recordDebugEvent("launch", "Resolved appId=$launchAppId candidate=$candidateId game=${game.title}") + + _state.update { it.copy(launchPhase = "Checking active sessions") } + val active = sessionRepository.getActiveSessions(token, baseUrl, settings) + recordDebugEvent("queue", "Active sessions checked count=${active.size} ${active.joinToString(limit = 4) { it.debugSummary() }}") + val numericLaunchAppId = launchAppId.toIntOrNull() + val activeConflict = activeSessionLaunchConflict(active, numericLaunchAppId, settings) + if (activeConflict != null) { + pendingActiveSessionLaunch = PendingActiveSessionLaunch( + game = game, + launchAppId = launchAppId, + baseUrl = baseUrl, + settings = settings, + accountLinked = shouldSendAccountLinked(game, selectedVariant), + activeSession = activeConflict, + returnPage = returnPage, + ) + recordDebugEvent("queue", "Active session decision required ${activeConflict.debugSummary()} requestedApp=$launchAppId") + _state.update { + it.copy( + activeSession = activeConflict, + activeSessionDecision = ActiveSessionDecision( + activeSession = activeConflict, + requestedGameTitle = game.title, + ), + streamSession = null, + launchPhase = "Active session found", + queuePosition = activeConflict.queuePosition, + queueAdActiveId = null, + ) + } + return@runCatching null + } + _state.update { it.copy(launchPhase = "Creating session") } + val created = sessionRepository.createSession( + token = token, + streamingBaseUrl = baseUrl, + appId = launchAppId, + internalTitle = game.title, + zone = "prod", + settings = settings, + accountLinked = shouldSendAccountLinked(game, selectedVariant), + ) + recordDebugEvent("queue", "Created session ${created.debugSummary()}") + pollUntilReady(token, created, settings) + }.onSuccess { readySession -> + if (readySession == null) return@onSuccess + markSessionReadyForNativeStream(readySession, settings) + }.onFailure { error -> + if (error is CancellationException) return@onFailure + recordDebugEvent("launch", "Launch failed game=${game.title} error=${error.debugMessage()}") + val returnPage = state.value.streamReturnPage ?: AppPage.Home + _state.update { + it.copy( + error = normalizeLaunchError(error, game.title), + streamStatus = "idle", + activeStreamSettings = null, + streamReturnPage = null, + launchPhase = "", + streamLaunchMinimized = false, + queuePosition = null, + queueAdActiveId = null, + pendingStoreChoiceGame = null, + activeSessionDecision = null, + page = returnPage, + ) + } + } + } + } + + private fun recordQueuedGame(game: GameInfo) { + val next = queuedGameStore.record(gameTrackingKey(game)) + _state.update { it.copy(queuedGameKeys = next) } + } + + private fun markSessionReadyForNativeStream(readySession: SessionInfo, settings: StreamSettings) { + val anchoredSession = readySession.withSessionTimerAnchor() + if (streamReportLaunchProfile == null) { + prepareSessionReport( + gameTitle = state.value.streamGame?.title.orEmpty(), + selectedSettings = state.value.settings.stream, + eligibleSettings = settings, + initialSettings = settings, + ) + } + recordDebugEvent("stream", "Session ready for native stream ${anchoredSession.debugSummary()}") + _state.update { + it.copy( + streamSession = anchoredSession, + activeStreamSettings = settings, + streamStatus = "connecting", + launchPhase = "Connecting stream", + streamLaunchMinimized = false, + queuePosition = null, + queueAdActiveId = null, + activeSessionDecision = null, + page = AppPage.Stream, + ) + } + } + + private fun prepareSessionReport( + gameTitle: String, + selectedSettings: StreamSettings, + eligibleSettings: StreamSettings, + initialSettings: StreamSettings, + ) { + streamReportLaunchProfile = StreamReportLaunchProfile( + gameTitle = gameTitle, + selectedSettings = selectedSettings, + eligibleSettings = eligibleSettings, + initialSettings = initialSettings, + ) + streamSessionReportAccumulator = null + lastSessionReportNetworkSampleAtMs = 0L + sessionReportFinalizedForStop = false + } + + private fun ensureSessionReportAccumulator(nowMs: Long = System.currentTimeMillis()) { + if (sessionReportFinalizedForStop || streamSessionReportAccumulator != null) return + val snapshot = state.value + if (snapshot.streamSession == null || snapshot.streamStatus !in setOf("connecting", "streaming")) return + val initialSettings = snapshot.activeStreamSettings ?: return + val profile = streamReportLaunchProfile ?: StreamReportLaunchProfile( + gameTitle = snapshot.streamGame?.title.orEmpty(), + selectedSettings = snapshot.settings.stream, + eligibleSettings = initialSettings, + initialSettings = initialSettings, + ).also { streamReportLaunchProfile = it } + streamSessionReportAccumulator = StreamSessionReportAccumulator(profile, startedAtMs = nowMs) + lastSessionReportNetworkSampleAtMs = 0L + } + + private fun finishSessionReport(nowMs: Long = System.currentTimeMillis()): SessionReport? { + if (sessionReportFinalizedForStop) return null + sessionReportFinalizedForStop = true + val report = streamSessionReportAccumulator?.finish(nowMs) + if (report != null) { + recordDebugEvent( + "stream", + "Session report score=${report.score} samples=${report.sampleCount} " + + "ping=${report.averagePingMs ?: -1} loss=${report.packetLossPct ?: -1.0} " + + "bitrate=${report.averageBitrateKbps ?: -1}", + ) + } + streamSessionReportAccumulator = null + streamReportLaunchProfile = null + lastSessionReportNetworkSampleAtMs = 0L + return report + } + + fun stopStream() { + val beforeStop = state.value + val completedSessionReport = finishSessionReport() + recordDebugEvent( + "stream", + "Stop requested status=${beforeStop.streamStatus} session=${beforeStop.streamSession?.shortDebugId().orEmpty()} game=${beforeStop.streamGame?.title.orEmpty()}", + ) + launchJob?.cancel() + launchJob = null + pendingActiveSessionLaunch = null + viewModelScope.launch { + val auth = state.value.authSession + val snapshot = state.value + val returnPage = snapshot.streamReturnPage ?: AppPage.Home + val session = snapshot.streamSession + val streamSettings = snapshot.activeStreamSettings ?: effectiveStreamSettings() + if (auth != null && session != null) { + runCatching { sessionRepository.stopSession(auth.tokens.idToken ?: auth.tokens.accessToken, session, streamSettings) } + .onSuccess { + sessionTimerAnchorStore.clear(session.sessionId) + recordDebugEvent("stream", "Stopped cloud session ${session.shortDebugId()}") + } + .onFailure { error -> recordDebugEvent("stream", "Failed to stop cloud session ${session.shortDebugId()} error=${error.debugMessage()}") } + } else if (auth != null) { + val token = auth.tokens.idToken ?: auth.tokens.accessToken + val active = snapshot.activeSession + ?: runCatching { + sessionRepository.getActiveSessions(token, effectiveStreamingBaseUrl(auth), streamSettings) + .firstOrNull { it.status in setOf(1, 2, 3) } + }.getOrNull() + if (active != null) { + runCatching { sessionRepository.stopActiveSession(token, active, streamSettings) } + .onSuccess { + sessionTimerAnchorStore.clear(active.sessionId) + recordDebugEvent("stream", "Stopped active session ${active.shortDebugId()}") + } + .onFailure { error -> recordDebugEvent("stream", "Failed to stop active session ${active.shortDebugId()} error=${error.debugMessage()}") } + } else { + recordDebugEvent("stream", "No cloud session found to stop") + } + } + OpenNowAnalytics.capture( + event = "stream_stopped", + properties = mapOf( + "game_title" to (state.value.streamGame?.title ?: ""), + "game_id" to (state.value.streamGame?.id ?: ""), + ), + ) + _state.update { + it.copy( + streamSession = null, + activeStreamSettings = null, + streamGame = null, + streamStatus = "idle", + streamLaunchMinimized = false, + streamReturnPage = null, + launchPhase = "", + queuePosition = null, + queueAdActiveId = null, + pendingStoreChoiceGame = null, + activeSessionDecision = null, + page = returnPage, + sessionReport = completedSessionReport ?: it.sessionReport, + ) + } + refreshActiveSession() + recordDebugEvent("stream", "Stream state reset returnPage=$returnPage") + } + } + + fun refreshPrintedWasteQueues() { + val game = state.value.pendingPrintedWasteGame ?: return + recordDebugEvent("queue", "Refreshing PrintedWaste queues game=${game.title}") + viewModelScope.launch { + loadPrintedWasteQueue(game) + } + } + + fun minimizeStreamLaunch() { + recordDebugEvent("queue", "Minimize launch requested status=${state.value.streamStatus} phase=${state.value.launchPhase}") + _state.update { current -> + if (current.streamStatus == "idle" || current.streamSession?.isReadyForStream() == true) { + current + } else { + current.copy(streamLaunchMinimized = true, page = current.streamReturnPage ?: AppPage.Home) + } + } + } + + fun restoreStreamLaunch() { + recordDebugEvent("queue", "Restore launch requested status=${state.value.streamStatus} phase=${state.value.launchPhase}") + _state.update { current -> + if (current.streamStatus == "idle") current else current.copy(streamLaunchMinimized = false, page = AppPage.Stream) + } + } + + fun dismissActiveSessionDecision() { + val pending = pendingActiveSessionLaunch + recordDebugEvent("queue", "Active session decision dismissed session=${pending?.activeSession?.shortDebugId().orEmpty()}") + pendingActiveSessionLaunch = null + val returnPage = pending?.returnPage ?: state.value.streamReturnPage ?: AppPage.Home + _state.update { + it.copy( + streamStatus = "idle", + activeStreamSettings = null, + streamGame = null, + streamSession = null, + activeSessionDecision = null, + streamReturnPage = null, + launchPhase = "", + streamLaunchMinimized = false, + queuePosition = null, + queueAdActiveId = null, + page = returnPage, + ) + } + } + + fun terminateActiveSessionAndStartNew() { + if (launchJob?.isActive == true) { + recordDebugEvent("queue", "Ignored replace active session request while another launch is active") + return + } + val pending = pendingActiveSessionLaunch ?: run { + recordDebugEvent("queue", "Replace active session ignored without pending launch") + return + } + pendingActiveSessionLaunch = null + recordDebugEvent("queue", "Replace active session requested active=${pending.activeSession.debugSummary()} game=${pending.game.title}") + launchJob = viewModelScope.launch { + val auth = state.value.authSession ?: run { + recordDebugEvent("queue", "Replace active session ignored without an auth session") + return@launch + } + val token = auth.tokens.idToken ?: auth.tokens.accessToken + _state.update { + it.copy( + streamStatus = "queue", + launchPhase = "Ending active session", + activeSession = pending.activeSession, + activeSessionDecision = null, + streamSession = null, + streamGame = pending.game, + activeStreamSettings = pending.settings, + page = AppPage.Stream, + streamReturnPage = pending.returnPage, + streamLaunchMinimized = false, + error = null, + queuePosition = null, + queueAdActiveId = null, + sessionReport = null, + ) + } + runCatching { + runCatching { sessionRepository.stopActiveSession(token, pending.activeSession, pending.settings) } + .onSuccess { + sessionTimerAnchorStore.clear(pending.activeSession.sessionId) + recordDebugEvent("queue", "Stopped active session before new launch ${pending.activeSession.shortDebugId()}") + } + .onFailure { error -> recordDebugEvent("queue", "Failed to stop active session before new launch ${pending.activeSession.shortDebugId()} error=${error.debugMessage()}") } + _state.update { it.copy(activeSession = null, launchPhase = "Creating session") } + val created = sessionRepository.createSession( + token = token, + streamingBaseUrl = pending.baseUrl, + appId = pending.launchAppId, + internalTitle = pending.game.title, + zone = "prod", + settings = pending.settings, + accountLinked = pending.accountLinked, + ) + recordDebugEvent("queue", "Created replacement session ${created.debugSummary()}") + pollUntilReady(token, created, pending.settings) + }.onSuccess { readySession -> + markSessionReadyForNativeStream(readySession, pending.settings) + }.onFailure { error -> + if (error is CancellationException) return@onFailure + recordDebugEvent("launch", "Replace active session launch failed game=${pending.game.title} error=${error.debugMessage()}") + val returnPage = state.value.streamReturnPage ?: pending.returnPage + _state.update { + it.copy( + error = normalizeLaunchError(error, pending.game.title), + streamStatus = "idle", + activeStreamSettings = null, + streamReturnPage = null, + activeSessionDecision = null, + launchPhase = "", + streamLaunchMinimized = false, + queuePosition = null, + queueAdActiveId = null, + pendingStoreChoiceGame = null, + page = returnPage, + ) + } + } + } + } + + fun resumeActiveSession() { + if (launchJob?.isActive == true) { + recordDebugEvent("queue", "Ignored resume request while another launch is active") + return + } + pendingActiveSessionLaunch?.let { pending -> + resumePendingActiveSession(pending) + return + } + recordDebugEvent("queue", "Resume active session requested cached=${state.value.activeSession?.debugSummary().orEmpty()}") + launchJob = viewModelScope.launch { + val auth = state.value.authSession ?: run { + recordDebugEvent("queue", "Resume ignored without an auth session") + return@launch + } + val settings = effectiveStreamSettings() + val token = auth.tokens.idToken ?: auth.tokens.accessToken + val baseUrl = effectiveStreamingBaseUrl(auth) + val cachedActive = state.value.activeSession + val returnPage = state.value.page.takeUnless { it == AppPage.Stream } ?: state.value.streamReturnPage ?: AppPage.Home + _state.update { + it.copy( + streamStatus = "queue", + launchPhase = "Checking active sessions", + activeStreamSettings = settings, + page = AppPage.Stream, + streamReturnPage = returnPage, + streamLaunchMinimized = false, + selectedGame = null, + pendingStoreChoiceGame = null, + pendingPrintedWasteGame = null, + activeSessionDecision = null, + error = null, + queuePosition = null, + queueAdActiveId = null, + sessionReport = null, + ) + } + runCatching { + val active = cachedActive ?: sessionRepository.getActiveSessions(token, baseUrl, settings) + .let { activeSessionLaunchConflict(it, launchAppId = null, settings = settings) } + ?: error("No active cloud session was found. Start a game to create a new one.") + val resumeSettings = resumeSettingsForActiveSession(active, settings) + prepareSessionReport( + gameTitle = gameForActiveSession(active)?.title.orEmpty(), + selectedSettings = state.value.settings.stream, + eligibleSettings = streamSettingsBeforeDeviceAdjustment(), + initialSettings = resumeSettings, + ) + recordDebugEvent("queue", "Resume found active ${active.debugSummary()} base=${hostForDebug(baseUrl)} settings=${resumeSettings.debugSummary()}") + val matchingGame = gameForActiveSession(active) + _state.update { + it.copy( + activeSession = active, + streamGame = matchingGame, + streamSession = active.toPendingSession(zone = "prod"), + activeStreamSettings = resumeSettings, + launchPhase = if (active.isReadyForClaim()) "Resuming session" else loadingPhaseFor(active.toPendingSession(zone = "prod")), + ) + } + resumeKnownActiveSession(token, active, resumeSettings, baseUrl) + }.onSuccess { readySession -> + recordDebugEvent("stream", "Resume ready for native stream ${readySession.debugSummary()}") + markSessionReadyForNativeStream(readySession, state.value.activeStreamSettings ?: settings) + }.onFailure { error -> + if (error is CancellationException) return@onFailure + recordDebugEvent("queue", "Resume failed error=${error.debugMessage()}") + val returnPage = state.value.streamReturnPage ?: AppPage.Home + _state.update { + it.copy( + error = normalizeLaunchError(error, state.value.streamGame?.title), + streamStatus = "idle", + activeStreamSettings = null, + streamReturnPage = null, + launchPhase = "", + streamLaunchMinimized = false, + queuePosition = null, + queueAdActiveId = null, + pendingStoreChoiceGame = null, + pendingPrintedWasteGame = null, + activeSessionDecision = null, + page = returnPage, + ) + } + } + } + } + + private fun resumePendingActiveSession(pending: PendingActiveSessionLaunch) { + pendingActiveSessionLaunch = null + recordDebugEvent("queue", "Resume pending active session requested active=${pending.activeSession.debugSummary()} requestedGame=${pending.game.title}") + launchJob = viewModelScope.launch { + val auth = state.value.authSession ?: run { + recordDebugEvent("queue", "Pending resume ignored without an auth session") + return@launch + } + val token = auth.tokens.idToken ?: auth.tokens.accessToken + val resumeSettings = resumeSettingsForActiveSession(pending.activeSession, pending.settings) + val pendingSession = pending.activeSession.toPendingSession(zone = "prod") + prepareSessionReport( + gameTitle = gameForActiveSession(pending.activeSession)?.title ?: pending.game.title, + selectedSettings = state.value.settings.stream, + eligibleSettings = streamSettingsBeforeDeviceAdjustment(), + initialSettings = resumeSettings, + ) + _state.update { + it.copy( + streamStatus = "queue", + launchPhase = if (pending.activeSession.isReadyForClaim()) "Resuming session" else loadingPhaseFor(pendingSession), + activeSession = pending.activeSession, + activeSessionDecision = null, + streamSession = pendingSession, + streamGame = gameForActiveSession(pending.activeSession) ?: pending.game.takeIf { pending.activeSession.appId == pending.launchAppId.toIntOrNull() }, + activeStreamSettings = resumeSettings, + page = AppPage.Stream, + streamReturnPage = pending.returnPage, + streamLaunchMinimized = false, + error = null, + queuePosition = queueDisplayPosition(pendingSession), + queueAdActiveId = null, + sessionReport = null, + ) + } + runCatching { + resumeKnownActiveSession(token, pending.activeSession, resumeSettings, pending.baseUrl) + }.onSuccess { readySession -> + recordDebugEvent("stream", "Pending resume ready for native stream ${readySession.debugSummary()}") + markSessionReadyForNativeStream(readySession, resumeSettings) + }.onFailure { error -> + if (error is CancellationException) return@onFailure + recordDebugEvent("queue", "Pending resume failed error=${error.debugMessage()}") + _state.update { + it.copy( + error = normalizeLaunchError(error, pending.game.title), + streamStatus = "idle", + activeStreamSettings = null, + streamReturnPage = null, + activeSessionDecision = null, + launchPhase = "", + streamLaunchMinimized = false, + queuePosition = null, + queueAdActiveId = null, + pendingStoreChoiceGame = null, + pendingPrintedWasteGame = null, + page = pending.returnPage, + ) + } + } + } + } + + fun launchWithPrintedWaste(zoneUrl: String?) { + val game = state.value.pendingPrintedWasteGame ?: return + recordDebugEvent("queue", "PrintedWaste selection game=${game.title} zone=${hostForDebug(zoneUrl)} auto=${zoneUrl == null}") + launchJob?.cancel() + launchJob = null + _state.update { + it.copy( + pendingPrintedWasteGame = null, + printedWasteError = null, + printedWasteLoading = false, + ) + } + play(game, streamingBaseUrlOverride = zoneUrl, skipPrintedWaste = true, skipStoreChoice = true) + } + + private fun effectiveStreamSettings(): StreamSettings { + return streamSettingsBeforeDeviceAdjustment().adjustedForDevice(state.value.codecReport) + } + + private fun resumeSettingsForActiveSession(active: ActiveSessionInfo, requested: StreamSettings): StreamSettings { + val resolution = active.resolution?.takeIf { parseResolutionPixelsOrNull(it) != null } + return requested + .let { settings -> + if (resolution == null) { + settings + } else { + settings.copy( + resolution = resolution, + aspectRatio = streamAspectRatioForResolution(resolution) ?: settings.aspectRatio, + ) + } + } + .let { settings -> active.fps?.takeIf { it > 0 }?.let { settings.copy(fps = it) } ?: settings } + .withCodecColorCompatibility() + } + + private fun gameForActiveSession(active: ActiveSessionInfo): GameInfo? = + (state.value.games + state.value.libraryGames) + .firstOrNull { game -> + game.launchAppId == active.appId.toString() || + game.variants.any { variant -> variant.id == active.appId.toString() } + } + + private fun streamSettingsBeforeDeviceAdjustment(): StreamSettings { + val snapshot = state.value + return snapshot.settings.stream + .withResolutionAllowed(snapshot.subscriptionInfo, snapshot.authSession?.user?.membershipTier) + .withFpsAllowed(snapshot.subscriptionInfo, snapshot.authSession?.user?.membershipTier) + .withHdrAllowed(snapshot.subscriptionInfo, snapshot.authSession?.user?.membershipTier) + .withAndroidSettingsAvailability() + .withCodecColorCompatibility() + } + + private suspend fun resolveFallbackLaunchAppId( + token: String, + game: GameInfo?, + active: ActiveSessionInfo?, + baseUrl: String, + ): String { + if (game == null) { + return active?.appId?.takeIf { it > 0 }?.toString() + ?: error("Could not resolve appId for safe H264 retry.") + } + val selectedVariant = game.variants.getOrNull(game.selectedVariantIndex) ?: game.variants.firstOrNull() + val candidateId = selectedVariant?.id ?: game.launchAppId ?: game.uuid ?: game.id + return candidateId.takeIf { it.all(Char::isDigit) } + ?: game.launchAppId?.takeIf { it.all(Char::isDigit) } + ?: active?.appId?.takeIf { it > 0 }?.toString() + ?: catalogRepository.resolveLaunchAppId(token, candidateId, baseUrl) + ?: error("Could not resolve numeric appId for ${game.title}") + } + + private fun String.isLikelyDirectServerUrl(): Boolean { + val host = runCatching { Uri.parse(this).host.orEmpty() }.getOrDefault("") + return Regex("^\\d{1,3}(\\.\\d{1,3}){3}$").matches(host) + } + + fun dismissPrintedWasteSelector() { + _state.update { + it.copy( + pendingPrintedWasteGame = null, + printedWasteLoading = false, + printedWasteError = null, + ) + } + } + + fun reportQueueAd( + adId: String, + action: String, + watchedTimeInMs: Long? = null, + pausedTimeInMs: Long? = null, + cancelReason: String? = null, + errorInfo: String? = null, + ) { + viewModelScope.launch { + val auth = state.value.authSession ?: run { + recordDebugEvent("ad", "Ignoring ad report without auth ad=${shortDebugId(adId)} action=$action") + return@launch + } + val session = state.value.streamSession ?: run { + recordDebugEvent("ad", "Ignoring ad report without session ad=${shortDebugId(adId)} action=$action") + return@launch + } + val normalizedAction = action.lowercase() + val isTerminalAction = normalizedAction == "finish" || normalizedAction == "cancel" + recordDebugEvent( + "ad", + "Report action=$normalizedAction ad=${shortDebugId(adId)} session=${session.shortDebugId()} watched=${watchedTimeInMs ?: 0} reason=${cancelReason.orEmpty()}", + ) + if (!isTerminalAction) { + _state.update { + it.copy(queueAdActiveId = adId) + } + } + runCatching { + queueAdReportMutex.withLock { + val reportSession = state.value.streamSession + ?.takeIf { it.sessionId == session.sessionId } + ?: session + sessionRepository.reportSessionAd( + token = auth.tokens.idToken ?: auth.tokens.accessToken, + session = reportSession, + adId = adId, + action = normalizedAction, + settings = state.value.settings.stream, + watchedTimeInMs = watchedTimeInMs, + pausedTimeInMs = pausedTimeInMs ?: 0L, + cancelReason = cancelReason, + errorInfo = errorInfo, + ) + } + }.onSuccess { updated -> + recordDebugEvent("ad", "Report accepted action=$normalizedAction updated=${updated.debugSummary()}") + _state.update { current -> + val previous = current.streamSession?.takeIf { it.sessionId == updated.sessionId } ?: session + val merged = mergeQueueSessionState( + previous, + updated, + preserveMissingAdState = !isTerminalAction, + ) + current.copy( + streamSession = merged, + queuePosition = queueDisplayPosition(merged), + queueAdActiveId = if (isTerminalAction) { + nextSessionAdId(merged.adState, adId) ?: adId + } else { + chooseQueueAdActiveId(current.queueAdActiveId, merged) + }, + ) + } + }.onFailure { error -> + recordDebugEvent("ad", "Report failed action=$normalizedAction ad=${shortDebugId(adId)} error=${error.debugMessage()}") + _state.update { current -> + val currentSession = current.streamSession + if (normalizedAction == "finish" && currentSession?.adState != null) { + current.copy( + streamSession = currentSession.copy( + adState = currentSession.adState.copy( + sessionAds = emptyList(), + ads = emptyList(), + serverSentEmptyAds = false, + ), + ), + queueAdActiveId = null, + ) + } else { + current.copy(error = error.message ?: "Queue ad update failed") + } + } + } + } + } + + fun markStreamConnected() { + ensureSessionReportAccumulator() + if (state.value.streamStatus == "streaming") return + recordDebugEvent("stream", "Native stream connected session=${state.value.streamSession?.shortDebugId().orEmpty()} game=${state.value.streamGame?.title.orEmpty()}") + OpenNowAnalytics.capture( + event = "stream_connected", + properties = mapOf( + "game_title" to (state.value.streamGame?.title ?: ""), + "game_id" to (state.value.streamGame?.id ?: ""), + "resolution" to (state.value.activeStreamSettings?.resolution ?: ""), + "fps" to (state.value.activeStreamSettings?.fps ?: 0), + "codec" to (state.value.activeStreamSettings?.codec?.name ?: ""), + ), + ) + _state.update { it.copy(streamStatus = "streaming", launchPhase = "") } + } + + fun setAndroidPictureInPictureActive(active: Boolean) { + _state.update { current -> + if (current.androidPictureInPictureActive == active) current else current.copy(androidPictureInPictureActive = active) + } + } + + fun updateStreamRuntimeStats(stats: StreamRuntimeStats) { + if (!stats.hasDebugValues()) return + val now = System.currentTimeMillis() + ensureSessionReportAccumulator(now) + val reportNetwork = if (now - lastSessionReportNetworkSampleAtMs >= SESSION_REPORT_NETWORK_SAMPLE_INTERVAL_MS) { + lastSessionReportNetworkSampleAtMs = now + AndroidRuntimeDiagnostics.networkSnapshot(getApplication()) + } else { + null + } + streamSessionReportAccumulator?.record(stats, reportNetwork) + latestStreamRuntimeStats = TimedStreamRuntimeStats( + capturedAtMs = now, + sessionId = state.value.streamSession?.sessionId, + stats = stats, + ) + if (now - lastRuntimeStatsEventAtMs >= STREAM_RUNTIME_STATS_EVENT_INTERVAL_MS) { + lastRuntimeStatsEventAtMs = now + recordDebugEvent( + "runtime", + "stats ${stats.debugSummary()} device=${AndroidRuntimeDiagnostics.snapshot(getApplication()).debugSummary()}", + ) + } + } + + fun markStreamError(message: String) { + recordDebugEvent("stream", "Native stream error message=${message.take(DEBUG_EVENT_MESSAGE_LIMIT)} session=${state.value.streamSession?.shortDebugId().orEmpty()}") + OpenNowAnalytics.capture( + event = "stream_error", + properties = mapOf( + "error_message" to message, + "game_title" to (state.value.streamGame?.title ?: ""), + "game_id" to (state.value.streamGame?.id ?: ""), + ), + ) + _state.update { it.copy(error = message, streamStatus = "idle", activeStreamSettings = null, launchPhase = "") } + } + + fun recordNativeStreamState(message: String) { + recordDebugEvent("native", "state=$message session=${state.value.streamSession?.shortDebugId().orEmpty()}") + } + + fun recordLocalSafeVideoFallback(reason: String) { + ensureSessionReportAccumulator() + val currentSettings = state.value.activeStreamSettings ?: effectiveStreamSettings() + val safeSettings = currentSettings.androidSafeVideoFallback() + _state.update { current -> + if (current.streamSession == null || current.streamStatus == "idle") { + current + } else { + current.copy(activeStreamSettings = safeSettings) + } + } + streamSessionReportAccumulator?.recordRecovery(reason, safeSettings) + recordDebugEvent( + "recovery", + "Restarted local transport with safe video profile while keeping cloud session reason=${reason.take(DEBUG_EVENT_MESSAGE_LIMIT)} current=${currentSettings.debugSummary()} safe=${safeSettings.debugSummary()}", + ) + } + + internal fun recordActiveStreamMode(status: ActiveStreamModeStatus) { + ensureSessionReportAccumulator() + streamSessionReportAccumulator?.recordActiveMode(status) + val current = state.value + val currentSettings = current.activeStreamSettings ?: effectiveStreamSettings() + val noticeKey = listOf( + current.streamGame?.id ?: current.activeSession?.appId?.toString() ?: current.streamSession?.sessionId.orEmpty(), + streamSettingsSessionSignature(currentSettings), + status.displayedResolution, + status.requestedResolution, + status.serverNegotiatedResolution.orEmpty(), + status.serverFinalSelectedResolution.orEmpty(), + status.resolutionSource?.name.orEmpty(), + status.safeVideoRecoveryActive.toString(), + status.transportCodec.name, + ).joinToString("|") + if (!runtimeResolutionNoticeKeys.add(noticeKey)) return + val resolutionSource = when (status.resolutionSource) { + StreamResolutionChangeSource.ServerNegotiatedFallback -> "Server negotiated fallback" + StreamResolutionChangeSource.ProviderOrGameModeChange -> "Provider/game runtime mode changed" + null -> "Client transport profile changed" + } + val recovery = if (status.safeVideoRecoveryActive) { + " clientRecovery=safe-${status.transportCodec.name}" + } else { + "" + } + recordDebugEvent( + "stream", + "$resolutionSource displayed=${status.displayedResolution} requested=${status.requestedResolution} " + + "server=${status.serverNegotiatedResolution.orEmpty()} final=${status.serverFinalSelectedResolution.orEmpty()}" + + "$recovery; keeping connected transport=${currentSettings.debugSummary()}", + ) + if (status.resolutionSource != null) { + refreshRuntimeSessionSnapshot(status) + } + } + + private fun refreshRuntimeSessionSnapshot(observedMode: ActiveStreamModeStatus) { + val initial = state.value + val auth = initial.authSession ?: return + val session = initial.streamSession ?: return + val settings = initial.activeStreamSettings ?: effectiveStreamSettings() + viewModelScope.launch { + val latest = runCatching { + sessionRepository.pollSession( + token = auth.tokens.idToken ?: auth.tokens.accessToken, + streamingBaseUrl = session.streamingBaseUrl ?: effectiveStreamingBaseUrl(auth), + serverIp = session.serverIp, + zone = session.zone, + sessionId = session.sessionId, + clientId = session.clientId, + deviceId = session.deviceId, + settings = settings, + ) + }.getOrElse { error -> + if (error is CancellationException) throw error + recordDebugEvent( + "stream", + "Runtime mode server snapshot failed session=${session.shortDebugId()} " + + "displayed=${observedMode.displayedResolution} error=${error.debugMessage()}", + ) + return@launch + } + if (state.value.streamSession?.sessionId != session.sessionId) return@launch + recordDebugEvent( + "stream", + "Runtime mode server snapshot session=${session.shortDebugId()} status=${latest.status} " + + "source=${observedMode.resolutionSource?.name.orEmpty()} displayed=${observedMode.displayedResolution} " + + "${latest.monitorSnapshot?.debugSummary().orEmpty()}", + ) + _state.update { current -> + val currentSession = current.streamSession + if (currentSession?.sessionId != session.sessionId) { + current + } else { + current.copy( + streamSession = currentSession.copy( + status = latest.status, + negotiatedStreamProfile = latest.negotiatedStreamProfile, + monitorSnapshot = latest.monitorSnapshot, + requestedStreamingFeatures = latest.requestedStreamingFeatures, + finalizedStreamingFeatures = latest.finalizedStreamingFeatures, + ), + ) + } + } + } + } + + fun recoverStreamSession(reason: String) { + if (launchJob?.isActive == true) { + recordDebugEvent("recovery", "Ignored stream recovery while launch job is active reason=${reason.take(DEBUG_EVENT_MESSAGE_LIMIT)}") + return + } + val initial = state.value + val auth = initial.authSession ?: run { + recordDebugEvent("recovery", "Recovery missing auth reason=${reason.take(DEBUG_EVENT_MESSAGE_LIMIT)}") + markStreamError(reason) + return + } + val initialSession = initial.streamSession ?: run { + recordDebugEvent("recovery", "Recovery missing stream session reason=${reason.take(DEBUG_EVENT_MESSAGE_LIMIT)}") + markStreamError(reason) + return + } + val currentSettings = initial.activeStreamSettings ?: effectiveStreamSettings() + recordDebugEvent("recovery", "Recovery requested reason=${reason.take(DEBUG_EVENT_MESSAGE_LIMIT)} session=${initialSession.debugSummary()} settings=${currentSettings.debugSummary()}") + launchJob = viewModelScope.launch { + val token = auth.tokens.idToken ?: auth.tokens.accessToken + val snapshot = state.value + val previousSession = snapshot.streamSession ?: initialSession + val active = snapshot.activeSession + val game = snapshot.streamGame + val baseUrl = listOfNotNull( + previousSession.streamingBaseUrl, + active?.streamingBaseUrl, + effectiveStreamingBaseUrl(auth), + ).firstOrNull { !it.isLikelyDirectServerUrl() } ?: effectiveStreamingBaseUrl(auth) + val returnPage = snapshot.streamReturnPage ?: snapshot.page.takeUnless { it == AppPage.Stream } ?: AppPage.Home + + _state.update { + it.copy( + streamSession = null, + activeStreamSettings = currentSettings, + streamStatus = "connecting", + launchPhase = "Recovering stream", + page = AppPage.Stream, + streamReturnPage = returnPage, + streamLaunchMinimized = false, + error = null, + queuePosition = null, + queueAdActiveId = null, + ) + } + + runCatching { + val activeSessions = sessionRepository.getActiveSessions(token, baseUrl, currentSettings) + recordDebugEvent("recovery", "Recovery active sessions count=${activeSessions.size} base=${hostForDebug(baseUrl)}") + val resolvedAppId = runCatching { + resolveFallbackLaunchAppId( + token = token, + game = game, + active = active, + baseUrl = baseUrl, + ).toIntOrNull() + }.getOrNull() + val readyCandidate = activeSessionRecoveryCandidate( + sessions = activeSessions, + previousSessionId = previousSession.sessionId, + launchAppId = resolvedAppId, + settings = currentSettings, + ) + if (readyCandidate?.sessionId == previousSession.sessionId && !readyCandidate.matchesStreamSettings(currentSettings)) { + recordDebugEvent( + "recovery", + "Reclaiming current session after local profile fallback active=${readyCandidate.debugSummary()} settings=${currentSettings.debugSummary()}", + ) + } + val cachedCurrentSession = active?.takeIf { + it.sessionId == previousSession.sessionId && it.matchesStreamGeometry(currentSettings) + } + val fallbackCandidate = readyCandidate + ?: previousSession.toRecoveryActiveSession( + appId = resolvedAppId ?: active?.appId ?: 0, + fallbackActive = cachedCurrentSession, + )?.takeIf { it.matchesStreamGeometry(currentSettings) } + ?: error("The running session could not be found anymore, so recovery was not possible.") + recordDebugEvent("recovery", "Claiming recovery candidate ${fallbackCandidate.debugSummary()}") + claimActiveSessionOrContinuePolling(token, fallbackCandidate, currentSettings) + }.onSuccess { readySession -> + val anchoredSession = readySession.withSessionTimerAnchor() + recordDebugEvent("recovery", "Recovery claim ready ${anchoredSession.debugSummary()}") + _state.update { + it.copy( + streamSession = anchoredSession, + activeSession = anchoredSession.toActiveRecoverySession(active), + activeStreamSettings = currentSettings, + streamStatus = "connecting", + launchPhase = "Reconnecting stream", + streamLaunchMinimized = false, + queuePosition = null, + queueAdActiveId = null, + page = AppPage.Stream, + ) + } + }.onFailure { error -> + if (error is CancellationException) return@onFailure + recordDebugEvent("recovery", "Recovery failed error=${error.debugMessage()}") + _state.update { + it.copy( + error = normalizeLaunchError(error, game?.title), + streamStatus = "idle", + activeStreamSettings = null, + streamReturnPage = null, + launchPhase = "", + streamLaunchMinimized = false, + queuePosition = null, + queueAdActiveId = null, + page = returnPage, + ) + } + } + } + } + + private fun SessionInfo.withSessionTimerAnchor(): SessionInfo = + copy( + timerStartedAtMs = sessionTimerAnchorStore.startedAtMsFor( + sessionId = sessionId, + preferredStartedAtMs = timerStartedAtMs, + ), + ) + + fun handleExternalLaunchIntent(intent: Intent?) { + if (intent == null) return + val uri = intent.data + if (localTvConnector.isPairUri(uri)) { + if (state.value.androidTvProfile) { + _state.update { it.copy(error = "Pairing links must be opened on the Android phone") } + } else if (uri != null) { + localTvConnector.pairPhone(uri) + } + return + } + if (authRepository.handleOAuthRedirect(uri)) { + _state.update { it.copy(launchPhase = LOGIN_PHASE_GETTING_TOKENS, error = null) } + return + } + val id = extractExternalLaunchId(intent) + if (id.isNullOrBlank()) return + val allGames = state.value.games + state.value.libraryGames + val game = allGames.firstOrNull { game -> + game.id == id || game.uuid == id || game.launchAppId == id || game.variants.any { it.id == id } + } ?: GameInfo( + id = id, + uuid = id, + launchAppId = id.takeIf { it.all(Char::isDigit) }, + title = intent.getStringExtra("title") ?: "Game $id", + selectedVariantIndex = 0, + variants = listOf(GameVariant(id = id, store = "Unknown")), + ) + play(game) + } + + private fun extractExternalLaunchId(intent: Intent): String? { + val uri = intent.data + return externalLaunchIdFromParts( + extras = listOf( + intent.getStringExtra("id"), + intent.getStringExtra("appId"), + intent.getStringExtra("launchAppId"), + ), + scheme = uri?.scheme, + host = uri?.host, + pathSegments = uri?.pathSegments.orEmpty(), + schemeSpecificPart = uri?.schemeSpecificPart, + queryParameters = mapOf( + "id" to uri?.let { runCatching { it.getQueryParameter("id") }.getOrNull() }, + "appId" to uri?.let { runCatching { it.getQueryParameter("appId") }.getOrNull() }, + "launchAppId" to uri?.let { runCatching { it.getQueryParameter("launchAppId") }.getOrNull() }, + ), + ) + } + + private fun GameInfo.withSelectedVariant(variantId: String): GameInfo { + val selectedIndex = variants.indexOfFirst { it.id == variantId } + return if (selectedIndex >= 0) copy(selectedVariantIndex = selectedIndex) else this + } + + fun debugLogText(): String { + val snapshot = state.value + val session = snapshot.streamSession + val codecReport = snapshot.codecReport + return buildString { + appendLine("OpenNOW Android diagnostics") + appendLine(snapshot.androidUpdate.debugHeaderLine()) + appendLine("page=${snapshot.page} initializing=${snapshot.initializing} loadingGames=${snapshot.loadingGames}") + appendLine("user=${snapshot.authSession?.user?.displayName.orEmpty()} tier=${snapshot.subscriptionInfo?.membershipTier ?: snapshot.authSession?.user?.membershipTier.orEmpty()} provider=${snapshot.authSession?.provider?.code.orEmpty()}") + appendLine("streamStatus=${snapshot.streamStatus} launchPhase=${snapshot.launchPhase} queuePosition=${snapshot.queuePosition}") + appendLine("streamGame=${snapshot.streamGame?.title.orEmpty()} selectedGame=${snapshot.selectedGame?.title.orEmpty()}") + appendLine("sessionId=${session?.sessionId.orEmpty()} sessionStatus=${session?.status} seatSetupStep=${session?.seatSetupStep} serverIp=${session?.serverIp.orEmpty()} base=${session?.streamingBaseUrl.orEmpty()}") + appendLine("adsRequired=${isSessionAdsRequired(session?.adState)} ads=${sessionAdItems(session?.adState).size} activeAd=${snapshot.queueAdActiveId.orEmpty()} queuePaused=${session?.adState?.isQueuePaused}") + appendLine("adMessage=${session?.adState?.message.orEmpty()} grace=${session?.adState?.gracePeriodSeconds} serverSentEmptyAds=${session?.adState?.serverSentEmptyAds}") + appendLine("negotiated=${session?.negotiatedStreamProfile?.debugSummary().orEmpty()} monitors=${session?.monitorSnapshot?.debugSummary().orEmpty()} requestedFeatures=${session?.requestedStreamingFeatures?.debugSummary().orEmpty()} finalizedFeatures=${session?.finalizedStreamingFeatures?.debugSummary().orEmpty()}") + appendLine("printedWaste.loading=${snapshot.printedWasteLoading} queueZones=${snapshot.printedWasteQueue.size} mappingZones=${snapshot.printedWasteMapping.size} pings=${snapshot.printedWastePings.size} error=${snapshot.printedWasteError.orEmpty()}") + appendLine("settings.resolution=${snapshot.settings.stream.resolution} fps=${snapshot.settings.stream.fps} codec=${snapshot.settings.stream.codec} bitrate=${snapshot.settings.stream.maxBitrateMbps}") + appendLine("settings.preset=${snapshot.settings.streamPreset} recommendation=${deviceRecommendation?.debugSummary() ?: "pending"}") + snapshot.activeStreamSettings?.let { active -> + appendLine("active.resolution=${active.resolution} fps=${active.fps} codec=${active.codec} bitrate=${active.maxBitrateMbps}") + } + appendLine("input.keyboardLayout=${snapshot.settings.stream.keyboardLayout} touch=${snapshot.settings.androidTouch}") + appendLine("codec.native=${codecReport?.nativeRuntimeSummary.orEmpty()} lowPower=${codecReport?.lowPowerGpuProfile} constrained=${codecReport?.constrainedRuntimeProfile} tv=${codecReport?.androidTvProfile}") + appendLine("device.runtime=${AndroidRuntimeDiagnostics.snapshot(getApplication()).debugSummary()}") + appendLine("stream.runtime.latest=${latestStreamRuntimeStats?.debugSummary(System.currentTimeMillis()) ?: "empty"}") + codecReport?.capabilities?.forEach { cap -> + appendLine("codec.${cap.codec}: decoder=${cap.decoderName ?: "none"} hardware=${cap.hardwareDecoder} nativeAvailable=${cap.nativeDecoderAvailable ?: "unknown"} webRtc=${cap.webRtcDecoderName ?: "none"} webRtcAvailable=${cap.webRtcDecoderAvailable ?: "unknown"} webRtcHardware=${cap.webRtcHardwareDecoderAvailable ?: "unknown"} encoder=${cap.encoderName ?: "none"}") + } + appendLine(DisplayRefreshDiagnostics.snapshot()) + appendLine(NativeInputDiagnostics.snapshot()) + appendLine(OpenNowHttpDiagnostics.snapshot()) + snapshot.error?.let { appendLine("error=$it") } + val events = debugEventSnapshot() + appendLine("events.count=${events.size} max=$DEBUG_EVENT_LIMIT") + if (events.isEmpty()) { + appendLine("events=(empty)") + } else { + val formatter = DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.US) + events.forEachIndexed { index, event -> + appendLine("event.${index + 1} ${formatter.format(Date(event.timestampMs))} [${event.category}] ${event.message}") + } + } + val payloads = debugPayloadSnapshot() + appendLine("advancedJson.count=${payloads.size} max=$DEBUG_PAYLOAD_LIMIT") + if (payloads.isEmpty()) { + appendLine("advancedJson=(empty)") + } else { + val formatter = DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.US) + payloads.forEachIndexed { index, payload -> + appendLine("advancedJson.${index + 1} ${formatter.format(Date(payload.timestampMs))} [${payload.operation}] ${payload.method} http=${payload.statusCode} url=${payload.url}") + if (payload.requestBody.isNotBlank()) { + appendLine("request:") + appendLine(payload.requestBody) + } + appendLine("response:") + appendLine(payload.body) + } + } + } + } + + fun sanitizedDebugLogText(): String = sanitizeDiagnosticExport(debugLogText()) + + fun debugLogFileName(): String { + val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) + return "opennow-android-logs-$timestamp.txt" + } + + private companion object { + const val TV_INITIAL_CATALOG_PAGE_COUNT = 1 + const val TV_INITIAL_CATALOG_GAME_LIMIT = 120 + const val TV_LAYOUT_PROFILE_VERSION = 1 + } + + private suspend fun refreshAfterAuth(session: AuthSession, keepRefreshVisibleWithCache: Boolean = false) { + _state.update { it.copy(loadingGames = true, error = null) } + val baseUrl = effectiveStreamingBaseUrl(session) + val token = session.tokens.idToken ?: session.tokens.accessToken + val initialCatalogSearch = state.value.catalogSearch + val initialCatalogSortId = state.value.catalogSortId + val initialCatalogFilterIds = state.value.catalogFilterIds + val (cachedMain, cachedLibrary, unboundedCachedCatalog) = withContext(Dispatchers.IO) { + Triple( + catalogCacheStore.loadMainGames(session.user.userId, baseUrl), + catalogCacheStore.loadLibraryGames(session.user.userId, baseUrl), + catalogCacheStore.loadCatalog( + userId = session.user.userId, + providerStreamingBaseUrl = baseUrl, + searchQuery = initialCatalogSearch, + sortId = initialCatalogSortId, + filterIds = initialCatalogFilterIds, + ), + ) + } + val cachedCatalog = if (androidTvProfile) { + unboundedCachedCatalog?.copy(games = unboundedCachedCatalog.games.take(TV_INITIAL_CATALOG_GAME_LIMIT)) + } else { + unboundedCachedCatalog + } + if (cachedMain != null || cachedLibrary != null || cachedCatalog != null) { + val cachedMergedLibrary = withContext(Dispatchers.Default) { + mergeKnownLibraryGames( + cachedLibrary.orEmpty(), + cachedMain.orEmpty(), + cachedCatalog?.games.orEmpty(), + ) + } + _state.update { + it.copy( + games = cachedMain ?: it.games, + libraryGames = cachedMergedLibrary.ifEmpty { cachedLibrary ?: it.libraryGames }, + catalogResult = cachedCatalog ?: it.catalogResult, + loadingGames = keepRefreshVisibleWithCache, + error = null, + ) + } + } + val subscriptionJob = viewModelScope.launch { + val sub = withContext(Dispatchers.IO) { + runCatching { + val vpcId = catalogRepository.getVpcId(token, session.provider.streamingServiceUrl) + subscriptionRepository.fetchSubscription(token, session.user.userId, vpcId) + }.getOrNull() + } + val enrichedSession = persistSubscriptionTier(session, sub) + _state.update { current -> + current.copy( + authSession = current.authSession + ?.takeIf { it.user.userId == enrichedSession.user.userId } + ?.let { enrichedSession } + ?: current.authSession, + savedAccounts = savedAccountsSnapshot(), + subscriptionInfo = sub, + ) + } + } + activeSubscriptionJob = subscriptionJob + val accountConnectorsJob = viewModelScope.launch { + _state.update { it.copy(loadingAccountConnectors = true) } + val connectors = withContext(Dispatchers.IO) { + runCatching { accountConnectorRepository.fetchConnectors(token) }.getOrDefault(emptyList()) + } + _state.update { it.copy(accountConnectors = connectors, loadingAccountConnectors = false) } + } + val regionsJob = viewModelScope.launch { + val regions = withContext(Dispatchers.IO) { + runCatching { fetchDynamicRegions(http, token, session.provider.streamingServiceUrl).first }.getOrDefault(emptyList()) + } + _state.update { it.copy(regions = regions) } + } + gamesJob?.cancel() + gamesJob = viewModelScope.launch { + runCatching { + coroutineScope { + val includeSupplementalPublicVariants = !androidTvProfile + val catalogDeferred = async(Dispatchers.IO) { + catalogRepository.browseCatalog( + token = token, + providerStreamingBaseUrl = baseUrl, + searchQuery = initialCatalogSearch, + sortId = initialCatalogSortId, + filterIds = initialCatalogFilterIds, + maxPages = if (androidTvProfile) TV_INITIAL_CATALOG_PAGE_COUNT else 3, + includeSupplementalPublicVariants = includeSupplementalPublicVariants, + ).also { catalog -> + _state.update { current -> + if ( + current.authSession?.user?.userId == session.user.userId && + current.catalogSearch == initialCatalogSearch && + current.catalogSortId == initialCatalogSortId && + current.catalogFilterIds == initialCatalogFilterIds + ) { + current.copy( + catalogResult = catalog, + games = if (androidTvProfile) catalog.games else current.games, + ) + } else { + current + } + } + } + } + // MainV2 is a ~600KB personalized panel response on this TV and then + // triggers additional metadata batches. The bounded catalog already + // contains the launchable TV store data; retain MainV2 on mobile. + val mainDeferred = if (androidTvProfile) { + null + } else { + async(Dispatchers.IO) { + catalogRepository.fetchMainGames(token, baseUrl, includeSupplementalPublicVariants) + .also { main -> + _state.update { current -> + if (current.authSession?.user?.userId == session.user.userId) { + current.copy(games = main) + } else { + current + } + } + } + } + } + val libraryDeferred = async(Dispatchers.IO) { + catalogRepository.fetchLibraryGames(token, baseUrl, includeSupplementalPublicVariants) + .also { library -> + _state.update { current -> + if (current.authSession?.user?.userId == session.user.userId) { + current.copy(libraryGames = library) + } else { + current + } + } + } + } + val catalog = catalogDeferred.await() + val main = mainDeferred?.await() ?: catalog.games + val library = libraryDeferred.await() + val mergedLibrary = withContext(Dispatchers.Default) { + mergeKnownLibraryGames(library, main, catalog.games) + } + withContext(Dispatchers.IO) { + catalogCacheStore.saveMainGames(session.user.userId, baseUrl, main) + catalogCacheStore.saveLibraryGames(session.user.userId, baseUrl, mergedLibrary) + catalogCacheStore.saveCatalog( + userId = session.user.userId, + providerStreamingBaseUrl = baseUrl, + searchQuery = initialCatalogSearch, + sortId = initialCatalogSortId, + filterIds = initialCatalogFilterIds, + result = catalog, + ) + } + Triple(main, mergedLibrary, catalog) + } + }.onSuccess { (main, library, catalog) -> + _state.update { + it.copy( + games = main, + libraryGames = library, + catalogResult = catalog, + loadingGames = false, + error = null, + ) + } + refreshActiveSession() + }.onFailure { error -> + if (error is CancellationException) return@onFailure + _state.update { current -> + val hasUsableGames = + cachedMain != null || + cachedLibrary != null || + cachedCatalog != null || + current.games.isNotEmpty() || + current.libraryGames.isNotEmpty() || + current.catalogResult.games.isNotEmpty() + current.copy( + loadingGames = false, + error = if (hasUsableGames) null else error.message ?: "Failed to load games", + ) + } + } + } + subscriptionJob.join() + accountConnectorsJob.join() + regionsJob.join() + } + + private fun refreshCatalogDebounced() { + gamesJob?.cancel() + gamesJob = viewModelScope.launch { + val auth = state.value.authSession ?: return@launch + val baseUrl = effectiveStreamingBaseUrl(auth) + val searchQuery = state.value.catalogSearch + val sortId = state.value.catalogSortId + val filterIds = state.value.catalogFilterIds + val unboundedCachedCatalog = withContext(Dispatchers.IO) { + catalogCacheStore.loadCatalog( + userId = auth.user.userId, + providerStreamingBaseUrl = baseUrl, + searchQuery = searchQuery, + sortId = sortId, + filterIds = filterIds, + ) + } + val cachedCatalog = if (androidTvProfile) { + unboundedCachedCatalog?.copy(games = unboundedCachedCatalog.games.take(TV_INITIAL_CATALOG_GAME_LIMIT)) + } else { + unboundedCachedCatalog + } + _state.update { + it.copy( + loadingGames = cachedCatalog == null, + catalogResult = cachedCatalog ?: it.catalogResult, + games = cachedCatalog?.games ?: it.games, + ) + } + runCatching { + withContext(Dispatchers.IO) { + catalogRepository.browseCatalog( + token = auth.tokens.idToken ?: auth.tokens.accessToken, + providerStreamingBaseUrl = baseUrl, + searchQuery = searchQuery, + sortId = sortId, + filterIds = filterIds, + maxPages = if (androidTvProfile) TV_INITIAL_CATALOG_PAGE_COUNT else 3, + includeSupplementalPublicVariants = !androidTvProfile, + ) + } + }.onSuccess { result -> + val mergedLibrary = withContext(Dispatchers.Default) { + mergeKnownLibraryGames(state.value.libraryGames, result.games) + } + withContext(Dispatchers.IO) { + catalogCacheStore.saveCatalog( + userId = auth.user.userId, + providerStreamingBaseUrl = baseUrl, + searchQuery = searchQuery, + sortId = sortId, + filterIds = filterIds, + result = result, + ) + } + _state.update { + it.copy( + catalogResult = result, + loadingGames = false, + games = result.games, + libraryGames = mergedLibrary.ifEmpty { it.libraryGames }, + ) + } + }.onFailure { error -> + if (error is CancellationException) return@onFailure + _state.update { it.copy(error = if (cachedCatalog != null) null else error.message ?: "Catalog refresh failed", loadingGames = false) } + } + } + } + + private suspend fun showPrintedWasteSelector(game: GameInfo) { + recordDebugEvent("queue", "Loading PrintedWaste selector game=${game.title}") + _state.update { + it.copy( + pendingStoreChoiceGame = null, + pendingPrintedWasteGame = game, + printedWasteLoading = true, + printedWasteError = null, + printedWasteQueue = emptyMap(), + printedWasteMapping = emptyMap(), + printedWastePings = emptyMap(), + ) + } + loadPrintedWasteQueue(game) + } + + private suspend fun loadPrintedWasteQueue(game: GameInfo) { + recordDebugEvent("queue", "Fetching PrintedWaste queue data game=${game.title}") + _state.update { + it.copy( + pendingStoreChoiceGame = null, + pendingPrintedWasteGame = game, + printedWasteLoading = true, + printedWasteError = null, + ) + } + runCatching { + coroutineScope { + val queue = async { printedWasteRepository.fetchQueue() } + val mapping = async { printedWasteRepository.fetchServerMapping() } + val queueData = queue.await() + val mappingData = mapping.await() + val regions = queueData + .filter { (zoneId, _) -> isStandardPrintedWasteZoneId(zoneId) && mappingData[zoneId]?.nuked != true } + .map { (zoneId, _) -> + StreamRegion(name = zoneId, url = printedWasteZoneUrlForId(zoneId), pingMs = null) + } + val pings = printedWasteRepository.pingRegions(regions).associate { it.url to it.pingMs } + Triple(queueData, mappingData, pings) + } + }.onSuccess { (queue, mapping, pings) -> + val usableZones = queue + .filter { (zoneId, _) -> isStandardPrintedWasteZoneId(zoneId) && mapping[zoneId]?.nuked != true } + .keys + val bestZone = usableZones + .mapNotNull { zoneId -> + val zone = queue[zoneId] ?: return@mapNotNull null + val url = printedWasteZoneUrlForId(zoneId) + Triple(zoneId, zone.QueuePosition, pings[url]) + } + .minWithOrNull( + compareBy>( + { it.third ?: Long.MAX_VALUE }, + { it.second }, + ), + ) + recordDebugEvent( + "queue", + "PrintedWaste queue loaded zones=${queue.size} usable=${usableZones.size} best=${bestZone?.first.orEmpty()} bestQueue=${bestZone?.second ?: 0} bestPing=${bestZone?.third ?: -1}", + ) + _state.update { + it.copy( + printedWasteQueue = queue, + printedWasteMapping = mapping, + printedWastePings = pings, + printedWasteLoading = false, + printedWasteError = null, + ) + } + }.onFailure { error -> + recordDebugEvent("queue", "PrintedWaste queue load failed error=${error.debugMessage()}") + _state.update { + it.copy( + printedWasteLoading = false, + printedWasteError = error.message ?: "PrintedWaste queue data unavailable", + ) + } + } + } + + private suspend fun refreshActiveSession() { + val auth = state.value.authSession ?: return + val settings = effectiveStreamSettings() + val token = auth.tokens.idToken ?: auth.tokens.accessToken + val active = runCatching { sessionRepository.getActiveSessions(token, effectiveStreamingBaseUrl(auth), settings) } + .getOrDefault(emptyList()) + .firstOrNull { it.status in setOf(1, 2, 3) && it.matchesStreamSettings(settings) } + _state.update { it.copy(activeSession = active) } + } + + private suspend fun resumeKnownActiveSession( + token: String, + active: ActiveSessionInfo, + settings: StreamSettings, + baseUrl: String, + ): SessionInfo { + if (!active.matchesStreamSettings(settings)) { + recordDebugEvent( + "queue", + "Explicit resume is using active session settings active=${active.debugSummary()} requested=${settings.debugSummary()}", + ) + } + if (active.isReadyForClaim()) { + recordDebugEvent("queue", "Active session already ready for claim ${active.debugSummary()}") + _state.update { it.copy(launchPhase = "Resuming session") } + return claimActiveSessionOrContinuePolling(token, active, settings) + } + + val pending = active.toPendingSession(zone = "prod") + recordDebugEvent("queue", "Hydrating active session before resume ${pending.debugSummary()}") + val hydrated = runCatching { + sessionRepository.pollSession( + token = token, + streamingBaseUrl = active.streamingBaseUrl ?: baseUrl, + serverIp = active.serverIp, + zone = "prod", + sessionId = active.sessionId, + clientId = null, + deviceId = null, + settings = settings, + ) + }.getOrElse { error -> + recordDebugEvent("queue", "Resume hydrate failed session=${pending.shortDebugId()} error=${error.debugMessage()}") + pending + } + val latest = mergeQueueSessionState(pending, hydrated) + recordDebugEvent("queue", "Resume hydrate result ${latest.debugSummary()}") + _state.update { + it.copy( + streamSession = latest, + launchPhase = loadingPhaseFor(latest), + queuePosition = queueDisplayPosition(latest), + queueAdActiveId = chooseQueueAdActiveId(it.queueAdActiveId, latest), + ) + } + if (latest.isReadyForStream()) { + val hydratedActive = active.copy( + status = latest.status, + queuePosition = latest.queuePosition, + seatSetupStep = latest.seatSetupStep, + streamingBaseUrl = latest.streamingBaseUrl ?: active.streamingBaseUrl, + serverIp = latest.serverIp, + signalingUrl = latest.signalingUrl, + ) + recordDebugEvent("queue", "Resume session became ready ${latest.debugSummary()}") + _state.update { it.copy(launchPhase = "Resuming session") } + return claimActiveSessionOrContinuePolling(token, hydratedActive, settings) + } + return pollUntilReady(token, latest, settings) + } + + private suspend fun claimActiveSessionOrContinuePolling( + token: String, + active: ActiveSessionInfo, + settings: StreamSettings, + ): SessionInfo { + return try { + sessionRepository.claimSession(token, active, settings) + } catch (error: SessionClaimNotReadyException) { + val fallback = active.toPendingSession(zone = "prod") + val latest = error.latestSession?.let { mergeQueueSessionState(fallback, it) } ?: fallback + recordDebugEvent("queue", "Claim stayed pending; continuing queue polling ${latest.debugSummary()}") + _state.update { + it.copy( + streamSession = latest, + activeStreamSettings = settings, + launchPhase = loadingPhaseFor(latest), + queuePosition = queueDisplayPosition(latest), + queueAdActiveId = chooseQueueAdActiveId(it.queueAdActiveId, latest), + ) + } + pollUntilReady(token, latest, settings) + } + } + + private suspend fun pollUntilReady(token: String, created: SessionInfo, settings: StreamSettings): SessionInfo { + var latest = created + var pollCount = 0 + recordDebugEvent("queue", "Begin polling ${latest.debugSummary()}") + _state.update { + it.copy( + streamSession = latest, + launchPhase = loadingPhaseFor(latest), + queuePosition = queueDisplayPosition(latest), + queueAdActiveId = chooseQueueAdActiveId(it.queueAdActiveId, latest), + ) + } + while (!latest.isReadyForStream()) { + val waitMs = if (shouldWaitForQueueAdPlayback(latest.adState)) 30_000L else 2_000L + if (waitMs > 2_000L) { + recordDebugEvent( + "queue", + "Waiting for queue ad playback session=${latest.shortDebugId()} ads=${sessionAdItems(latest.adState).size} paused=${latest.adState?.isQueuePaused} message=${latest.adState?.message.orEmpty()}", + ) + } + if (waitMs > 2_000L) { + var elapsedMs = 0L + while (elapsedMs < waitMs) { + kotlinx.coroutines.delay(500L) + elapsedMs += 500L + state.value.streamSession + ?.takeIf { it.sessionId == latest.sessionId } + ?.let { latest = mergeQueueSessionState(latest, it) } + if (!shouldWaitForQueueAdPlayback(latest.adState) || latest.isReadyForStream()) { + break + } + } + } else { + kotlinx.coroutines.delay(waitMs) + } + if (latest.isReadyForStream()) { + break + } + pollCount += 1 + val polled = try { + sessionRepository.pollSession( + token = token, + streamingBaseUrl = latest.streamingBaseUrl ?: effectiveStreamingBaseUrl(), + serverIp = latest.serverIp, + zone = latest.zone, + sessionId = latest.sessionId, + clientId = latest.clientId, + deviceId = latest.deviceId, + settings = settings, + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + recordDebugEvent("queue", "Poll #$pollCount failed due to network error: ${e.message}. Retrying in 2 seconds...") + kotlinx.coroutines.delay(2_000L) + continue + } + latest = mergeQueueSessionState(latest, polled) + recordDebugEvent("queue", "Poll #$pollCount result ${latest.debugSummary()}") + _state.update { + it.copy( + streamSession = latest, + launchPhase = loadingPhaseFor(latest), + queuePosition = queueDisplayPosition(latest), + queueAdActiveId = chooseQueueAdActiveId(it.queueAdActiveId, latest), + ) + } + } + recordDebugEvent("queue", "Polling complete after $pollCount polls ${latest.debugSummary()}") + return latest + } + + private fun effectiveStreamingBaseUrl(sessionOverride: AuthSession? = null): String { + val settings = state.value.settings + val auth = sessionOverride ?: state.value.authSession + return settings.stream.region.trim().ifBlank { auth?.provider?.streamingServiceUrl ?: state.value.selectedProvider.streamingServiceUrl } + } + + private fun shouldUsePrintedWasteQueue(auth: AuthSession): Boolean { + if (state.value.settings.hideServerSelector) return false + if (!auth.provider.code.equals("NVIDIA", ignoreCase = true)) return false + if (!isFreeTier()) return false + return !isAllianceStreamingBaseUrl(effectiveStreamingBaseUrl(auth)) + } + + private fun isFreeTier(): Boolean { + val tier = state.value.subscriptionInfo?.membershipTier ?: state.value.authSession?.user?.membershipTier + return tier.isNullOrBlank() || tier.equals("FREE", ignoreCase = true) + } + + private fun isAllianceStreamingBaseUrl(streamingBaseUrl: String): Boolean { + val host = runCatching { Uri.parse(streamingBaseUrl).host.orEmpty() }.getOrDefault("") + return host.isNotBlank() && !host.endsWith(".nvidiagrid.net", ignoreCase = true) + } + + private fun isStandardPrintedWasteZoneId(zoneId: String): Boolean = + zoneId.startsWith("NP-") && !zoneId.startsWith("NPA-") + + private fun printedWasteZoneUrlForId(zoneId: String): String = + "https://${zoneId.lowercase()}.cloudmatchbeta.nvidiagrid.net/" + + private fun chooseQueueAdActiveId(currentId: String?, session: SessionInfo?): String? { + val ads = sessionAdItems(session?.adState) + if (!isSessionAdsRequired(session?.adState) || ads.isEmpty()) return null + return ads.firstOrNull { it.adId == currentId }?.adId ?: ads.first().adId + } + + private fun loadingPhaseFor(session: SessionInfo): String = + when { + queueDisplayPosition(session) != null || session.seatSetupStep == 1 -> "Queue" + session.status == 0 || session.status == 1 -> "Checking queue" + else -> "Setting up rig" + } + + private fun ActiveSessionInfo.toPendingSession(zone: String): SessionInfo { + val host = serverIp.orEmpty() + val signalingServer = when { + host.isBlank() -> "" + host.contains(":") -> host + else -> "$host:443" + } + return SessionInfo( + sessionId = sessionId, + status = status, + queuePosition = queuePosition, + seatSetupStep = seatSetupStep, + zone = zone, + streamingBaseUrl = streamingBaseUrl, + serverIp = host, + signalingServer = signalingServer, + signalingUrl = signalingUrl ?: host.takeIf { it.isNotBlank() }?.let { "wss://$it:443/nvst/" }.orEmpty(), + gpuType = gpuType, + deviceId = authStore.stableDeviceId(), + ) + } + + private fun SessionInfo.toRecoveryActiveSession(appId: Int, fallbackActive: ActiveSessionInfo?): ActiveSessionInfo? { + if (serverIp.isBlank() || appId <= 0) return null + return ActiveSessionInfo( + sessionId = sessionId, + appId = appId, + gpuType = gpuType ?: fallbackActive?.gpuType, + status = status.takeIf { it in setOf(2, 3) } ?: 2, + queuePosition = queuePosition, + seatSetupStep = seatSetupStep, + streamingBaseUrl = streamingBaseUrl ?: fallbackActive?.streamingBaseUrl, + serverIp = serverIp, + signalingUrl = signalingUrl.takeIf { it.isNotBlank() } ?: fallbackActive?.signalingUrl, + resolution = fallbackActive?.resolution, + fps = fallbackActive?.fps, + settingsSignature = fallbackActive?.settingsSignature, + ) + } + + private fun SessionInfo.toActiveRecoverySession(fallbackActive: ActiveSessionInfo?): ActiveSessionInfo? { + val appId = fallbackActive?.takeIf { it.sessionId == sessionId }?.appId ?: fallbackActive?.appId ?: return null + return toRecoveryActiveSession(appId, fallbackActive) + } + + private fun shouldSendAccountLinked(game: GameInfo, variant: GameVariant?): Boolean { + return shouldLaunchWithAccountLinked(game, variant) + } + + private fun normalizeLaunchError(error: Throwable, gameTitle: String? = null): String = + normalizeLaunchErrorMessage(error, gameTitle) + + private fun AuthSession.toSavedAccount(): SavedAccount = + SavedAccount( + userId = user.userId, + displayName = user.displayName, + email = user.email, + avatarUrl = user.avatarUrl, + membershipTier = user.membershipTier, + providerCode = provider.code, + ) + + private fun AuthSession.withSubscriptionTier(subscription: SubscriptionInfo?): AuthSession { + val tier = subscription?.membershipTier?.takeIf { it.isNotBlank() } ?: return this + return if (user.membershipTier == tier) this else copy(user = user.copy(membershipTier = tier)) + } + + private fun persistSubscriptionTier(session: AuthSession, subscription: SubscriptionInfo?): AuthSession { + val enriched = session.withSubscriptionTier(subscription) + if (enriched != session) authStore.upsertSession(enriched) + return enriched + } + + private fun savedAccountsSnapshot(): List = + authStore.state.value.sessions.map { session -> session.toSavedAccount() } +} + +internal fun externalLaunchIdFromParts( + extras: List, + scheme: String?, + host: String?, + pathSegments: List, + schemeSpecificPart: String?, + queryParameters: Map, +): String? { + val normalizedScheme = scheme.orEmpty().lowercase(Locale.US) + val uriCandidates = if (normalizedScheme == "opennow") { + buildList { + add(queryParameters["id"]) + add(queryParameters["appId"]) + add(queryParameters["launchAppId"]) + val routeHost = host.orEmpty() + if (routeHost.equals("launch", ignoreCase = true)) { + add(pathSegments.firstOrNull()) + } else if (routeHost.isNotBlank()) { + add(routeHost) + } + if (pathSegments.firstOrNull()?.equals("launch", ignoreCase = true) == true) { + add(pathSegments.getOrNull(1)) + } + add(pathSegments.lastOrNull()) + add(schemeSpecificPart) + } + } else { + emptyList() + } + return (extras + uriCandidates).firstNotNullOfOrNull { it.normalizedExternalLaunchId() } +} + +private fun String?.normalizedExternalLaunchId(): String? { + val trimmed = this?.trim()?.trim('/', '?', '#') ?: return null + if (trimmed.isBlank() || trimmed.equals("launch", ignoreCase = true)) return null + val cleaned = trimmed + .removePrefix("//") + .substringBefore('#') + .substringBefore('?') + .trim('/') + if (cleaned.isBlank() || cleaned.equals("launch", ignoreCase = true)) return null + return cleaned.split('/').lastOrNull { it.isNotBlank() && !it.equals("launch", ignoreCase = true) } +} + +private fun shortDebugId(value: String?): String { + val text = value.orEmpty() + if (text.length <= 12) return text + return "${text.take(6)}...${text.takeLast(4)}" +} + +private fun hostForDebug(url: String?): String = + runCatching { Uri.parse(url.orEmpty()).host.orEmpty() } + .getOrDefault("") + .ifBlank { url.orEmpty().take(80) } + +private fun Throwable.debugMessage(): String { + val type = javaClass.simpleName.ifBlank { "Throwable" } + val text = message.orEmpty() + .lineSequence() + .joinToString(" ") { it.trim() } + .take(DEBUG_EVENT_MESSAGE_LIMIT) + return if (text.isBlank()) type else "$type: $text" +} + +private fun StreamSettings.debugSummary(): String = + "res=$resolution aspect=$aspectRatio fps=$fps bitrate=$maxBitrateMbps codec=$codec color=${colorQuality.name} hdr=$hdrEnabled l4s=$enableL4S gsync=$enableCloudGsync sharp=$streamSharpeningEnabled" + +private fun StreamRuntimeStats.hasDebugValues(): Boolean = + bitrateKbps != null || + pingMs != null || + fps != null || + !resolution.isNullOrBlank() || + !codec.isNullOrBlank() + +private fun StreamRuntimeStats.debugSummary(): String = + "bitrateKbps=${bitrateKbps ?: 0} pingMs=${pingMs ?: -1} fps=${fps ?: 0} resolution=${resolution.orEmpty()} codec=${codec.orEmpty()}" + +private fun TimedStreamRuntimeStats.debugSummary(nowMs: Long): String { + val formatter = DateFormat.getTimeInstance(DateFormat.MEDIUM, Locale.US) + val ageMs = (nowMs - capturedAtMs).coerceAtLeast(0L) + return "capturedAt=${formatter.format(Date(capturedAtMs))} ageMs=$ageMs session=${shortDebugId(sessionId)} ${stats.debugSummary()}" +} + +private fun SessionInfo.shortDebugId(): String = shortDebugId(sessionId) + +private fun SessionInfo.debugSummary(): String = + "id=${shortDebugId()} status=$status ready=${isReadyForStream()} queue=${queuePosition ?: "-"} seat=${seatSetupStep ?: "-"} adsRequired=${isSessionAdsRequired(adState)} ads=${sessionAdItems(adState).size} paused=${adState?.isQueuePaused} base=${hostForDebug(streamingBaseUrl)} server=${serverIp.take(80)}" + +private fun ActiveSessionInfo.shortDebugId(): String = shortDebugId(sessionId) + +private fun ActiveSessionInfo.debugSummary(): String = + "id=${shortDebugId()} app=$appId status=$status queue=${queuePosition ?: "-"} seat=${seatSetupStep ?: "-"} base=${hostForDebug(streamingBaseUrl)} server=${serverIp.orEmpty().take(80)} res=${resolution.orEmpty()} fps=${fps ?: 0}" + +private fun NegotiatedStreamProfile.debugSummary(): String = + "res=${resolution.orEmpty()} fps=${fps ?: 0} codec=${codec?.name.orEmpty()} color=${colorQuality?.name.orEmpty()} l4s=$enableL4S gsync=$enableCloudGsync reflex=$enableReflex" + +private fun SessionMonitorSnapshot.debugSummary(): String = + "requested=${requestedResolution.orEmpty()}@${requestedFps ?: 0} " + + "returned=${returnedResolution.orEmpty()}@${returnedFps ?: 0} final=${finalSelectedResolution.orEmpty()}" + +private fun StreamingFeatures.debugSummary(): String = + "reflex=$reflex bitDepth=$bitDepth gsync=$cloudGsync chroma=$chromaFormat l4s=$enabledL4S hdr=$trueHdr" diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/Persistence.kt b/android/app/src/main/java/com/opencloudgaming/opennow/Persistence.kt new file mode 100644 index 000000000..68d754436 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/Persistence.kt @@ -0,0 +1,607 @@ +package com.opencloudgaming.opennow + +import android.content.Context +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put +import kotlinx.serialization.encodeToString +import java.security.MessageDigest +import java.util.UUID +import android.util.Xml +import org.xmlpull.v1.XmlPullParser +import java.io.File +import java.io.FileInputStream +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +private const val STORE_NAME = "opennow_native" +private const val CATALOG_CACHE_STORE_NAME = "opennow_catalog_cache" +private const val SECURE_STORE_NAME = "opennow_auth_secure" +private const val KEY_SETTINGS = "settings" +private const val KEY_AUTH = "auth" +private const val KEY_DEVICE_ID = "gfn_device_id" +private const val KEY_CATALOG_CACHE_PREFIX = "catalog_cache_" +private const val KEY_ANDROID_UPDATE_DISMISSED_NOTICE = "android_update_dismissed_notice" +private const val KEY_QUEUED_GAME_KEYS = "queued_game_keys" +private const val CATALOG_CACHE_TTL_MS = 12L * 60L * 60L * 1000L +private const val QUEUED_GAME_LIMIT = 24 +// Keys that must never be written to the external (potentially world-readable) file. +private val SENSITIVE_KEYS = setOf(KEY_AUTH, KEY_DEVICE_ID) +private val AUTH_STORE_LOCK = Any() + +class ExternalPrefs private constructor(context: Context, val name: String) { + private val primaryFile: File + private val fallbackFile: File + private val data = mutableMapOf() + private val lock = Any() + // Single-thread dispatcher: apply() writes are serialized in submission order + // (last-write-wins) without blocking the caller. + private val writeScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1)) + + init { + val extDir = context.getExternalFilesDir(null) + primaryFile = File(extDir ?: context.filesDir, "$name.xml") + fallbackFile = File(context.filesDir, "$name.xml") + synchronized(lock) { + migrateFromInternal(context, name) + load() + } + } + + companion object { + private val instances = mutableMapOf() + private val globalLock = Any() + + fun get(context: Context, name: String): ExternalPrefs { + return synchronized(globalLock) { + instances.getOrPut(name) { + ExternalPrefs(context.applicationContext, name) + } + } + } + } + + private fun migrateFromInternal(context: Context, name: String) { + if (primaryFile.exists() || fallbackFile.exists()) return + val internalPrefs = context.applicationContext.getSharedPreferences(name, Context.MODE_PRIVATE) + val allInternal = internalPrefs.all + if (allInternal.isNotEmpty()) { + // Sensitive keys must not land in the external (world-readable) file. + // Migrate them directly into the secure internal store instead, + // skipping any key that is already present there. + val securePrefs = context.applicationContext + .getSharedPreferences(SECURE_STORE_NAME, Context.MODE_PRIVATE) + val secureEdit = securePrefs.edit() + var hasSensitive = false + allInternal.forEach { (k, v) -> + if (v is String && k in SENSITIVE_KEYS && !securePrefs.contains(k)) { + secureEdit.putString(k, v) + hasSensitive = true + } + } + if (hasSensitive) secureEdit.commit() + + // Migrate non-sensitive keys to the external file + allInternal.forEach { (k, v) -> + if (v is String && k !in SENSITIVE_KEYS) data[k] = v + } + val primarySuccess = writeToFile(primaryFile, data.toMap()) + val fallbackSuccess = if (!primarySuccess) writeToFile(fallbackFile, data.toMap()) else true + if (primarySuccess || fallbackSuccess) { + internalPrefs.edit().clear().commit() + } + } + } + + private fun load() { + val hasPrimary = primaryFile.exists() + val hasFallback = fallbackFile.exists() + val targetFile = when { + hasPrimary && hasFallback -> { + if (primaryFile.lastModified() >= fallbackFile.lastModified()) { + primaryFile + } else { + fallbackFile + } + } + hasPrimary -> primaryFile + hasFallback -> fallbackFile + else -> return + } + // Snapshot existing data so we can restore it if parsing fails mid-way + val existing = data.toMap() + runCatching { + val parser = Xml.newPullParser() + FileInputStream(targetFile).use { fis -> + parser.setInput(fis, "UTF-8") + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + if (event == XmlPullParser.START_TAG && parser.name == "string") { + val name = parser.getAttributeValue(null, "name") + val value = parser.nextText() + if (name != null) { + data[name] = value + } + } + event = parser.next() + } + } + }.onFailure { + it.printStackTrace() + // Restore pre-parse snapshot to avoid leaving data in a partial state + data.clear() + data.putAll(existing) + val alternativeFile = if (targetFile == primaryFile) fallbackFile else primaryFile + if (alternativeFile.exists()) { + val existingBeforeAlt = data.toMap() + runCatching { + val parser = Xml.newPullParser() + FileInputStream(alternativeFile).use { fis -> + parser.setInput(fis, "UTF-8") + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + if (event == XmlPullParser.START_TAG && parser.name == "string") { + val name = parser.getAttributeValue(null, "name") + val value = parser.nextText() + if (name != null) { + data[name] = value + } + } + event = parser.next() + } + } + }.onFailure { e -> + e.printStackTrace() + // Restore again if the alternative file also failed mid-parse + data.clear() + data.putAll(existingBeforeAlt) + } + } + } + } + + private fun save(mapSnapshot: Map) { + synchronized(lock) { + val success = writeToFile(primaryFile, mapSnapshot) + if (!success) { + writeToFile(fallbackFile, mapSnapshot) + } + } + } + + private fun writeToFile(file: File, mapSnapshot: Map): Boolean { + return runCatching { + val parent = file.parentFile ?: return false + parent.mkdirs() + val tmpFile = File(parent, "${file.name}.tmp") + tmpFile.bufferedWriter().use { writer -> + writer.write("\n") + writer.write("\n") + for ((k, v) in mapSnapshot) { + writer.write(" ") + writer.write(escapeXmlText(v)) + writer.write("\n") + } + writer.write("\n") + } + if (tmpFile.exists()) { + if (tmpFile.renameTo(file)) { + true + } else { + tmpFile.copyTo(file, overwrite = true) + tmpFile.delete() + true + } + } else { + false + } + }.getOrElse { + it.printStackTrace() + false + } + } + + private fun escapeXmlAttribute(str: String): String = + str.replace("&", "&").replace("<", "<").replace(">", ">") + .replace("\"", """).replace("'", "'") + + private fun escapeXmlText(str: String): String = + str.replace("&", "&").replace("<", "<").replace(">", ">") + + fun getString(key: String, defValue: String?): String? = synchronized(lock) { data[key] ?: defValue } + + val all: Map get() = synchronized(lock) { data.toMap() } + + fun edit(): Editor = Editor() + + inner class Editor { + private val actions = mutableListOf<() -> Unit>() + + fun putString(key: String, value: String?): Editor { + actions.add { + if (value == null) data.remove(key) else data[key] = value + } + return this + } + + fun remove(key: String): Editor { + actions.add { data.remove(key) } + return this + } + + // Captures snapshot synchronously under the lock then dispatches the write + // on a single-thread background scope, so: + // - The caller is never blocked by IO + // - Writes are still dispatched in submission order (last-write-wins guaranteed) + fun apply() { + val snapshot = synchronized(lock) { + actions.forEach { it() } + actions.clear() + data.toMap() + } + writeScope.launch { save(snapshot) } + } + + // Single synchronized block keeps action application, snapshot capture, and file + // write atomic — eliminating the interleaving window where a concurrent commit() + // could write a newer snapshot between our two previously-separate lock sections. + fun commit(): Boolean = synchronized(lock) { + actions.forEach { it() } + actions.clear() + val snapshot = data.toMap() + val success = writeToFile(primaryFile, snapshot) + if (!success) writeToFile(fallbackFile, snapshot) else true + } + } +} + +class SettingsStore(context: Context) { + private val prefs = ExternalPrefs.get(context, STORE_NAME) + private val androidTvProfile = isAndroidTvProfile(context) + private val _settings = MutableStateFlow(load().withCurrentStreamPresentationDefaults(androidTvProfile)) + val settings: StateFlow = _settings + + private fun load(): AppSettings { + val raw = prefs.getString(KEY_SETTINGS, null) ?: return AppSettings() + return runCatching { OpenNowJson.decodeFromString(raw) }.getOrElse { AppSettings() } + } + + fun update(transform: (AppSettings) -> AppSettings) { + val next = transform(_settings.value) + .withCurrentStreamPresentationDefaults(androidTvProfile) + .normalizedForAndroid() + prefs.edit().putString(KEY_SETTINGS, OpenNowJson.encodeToString(next)).commit() + _settings.value = next + } + + fun replace(next: AppSettings) { + val normalized = next + .withCurrentStreamPresentationDefaults(androidTvProfile) + .normalizedForAndroid() + prefs.edit().putString(KEY_SETTINGS, OpenNowJson.encodeToString(normalized)).commit() + _settings.value = normalized + } + + fun reset() { + replace(AppSettings()) + } + + private fun AppSettings.normalizedForAndroid(): AppSettings { + val compatibleStream = stream.withCodecColorCompatibility() + val lowPowerSafe = compatibleStream.copy( + codec = compatibleStream.codec, + sessionProxyUrl = stream.sessionProxyUrl.trim(), + maxBitrateMbps = compatibleStream.maxBitrateMbps.coerceIn(1, 150), + fps = compatibleStream.fps.coerceIn(30, 240), + streamSharpeningAmount = compatibleStream.streamSharpeningAmount.coerceIn(0f, 1f), + ) + return copy( + stream = lowPowerSafe, + posterSizeScale = posterSizeScale.coerceIn(MIN_GAME_CARD_SCALE, MAX_GAME_CARD_SCALE), + androidTouch = androidTouch.copy( + opacity = androidTouch.opacity.coerceIn(0.15f, 1f), + scale = androidTouch.scale.coerceIn(0.6f, 1.4f), + buttonScale = androidTouch.buttonScale.coerceIn(0.65f, 1.5f), + stickScale = androidTouch.stickScale.coerceIn(0.65f, 1.5f), + joystickDeadZone = androidTouch.joystickDeadZone.coerceIn(0f, 0.3f), + edgePaddingDp = androidTouch.edgePaddingDp.coerceIn(0f, 72f), + bottomPaddingDp = androidTouch.bottomPaddingDp.coerceIn(0f, 120f), + leftOffsetXDp = androidTouch.leftOffsetXDp.coerceIn(-220f, 220f), + leftOffsetYDp = androidTouch.leftOffsetYDp.coerceIn(-160f, 160f), + rightOffsetXDp = androidTouch.rightOffsetXDp.coerceIn(-220f, 220f), + rightOffsetYDp = androidTouch.rightOffsetYDp.coerceIn(-160f, 160f), + offsets = androidTouch.offsets.mapValues { (_, offset) -> + TouchOffset( + x = offset.x.coerceIn(-320f, 320f), + y = offset.y.coerceIn(-320f, 320f) + ) + }, + ), + streamIntroMusic = streamIntroMusic, + queueReadyMusic = queueReadyMusic, + legacyCropStreamToFill = false, + stretchStreamToFit = stretchStreamToFit, + streamPresentationProfileVersion = streamPresentationProfileVersion.coerceAtLeast(STREAM_PRESENTATION_PROFILE_VERSION), + nerdCatalogBackgroundUri = nerdCatalogBackgroundUri?.trim()?.takeIf { it.isNotBlank() }, + tvSafeAreaPaddingDp = tvSafeAreaPaddingDp.coerceIn(0f, 120f), + tvLayoutProfileVersion = tvLayoutProfileVersion.coerceAtLeast(0), + controllerUiSounds = controllerUiSounds, + autoFullScreen = true, + ) + } +} + +class AuthStore(context: Context) { + private val sharedPrefs = context.applicationContext.getSharedPreferences(SECURE_STORE_NAME, Context.MODE_PRIVATE) + private val _state = MutableStateFlow(loadAndMigrate(context)) + val state: StateFlow = _state + + private fun loadAndMigrate(context: Context): PersistedAuthState { + val legacyPrefs = ExternalPrefs.get(context, STORE_NAME) + + // Migrate auth credentials if not yet in secure storage + val hasSecureAuth = sharedPrefs.contains(KEY_AUTH) + var migratedState: PersistedAuthState? = null + if (!hasSecureAuth) { + val legacyRaw = legacyPrefs.getString(KEY_AUTH, null) + if (!legacyRaw.isNullOrBlank()) { + val parsed = runCatching { OpenNowJson.decodeFromString(legacyRaw) }.getOrNull() + if (parsed != null) { + val secureCommitSuccess = sharedPrefs.edit().putString(KEY_AUTH, legacyRaw).commit() + if (secureCommitSuccess) { + migratedState = parsed + legacyPrefs.edit().remove(KEY_AUTH).commit() + } + } + } + } + + // Migrate device ID independently — always run even if auth was already migrated, + // since hasSecureAuth being true does not guarantee KEY_DEVICE_ID is in secure storage. + if (!sharedPrefs.contains(KEY_DEVICE_ID)) { + val legacyDeviceId = legacyPrefs.getString(KEY_DEVICE_ID, null) + if (!legacyDeviceId.isNullOrBlank()) { + val secureCommitSuccess = sharedPrefs.edit().putString(KEY_DEVICE_ID, legacyDeviceId).commit() + if (secureCommitSuccess) { + legacyPrefs.edit().remove(KEY_DEVICE_ID).commit() + } + } + } + + if (migratedState != null) { + return migratedState + } + return load() + } + + private fun load(): PersistedAuthState { + val raw = sharedPrefs.getString(KEY_AUTH, null) ?: return PersistedAuthState() + return runCatching { OpenNowJson.decodeFromString(raw) }.getOrElse { PersistedAuthState() } + } + + fun reload(): PersistedAuthState = synchronized(AUTH_STORE_LOCK) { + load().also { latest -> _state.value = latest } + } + + fun save(next: PersistedAuthState) = synchronized(AUTH_STORE_LOCK) { + sharedPrefs.edit().putString(KEY_AUTH, OpenNowJson.encodeToString(next)).commit() + _state.value = next + } + + fun activeSession(): AuthSession? = synchronized(AUTH_STORE_LOCK) { + val state = _state.value + state.sessions.firstOrNull { it.user.userId == state.activeUserId } ?: state.sessions.firstOrNull() + } + + fun setActiveSession(userId: String) = synchronized(AUTH_STORE_LOCK) { + val current = _state.value + val session = current.sessions.firstOrNull { it.user.userId == userId } ?: return@synchronized + save(current.copy(activeUserId = session.user.userId, selectedProvider = session.provider)) + } + + fun upsertSession(session: AuthSession) = synchronized(AUTH_STORE_LOCK) { + val current = _state.value + val sessions = buildList { + add(session) + addAll(current.sessions.filterNot { it.user.userId == session.user.userId }) + } + save( + current.copy( + sessions = sessions, + activeUserId = session.user.userId, + selectedProvider = session.provider, + ), + ) + } + + fun updateSessionIfUnchanged(expected: AuthSession, updated: AuthSession): Boolean = synchronized(AUTH_STORE_LOCK) { + val current = _state.value + val existing = current.sessions.firstOrNull { it.user.userId == expected.user.userId } + if (existing != expected) return@synchronized false + val sessions = current.sessions.map { session -> + if (session.user.userId == expected.user.userId) updated else session + } + save(current.copy(sessions = sessions)) + true + } + + fun removeSession(userId: String) = synchronized(AUTH_STORE_LOCK) { + val current = _state.value + val sessions = current.sessions.filterNot { it.user.userId == userId } + save(current.copy(sessions = sessions, activeUserId = sessions.firstOrNull()?.user?.userId)) + } + + fun clear() = synchronized(AUTH_STORE_LOCK) { + save(PersistedAuthState()) + } + + fun stableDeviceId(): String { + val existing = sharedPrefs.getString(KEY_DEVICE_ID, null) + if (!existing.isNullOrBlank()) return existing + val next = UUID.randomUUID().toString() + sharedPrefs.edit().putString(KEY_DEVICE_ID, next).commit() + return next + } +} + +class CatalogCacheStore(context: Context) { + private val appContext = context.applicationContext + + init { + // Catalog payloads reached multiple megabytes and shared a file with ordinary + // settings. Every small settings commit therefore rewrote the entire catalog on + // the UI thread. Drop the old cache (it is disposable) and keep it isolated. + val legacyPrefs = ExternalPrefs.get(appContext, STORE_NAME) + val legacyKeys = legacyPrefs.all.keys.filter { it.startsWith(KEY_CATALOG_CACHE_PREFIX) } + if (legacyKeys.isNotEmpty()) { + legacyPrefs.edit().apply { + legacyKeys.forEach(::remove) + }.commit() + } + } + + // Cache construction can parse a sizeable file, so defer it until a caller already + // running on Dispatchers.IO asks for cache data. + private val prefs by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + ExternalPrefs.get(appContext, CATALOG_CACHE_STORE_NAME) + } + + fun loadMainGames(userId: String, providerStreamingBaseUrl: String): List? = + loadGameList(key("main", userId, providerStreamingBaseUrl)) + + fun saveMainGames(userId: String, providerStreamingBaseUrl: String, games: List) { + saveGameList(key("main", userId, providerStreamingBaseUrl), games) + } + + fun loadLibraryGames(userId: String, providerStreamingBaseUrl: String): List? = + loadGameList(key("library", userId, providerStreamingBaseUrl)) + + fun saveLibraryGames(userId: String, providerStreamingBaseUrl: String, games: List) { + saveGameList(key("library", userId, providerStreamingBaseUrl), games) + } + + fun loadCatalog( + userId: String, + providerStreamingBaseUrl: String, + searchQuery: String, + sortId: String, + filterIds: List, + ): CatalogBrowseResult? = + load(key("catalog", userId, providerStreamingBaseUrl, searchQuery, sortId, filterIds.sorted().joinToString(","))) + + fun saveCatalog( + userId: String, + providerStreamingBaseUrl: String, + searchQuery: String, + sortId: String, + filterIds: List, + result: CatalogBrowseResult, + ) { + save(key("catalog", userId, providerStreamingBaseUrl, searchQuery, sortId, filterIds.sorted().joinToString(",")), result) + } + + fun clear(): Int { + val keys = prefs.all.keys.filter { it.startsWith(KEY_CATALOG_CACHE_PREFIX) } + if (keys.isEmpty()) return 0 + prefs.edit().apply { + keys.forEach(::remove) + }.apply() + return keys.size + } + + private fun loadGameList(key: String): List? = + load(key, ListSerializer(GameInfo.serializer())) + + private fun saveGameList(key: String, games: List) { + save(key, games, ListSerializer(GameInfo.serializer())) + } + + private inline fun load(key: String): T? = + runCatching { + val raw = prefs.getString(storageKey(key), null) ?: return null + val obj = OpenNowJson.parseToJsonElement(raw).jsonObject + val expiresAt = obj["expiresAt"]?.jsonPrimitive?.longOrNull ?: return null + if (System.currentTimeMillis() > expiresAt) return null + val data = obj["data"] ?: return null + OpenNowJson.decodeFromJsonElement(data) + }.getOrNull() + + private fun load(key: String, serializer: kotlinx.serialization.KSerializer): T? = + runCatching { + val raw = prefs.getString(storageKey(key), null) ?: return null + val obj = OpenNowJson.parseToJsonElement(raw).jsonObject + val expiresAt = obj["expiresAt"]?.jsonPrimitive?.longOrNull ?: return null + if (System.currentTimeMillis() > expiresAt) return null + val data = obj["data"] ?: return null + OpenNowJson.decodeFromJsonElement(serializer, data) + }.getOrNull() + + private inline fun save(key: String, data: T) { + val now = System.currentTimeMillis() + val payload = kotlinx.serialization.json.buildJsonObject { + put("expiresAt", kotlinx.serialization.json.JsonPrimitive(now + CATALOG_CACHE_TTL_MS)) + put("data", OpenNowJson.encodeToJsonElement(data)) + } + prefs.edit().putString(storageKey(key), payload.toString()).apply() + } + + private fun save(key: String, data: T, serializer: kotlinx.serialization.KSerializer) { + val now = System.currentTimeMillis() + val payload = kotlinx.serialization.json.buildJsonObject { + put("expiresAt", kotlinx.serialization.json.JsonPrimitive(now + CATALOG_CACHE_TTL_MS)) + put("data", OpenNowJson.encodeToJsonElement(serializer, data)) + } + prefs.edit().putString(storageKey(key), payload.toString()).apply() + } + + private fun key(vararg parts: String): String = + parts.joinToString("|") { it.trim() } + + private fun storageKey(key: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(key.toByteArray()) + return KEY_CATALOG_CACHE_PREFIX + digest.joinToString("") { "%02x".format(it) } + } +} + +class QueuedGameStore(context: Context) { + private val prefs = ExternalPrefs.get(context, STORE_NAME) + + fun load(): List { + val raw = prefs.getString(KEY_QUEUED_GAME_KEYS, null) ?: return emptyList() + return runCatching { OpenNowJson.decodeFromString>(raw) } + .getOrElse { emptyList() } + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + .take(QUEUED_GAME_LIMIT) + } + + fun record(gameKey: String): List { + val normalized = gameKey.trim() + if (normalized.isBlank()) return load() + val next = (listOf(normalized) + load().filterNot { it == normalized }) + .take(QUEUED_GAME_LIMIT) + prefs.edit().putString(KEY_QUEUED_GAME_KEYS, OpenNowJson.encodeToString(next)).apply() + return next + } +} + +class AndroidUpdateNoticeStore(context: Context) { + private val prefs = ExternalPrefs.get(context, STORE_NAME) + + fun dismissedKey(): String? = + prefs.getString(KEY_ANDROID_UPDATE_DISMISSED_NOTICE, null)?.takeIf { it.isNotBlank() } + + fun dismiss(key: String) { + prefs.edit().putString(KEY_ANDROID_UPDATE_DISMISSED_NOTICE, key).apply() + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/QrCode.kt b/android/app/src/main/java/com/opencloudgaming/opennow/QrCode.kt new file mode 100644 index 000000000..77766f83e --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/QrCode.kt @@ -0,0 +1,228 @@ +package com.opencloudgaming.opennow + +private val QR_DATA_CODEWORDS_LOW = intArrayOf(0, 19, 34, 55, 80, 108, 136, 156, 194, 232) +private val QR_ECC_CODEWORDS_LOW = intArrayOf(0, 7, 10, 15, 20, 26, 18, 20, 24, 30) +private val QR_BLOCKS_LOW = intArrayOf(0, 1, 1, 1, 1, 1, 2, 2, 2, 2) +private val QR_ALIGN_POSITIONS = arrayOf( + intArrayOf(), + intArrayOf(), + intArrayOf(6, 18), + intArrayOf(6, 22), + intArrayOf(6, 26), + intArrayOf(6, 30), + intArrayOf(6, 34), + intArrayOf(6, 22, 38), + intArrayOf(6, 24, 42), + intArrayOf(6, 26, 46), +) + +data class QrCode( + val size: Int, + private val modules: BooleanArray, +) { + fun isDark(x: Int, y: Int): Boolean = + x in 0 until size && y in 0 until size && modules[y * size + x] + + companion object { + fun encodeText(text: String): QrCode? { + val bytes = text.encodeToByteArray() + val version = (1 until QR_DATA_CODEWORDS_LOW.size).firstOrNull { version -> + val capacityBits = QR_DATA_CODEWORDS_LOW[version] * 8 + 4 + 8 + bytes.size * 8 <= capacityBits + } ?: return null + return encodeBytes(bytes, version) + } + + private fun encodeBytes(bytes: ByteArray, version: Int): QrCode { + val dataCodewords = QR_DATA_CODEWORDS_LOW[version] + val bits = ArrayList(dataCodewords * 8) + appendBits(bits, 0x4, 4) + appendBits(bits, bytes.size, 8) + bytes.forEach { appendBits(bits, it.toInt() and 0xff, 8) } + repeat(minOf(4, dataCodewords * 8 - bits.size)) { bits += false } + while (bits.size % 8 != 0) bits += false + val data = ArrayList(dataCodewords) + for (i in bits.indices step 8) { + var value = 0 + for (j in 0 until 8) value = (value shl 1) or if (bits[i + j]) 1 else 0 + data += value + } + var pad = 0xec + while (data.size < dataCodewords) { + data += pad + pad = pad xor 0xfd + } + val allCodewords = addErrorCorrection(data, version) + val builder = QrBuilder(version) + builder.drawFunctionPatterns() + builder.drawFormatBits(mask = 0) + builder.drawCodewords(allCodewords) + builder.drawFormatBits(mask = 0) + return QrCode(builder.size, builder.modules) + } + + private fun appendBits(bits: MutableList, value: Int, count: Int) { + for (i in count - 1 downTo 0) bits += ((value ushr i) and 1) != 0 + } + + private fun addErrorCorrection(data: List, version: Int): List { + val blockCount = QR_BLOCKS_LOW[version] + val eccLen = QR_ECC_CODEWORDS_LOW[version] + val generator = reedSolomonGenerator(eccLen) + val shortBlockLen = data.size / blockCount + val blocks = (0 until blockCount).map { blockIndex -> + val start = blockIndex * shortBlockLen + data.subList(start, start + shortBlockLen) + } + val eccBlocks = blocks.map { reedSolomonRemainder(it, generator) } + val output = ArrayList(data.size + blockCount * eccLen) + for (i in 0 until shortBlockLen) { + blocks.forEach { output += it[i] } + } + for (i in 0 until eccLen) { + eccBlocks.forEach { output += it[i] } + } + return output + } + + private fun reedSolomonGenerator(degree: Int): IntArray { + val result = IntArray(degree) + result[degree - 1] = 1 + var root = 1 + repeat(degree) { + for (i in result.indices) { + result[i] = gfMultiply(result[i], root) + if (i + 1 < result.size) { + result[i] = result[i] xor result[i + 1] + } + } + root = gfMultiply(root, 2) + } + return result + } + + private fun reedSolomonRemainder(data: List, generator: IntArray): IntArray { + val result = IntArray(generator.size) + data.forEach { value -> + val factor = value xor result[0] + for (i in 0 until result.lastIndex) result[i] = result[i + 1] + result[result.lastIndex] = 0 + for (i in generator.indices) result[i] = result[i] xor gfMultiply(generator[i], factor) + } + return result + } + + private fun gfMultiply(x: Int, y: Int): Int { + var a = x + var b = y + var result = 0 + while (b != 0) { + if ((b and 1) != 0) result = result xor a + a = a shl 1 + if ((a and 0x100) != 0) a = a xor 0x11d + b = b ushr 1 + } + return result + } + } +} + +private class QrBuilder(private val version: Int) { + val size = version * 4 + 17 + val modules = BooleanArray(size * size) + private val functionModules = BooleanArray(size * size) + + fun drawFunctionPatterns() { + drawFinder(3, 3) + drawFinder(size - 4, 3) + drawFinder(3, size - 4) + for (i in 8 until size - 8) { + setFunction(6, i, i % 2 == 0) + setFunction(i, 6, i % 2 == 0) + } + val align = QR_ALIGN_POSITIONS[version] + for (x in align) { + for (y in align) { + val overlapsFinder = (x == 6 && y == 6) || (x == 6 && y == size - 7) || (x == size - 7 && y == 6) + if (!overlapsFinder) drawAlignment(x, y) + } + } + setFunction(8, size - 8, true) + } + + fun drawCodewords(codewords: List) { + var bitIndex = 0 + var upward = true + var right = size - 1 + while (right >= 1) { + if (right == 6) right-- + for (vertical in 0 until size) { + val y = if (upward) size - 1 - vertical else vertical + for (dx in 0..1) { + val x = right - dx + if (functionModules[index(x, y)]) continue + val bit = if (bitIndex < codewords.size * 8) { + ((codewords[bitIndex / 8] ushr (7 - bitIndex % 8)) and 1) != 0 + } else { + false + } + val masked = bit xor ((x + y) % 2 == 0) + modules[index(x, y)] = masked + bitIndex++ + } + } + upward = !upward + right -= 2 + } + } + + fun drawFormatBits(mask: Int) { + val bits = formatBits(mask) + for (i in 0..5) setFunction(8, i, bit(bits, i)) + setFunction(8, 7, bit(bits, 6)) + setFunction(8, 8, bit(bits, 7)) + setFunction(7, 8, bit(bits, 8)) + for (i in 9..14) setFunction(14 - i, 8, bit(bits, i)) + for (i in 0..7) setFunction(size - 1 - i, 8, bit(bits, i)) + for (i in 8..14) setFunction(8, size - 15 + i, bit(bits, i)) + setFunction(8, size - 8, true) + } + + private fun drawFinder(cx: Int, cy: Int) { + for (dy in -4..4) { + for (dx in -4..4) { + val x = cx + dx + val y = cy + dy + if (x !in 0 until size || y !in 0 until size) continue + val dist = maxOf(kotlin.math.abs(dx), kotlin.math.abs(dy)) + setFunction(x, y, dist != 2 && dist != 4) + } + } + } + + private fun drawAlignment(cx: Int, cy: Int) { + for (dy in -2..2) { + for (dx in -2..2) { + setFunction(cx + dx, cy + dy, maxOf(kotlin.math.abs(dx), kotlin.math.abs(dy)) != 1) + } + } + } + + private fun setFunction(x: Int, y: Int, dark: Boolean) { + modules[index(x, y)] = dark + functionModules[index(x, y)] = true + } + + private fun index(x: Int, y: Int): Int = y * size + x + + private fun formatBits(mask: Int): Int { + var data = (1 shl 3) or mask + var rem = data + repeat(10) { + rem = (rem shl 1) xor if ((rem and (1 shl 9)) != 0) 0x537 else 0 + } + return ((data shl 10) or rem) xor 0x5412 + } + + private fun bit(value: Int, index: Int): Boolean = ((value ushr index) and 1) != 0 +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/QueueLaunchStatus.kt b/android/app/src/main/java/com/opencloudgaming/opennow/QueueLaunchStatus.kt new file mode 100644 index 000000000..08810ba0e --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/QueueLaunchStatus.kt @@ -0,0 +1,31 @@ +package com.opencloudgaming.opennow + +internal fun queueLaunchStatusText(state: OpenNowUiState): String { + val session = state.streamSession + val queuePosition = queueDisplayPosition(state) + return when { + queuePosition != null -> "Queue position $queuePosition" + session?.seatSetupStep == 1 -> "Waiting for a rig" + state.launchPhase.equals("Connecting stream", ignoreCase = true) -> "Connecting stream" + state.launchPhase.equals("Resuming session", ignoreCase = true) -> "Resuming session" + state.launchPhase.equals("Setting up rig", ignoreCase = true) -> "Setting up rig" + else -> "Starting session" + } +} + +internal fun queueDisplayPosition(state: OpenNowUiState): Int? { + val session = state.streamSession + if (session?.seatSetupStep == 5) return null + return state.queuePosition?.takeIf { it > 0 } ?: queueDisplayPosition(session) +} + +internal fun queueDisplayPosition(session: SessionInfo?): Int? { + if (session?.seatSetupStep == 5) return null + return session?.queuePosition?.takeIf { it > 0 } +} + +internal fun shouldShowQueueLaunchStatus(state: OpenNowUiState): Boolean { + if (state.streamStatus == "idle") return false + val sessionStatus = state.streamSession?.status + return sessionStatus == null || sessionStatus !in setOf(2, 3) +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/RapidTapTracker.kt b/android/app/src/main/java/com/opencloudgaming/opennow/RapidTapTracker.kt new file mode 100644 index 000000000..9d2c9496f --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/RapidTapTracker.kt @@ -0,0 +1,24 @@ +package com.opencloudgaming.opennow + +internal class RapidTapTracker( + private val requiredTapCount: Int = 10, + private val windowMs: Long = 8_000L, +) { + private val tapTimes = ArrayDeque() + + init { + require(requiredTapCount > 0) { "Tap count must be positive." } + require(windowMs > 0) { "Tap window must be positive." } + } + + fun recordTap(nowMs: Long): Boolean { + while (tapTimes.firstOrNull()?.let { nowMs - it > windowMs } == true) { + tapTimes.removeFirst() + } + tapTimes.addLast(nowMs) + if (tapTimes.size < requiredTapCount) return false + + tapTimes.clear() + return true + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/SessionReport.kt b/android/app/src/main/java/com/opencloudgaming/opennow/SessionReport.kt new file mode 100644 index 000000000..a87724e3e --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/SessionReport.kt @@ -0,0 +1,556 @@ +package com.opencloudgaming.opennow + +import kotlin.math.roundToInt + +enum class SessionReportRating(val label: String) { + Excellent("Excellent"), + Good("Good"), + Fair("Fair"), + Poor("Needs work"), +} + +enum class SessionReportFindingKind { + Info, + Warning, +} + +data class SessionReportFinding( + val title: String, + val detail: String, + val kind: SessionReportFindingKind = SessionReportFindingKind.Info, +) + +internal data class StreamReportLaunchProfile( + val gameTitle: String, + val selectedSettings: StreamSettings, + val eligibleSettings: StreamSettings, + val initialSettings: StreamSettings, +) + +data class SessionReport( + val gameTitle: String, + val score: Int, + val rating: SessionReportRating, + val durationSeconds: Int, + val sampleCount: Int, + val limitedData: Boolean, + val averagePingMs: Int?, + val peakPingMs: Int?, + val averageBitrateKbps: Int?, + val peakBitrateKbps: Int?, + val packetLossPct: Double?, + val averageJitterMs: Double?, + val averageFps: Double?, + val targetFps: Int, + val averageDecodeMs: Double?, + val requestedResolution: String, + val deliveredResolution: String?, + val requestedCodec: VideoCodec, + val deliveredCodec: String?, + val networkKind: AndroidNetworkKind, + val wifiBand: AndroidWifiBand, + val estimatedLinkDownstreamKbps: Int?, + val downgrades: List, + val recommendations: List, +) + +internal class StreamSessionReportAccumulator( + private val launchProfile: StreamReportLaunchProfile, + private val startedAtMs: Long, +) { + private var sampleCount = 0 + private var pingCount = 0 + private var pingTotal = 0L + private var peakPingMs: Int? = null + private var bitrateCount = 0 + private var bitrateTotal = 0L + private var peakBitrateKbps: Int? = null + private var jitterCount = 0 + private var jitterTotal = 0.0 + private var fpsCount = 0 + private var fpsTotal = 0L + private var decodeCount = 0 + private var decodeTotal = 0.0 + private var packetLossSampleCount = 0 + private var packetLossSampleTotal = 0.0 + private var packetsLost = 0L + private var packetsReceived = 0L + private var hasPacketDeltas = false + private var lastResolution: String? = null + private var lastCodec: String? = null + private val networkKindCounts = mutableMapOf() + private val wifiBandCounts = mutableMapOf() + private var linkEstimateCount = 0 + private var linkEstimateTotal = 0L + private var lowestNetworkBars: Int? = null + private var finalSettings = launchProfile.initialSettings + private var recoveryReason: String? = null + private var activeMode: ActiveStreamModeStatus? = null + + fun record(stats: StreamRuntimeStats, network: AndroidRuntimeDiagnosticsSnapshot? = null) { + if (stats.hasSessionReportValues()) { + sampleCount += 1 + } + stats.pingMs?.takeIf { it >= 0 }?.let { value -> + pingCount += 1 + pingTotal += value + peakPingMs = maxOf(peakPingMs ?: value, value) + } + stats.bitrateKbps?.takeIf { it >= 0 }?.let { value -> + bitrateCount += 1 + bitrateTotal += value + peakBitrateKbps = maxOf(peakBitrateKbps ?: value, value) + } + stats.jitterMs?.takeIf { it >= 0.0 }?.let { value -> + jitterCount += 1 + jitterTotal += value + } + stats.fps?.takeIf { it > 0 }?.let { value -> + fpsCount += 1 + fpsTotal += value + } + stats.decodeMs?.takeIf { it >= 0.0 }?.let { value -> + decodeCount += 1 + decodeTotal += value + } + val lostDelta = stats.packetsLostDelta + val receivedDelta = stats.packetsReceivedDelta + if (lostDelta != null && receivedDelta != null && lostDelta >= 0L && receivedDelta >= 0L) { + hasPacketDeltas = true + packetsLost += lostDelta + packetsReceived += receivedDelta + } else { + stats.packetLossPct?.takeIf { it >= 0.0 }?.let { value -> + packetLossSampleCount += 1 + packetLossSampleTotal += value + } + } + stats.resolution?.takeIf { parseResolutionPixelsOrNull(it) != null }?.let { lastResolution = it } + stats.codec?.takeIf { it.isNotBlank() }?.let { lastCodec = it } + network?.let(::recordNetwork) + } + + fun recordRecovery(reason: String, settings: StreamSettings) { + recoveryReason = reason.trim().takeIf { it.isNotEmpty() } + finalSettings = settings + } + + fun recordActiveMode(status: ActiveStreamModeStatus) { + activeMode = status + if (status.safeVideoRecoveryActive) { + finalSettings = finalSettings.copy(codec = status.transportCodec) + } + } + + fun finish(finishedAtMs: Long): SessionReport? { + if (sampleCount == 0) return null + val averagePingMs = averageLong(pingTotal, pingCount)?.roundToInt() + val averageBitrateKbps = averageLong(bitrateTotal, bitrateCount)?.roundToInt() + val averageJitterMs = averageDouble(jitterTotal, jitterCount) + val averageFps = averageLong(fpsTotal, fpsCount) + val averageDecodeMs = averageDouble(decodeTotal, decodeCount) + val packetLossPct = if (hasPacketDeltas && packetsLost + packetsReceived > 0L) { + packetsLost.toDouble() / (packetsLost + packetsReceived).toDouble() * 100.0 + } else { + averageDouble(packetLossSampleTotal, packetLossSampleCount) + } + val networkKind = dominantValue(networkKindCounts, AndroidNetworkKind.Unknown) + val wifiBand = dominantValue(wifiBandCounts, AndroidWifiBand.Unknown) + val estimatedLinkDownstreamKbps = averageLong(linkEstimateTotal, linkEstimateCount)?.roundToInt() + val mode = activeMode + val deliveredResolution = mode?.displayedResolution ?: lastResolution + val deliveredCodec = lastCodec ?: finalSettings.codec.name + val score = sessionQualityScore( + averagePingMs = averagePingMs, + packetLossPct = packetLossPct, + averageJitterMs = averageJitterMs, + averageFps = averageFps, + targetFps = launchProfile.initialSettings.fps, + averageDecodeMs = averageDecodeMs, + ) + val downgrades = buildSessionDowngrades( + launchProfile = launchProfile, + finalSettings = finalSettings, + deliveredResolution = deliveredResolution, + deliveredCodec = deliveredCodec, + activeMode = mode, + recoveryReason = recoveryReason, + ) + val recommendations = buildSessionRecommendations( + averagePingMs = averagePingMs, + packetLossPct = packetLossPct, + averageJitterMs = averageJitterMs, + averageFps = averageFps, + averageDecodeMs = averageDecodeMs, + targetFps = launchProfile.initialSettings.fps, + targetBitrateMbps = launchProfile.initialSettings.maxBitrateMbps, + averageBitrateKbps = averageBitrateKbps, + networkKind = networkKind, + wifiBand = wifiBand, + estimatedLinkDownstreamKbps = estimatedLinkDownstreamKbps, + lowestNetworkBars = lowestNetworkBars, + ) + return SessionReport( + gameTitle = launchProfile.gameTitle.ifBlank { "Cloud session" }, + score = score, + rating = sessionReportRating(score), + durationSeconds = ((finishedAtMs - startedAtMs).coerceAtLeast(0L) / 1000L) + .coerceAtMost(Int.MAX_VALUE.toLong()) + .toInt(), + sampleCount = sampleCount, + limitedData = sampleCount < MIN_CONFIDENT_SESSION_REPORT_SAMPLES, + averagePingMs = averagePingMs, + peakPingMs = peakPingMs, + averageBitrateKbps = averageBitrateKbps, + peakBitrateKbps = peakBitrateKbps, + packetLossPct = packetLossPct, + averageJitterMs = averageJitterMs, + averageFps = averageFps, + targetFps = launchProfile.initialSettings.fps, + averageDecodeMs = averageDecodeMs, + requestedResolution = streamResolutionLabel(launchProfile.initialSettings), + deliveredResolution = deliveredResolution, + requestedCodec = launchProfile.initialSettings.codec, + deliveredCodec = deliveredCodec, + networkKind = networkKind, + wifiBand = wifiBand, + estimatedLinkDownstreamKbps = estimatedLinkDownstreamKbps, + downgrades = downgrades, + recommendations = recommendations, + ) + } + + private fun recordNetwork(network: AndroidRuntimeDiagnosticsSnapshot) { + networkKindCounts[network.networkKind] = (networkKindCounts[network.networkKind] ?: 0) + 1 + if (network.networkKind == AndroidNetworkKind.Wifi) { + wifiBandCounts[network.wifiBand] = (wifiBandCounts[network.wifiBand] ?: 0) + 1 + } + network.networkDownstreamKbps?.takeIf { it > 0 }?.let { value -> + linkEstimateCount += 1 + linkEstimateTotal += value + } + network.networkSignalBars?.let { value -> + lowestNetworkBars = minOf(lowestNetworkBars ?: value, value) + } + } +} + +internal fun sessionQualityScore( + averagePingMs: Int?, + packetLossPct: Double?, + averageJitterMs: Double?, + averageFps: Double?, + targetFps: Int, + averageDecodeMs: Double?, +): Int { + val components = buildList { + averagePingMs?.let { add(weightedScore(latencyScore(it), 35)) } + packetLossPct?.let { add(weightedScore(packetLossScore(it), 30)) } + averageJitterMs?.let { add(weightedScore(jitterScore(it), 15)) } + averageFps?.let { add(weightedScore(frameRateScore(it, targetFps), 15)) } + averageDecodeMs?.let { add(weightedScore(decodeScore(it, targetFps), 5)) } + } + if (components.isEmpty()) return 50 + val weightedTotal = components.sumOf { it.first } + val availableWeight = components.sumOf { it.second } + return (weightedTotal / availableWeight.toDouble()).roundToInt().coerceIn(0, 100) +} + +internal fun sessionReportRating(score: Int): SessionReportRating = when { + score >= 90 -> SessionReportRating.Excellent + score >= 75 -> SessionReportRating.Good + score >= 60 -> SessionReportRating.Fair + else -> SessionReportRating.Poor +} + +private fun latencyScore(value: Int): Int = when { + value <= 30 -> 100 + value <= 50 -> 92 + value <= 80 -> 80 + value <= 120 -> 60 + value <= 180 -> 35 + else -> 10 +} + +private fun packetLossScore(value: Double): Int = when { + value <= 0.1 -> 100 + value <= 0.5 -> 90 + value <= 1.0 -> 75 + value <= 2.0 -> 55 + value <= 5.0 -> 25 + else -> 5 +} + +private fun jitterScore(value: Double): Int = when { + value <= 5.0 -> 100 + value <= 10.0 -> 90 + value <= 20.0 -> 70 + value <= 30.0 -> 50 + value <= 50.0 -> 25 + else -> 5 +} + +private fun frameRateScore(value: Double, targetFps: Int): Int { + val ratio = value / targetFps.coerceAtLeast(1).toDouble() + return when { + ratio >= 0.98 -> 100 + ratio >= 0.95 -> 95 + ratio >= 0.90 -> 82 + ratio >= 0.80 -> 60 + ratio >= 0.65 -> 35 + else -> 10 + } +} + +private fun decodeScore(value: Double, targetFps: Int): Int { + val frameBudgetMs = 1000.0 / targetFps.coerceAtLeast(1).toDouble() + val ratio = value / frameBudgetMs + return when { + ratio <= 0.50 -> 100 + ratio <= 0.75 -> 90 + ratio <= 1.00 -> 75 + ratio <= 1.50 -> 45 + else -> 15 + } +} + +private fun buildSessionDowngrades( + launchProfile: StreamReportLaunchProfile, + finalSettings: StreamSettings, + deliveredResolution: String?, + deliveredCodec: String?, + activeMode: ActiveStreamModeStatus?, + recoveryReason: String?, +): List = buildList { + val selected = launchProfile.selectedSettings + val eligible = launchProfile.eligibleSettings + val initial = launchProfile.initialSettings + if ( + selected.resolution != eligible.resolution || + selected.fps != eligible.fps || + selected.hdrEnabled != eligible.hdrEnabled + ) { + add( + SessionReportFinding( + title = "Account or session limit", + detail = "Your saved ${profileSummary(selected)} profile was limited to ${profileSummary(eligible)} before launch based on the features available to this session.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + if (selected.codec != eligible.codec || selected.colorQuality != eligible.colorQuality) { + add( + SessionReportFinding( + title = "Android format compatibility", + detail = "The selected ${selected.codec.name}/${selected.colorQuality.name} format was normalized to ${eligible.codec.name}/${eligible.colorQuality.name} so Android and WebRTC could decode it reliably.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + if (!eligible.hasSameSessionReportProfile(initial)) { + add( + SessionReportFinding( + title = "Device compatibility adjustment", + detail = "The device probe changed ${profileSummary(eligible)} to ${profileSummary(initial)} to stay within the detected decoder and performance limits.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + if (recoveryReason != null || !finalSettings.hasSameSessionReportProfile(initial)) { + add( + SessionReportFinding( + title = "Safe video recovery", + detail = buildString { + append("OpenNOW changed the live transport from ${profileSummary(initial)} to ${profileSummary(finalSettings)} to keep the session connected") + recoveryReason?.let { append(". Reason: ${it.trimEnd('.')}.") } ?: append(".") + }, + kind = SessionReportFindingKind.Warning, + ), + ) + } + val normalizedDeliveredResolution = deliveredResolution?.let(::normalizeResolutionLabel) + val initialResolution = normalizeResolutionLabel(streamResolutionLabel(initial)) + if ( + normalizedDeliveredResolution != null && + normalizedDeliveredResolution != initialResolution && + none { it.title == "Safe video recovery" && finalSettings.resolution != initial.resolution } + ) { + val source = when (activeMode?.resolutionSource) { + StreamResolutionChangeSource.ServerNegotiatedFallback -> "The cloud server negotiated" + StreamResolutionChangeSource.ProviderOrGameModeChange -> "The provider or game switched to" + null -> "The delivered stream used" + } + add( + SessionReportFinding( + title = "Delivered resolution changed", + detail = "$source $normalizedDeliveredResolution instead of the requested $initialResolution. This reflects the cloud/game runtime mode, not a silent change to your saved setting.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + val deliveredCodecName = deliveredCodec?.substringAfterLast('/')?.uppercase(java.util.Locale.US) + if ( + deliveredCodecName != null && + !deliveredCodecName.contains(finalSettings.codec.name) && + recoveryReason == null + ) { + add( + SessionReportFinding( + title = "Delivered codec changed", + detail = "WebRTC reported $deliveredCodec instead of the requested ${finalSettings.codec.name}. The negotiated transport codec determines what the device actually decoded.", + kind = SessionReportFindingKind.Warning, + ), + ) + } +} + +private fun buildSessionRecommendations( + averagePingMs: Int?, + packetLossPct: Double?, + averageJitterMs: Double?, + averageFps: Double?, + averageDecodeMs: Double?, + targetFps: Int, + targetBitrateMbps: Int, + averageBitrateKbps: Int?, + networkKind: AndroidNetworkKind, + wifiBand: AndroidWifiBand, + estimatedLinkDownstreamKbps: Int?, + lowestNetworkBars: Int?, +): List = buildList { + when { + networkKind == AndroidNetworkKind.Wifi && wifiBand == AndroidWifiBand.TwoPointFourGhz -> add( + SessionReportFinding( + title = "Use 5 GHz or 6 GHz Wi-Fi", + detail = "This session used 2.4 GHz Wi-Fi, which is usually busier and more prone to interference. Use 5/6 GHz when you are near the router; Ethernet is the most consistent option.", + kind = SessionReportFindingKind.Warning, + ), + ) + networkKind == AndroidNetworkKind.Wifi && + wifiBand in setOf(AndroidWifiBand.FiveGhz, AndroidWifiBand.SixGhz) && + lowestNetworkBars != null && lowestNetworkBars <= 2 -> add( + SessionReportFinding( + title = "Move closer to the Wi-Fi access point", + detail = "5/6 GHz can provide lower latency and more capacity, but its range is shorter. The session saw a weak signal, so reducing walls and distance may help.", + kind = SessionReportFindingKind.Warning, + ), + ) + networkKind == AndroidNetworkKind.Wifi && wifiBand == AndroidWifiBand.Unknown && + ((averagePingMs ?: 0) > 60 || (packetLossPct ?: 0.0) > 0.5) -> add( + SessionReportFinding( + title = "Check your Wi-Fi band", + detail = "Android did not expose the current band. When you are near the router, prefer 5 GHz or 6 GHz over 2.4 GHz; use Ethernet for the most predictable latency.", + kind = SessionReportFindingKind.Warning, + ), + ) + networkKind == AndroidNetworkKind.Cellular -> add( + SessionReportFinding( + title = "Prefer Wi-Fi or Ethernet", + detail = "Cellular latency and capacity can change quickly as signal and tower load vary. Stable 5/6 GHz Wi-Fi or Ethernet is usually better for cloud gaming.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + if ((packetLossPct ?: 0.0) > 1.0) { + add( + SessionReportFinding( + title = "Reduce packet loss", + detail = "Packet loss above 1% can cause blur, stutter, or recovery events. Pause competing uploads, reduce wireless interference, or try Ethernet.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + if ((averagePingMs ?: 0) > 80 || (averageJitterMs ?: 0.0) > 20.0) { + add( + SessionReportFinding( + title = "Stabilize latency", + detail = "Choose the closest available server, disable VPN routing, and pause background downloads. Consistent latency matters as much as raw download speed.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + if ( + estimatedLinkDownstreamKbps != null && + estimatedLinkDownstreamKbps < targetBitrateMbps * 1_200 + ) { + val actual = averageBitrateKbps?.let { " The stream averaged ${formatMbps(it)} Mbps." }.orEmpty() + add( + SessionReportFinding( + title = "Lower the maximum bitrate", + detail = "Android estimated about ${formatMbps(estimatedLinkDownstreamKbps)} Mbps of link capacity for a $targetBitrateMbps Mbps profile.$actual Leave headroom for network variation.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + val frameBudgetMs = 1000.0 / targetFps.coerceAtLeast(1) + if ( + (averageFps != null && averageFps < targetFps * 0.85) && + (averageDecodeMs ?: 0.0) > frameBudgetMs * 0.85 + ) { + add( + SessionReportFinding( + title = "Reduce device decode load", + detail = "The decoder used much of the ${"%.1f".format(java.util.Locale.US, frameBudgetMs)} ms frame budget. A lower resolution/FPS profile or the reliable H264 codec may render more consistently.", + kind = SessionReportFindingKind.Warning, + ), + ) + } + if (isEmpty()) { + add( + SessionReportFinding( + title = "Connection looked healthy", + detail = "No specific network or decoder issue crossed the report thresholds. Keep the same server and network setup for similarly consistent sessions.", + ), + ) + } +}.take(MAX_SESSION_REPORT_RECOMMENDATIONS) + +private fun StreamRuntimeStats.hasSessionReportValues(): Boolean = + pingMs != null || + bitrateKbps != null || + fps != null || + decodeMs != null || + jitterMs != null || + packetLossPct != null + +private fun weightedScore(score: Int, weight: Int): Pair = score * weight.toDouble() to weight + +private fun averageLong(total: Long, count: Int): Double? = + if (count > 0) total.toDouble() / count.toDouble() else null + +private fun averageDouble(total: Double, count: Int): Double? = + if (count > 0) total / count.toDouble() else null + +private fun dominantValue(counts: Map, fallback: T): T = + counts.maxByOrNull { it.value }?.key ?: fallback + +private fun streamResolutionLabel(settings: StreamSettings): String { + val pixels = streamResolutionPixels(settings) + return "${pixels.first}x${pixels.second}" +} + +private fun normalizeResolutionLabel(value: String): String = + parseResolutionPixelsOrNull(value)?.let { "${it.first}x${it.second}" } ?: value + +private fun profileSummary(settings: StreamSettings): String = + buildString { + append("${streamResolutionLabel(settings)}@${settings.fps} ${settings.codec.name}/${settings.colorQuality.name}") + append(" ${settings.maxBitrateMbps} Mbps") + if (settings.hdrEnabled) append(" HDR") + } + +private fun StreamSettings.hasSameSessionReportProfile(other: StreamSettings): Boolean = + resolution == other.resolution && + aspectRatio == other.aspectRatio && + fps == other.fps && + maxBitrateMbps == other.maxBitrateMbps && + codec == other.codec && + colorQuality == other.colorQuality && + hdrEnabled == other.hdrEnabled && + enableCloudGsync == other.enableCloudGsync + +private fun formatMbps(kbps: Int): String = + "%.1f".format(java.util.Locale.US, kbps.coerceAtLeast(0) / 1000.0) + +private const val MIN_CONFIDENT_SESSION_REPORT_SAMPLES = 10 +private const val MAX_SESSION_REPORT_RECOMMENDATIONS = 4 diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/SessionTimerAnchorStore.kt b/android/app/src/main/java/com/opencloudgaming/opennow/SessionTimerAnchorStore.kt new file mode 100644 index 000000000..60442bfd9 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/SessionTimerAnchorStore.kt @@ -0,0 +1,61 @@ +package com.opencloudgaming.opennow + +import android.content.Context + +private const val SESSION_TIMER_STORE_NAME = "opennow_session_timer" +private const val KEY_SESSION_ID = "session_id" +private const val KEY_STARTED_AT_MS = "started_at_ms" + +internal fun resolveSessionTimerStartedAtMs( + sessionId: String, + persistedSessionId: String?, + persistedStartedAtMs: Long, + preferredStartedAtMs: Long?, + nowMs: Long, +): Long { + val persistedIsValid = + persistedSessionId == sessionId && persistedStartedAtMs > 0L && persistedStartedAtMs <= nowMs + if (persistedIsValid) return persistedStartedAtMs + + return preferredStartedAtMs + ?.takeIf { it > 0L && it <= nowMs } + ?: nowMs +} + +internal class SessionTimerAnchorStore(context: Context) { + private val prefs = context.applicationContext.getSharedPreferences( + SESSION_TIMER_STORE_NAME, + Context.MODE_PRIVATE, + ) + private val lock = Any() + + fun startedAtMsFor( + sessionId: String, + preferredStartedAtMs: Long? = null, + nowMs: Long = System.currentTimeMillis(), + ): Long = synchronized(lock) { + val startedAtMs = resolveSessionTimerStartedAtMs( + sessionId = sessionId, + persistedSessionId = prefs.getString(KEY_SESSION_ID, null), + persistedStartedAtMs = prefs.getLong(KEY_STARTED_AT_MS, 0L), + preferredStartedAtMs = preferredStartedAtMs, + nowMs = nowMs, + ) + prefs.edit() + .putString(KEY_SESSION_ID, sessionId) + .putLong(KEY_STARTED_AT_MS, startedAtMs) + .commit() + startedAtMs + } + + fun clear(sessionId: String) { + synchronized(lock) { + if (prefs.getString(KEY_SESSION_ID, null) == sessionId) { + prefs.edit() + .remove(KEY_SESSION_ID) + .remove(KEY_STARTED_AT_MS) + .commit() + } + } + } +} diff --git a/android/app/src/main/java/com/opencloudgaming/opennow/Streaming.kt b/android/app/src/main/java/com/opencloudgaming/opennow/Streaming.kt new file mode 100644 index 000000000..21e2231f5 --- /dev/null +++ b/android/app/src/main/java/com/opencloudgaming/opennow/Streaming.kt @@ -0,0 +1,6011 @@ +package com.opencloudgaming.opennow + +import android.Manifest +import android.app.ActivityManager +import android.content.Context +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.media.AudioAttributes +import android.media.MediaCodecInfo +import android.media.MediaCodecList +import android.media.MediaRecorder +import android.opengl.GLES11Ext +import android.opengl.GLES20 +import android.os.Build +import android.os.CombinedVibration +import android.os.SystemClock +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.util.Log +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.SurfaceHolder +import android.view.View +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import okhttp3.ConnectionSpec +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.TlsVersion +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import org.webrtc.AudioSource +import org.webrtc.AudioTrack +import org.webrtc.DataChannel +import org.webrtc.DefaultVideoDecoderFactory +import org.webrtc.DefaultVideoEncoderFactory +import org.webrtc.EglBase +import org.webrtc.GlShader +import org.webrtc.GlUtil +import org.webrtc.HardwareVideoDecoderFactory +import org.webrtc.IceCandidate +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.MediaStreamTrack +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.Predicate +import org.webrtc.RTCStats +import org.webrtc.RTCStatsCollectorCallback +import org.webrtc.RendererCommon +import org.webrtc.RtpCapabilities +import org.webrtc.RtpReceiver +import org.webrtc.RtpSender +import org.webrtc.RtpTransceiver +import org.webrtc.SdpObserver +import org.webrtc.SessionDescription +import org.webrtc.SurfaceViewRenderer +import org.webrtc.VideoCodecInfo +import org.webrtc.VideoDecoder +import org.webrtc.VideoDecoderFactory +import org.webrtc.VideoTrack +import org.webrtc.audio.AudioDeviceModule +import org.webrtc.audio.JavaAudioDeviceModule +import java.net.InetAddress +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.FloatBuffer +import java.security.SecureRandom +import java.util.Locale +import java.util.concurrent.TimeUnit +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sqrt + +object NativeCodecProbe { + init { + runCatching { System.loadLibrary("opennow_native") } + } + + external fun nativeRuntimeSummary(): String + external fun nativeDecoderAvailable(mimeType: String): Boolean +} + +private object WebRtcRuntime { + @Volatile + private var initialized = false + + fun ensureInitialized(context: Context) { + if (initialized) return + synchronized(this) { + if (initialized) return + PeerConnectionFactory.initialize( + PeerConnectionFactory.InitializationOptions.builder(context.applicationContext) + .setEnableInternalTracer(false) + .createInitializationOptions(), + ) + initialized = true + } + } +} + +object CodecProbe { + private data class DecoderLimits( + val maxSupportedWidth: Int?, + val maxSupportedHeight: Int?, + val maxFpsByResolution: Map, + ) + + fun report(context: Context): RuntimeCodecReport { + WebRtcRuntime.ensureInitialized(context) + val isTv = isAndroidTvProfile(context) + val renderer = listOf(Build.HARDWARE, Build.BOARD, Build.DEVICE, Build.MODEL, Build.MANUFACTURER) + .joinToString(" ") + .lowercase(Locale.US) + val memoryInfo = ActivityManager.MemoryInfo() + val totalMemoryBytes = runCatching { + (context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager) + ?.getMemoryInfo(memoryInfo) + memoryInfo.totalMem.takeIf { it > 0L } + }.getOrNull() + val is64BitRuntime = android.os.Process.is64Bit() + val constrainedRuntime = isConstrainedStreamingRuntime( + androidTvProfile = isTv, + is64BitRuntime = is64BitRuntime, + totalMemoryBytes = totalMemoryBytes, + ) + val lowPower = isLowPowerStreamingProfile( + androidTvProfile = isTv, + renderer = renderer, + totalMemoryBytes = totalMemoryBytes, + is64BitRuntime = is64BitRuntime, + ) + val webRtcDecoders = probeWebRtcDecoders() + val capabilities = VideoCodec.entries.map { codec -> + val mime = codec.mimeType() + val decoders = codecInfos(mime, encoder = false) + val encoders = codecInfos(mime, encoder = true) + val webRtc = webRtcDecoders[codec] + val nativeDecoderAvailable = runCatching { NativeCodecProbe.nativeDecoderAvailable(mime) }.getOrNull() + val preferredDecoder = decoders.firstOrNull(::isHardwareCodec) ?: decoders.firstOrNull() + val preferredEncoder = encoders.firstOrNull(::isHardwareCodec) ?: encoders.firstOrNull() + val decoderLimits = decoderLimits(mime, decoders) + CodecCapability( + codec = codec, + decoderAvailable = decoders.isNotEmpty(), + encoderAvailable = encoders.isNotEmpty(), + hardwareDecoder = decoders.any(::isHardwareCodec), + hardwareEncoder = encoders.any(::isHardwareCodec), + decoderName = preferredDecoder?.name, + encoderName = preferredEncoder?.name, + realtimeSafe = decoders.any { isRealtimeSafeDecoder(codec, it) }, + nativeDecoderAvailable = nativeDecoderAvailable, + webRtcDecoderAvailable = webRtc?.decoderAvailable, + webRtcHardwareDecoderAvailable = webRtc?.hardwareDecoderAvailable, + webRtcDecoderName = webRtc?.decoderName, + webRtcCodecProfiles = webRtc?.profiles.orEmpty(), + maxSupportedWidth = decoderLimits.maxSupportedWidth, + maxSupportedHeight = decoderLimits.maxSupportedHeight, + maxFpsByResolution = decoderLimits.maxFpsByResolution, + ) + } + return RuntimeCodecReport( + capabilities = capabilities, + nativeRuntimeSummary = runCatching { NativeCodecProbe.nativeRuntimeSummary() }.getOrElse { "{\"nativeLibrary\":\"unavailable\"}" }, + androidTvProfile = isTv, + lowPowerGpuProfile = lowPower, + constrainedRuntimeProfile = constrainedRuntime, + ).also { report -> + NativeInputDiagnostics.add( + "codec probe device=${Build.MANUFACTURER}/${Build.MODEL} hardware=${Build.HARDWARE} tv=$isTv lowPower=$lowPower " + + "constrained=$constrainedRuntime runtimeBits=${if (is64BitRuntime) 64 else 32} " + + "memoryMiB=${totalMemoryBytes?.div(BYTES_PER_MEBIBYTE) ?: 0L}", + ) + report.capabilities.forEach { capability -> + NativeInputDiagnostics.add( + "codec probe codec=${capability.codec} platform=${capability.decoderName ?: "none"} " + + "platformHw=${capability.hardwareDecoder} native=${capability.nativeDecoderAvailable} " + + "webrtc=${capability.webRtcDecoderName ?: "none"} webrtcHw=${capability.webRtcHardwareDecoderAvailable} " + + "profiles=${capability.webRtcCodecProfiles.joinToString("|").ifBlank { "none" }} " + + "max=${capability.maxSupportedWidth ?: 0}x${capability.maxSupportedHeight ?: 0} " + + "launch=${capability.streamingDecoderUsableForLaunch()}", + ) + } + } + } + + private fun decoderLimits(mime: String, decoders: List): DecoderLimits { + val candidates = decoders.filter(::isHardwareCodec).ifEmpty { decoders } + if (candidates.isEmpty()) return DecoderLimits(null, null, emptyMap()) + val knownResolutions = streamAspectRatioOptions() + .flatMap(::streamResolutionOptionsForAspect) + .distinct() + val supportedPixels = mutableListOf>() + val maxFpsByResolution = buildMap { + for (resolution in knownResolutions) { + val (width, height) = parseResolutionPixelsOrNull(resolution) ?: continue + val videoCapabilities = candidates.mapNotNull { decoder -> + runCatching { decoder.getCapabilitiesForType(mime).videoCapabilities }.getOrNull() + }.filter { video -> + runCatching { video.isSizeSupported(width, height) }.getOrDefault(false) + } + if (videoCapabilities.isEmpty()) continue + supportedPixels += width to height + val maxFps = videoCapabilities.mapNotNull { video -> + runCatching { + video.getSupportedFrameRatesFor(width, height).upper + .takeIf { it.isFinite() && it > 0.0 } + ?.roundToInt() + ?.coerceIn(1, 360) + }.getOrNull() + }.maxOrNull() + if (maxFps != null) put(resolution, maxFps) + } + } + return DecoderLimits( + maxSupportedWidth = supportedPixels.maxOfOrNull { it.first }, + maxSupportedHeight = supportedPixels.maxOfOrNull { it.second }, + maxFpsByResolution = maxFpsByResolution, + ) + } + + private fun codecInfos(mime: String, encoder: Boolean): List { + val list = if (Build.VERSION.SDK_INT >= 21) { + MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos.toList() + } else { + emptyList() + } + return list.filter { info -> + info.isEncoder == encoder && info.supportedTypes.any { it.equals(mime, ignoreCase = true) } + } + } + + private data class WebRtcCodecProbe( + val decoderAvailable: Boolean, + val hardwareDecoderAvailable: Boolean, + val decoderName: String?, + val profiles: List, + ) + + private fun probeWebRtcDecoders(): Map { + val eglBase = runCatching { EglBase.create() }.getOrNull() ?: return emptyMap() + return try { + val streamingFactory = OpenNowVideoDecoderFactory(eglBase.eglBaseContext) + val hardwareFactory = openNowHardwareVideoDecoderFactory(eglBase.eglBaseContext) + val streamingSupported = streamingFactory.supportedCodecsByVideoCodec() + val hardwareSupported = hardwareFactory.supportedCodecsByVideoCodec() + VideoCodec.entries.associateWith { codec -> + val defaultInfos = streamingSupported[codec].orEmpty() + val hardwareInfos = hardwareSupported[codec].orEmpty() + val decoderName = streamingFactory.firstDecoderName(defaultInfos) + WebRtcCodecProbe( + decoderAvailable = decoderName != null, + hardwareDecoderAvailable = hardwareFactory.firstDecoderName(hardwareInfos) != null, + decoderName = decoderName, + profiles = defaultInfos.map(::formatWebRtcCodecInfo).distinct(), + ) + } + } catch (_: Throwable) { + emptyMap() + } finally { + eglBase.release() + } + } + + private fun VideoDecoderFactory.supportedCodecsByVideoCodec(): Map> = + getSupportedCodecs() + .groupBy { info -> info.name.toVideoCodec() } + .mapNotNull { (codec, infos) -> codec?.let { it to infos } } + .toMap() + + private fun VideoDecoderFactory.firstDecoderName(infos: List): String? { + for (info in infos) { + val decoder = runCatching { createDecoder(info) }.getOrNull() ?: continue + return try { + decoder.getImplementationName() + } finally { + runCatching { decoder.release() } + } + } + return null + } + + private fun String.toVideoCodec(): VideoCodec? = + when (uppercase(Locale.US)) { + "AVC", "H264", "H.264" -> VideoCodec.H264 + "HEVC", "H265", "H.265" -> VideoCodec.H265 + "AV01", "AV1" -> VideoCodec.AV1 + else -> null + } + + private fun formatWebRtcCodecInfo(info: VideoCodecInfo): String { + val profile = info.params["profile-level-id"] + val packetization = info.params["packetization-mode"] + return listOfNotNull(info.name.toVideoCodec()?.name ?: info.name, profile?.let { "profile=$it" }, packetization?.let { "packet=$it" }) + .joinToString(" ") + } + + private fun isHardwareCodec(info: MediaCodecInfo): Boolean { + val name = info.name.lowercase(Locale.US) + if (name.contains("google") || name.contains("sw") || name.contains("software")) return false + return if (Build.VERSION.SDK_INT >= 29) { + info.isHardwareAccelerated + } else { + true + } + } + + internal fun isOpenNowHardwareDecoderAllowed(info: MediaCodecInfo): Boolean { + if (!isHardwareCodec(info)) return false + val name = info.name.lowercase(Locale.US) + if (name.contains("google") || name.contains("software") || name.contains("sw")) return false + if (name.contains("exynos")) return false + return true + } + + private fun isRealtimeSafeDecoder(codec: VideoCodec, info: MediaCodecInfo): Boolean { + if (!isHardwareCodec(info)) return false + val name = info.name.lowercase(Locale.US) + return when (codec) { + VideoCodec.H264 -> true + // Android WebRTC HEVC/AV1 decode is still device-fragile here. Exynos HEVC black-screens + // and Google AV1 falls back to a laggy software path even when the codec list advertises it. + VideoCodec.H265 -> !name.contains("exynos") + VideoCodec.AV1 -> !name.contains("google") + } + } + + private fun VideoCodec.mimeType(): String = + when (this) { + VideoCodec.H264 -> "video/avc" + VideoCodec.H265 -> "video/hevc" + VideoCodec.AV1 -> "video/av01" + } +} + +internal fun isAndroidTvProfile(context: Context): Boolean = + context.packageManager.hasSystemFeature("android.software.leanback") || + context.resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK == Configuration.UI_MODE_TYPE_TELEVISION + +internal fun isLowPowerStreamingProfile( + androidTvProfile: Boolean, + renderer: String, + totalMemoryBytes: Long?, + is64BitRuntime: Boolean = true, +): Boolean { + val normalizedRenderer = renderer.lowercase(Locale.US) + val knownLowPowerGpu = + normalizedRenderer.contains("powervr") || + normalizedRenderer.contains("ge8320") || + normalizedRenderer.contains("ge83") + return knownLowPowerGpu || isConstrainedStreamingRuntime( + androidTvProfile = androidTvProfile, + is64BitRuntime = is64BitRuntime, + totalMemoryBytes = totalMemoryBytes, + ) +} + +internal fun isConstrainedStreamingRuntime( + androidTvProfile: Boolean, + is64BitRuntime: Boolean, + totalMemoryBytes: Long?, +): Boolean { + val constrainedTvMemory = androidTvProfile && + totalMemoryBytes != null && + totalMemoryBytes in 1..LOW_POWER_TV_MEMORY_LIMIT_BYTES + return !is64BitRuntime || constrainedTvMemory +} + +private fun openNowHardwareVideoDecoderFactory(sharedContext: EglBase.Context): VideoDecoderFactory = + HardwareVideoDecoderFactory( + sharedContext, + Predicate { info -> CodecProbe.isOpenNowHardwareDecoderAllowed(info) }, + ) + +private class OpenNowVideoDecoderFactory( + sharedContext: EglBase.Context, + private val nativeLowLatencyDecoderEnabled: Boolean = false, +) : VideoDecoderFactory { + private val defaultFactory = DefaultVideoDecoderFactory(sharedContext) + private val hardwareFactory = openNowHardwareVideoDecoderFactory(sharedContext) + + override fun createDecoder(info: VideoCodecInfo): VideoDecoder? { + val codec = info.name.toOpenNowVideoCodec() + val hardwareDecoder = if (codec != null) hardwareFactory.createDecoder(info) else null + val decoder = when (codec) { + VideoCodec.H264 -> hardwareDecoder ?: defaultFactory.createDecoder(info) + VideoCodec.H265, + VideoCodec.AV1, + -> hardwareDecoder + null -> defaultFactory.createDecoder(info) + } + if (codec != null && hardwareDecoder != null) { + val isLowLatency = nativeLowLatencyDecoderEnabled + NativeInputDiagnostics.add("native MediaCodec decoder selected codec=${codec.name} implementation=${hardwareDecoder.getImplementationName()} lowLatency=$isLowLatency") + } + return if (decoder != null && nativeLowLatencyDecoderEnabled) { + LowLatencyVideoDecoder(decoder) + } else { + decoder + } + } + + override fun getSupportedCodecs(): Array { + val defaultCodecs = defaultFactory.getSupportedCodecs() + .filterNot { it.name.toOpenNowVideoCodec() in ADVANCED_STREAM_CODECS } + val nativeAdvancedCodecs = hardwareFactory.getSupportedCodecs() + .filter { it.name.toOpenNowVideoCodec() in ADVANCED_STREAM_CODECS } + return (defaultCodecs + nativeAdvancedCodecs) + .distinctBy { it.stableKey() } + .toTypedArray() + } + + private fun VideoCodecInfo.stableKey(): String = + "${name.uppercase(Locale.US)}:${params.toSortedMap()}" + + private companion object { + private val ADVANCED_STREAM_CODECS = setOf(VideoCodec.H265, VideoCodec.AV1) + } +} + +private fun String.toOpenNowVideoCodec(): VideoCodec? = + when (uppercase(Locale.US)) { + "AVC", "H264", "H.264" -> VideoCodec.H264 + "HEVC", "H265", "H.265" -> VideoCodec.H265 + "AV01", "AV1" -> VideoCodec.AV1 + else -> null + } + +private fun VideoCodec.webRtcCodecName(): String = + when (this) { + VideoCodec.H264 -> "H264" + VideoCodec.H265 -> "H265" + VideoCodec.AV1 -> "AV1" + } + +private fun RtpCapabilities.CodecCapability.openNowCodecName(): String? { + val fromMime = mimeType + ?.substringAfter("/", "") + ?.takeIf { it.isNotBlank() } + ?.toOpenNowVideoCodec() + ?.webRtcCodecName() + if (fromMime != null) return fromMime + return name?.toOpenNowVideoCodec()?.webRtcCodecName() ?: name?.uppercase(Locale.US) +} + +private fun RtpCapabilities.CodecCapability.codecParameterInt(name: String): Int? = + parameters + ?.entries + ?.firstOrNull { it.key.equals(name, ignoreCase = true) } + ?.value + ?.toIntOrNull() + +private fun RtpCapabilities.CodecCapability.h265ProfilePriority(preferTenBit: Boolean): Int { + val profile = codecParameterInt("profile-id") + return if (preferTenBit) { + when (profile) { + 2 -> 0 + 1 -> 1 + else -> 2 + } + } else { + when (profile) { + 1 -> 0 + null -> 1 + 2 -> 2 + else -> 3 + } + } +} + +private fun RtpCapabilities.CodecCapability.preferenceKey(): String = + "${openNowCodecName().orEmpty()}:${parameters.orEmpty().toSortedMap()}" + +private fun StreamSettings.prefersTenBitVideo(): Boolean = + hdrEnabled || + colorQuality == ColorQuality.TenBit420 || + colorQuality == ColorQuality.TenBit444 + +private val WEBRTC_AUXILIARY_VIDEO_CODECS = setOf("RTX", "RED", "ULPFEC", "FLEXFEC-03") + +private fun streamDiagnosticId(value: String?): String { + val cleaned = value.orEmpty().trim() + if (cleaned.isBlank()) return "-" + return if (cleaned.length <= 12) cleaned else "${cleaned.take(4)}...${cleaned.takeLast(6)}" +} + +private fun signalingUrlForDiagnostics(url: String, sessionId: String): String = + redactDiagnosticUrl(url).replace(sessionId, streamDiagnosticId(sessionId)) + +internal enum class SignalingFailureDisposition { + RetryTransport, + RecoverSession, + SessionEnded, +} + +internal fun signalingFailureDisposition( + message: String, + normalClosureMeansSessionEnded: Boolean = false, +): SignalingFailureDisposition = when { + message.contains("http=410", ignoreCase = true) -> SignalingFailureDisposition.SessionEnded + message.contains("http=404", ignoreCase = true) || + message.contains("Not Found", ignoreCase = true) -> SignalingFailureDisposition.RecoverSession + normalClosureMeansSessionEnded && message.contains("code=1000", ignoreCase = true) -> + SignalingFailureDisposition.SessionEnded + else -> SignalingFailureDisposition.RetryTransport +} + +private fun IceCandidate.diagnosticSummary(): String { + val raw = sdp + val protocol = Regex("""\s(udp|tcp)\s""", RegexOption.IGNORE_CASE) + .find(raw) + ?.value + ?.trim() + ?.lowercase(Locale.US) + ?: "unknown" + val type = Regex("""\styp\s+([a-z0-9]+)""", RegexOption.IGNORE_CASE) + .find(raw) + ?.groupValues + ?.getOrNull(1) + ?.lowercase(Locale.US) + ?: "unknown" + val address = Regex("""candidate:\S+\s+\d+\s+\S+\s+\d+\s+([^\s]+)\s+(\d+)""") + .find(raw) + ?.let { match -> "${match.groupValues[1]}:${match.groupValues[2]}" } + ?: "unknown" + return "mid=${sdpMid.orEmpty()} line=$sdpMLineIndex type=$type protocol=$protocol address=$address raw=${raw.take(240)}" +} + +private fun sdpDiagnosticSummary(label: String, sdp: String): String { + val lines = sdp.split(Regex("\\r?\\n")).filter { it.isNotBlank() } + val media = lines.filter { it.startsWith("m=") }.joinToString("|").take(180) + val candidateEndpoints = lines + .filter { it.startsWith("a=candidate:") } + .mapNotNull { line -> + Regex("""a=candidate:\S+\s+\d+\s+\S+\s+\d+\s+([^\s]+)\s+(\d+)""") + .find(line) + ?.let { match -> "${match.groupValues[1]}:${match.groupValues[2]}" } + } + .distinct() + .joinToString(limit = 6) + val codecs = lines + .filter { it.startsWith("a=rtpmap:") } + .mapNotNull { line -> line.substringAfter(' ', "").substringBefore('/').takeIf { it.isNotBlank() } } + .distinct() + .take(12) + .joinToString(",") + val candidates = lines.count { it.startsWith("a=candidate:") } + val hasIce = lines.any { it.startsWith("a=ice-ufrag:") } && lines.any { it.startsWith("a=ice-pwd:") } + val hasFingerprint = lines.any { it.startsWith("a=fingerprint:") } + return "$label lines=${lines.size} media=$media codecs=$codecs candidates=$candidates endpoints=$candidateEndpoints ice=$hasIce fingerprint=$hasFingerprint" +} + +sealed interface SignalingEvent { + data object Connected : SignalingEvent + data class Disconnected(val reason: String) : SignalingEvent + data class Offer(val sdp: String) : SignalingEvent + data class RemoteIce(val candidate: IceCandidate) : SignalingEvent + data class Error(val message: String) : SignalingEvent + data class Log(val message: String) : SignalingEvent +} + +class GfnSignalingClient( + private val session: SessionInfo, + private val settings: StreamSettings, + private val http: OkHttpClient = defaultHttpClient(), + private val onEvent: (SignalingEvent) -> Unit, +) { + private val signalingHttp = signalingWebSocketHttpClient(http) + private var webSocket: WebSocket? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var heartbeatJob: Job? = null + private var peerId = 0 + private var remotePeerId = 1 + private val peerName = "peer-${java.util.UUID.randomUUID().toString().replace("-", "").take(12)}" + private var ackCounter = 0 + + fun connect() { + val url = buildSignInUrl() + val host = url.removePrefix("wss://").substringBefore("/") + onEvent(SignalingEvent.Log("Signaling connecting url=${signalingUrlForDiagnostics(url, session.sessionId)} session=${streamDiagnosticId(session.sessionId)}")) + val request = Request.Builder() + .url(url) + .header("Sec-WebSocket-Protocol", "x-nv-sessionid.${session.sessionId}") + .header("Host", host) + .header("Origin", "https://play.geforcenow.com") + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/131.0.0.0 Safari/537.36") + .build() + webSocket = signalingHttp.newWebSocket( + request, + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + onEvent( + SignalingEvent.Log( + "Signaling open http=${response.code} tls=${response.handshake?.tlsVersion?.javaName ?: "unknown"} " + + "protocol=${response.header("Sec-WebSocket-Protocol").orEmpty().replace(session.sessionId, streamDiagnosticId(session.sessionId))}", + ), + ) + sendPeerInfo() + heartbeatJob?.cancel() + heartbeatJob = scope.launch { + while (true) { + delay(5000) + sendJson("""{"hb":1}""") + } + } + onEvent(SignalingEvent.Connected) + } + + override fun onMessage(webSocket: WebSocket, text: String) = handleMessage(text) + override fun onMessage(webSocket: WebSocket, bytes: ByteString) = handleMessage(bytes.utf8()) + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + heartbeatJob?.cancel() + onEvent(SignalingEvent.Disconnected("socket closed code=$code reason=${reason.ifBlank { "none" }}")) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + heartbeatJob?.cancel() + val responseText = response?.let { " http=${it.code} message=${it.message}" }.orEmpty() + onEvent(SignalingEvent.Error("${t.javaClass.simpleName}: ${t.message ?: "Signaling failed"}$responseText")) + } + }, + ) + } + + fun sendAnswer(sdp: String, nvstSdp: String?) { + onEvent(SignalingEvent.Log(sdpDiagnosticSummary("Sending answer", sdp))) + if (!nvstSdp.isNullOrBlank()) { + onEvent(SignalingEvent.Log("Sending NVST SDP lines=${nvstSdp.lineSequence().count()} bytes=${nvstSdp.length}")) + } + val msg = buildJsonObject { + put("type", "answer") + put("sdp", sdp) + if (nvstSdp != null) put("nvstSdp", nvstSdp) + }.toString() + sendPeerMessage(msg) + } + + fun sendIceCandidate(candidate: IceCandidate) { + if (candidate.sdp.contains(" tcp ", ignoreCase = true)) { + onEvent(SignalingEvent.Log("Dropping TCP local ICE candidate ${candidate.diagnosticSummary()}")) + return + } + onEvent(SignalingEvent.Log("Sending local ICE candidate ${candidate.diagnosticSummary()}")) + val msg = buildJsonObject { + put("candidate", candidate.sdp) + put("sdpMid", candidate.sdpMid) + put("sdpMLineIndex", candidate.sdpMLineIndex) + }.toString() + sendPeerMessage(msg) + } + + fun requestKeyframe(reason: String, backlogFrames: Int, attempt: Int) { + val msg = buildJsonObject { + put("type", "request_keyframe") + put("reason", reason) + put("backlogFrames", backlogFrames) + put("attempt", attempt) + }.toString() + sendPeerMessage(msg) + } + + fun disconnect() { + heartbeatJob?.cancel() + webSocket?.close(1000, "closed") + webSocket = null + } + + private fun buildSignInUrl(): String { + val base = session.signalingUrl.ifBlank { + val host = if (session.signalingServer.contains(":")) session.signalingServer else "${session.signalingServer}:443" + "wss://$host/nvst/" + } + val normalized = base.replace("wss://", "").trimEnd('/') + return "wss://$normalized/sign_in?peer_id=$peerName&version=2&peer_role=1&pairing_id=${session.sessionId}" + } + + private fun handleMessage(text: String) { + val parsed = runCatching { OpenNowJson.parseToJsonElement(text).jsonObject }.getOrNull() + if (parsed == null) { + onEvent(SignalingEvent.Log("Ignoring non-JSON signaling packet")) + return + } + parsed["peer_info"]?.jsonObject?.let { info -> + if (info["name"]?.jsonPrimitive?.contentOrNull == peerName) { + peerId = info["id"]?.jsonPrimitive?.intOrNull ?: peerId + } + } + parsed["ackid"]?.jsonPrimitive?.intOrNull?.let { ack -> + val shouldAck = parsed["peer_info"]?.jsonObject?.get("id")?.jsonPrimitive?.intOrNull != peerId + if (shouldAck) sendJson("""{"ack":$ack}""") + } + if (parsed["hb"] != null) { + // The client already sends its own heartbeat every five seconds. + // Treat server heartbeat frames as acknowledgements instead of + // creating an unnecessary reply round trip. + return + } + val peerMsg = parsed["peer_msg"]?.jsonObject ?: return + remotePeerId = peerMsg["from"]?.jsonPrimitive?.intOrNull ?: remotePeerId + val msg = peerMsg["msg"]?.jsonPrimitive?.contentOrNull ?: return + val payload = runCatching { OpenNowJson.parseToJsonElement(msg).jsonObject }.getOrNull() ?: return + when { + payload["type"]?.jsonPrimitive?.contentOrNull == "offer" -> { + val sdp = payload["sdp"]?.jsonPrimitive?.contentOrNull + if (sdp != null) { + onEvent(SignalingEvent.Log(sdpDiagnosticSummary("Received offer", sdp))) + onEvent(SignalingEvent.Offer(sdp)) + } + } + payload["candidate"]?.jsonPrimitive?.contentOrNull != null -> { + val candidate = IceCandidate( + payload["sdpMid"]?.jsonPrimitive?.contentOrNull, + payload["sdpMLineIndex"]?.jsonPrimitive?.intOrNull ?: 0, + payload["candidate"]?.jsonPrimitive?.contentOrNull.orEmpty(), + ) + onEvent(SignalingEvent.Log("Received remote ICE candidate ${candidate.diagnosticSummary()}")) + onEvent(SignalingEvent.RemoteIce(candidate)) + } + } + } + + private fun sendPeerInfo() { + val (width, height) = streamResolutionPixels(settings) + onEvent(SignalingEvent.Log("Sending peer info resolution=${width}x$height peer=$peerName")) + sendJson( + """ + {"ackid":${nextAckId()},"peer_info":{"browser":"Chrome","browserVersion":"131","connected":true,"id":$peerId,"name":"$peerName","peerRole":0,"resolution":"${width}x$height","version":2}} + """.trimIndent(), + ) + } + + private fun sendPeerMessage(message: String) { + val escaped = message.replace("\\", "\\\\").replace("\"", "\\\"") + sendJson("""{"peer_msg":{"from":$peerId,"to":$remotePeerId,"msg":"$escaped"},"ackid":${nextAckId()}}""") + } + + private fun sendJson(text: String) { + webSocket?.send(text) + } + + private fun nextAckId(): Int { + ackCounter += 1 + return ackCounter + } +} + +private val SIGNALING_TLS_1_2 = + ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .tlsVersions(TlsVersion.TLS_1_2) + .build() + +internal fun signalingWebSocketHttpClient(base: OkHttpClient): OkHttpClient = + base.newBuilder() + // GFN already has an application heartbeat. Avoid a second WebSocket + // ping loop and Android TV's TLS 1.3/Conscrypt reader spin on this + // long-lived signaling socket; media remains DTLS/WebRTC and unchanged. + .pingInterval(0, TimeUnit.MILLISECONDS) + .connectionSpecs(listOf(SIGNALING_TLS_1_2)) + .build() + +object NativeStreamInputRouter { + @Volatile + private var client: NativeStreamClient? = null + @Volatile + private var androidTvProfile = false + fun setAndroidTvProfile(enabled: Boolean) { + androidTvProfile = enabled + } + @Volatile + private var touchMouseEnabled = false + @Volatile + private var mouseDirectClick = false + @Volatile + private var stretchToFit = false + @Volatile + private var renderingAspectRatio = 0f + @Volatile + private var captureAllTouch = false + @Volatile + private var systemMenuHandler: (() -> Unit)? = null + + @Volatile + private var systemBackHandler: (() -> Unit)? = null + @Volatile + private var streamUiActive = false + @Volatile + private var streamChromePassthroughBounds: TouchPassthroughBounds? = null + @Volatile + private var streamPanelPassthroughBounds: TouchPassthroughBounds? = null + @Volatile + private var touchControllerPassthroughBounds: Map = emptyMap() + @Volatile + private var touchControllerVisible = false + @Volatile + private var uiTouchPassthroughActive = false + private val nativeUiTouchPointerIds = mutableSetOf() + private val touchMouseState = TouchMouseState() + + fun attach(next: NativeStreamClient) { + client = next + } + + fun detach(next: NativeStreamClient) { + if (client === next) { + client = null + } + } + + fun setTouchMouseEnabled(enabled: Boolean) { + touchMouseEnabled = enabled + if (!enabled) { + touchMouseState.reset(client) + } + } + + fun setMouseDirectClick(enabled: Boolean) { + mouseDirectClick = enabled + } + + fun setStretchToFit(enabled: Boolean) { + stretchToFit = enabled + } + + fun setRenderingAspectRatio(ratio: Float) { + renderingAspectRatio = ratio + } + + fun setCaptureAllTouch(enabled: Boolean) { + captureAllTouch = enabled + } + + fun setSystemMenuHandler(handler: (() -> Unit)?) { + systemMenuHandler = handler + } + + fun setSystemBackHandler(handler: (() -> Unit)?) { + systemBackHandler = handler + } + + fun dispatchSystemBack(): Boolean { + val handler = systemBackHandler ?: return false + handler() + return true + } + + + fun setStreamUiActive(active: Boolean) { + streamUiActive = active + } + + fun normalizedStreamUiKeyCode(event: KeyEvent): Int? { + if (!streamUiActive) return null + return when (event.keyCode) { + KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER + KeyEvent.KEYCODE_BUTTON_B, + KeyEvent.KEYCODE_BUTTON_SELECT -> KeyEvent.KEYCODE_BACK + else -> null + } + } + + fun normalizedAppUiKeyCode(event: KeyEvent): Int? { + return normalizedAppUiKeyCode(event.keyCode, streamUiActive) + } + + fun normalizedAppUiKeyCode(keyCode: Int, streamUiActive: Boolean): Int? { + if (streamUiActive) return null + return when (keyCode) { + KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER + else -> null + } + } + + fun isControllerAppBackKey(event: KeyEvent): Boolean = + isControllerAppBackKey( + keyCode = event.keyCode, + controllerSource = event.isControllerInputDevice(), + streamUiActive = streamUiActive, + ) + + fun isControllerAppBackKey(keyCode: Int, controllerSource: Boolean, streamUiActive: Boolean): Boolean = + !streamUiActive && + (keyCode == KeyEvent.KEYCODE_BUTTON_B || + (keyCode == KeyEvent.KEYCODE_BACK && controllerSource)) + + fun setUiTouchPassthroughBounds(left: Int, top: Int, right: Int, bottom: Int) { + streamChromePassthroughBounds = TouchPassthroughBounds(left, top, right, bottom) + } + + fun clearUiTouchPassthroughBounds() { + streamChromePassthroughBounds = null + uiTouchPassthroughActive = false + nativeUiTouchPointerIds.clear() + } + + fun setStreamPanelTouchPassthroughBounds(left: Int, top: Int, right: Int, bottom: Int) { + streamPanelPassthroughBounds = TouchPassthroughBounds(left, top, right, bottom) + } + + fun clearStreamPanelTouchPassthroughBounds() { + streamPanelPassthroughBounds = null + uiTouchPassthroughActive = false + nativeUiTouchPointerIds.clear() + } + + fun setTouchControllerPassthroughBounds(left: Int, top: Int, right: Int, bottom: Int) { + touchControllerPassthroughBounds = mapOf("default" to TouchPassthroughBounds(left, top, right, bottom)) + } + + fun setTouchControllerPassthroughBound(id: String, left: Int, top: Int, right: Int, bottom: Int) { + touchControllerPassthroughBounds = touchControllerPassthroughBounds.toMutableMap().also { + it[id] = TouchPassthroughBounds(left, top, right, bottom) + } + } + + fun clearTouchControllerPassthroughBound(id: String) { + if (id !in touchControllerPassthroughBounds) return + touchControllerPassthroughBounds = touchControllerPassthroughBounds.toMutableMap().also { it.remove(id) } + } + + fun setTouchControllerVisible(visible: Boolean) { + touchControllerVisible = visible + if (!visible) { + touchControllerPassthroughBounds = emptyMap() + uiTouchPassthroughActive = false + nativeUiTouchPointerIds.clear() + } + } + + fun clearTouchControllerPassthroughBounds() { + touchControllerPassthroughBounds = emptyMap() + touchControllerVisible = false + uiTouchPassthroughActive = false + nativeUiTouchPointerIds.clear() + } + + fun cancelTouchMouse() { + touchMouseState.reset(client) + } + + fun isNativeUiTouchGestureActive(): Boolean = + nativeUiTouchPointerIds.isNotEmpty() + + fun shouldForwardTouchBeforeViews(event: MotionEvent, width: Int, height: Int): Boolean { + val isDirectClick = mouseDirectClick && event.isExternalMousePointerEvent() + if ( + client == null || + streamUiActive || + !(touchMouseEnabled || isDirectClick) || + !captureAllTouch || + width <= 0 || + height <= 0 || + !(event.isFingerTouchEvent() || isDirectClick) + ) { + return false + } + updateNativeUiTouchPointers(event, width, height) + if (!eventHasStreamTouchPointer(event, width, height)) return false + return event.pointerCount == 1 || nativeUiTouchPointerIds.isNotEmpty() + } + + fun shouldCaptureTouchBeforeViews(event: MotionEvent, width: Int, height: Int): Boolean = + shouldForwardTouchBeforeViews(event, width, height) && + nativeUiTouchPointerIds.isEmpty() + + fun dispatchTouch(event: MotionEvent, width: Int, height: Int): Boolean { + val current = client ?: return false + if (streamUiActive) return false + val isDirectClick = mouseDirectClick && event.isExternalMousePointerEvent() + if (!event.isFingerTouchEvent() && !isDirectClick) return false + updateNativeUiTouchPointers(event, width, height) + return touchMouseState.handle( + event = event, + enabled = (touchMouseEnabled || isDirectClick) && width > 0 && height > 0, + client = current, + ignoredPointerIds = nativeUiTouchPointerIds, + directClick = mouseDirectClick, + width = width, + height = height, + stretchToFit = stretchToFit, + renderingAspectRatio = renderingAspectRatio, + ) + } + + fun dispatchExternalMouseTouch(event: MotionEvent, width: Int, height: Int): Boolean { + if (streamUiActive) return false + if (!event.isExternalMousePointerEvent()) return false + if (mouseDirectClick) return false // Handled in dispatchTouch instead + if (shouldPassTouchToNativeUi(event, width, height)) return false + return client?.dispatchMotion(event) == true + } + + fun dispatchKey(event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 && event.isStreamSystemMenuKey()) { + systemMenuHandler?.invoke() + return systemMenuHandler != null + } + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 && event.isStreamControlsShortcutKey()) { + systemMenuHandler?.invoke() + return systemMenuHandler != null + } + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0 && event.isStreamExitShortcutKey()) { + return dispatchSystemBack() + } + if (event.action == KeyEvent.ACTION_UP && event.isStreamExitShortcutKey()) { + return systemBackHandler != null + } + if (streamUiActive) return false + val current = client ?: return false + if (event.shouldConsumeAsStreamKeyboard()) { + current.dispatchKey(event) + return true + } + return current.dispatchKey(event) + } + + fun dispatchMotion(event: MotionEvent): Boolean { + if (streamUiActive && event.isExternalMousePointerEvent()) { + return false + } + if (streamUiActive && event.isNativeUiNavigationMotion()) { + return false + } + return client?.dispatchMotion(event) == true + } + + private fun KeyEvent.isStreamSystemMenuKey(): Boolean = + shouldOpenStreamSystemMenuKey( + keyCode = keyCode, + controllerInputDevice = isControllerInputDevice(), + ) + + private fun KeyEvent.isStreamControlsShortcutKey(): Boolean = + !streamUiActive && + !isControllerInputDevice() && + !isHardwareKeyboardSource() && + isDpadSource() && + (keyCode == KeyEvent.KEYCODE_ENTER || + keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER || + keyCode == KeyEvent.KEYCODE_DPAD_CENTER) + + private fun KeyEvent.isStreamExitShortcutKey(): Boolean = + shouldHandleStreamExitKey( + keyCode = keyCode, + controllerInputDevice = isControllerInputDevice(), + hardwareKeyboardSource = isHardwareKeyboardSource(), + androidTvProfile = androidTvProfile, + ) + + fun shouldOpenStreamSystemMenuKey(keyCode: Int, controllerInputDevice: Boolean): Boolean = + keyCode == KeyEvent.KEYCODE_MENU && !controllerInputDevice + + fun shouldHandleStreamExitKey( + keyCode: Int, + controllerInputDevice: Boolean, + hardwareKeyboardSource: Boolean, + androidTvProfile: Boolean = false, + ): Boolean = + (keyCode == KeyEvent.KEYCODE_BACK && (androidTvProfile || !controllerInputDevice)) || + (keyCode == KeyEvent.KEYCODE_ESCAPE && !hardwareKeyboardSource) + + private fun KeyEvent.isControllerInputDevice(): Boolean = + AndroidControllerInput.isControllerEvent(source, deviceId) + + private fun KeyEvent.isDpadSource(): Boolean = + (source and InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD + + private fun KeyEvent.isHardwareKeyboardSource(): Boolean = + !isControllerInputDevice() && + ((source and InputDevice.SOURCE_KEYBOARD) == InputDevice.SOURCE_KEYBOARD || + InputDevice.getDevice(deviceId)?.keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC) + + private fun KeyEvent.shouldConsumeAsStreamKeyboard(): Boolean = + (action == KeyEvent.ACTION_DOWN || action == KeyEvent.ACTION_UP) && + !isControllerInputDevice() && + !isAndroidSystemKey() && + (isHardwareKeyboardSource() || keyCode.isTextEntryKeyCode()) + + private fun KeyEvent.isAndroidSystemKey(): Boolean = + keyCode == KeyEvent.KEYCODE_VOLUME_UP || + keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + keyCode == KeyEvent.KEYCODE_VOLUME_MUTE || + keyCode == KeyEvent.KEYCODE_POWER || + keyCode == KeyEvent.KEYCODE_HOME + + private fun Int.isKeyboardLikeKeyCode(): Boolean = + this == KeyEvent.KEYCODE_ENTER || + this == KeyEvent.KEYCODE_NUMPAD_ENTER || + this == KeyEvent.KEYCODE_ESCAPE || + this == KeyEvent.KEYCODE_DEL || + this == KeyEvent.KEYCODE_TAB || + this == KeyEvent.KEYCODE_SPACE || + this == KeyEvent.KEYCODE_DPAD_LEFT || + this == KeyEvent.KEYCODE_DPAD_UP || + this == KeyEvent.KEYCODE_DPAD_RIGHT || + this == KeyEvent.KEYCODE_DPAD_DOWN || + this == KeyEvent.KEYCODE_PAGE_UP || + this == KeyEvent.KEYCODE_PAGE_DOWN || + this == KeyEvent.KEYCODE_FORWARD_DEL || + this == KeyEvent.KEYCODE_INSERT || + this == KeyEvent.KEYCODE_MOVE_HOME || + this == KeyEvent.KEYCODE_MOVE_END || + this == KeyEvent.KEYCODE_SHIFT_LEFT || + this == KeyEvent.KEYCODE_SHIFT_RIGHT || + this == KeyEvent.KEYCODE_CTRL_LEFT || + this == KeyEvent.KEYCODE_CTRL_RIGHT || + this == KeyEvent.KEYCODE_ALT_LEFT || + this == KeyEvent.KEYCODE_ALT_RIGHT || + this == KeyEvent.KEYCODE_CAPS_LOCK || + this == KeyEvent.KEYCODE_NUM_LOCK || + this == KeyEvent.KEYCODE_SCROLL_LOCK || + this == KeyEvent.KEYCODE_MINUS || + this == KeyEvent.KEYCODE_EQUALS || + this == KeyEvent.KEYCODE_LEFT_BRACKET || + this == KeyEvent.KEYCODE_RIGHT_BRACKET || + this == KeyEvent.KEYCODE_BACKSLASH || + this == KeyEvent.KEYCODE_SEMICOLON || + this == KeyEvent.KEYCODE_APOSTROPHE || + this == KeyEvent.KEYCODE_COMMA || + this == KeyEvent.KEYCODE_PERIOD || + this == KeyEvent.KEYCODE_SLASH || + this == KeyEvent.KEYCODE_GRAVE || + this in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z || + this in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 || + this in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9 || + this in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12 + + private fun Int.isTextEntryKeyCode(): Boolean = + this == KeyEvent.KEYCODE_ENTER || + this == KeyEvent.KEYCODE_NUMPAD_ENTER || + this == KeyEvent.KEYCODE_ESCAPE || + this == KeyEvent.KEYCODE_DEL || + this == KeyEvent.KEYCODE_TAB || + this == KeyEvent.KEYCODE_SPACE || + this == KeyEvent.KEYCODE_FORWARD_DEL || + this == KeyEvent.KEYCODE_SHIFT_LEFT || + this == KeyEvent.KEYCODE_SHIFT_RIGHT || + this == KeyEvent.KEYCODE_CTRL_LEFT || + this == KeyEvent.KEYCODE_CTRL_RIGHT || + this == KeyEvent.KEYCODE_ALT_LEFT || + this == KeyEvent.KEYCODE_ALT_RIGHT || + this == KeyEvent.KEYCODE_CAPS_LOCK || + this == KeyEvent.KEYCODE_MINUS || + this == KeyEvent.KEYCODE_EQUALS || + this == KeyEvent.KEYCODE_LEFT_BRACKET || + this == KeyEvent.KEYCODE_RIGHT_BRACKET || + this == KeyEvent.KEYCODE_BACKSLASH || + this == KeyEvent.KEYCODE_SEMICOLON || + this == KeyEvent.KEYCODE_APOSTROPHE || + this == KeyEvent.KEYCODE_COMMA || + this == KeyEvent.KEYCODE_PERIOD || + this == KeyEvent.KEYCODE_SLASH || + this == KeyEvent.KEYCODE_GRAVE || + this in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z || + this in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 || + this in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9 || + this in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12 + + private fun MotionEvent.isNativeUiNavigationMotion(): Boolean = + isFromSource(InputDevice.SOURCE_JOYSTICK) || + isFromSource(InputDevice.SOURCE_GAMEPAD) || + AndroidControllerInput.isControllerEvent(source, deviceId) + + private fun MotionEvent.isFromSource(source: Int): Boolean = (this.source and source) == source + + private fun MotionEvent.isFingerTouchEvent(): Boolean = + isFromSource(InputDevice.SOURCE_TOUCHSCREEN) && + !isFromSource(InputDevice.SOURCE_MOUSE) && + !isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE) + + private fun MotionEvent.isExternalMousePointerEvent(): Boolean { + val controllerSource = isFromSource(InputDevice.SOURCE_JOYSTICK) || isFromSource(InputDevice.SOURCE_GAMEPAD) + return isFromSource(InputDevice.SOURCE_MOUSE) || + isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE) || + (isFromSource(InputDevice.SOURCE_TOUCHPAD) && !controllerSource) + } + + private fun shouldPassTouchToNativeUi(event: MotionEvent, width: Int, height: Int): Boolean { + if (event.isFingerTouchEvent()) { + updateNativeUiTouchPointers(event, width, height) + return eventHasNativeUiTouchPointer(event, width, height) && + !eventHasStreamTouchPointer(event, width, height) + } + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + uiTouchPassthroughActive = + pointerTouchesNativeUi(event, 0, width, height) + return uiTouchPassthroughActive + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + val wasActive = uiTouchPassthroughActive + uiTouchPassthroughActive = false + return wasActive + } + else -> if (uiTouchPassthroughActive) { + return true + } + } + return false + } + + private fun updateNativeUiTouchPointers(event: MotionEvent, width: Int, height: Int) { + if (!event.isFingerTouchEvent()) return + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + nativeUiTouchPointerIds.clear() + if (pointerTouchesNativeUi(event, 0, width, height)) { + nativeUiTouchPointerIds += event.getPointerId(0) + } + } + MotionEvent.ACTION_POINTER_DOWN -> { + val index = event.actionIndex + if (index in 0 until event.pointerCount && pointerTouchesNativeUi(event, index, width, height)) { + nativeUiTouchPointerIds += event.getPointerId(index) + } + } + } + uiTouchPassthroughActive = nativeUiTouchPointerIds.isNotEmpty() + } + + fun postDispatchTouch(event: MotionEvent) { + if (!event.isFingerTouchEvent()) return + when (event.actionMasked) { + MotionEvent.ACTION_POINTER_UP -> { + val index = event.actionIndex + if (index in 0 until event.pointerCount) { + nativeUiTouchPointerIds.remove(event.getPointerId(index)) + } + } + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> { + nativeUiTouchPointerIds.clear() + } + } + uiTouchPassthroughActive = nativeUiTouchPointerIds.isNotEmpty() + } + + private fun eventHasNativeUiTouchPointer(event: MotionEvent, width: Int, height: Int): Boolean = + (0 until event.pointerCount).any { index -> + isNativeUiTouchPointer(event, index, width, height) + } + + private fun eventHasStreamTouchPointer(event: MotionEvent, width: Int, height: Int): Boolean = + (0 until event.pointerCount).any { index -> + !isNativeUiTouchPointer(event, index, width, height) + } + + private fun isNativeUiTouchPointer(event: MotionEvent, index: Int, width: Int, height: Int): Boolean = + event.getPointerId(index) in nativeUiTouchPointerIds || + pointerTouchesNativeUi(event, index, width, height) + + private fun pointerTouchesNativeUi(event: MotionEvent, index: Int, width: Int, height: Int): Boolean { + if (index !in 0 until event.pointerCount) return false + val x = event.getX(index) + val y = event.getY(index) + return streamChromePassthroughBounds?.contains(x, y) == true || + streamPanelPassthroughBounds?.contains(x, y) == true || + touchControllerContains(x, y, width, height) + } + + private fun touchControllerContains(x: Float, y: Float, width: Int, height: Int): Boolean { + val bounds = touchControllerPassthroughBounds + if (bounds.isNotEmpty()) return bounds.values.any { it.contains(x, y) } + return touchControllerVisible && + width > 0 && + height > 0 && + y >= height * TOUCH_CONTROLLER_FALLBACK_TOP_RATIO + } + + private data class TouchPassthroughBounds( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, + ) { + fun contains(x: Float, y: Float): Boolean = + x >= left - EDGE_SLOP_PX && + x <= right + EDGE_SLOP_PX && + y >= top - EDGE_SLOP_PX && + y <= bottom + EDGE_SLOP_PX + + companion object { + private const val EDGE_SLOP_PX = 24 + } + } + + private const val TOUCH_CONTROLLER_FALLBACK_TOP_RATIO = 0.52f +} + +object NativeInputDiagnostics { + private const val MAX_LINES = 240 + private val lines = ArrayDeque() + + @Synchronized + fun add(message: String) { + if (lines.size >= MAX_LINES) { + lines.removeFirst() + } + lines.addLast("${SystemClock.elapsedRealtime()} $message") + Log.d("OpenNOWInput", message) + } + + @Synchronized + fun snapshot(): String = + if (lines.isEmpty()) { + "input.diagnostics=empty" + } else { + buildString { + appendLine("input.diagnostics:") + lines.forEach { appendLine(it) } + }.trimEnd() + } + +} + +enum class InputDataChannelRole { + Reliable, + PartiallyReliable, + Other, +} + +object InputDataChannelLabels { + fun classify(label: String): InputDataChannelRole = + when (label.lowercase(Locale.US)) { + "input_channel_v1", + "input_channel", + -> InputDataChannelRole.Reliable + "input_channel_partially_reliable", + "input_channel_pr", + -> InputDataChannelRole.PartiallyReliable + else -> InputDataChannelRole.Other + } +} + +internal object AndroidControllerInput { + fun hasControllerSource(source: Int): Boolean = + source.hasSource(InputDevice.SOURCE_GAMEPAD) || + source.hasSource(InputDevice.SOURCE_JOYSTICK) + + fun isControllerDevice(device: InputDevice?): Boolean = + device != null && isControllerDevice(device.sources, device.name) + + fun isControllerDevice(source: Int, deviceName: String?): Boolean = + hasControllerSource(source) || + (source.hasSource(InputDevice.SOURCE_DPAD) && isKnownControllerName(deviceName)) + + fun isControllerEvent(source: Int, deviceId: Int): Boolean = + hasControllerSource(source) || + isControllerDevice(InputDevice.getDevice(deviceId)) + + fun isKnownControllerName(name: String?): Boolean { + val normalized = name.orEmpty().lowercase(Locale.US) + return normalized.contains("stadia controller") || + normalized == "stadia" || + normalized.contains("google stadia") || + normalized.contains("dualsense") || + normalized.contains("dualshock") || + normalized.contains("wireless controller") || + normalized.contains("xbox") || + normalized.contains("x-input") || + normalized.contains("xinput") || + normalized.contains("8bitdo") || + normalized.contains("gamesir") || + normalized.contains("backbone") || + normalized.contains("razer kishi") || + normalized.contains("switch pro") || + normalized.contains("gamepad") + } + + fun isPrimaryActivationKey(keyCode: Int): Boolean = + keyCode == KeyEvent.KEYCODE_DPAD_CENTER || + keyCode == KeyEvent.KEYCODE_ENTER || + keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER + + private fun Int.hasSource(source: Int): Boolean = (this and source) == source +} + +internal data class AndroidControllerSlotAssignment( + val slot: Int, + val removedDevices: Map, +) + +internal object AndroidControllerSlotRegistry { + fun retainConnected( + controllerSlots: MutableMap, + connectedDeviceIds: Set, + ): Map { + val removedDevices = controllerSlots.filterKeys { it !in connectedDeviceIds } + removedDevices.keys.forEach(controllerSlots::remove) + return removedDevices + } + + fun assign( + controllerSlots: MutableMap, + deviceId: Int, + connectedDeviceIds: Set, + maxControllers: Int, + ): AndroidControllerSlotAssignment { + val stableDeviceId = if (deviceId >= 0) deviceId else 0 + val removedDevices = retainConnected( + controllerSlots = controllerSlots, + connectedDeviceIds = connectedDeviceIds + stableDeviceId, + ) + val existingSlot = controllerSlots[stableDeviceId] + if (existingSlot != null) { + return AndroidControllerSlotAssignment(existingSlot, removedDevices) + } + val usedSlots = controllerSlots.values.toSet() + val slot = (0 until maxControllers).firstOrNull { it !in usedSlots } ?: 0 + controllerSlots[stableDeviceId] = slot + return AndroidControllerSlotAssignment(slot, removedDevices) + } +} + +internal data class ControllerMouseDelta( + val dx: Int, + val dy: Int, +) + +internal object AndroidControllerMouseAssist { + fun mouseDelta(stickX: Float, stickY: Float): ControllerMouseDelta? { + val x = stickX.coerceIn(-1f, 1f) + val y = stickY.coerceIn(-1f, 1f) + val magnitude = sqrt(x * x + y * y).coerceIn(0f, 1f) + if (magnitude < 0.001f) return null + val speed = CONTROLLER_MOUSE_BASE_DELTA_PX + CONTROLLER_MOUSE_ACCEL_DELTA_PX * magnitude * magnitude + val dx = (x * speed).roundToInt() + val dy = (y * speed).roundToInt() + return if (dx != 0 || dy != 0) ControllerMouseDelta(dx, dy) else null + } + + fun mouseButtonForGamepad(buttonMask: Int): Int? = + when (buttonMask) { + GamepadButtonMapping.A -> 1 + GamepadButtonMapping.B -> 3 + else -> null + } + + fun mouseButtonForTrigger(left: Boolean): Int? = null + + private const val CONTROLLER_MOUSE_BASE_DELTA_PX = 7f + private const val CONTROLLER_MOUSE_ACCEL_DELTA_PX = 34f +} + +internal data class AndroidGamepadRawAxes( + val x: Float = 0f, + val y: Float = 0f, + val z: Float = 0f, + val rz: Float = 0f, + val rx: Float = 0f, + val ry: Float = 0f, + val hatX: Float = 0f, + val hatY: Float = 0f, +) + +internal data class AndroidGamepadAxisAvailability( + val x: Boolean = true, + val y: Boolean = true, + val z: Boolean = true, + val rz: Boolean = true, + val rx: Boolean = true, + val ry: Boolean = true, + val hatX: Boolean = true, + val hatY: Boolean = true, +) { + fun hasLeftStickPair(): Boolean = x && y + fun hasHatPair(): Boolean = hatX && hatY +} + +internal data class AndroidGamepadResolvedAxes( + val leftX: Float, + val leftY: Float, + val rightX: Float, + val rightY: Float, + val leftSource: String, + val rightSource: String, + val hatUsedAsLeftStick: Boolean, +) + +internal object AndroidGamepadAxisMapping { + fun resolve(raw: AndroidGamepadRawAxes, available: AndroidGamepadAxisAvailability = AndroidGamepadAxisAvailability()): AndroidGamepadResolvedAxes { + val rightUsesZRz = axisPairActive(raw.z, raw.rz) || !axisPairActive(raw.rx, raw.ry) + val rightX = if (rightUsesZRz) raw.z else raw.rx + val rightY = if (rightUsesZRz) raw.rz else raw.ry + val rightSource = if (rightUsesZRz) "z/rz" else "rx/ry" + + val useHatForLeft = + !available.hasLeftStickPair() && + available.hasHatPair() && + axisPairActive(raw.hatX, raw.hatY) + val leftX = if (useHatForLeft) raw.hatX else raw.x + val leftY = if (useHatForLeft) raw.hatY else raw.y + return AndroidGamepadResolvedAxes( + leftX = leftX, + leftY = leftY, + rightX = rightX, + rightY = rightY, + leftSource = if (useHatForLeft) "hat" else "x/y", + rightSource = rightSource, + hatUsedAsLeftStick = useHatForLeft, + ) + } + + private fun axisPairActive(x: Float, y: Float): Boolean = + abs(x) > AXIS_NOISE || abs(y) > AXIS_NOISE + + private const val AXIS_NOISE = 0.001f +} + +internal data class GamepadRumbleCommand( + val controllerId: Int, + val weakMagnitude: Int, + val strongMagnitude: Int, +) + +internal object HapticsPacketParser { + fun parse(bytes: ByteArray): GamepadRumbleCommand? { + if (bytes.size < 2) return null + val view = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + val firstWord = view.getShort(0).toInt() and 0xffff + if (firstWord == LEGACY_HAPTIC_SUBMESSAGE_TYPE) { + return parseLegacy(view, 2) + } + + return when (firstWord and 0xff) { + WRAPPER_SINGLE_EVENT -> parseSubMessage(view, 1) + WRAPPER_BATCHED_EVENT, + WRAPPER_LEGACY_INPUT, + WRAPPER_TIMESTAMPED_SINGLE, + WRAPPER_TIMESTAMPED_BATCHED, + WRAPPER_RESERVED, + -> null + else -> parseLegacy(view, 0) + } + } + + private fun parseSubMessage(view: ByteBuffer, offset: Int): GamepadRumbleCommand? { + if (offset < 0 || offset + 4 > view.limit()) return null + val type = view.getInt(offset) + return when (type) { + LEGACY_HAPTIC_SUBMESSAGE_TYPE -> parseLegacy(view, offset + 4) + OC_HAPTIC_SUBMESSAGE_TYPE -> parseOc(view, offset + 4) + else -> null + } + } + + private fun parseLegacy(view: ByteBuffer, offset: Int): GamepadRumbleCommand? { + if (offset < 0 || offset + 10 > view.limit()) return null + val kind = view.getShort(offset).toInt() and 0xffff + if (kind != 1) return null + val length = view.getShort(offset + 2).toInt() and 0xffff + if (length < 6) return null + return GamepadRumbleCommand( + controllerId = view.getShort(offset + 4).toInt() and 0xffff, + weakMagnitude = view.getShort(offset + 6).toInt() and 0xffff, + strongMagnitude = view.getShort(offset + 8).toInt() and 0xffff, + ) + } + + private fun parseOc(view: ByteBuffer, offset: Int): GamepadRumbleCommand? { + if (offset < 0 || offset + 9 > view.limit()) return null + val controllerByte = view.get(offset).toInt() and 0xff + if (controllerByte !in 6 until 10) return null + val reportKind = view.get(offset + 3).toInt() and 0xff + val flags = view.get(offset + 4).toInt() and 0xff + if (reportKind != 5 || (flags and 0xfe) != 0) return null + return GamepadRumbleCommand( + controllerId = controllerByte - 6, + weakMagnitude = (view.get(offset + 7).toInt() and 0xff) shl 8, + strongMagnitude = (view.get(offset + 8).toInt() and 0xff) shl 8, + ) + } + + private const val LEGACY_HAPTIC_SUBMESSAGE_TYPE = 267 + private const val OC_HAPTIC_SUBMESSAGE_TYPE = 17 + private const val WRAPPER_BATCHED_EVENT = 32 + private const val WRAPPER_LEGACY_INPUT = 33 + private const val WRAPPER_SINGLE_EVENT = 34 + private const val WRAPPER_TIMESTAMPED_SINGLE = 35 + private const val WRAPPER_TIMESTAMPED_BATCHED = 36 + private const val WRAPPER_RESERVED = 255 +} + +internal object GamepadButtonMapping { + const val DPAD_UP = 0x0001 + const val DPAD_DOWN = 0x0002 + const val DPAD_LEFT = 0x0004 + const val DPAD_RIGHT = 0x0008 + const val START = 0x0010 + const val BACK = 0x0020 + const val LEFT_THUMB = 0x0040 + const val RIGHT_THUMB = 0x0080 + const val LEFT_SHOULDER = 0x0100 + const val RIGHT_SHOULDER = 0x0200 + const val GUIDE = 0x0400 + const val A = 0x1000 + const val B = 0x2000 + const val X = 0x4000 + const val Y = 0x8000 + + fun maskForKeyCode(keyCode: Int, controllerActivation: Boolean = false): Int? = when (keyCode) { + KeyEvent.KEYCODE_MENU -> if (controllerActivation) START else null + KeyEvent.KEYCODE_BACK -> if (controllerActivation) BACK else null + KeyEvent.KEYCODE_DPAD_UP -> DPAD_UP + KeyEvent.KEYCODE_DPAD_DOWN -> DPAD_DOWN + KeyEvent.KEYCODE_DPAD_LEFT -> DPAD_LEFT + KeyEvent.KEYCODE_DPAD_RIGHT -> DPAD_RIGHT + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER, + -> if (controllerActivation) A else null + KeyEvent.KEYCODE_BUTTON_START -> START + KeyEvent.KEYCODE_BUTTON_SELECT -> BACK + KeyEvent.KEYCODE_BUTTON_THUMBL -> LEFT_THUMB + KeyEvent.KEYCODE_BUTTON_THUMBR -> RIGHT_THUMB + KeyEvent.KEYCODE_BUTTON_L1 -> LEFT_SHOULDER + KeyEvent.KEYCODE_BUTTON_R1 -> RIGHT_SHOULDER + KeyEvent.KEYCODE_BUTTON_MODE -> GUIDE + KeyEvent.KEYCODE_BUTTON_A -> A + KeyEvent.KEYCODE_BUTTON_B -> B + KeyEvent.KEYCODE_BUTTON_X -> X + KeyEvent.KEYCODE_BUTTON_Y -> Y + else -> null + } + + fun isControllerButtonKeyCode(keyCode: Int): Boolean = + keyCode == KeyEvent.KEYCODE_BUTTON_L2 || + keyCode == KeyEvent.KEYCODE_BUTTON_R2 || + keyCode in KeyEvent.KEYCODE_BUTTON_A..KeyEvent.KEYCODE_BUTTON_MODE +} + +internal object SteamMenuChord { + // Send only the Guide (Home) button. The GUIDE+A chord previously used caused unintended + // A-button input during gameplay; a plain Guide press is sufficient to open Steam overlay. + fun buttons(aPressed: Boolean): Int = GamepadButtonMapping.GUIDE +} + +internal class SteamOverlayChordState { + private var latched = false + private var chordPressed = false + + fun update(rawButtons: Int): Boolean { + val topButtons = rawButtons and TOP_BUTTONS + val activated = !latched && topButtons == TOP_BUTTONS + if (activated) { + latched = true + chordPressed = true + } else if (latched && topButtons == 0) { + latched = false + chordPressed = false + } + return activated + } + + fun effectiveButtons(rawButtons: Int): Int { + val withoutTopButtons = if (latched) rawButtons and TOP_BUTTONS.inv() else rawButtons + return if (chordPressed) withoutTopButtons or SteamMenuChord.buttons(aPressed = true) else withoutTopButtons + } + + fun releaseChord(): Boolean { + if (!chordPressed) return false + chordPressed = false + return true + } + + fun reset() { + latched = false + chordPressed = false + } + + private companion object { + const val TOP_BUTTONS = 0x0030 + } +} + +internal fun streamSharpnessShaderStrength(enabled: Boolean, amount: Float): Float = + if (enabled) amount.coerceIn(0f, 1f) * STREAM_SHARPNESS_MAX_SHADER_STRENGTH else 0f + +private const val STREAM_SHARPNESS_MAX_SHADER_STRENGTH = 0.28f + +private class StreamSharpnessGlDrawer : RendererCommon.GlDrawer { + @Volatile + var amount: Float = 0f + + private val vertexBuffer: FloatBuffer = GlUtil.createFloatBuffer( + floatArrayOf( + -1f, -1f, + 1f, -1f, + -1f, 1f, + 1f, 1f, + ), + ) + private val textureBuffer: FloatBuffer = GlUtil.createFloatBuffer( + floatArrayOf( + 0f, 0f, + 1f, 0f, + 0f, 1f, + 1f, 1f, + ), + ) + + private var oesProgram: SharpnessProgram? = null + private var rgbProgram: SharpnessProgram? = null + private var yuvProgram: SharpnessProgram? = null + + override fun drawOes( + oesTextureId: Int, + texMatrix: FloatArray, + frameWidth: Int, + frameHeight: Int, + viewportX: Int, + viewportY: Int, + viewportWidth: Int, + viewportHeight: Int, + ) { + val program = oesProgram ?: SharpnessProgram(SHARPEN_OES_FRAGMENT_SHADER, TextureMode.Oes).also { oesProgram = it } + program.draw( + textureIds = intArrayOf(oesTextureId), + textureTarget = GLES11Ext.GL_TEXTURE_EXTERNAL_OES, + texMatrix = texMatrix, + frameWidth = frameWidth, + frameHeight = frameHeight, + viewportX = viewportX, + viewportY = viewportY, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + amount = amount, + vertexBuffer = vertexBuffer, + textureBuffer = textureBuffer, + ) + } + + override fun drawRgb( + textureId: Int, + texMatrix: FloatArray, + frameWidth: Int, + frameHeight: Int, + viewportX: Int, + viewportY: Int, + viewportWidth: Int, + viewportHeight: Int, + ) { + val program = rgbProgram ?: SharpnessProgram(SHARPEN_RGB_FRAGMENT_SHADER, TextureMode.Rgb).also { rgbProgram = it } + program.draw( + textureIds = intArrayOf(textureId), + textureTarget = GLES20.GL_TEXTURE_2D, + texMatrix = texMatrix, + frameWidth = frameWidth, + frameHeight = frameHeight, + viewportX = viewportX, + viewportY = viewportY, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + amount = amount, + vertexBuffer = vertexBuffer, + textureBuffer = textureBuffer, + ) + } + + override fun drawYuv( + yuvTextures: IntArray, + texMatrix: FloatArray, + frameWidth: Int, + frameHeight: Int, + viewportX: Int, + viewportY: Int, + viewportWidth: Int, + viewportHeight: Int, + ) { + val program = yuvProgram ?: SharpnessProgram(SHARPEN_YUV_FRAGMENT_SHADER, TextureMode.Yuv).also { yuvProgram = it } + program.draw( + textureIds = yuvTextures, + textureTarget = GLES20.GL_TEXTURE_2D, + texMatrix = texMatrix, + frameWidth = frameWidth, + frameHeight = frameHeight, + viewportX = viewportX, + viewportY = viewportY, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + amount = amount, + vertexBuffer = vertexBuffer, + textureBuffer = textureBuffer, + ) + } + + override fun release() { + oesProgram?.release() + rgbProgram?.release() + yuvProgram?.release() + oesProgram = null + rgbProgram = null + yuvProgram = null + } + + private class SharpnessProgram(fragmentShader: String, private val mode: TextureMode) { + private val shader = GlShader(SHARPEN_VERTEX_SHADER, fragmentShader) + private val texMatrixLocation = shader.getUniformLocation("tex_mat") + private val sharpnessLocation = shader.getUniformLocation("sharpness") + private val texelSizeLocation = shader.getUniformLocation("texel_size") + private val textureLocations: IntArray = when (mode) { + TextureMode.Oes, + TextureMode.Rgb, + -> intArrayOf(shader.getUniformLocation("tex")) + TextureMode.Yuv -> intArrayOf( + shader.getUniformLocation("y_tex"), + shader.getUniformLocation("u_tex"), + shader.getUniformLocation("v_tex"), + ) + } + + fun draw( + textureIds: IntArray, + textureTarget: Int, + texMatrix: FloatArray, + frameWidth: Int, + frameHeight: Int, + viewportX: Int, + viewportY: Int, + viewportWidth: Int, + viewportHeight: Int, + amount: Float, + vertexBuffer: FloatBuffer, + textureBuffer: FloatBuffer, + ) { + shader.useProgram() + GLES20.glViewport(viewportX, viewportY, viewportWidth, viewportHeight) + shader.setVertexAttribArray("in_pos", 2, vertexBuffer) + shader.setVertexAttribArray("in_tc", 2, textureBuffer) + GLES20.glUniformMatrix4fv(texMatrixLocation, 1, false, texMatrix, 0) + GLES20.glUniform1f(sharpnessLocation, amount.coerceIn(0f, STREAM_SHARPNESS_MAX_SHADER_STRENGTH)) + GLES20.glUniform2f( + texelSizeLocation, + 1f / frameWidth.coerceAtLeast(1).toFloat(), + 1f / frameHeight.coerceAtLeast(1).toFloat(), + ) + textureLocations.forEachIndexed { index, location -> + GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + index) + GLES20.glBindTexture(textureTarget, textureIds.getOrElse(index) { 0 }) + GLES20.glUniform1i(location, index) + } + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4) + textureLocations.indices.forEach { index -> + GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + index) + GLES20.glBindTexture(textureTarget, 0) + } + GlUtil.checkNoGLES2Error("StreamSharpnessGlDrawer.draw") + } + + fun release() { + shader.release() + } + } + + private enum class TextureMode { + Oes, + Rgb, + Yuv, + } + + private companion object { + private const val SHARPEN_VERTEX_SHADER = """ + attribute vec4 in_pos; + attribute vec2 in_tc; + uniform mat4 tex_mat; + varying vec2 tc; + + void main() { + gl_Position = in_pos; + tc = (tex_mat * vec4(in_tc, 0.0, 1.0)).xy; + } + """ + + private const val SHARPEN_BODY = """ + uniform float sharpness; + uniform vec2 texel_size; + varying vec2 tc; + + void main() { + vec4 center = sampleColor(tc); + if (sharpness <= 0.001) { + gl_FragColor = center; + return; + } + vec3 north = sampleColor(tc + vec2(0.0, -texel_size.y)).rgb; + vec3 south = sampleColor(tc + vec2(0.0, texel_size.y)).rgb; + vec3 west = sampleColor(tc + vec2(-texel_size.x, 0.0)).rgb; + vec3 east = sampleColor(tc + vec2(texel_size.x, 0.0)).rgb; + vec3 sharpened = center.rgb * (1.0 + 4.0 * sharpness) - (north + south + west + east) * sharpness; + gl_FragColor = vec4(clamp(sharpened, 0.0, 1.0), center.a); + } + """ + + private const val SHARPEN_OES_FRAGMENT_SHADER = """ + #extension GL_OES_EGL_image_external : require + precision mediump float; + uniform samplerExternalOES tex; + vec4 sampleColor(vec2 pos) { + return texture2D(tex, pos); + } + """ + SHARPEN_BODY + + private const val SHARPEN_RGB_FRAGMENT_SHADER = """ + precision mediump float; + uniform sampler2D tex; + vec4 sampleColor(vec2 pos) { + return texture2D(tex, pos); + } + """ + SHARPEN_BODY + + private const val SHARPEN_YUV_FRAGMENT_SHADER = """ + precision mediump float; + uniform sampler2D y_tex; + uniform sampler2D u_tex; + uniform sampler2D v_tex; + vec4 sampleColor(vec2 pos) { + float y = texture2D(y_tex, pos).r; + float u = texture2D(u_tex, pos).r - 0.5; + float v = texture2D(v_tex, pos).r - 0.5; + return vec4( + y + 1.403 * v, + y - 0.344 * u - 0.714 * v, + y + 1.770 * u, + 1.0 + ); + } + """ + SHARPEN_BODY + } +} + +internal sealed interface StreamLivenessAction { + data object None : StreamLivenessAction + data class RequestKeyframe(val stalledMs: Long, val attempt: Int) : StreamLivenessAction + data class RestartTransport(val stalledMs: Long) : StreamLivenessAction +} + +internal class StreamLivenessWatchdog( + private val keyframeAfterMs: Long = MEDIA_STALL_KEYFRAME_AFTER_MS, + private val keyframeIntervalMs: Long = MEDIA_STALL_KEYFRAME_INTERVAL_MS, + private val restartAfterMs: Long = MEDIA_STALL_RESTART_AFTER_MS, +) { + private var lastProgressAtMs: Long? = null + private var lastBytesReceived: Long? = null + private var lastFramesDecoded: Long? = null + private var lastKeyframeRequestAtMs = Long.MIN_VALUE + private var keyframeAttempts = 0 + var latestObservationProgressed: Boolean = false + private set + + fun reset() { + lastProgressAtMs = null + lastBytesReceived = null + lastFramesDecoded = null + lastKeyframeRequestAtMs = Long.MIN_VALUE + keyframeAttempts = 0 + latestObservationProgressed = false + } + + fun markConnected(nowMs: Long) { + lastProgressAtMs = nowMs + lastKeyframeRequestAtMs = Long.MIN_VALUE + keyframeAttempts = 0 + } + + fun observe(nowMs: Long, bytesReceived: Long?, framesDecoded: Long?, connected: Boolean): StreamLivenessAction { + latestObservationProgressed = false + if (!connected) { + reset() + return StreamLivenessAction.None + } + + val progressed = if (framesDecoded != null) { + lastFramesDecoded?.let { framesDecoded > it } ?: (framesDecoded > 0) + } else { + bytesReceived != null && (lastBytesReceived?.let { bytesReceived > it } ?: (bytesReceived > 0)) + } + if (bytesReceived != null) lastBytesReceived = bytesReceived + if (framesDecoded != null) lastFramesDecoded = framesDecoded + if (progressed) { + latestObservationProgressed = true + lastProgressAtMs = nowMs + lastKeyframeRequestAtMs = Long.MIN_VALUE + keyframeAttempts = 0 + return StreamLivenessAction.None + } + + val stalledMs = nowMs - (lastProgressAtMs ?: nowMs.also { lastProgressAtMs = it }) + if (stalledMs >= restartAfterMs) { + reset() + return StreamLivenessAction.RestartTransport(stalledMs) + } + val keyframeDue = lastKeyframeRequestAtMs == Long.MIN_VALUE || + nowMs - lastKeyframeRequestAtMs >= keyframeIntervalMs + if (stalledMs >= keyframeAfterMs && keyframeDue) { + lastKeyframeRequestAtMs = nowMs + keyframeAttempts += 1 + return StreamLivenessAction.RequestKeyframe(stalledMs, keyframeAttempts) + } + return StreamLivenessAction.None + } +} + +internal data class StreamRecoveryTiming( + val keyframeAfterMs: Long, + val keyframeIntervalMs: Long, + val restartAfterMs: Long, +) + +internal fun streamRecoveryTiming(androidTvProfile: Boolean): StreamRecoveryTiming = + if (androidTvProfile) { + StreamRecoveryTiming( + keyframeAfterMs = TV_MEDIA_STALL_KEYFRAME_AFTER_MS, + keyframeIntervalMs = TV_MEDIA_STALL_KEYFRAME_INTERVAL_MS, + restartAfterMs = TV_MEDIA_STALL_RESTART_AFTER_MS, + ) + } else { + StreamRecoveryTiming( + keyframeAfterMs = MEDIA_STALL_KEYFRAME_AFTER_MS, + keyframeIntervalMs = MEDIA_STALL_KEYFRAME_INTERVAL_MS, + restartAfterMs = MEDIA_STALL_RESTART_AFTER_MS, + ) + } + +internal fun firstVideoFrameRecoveryTimeoutMs(androidTvProfile: Boolean): Long = + streamRecoveryTiming(androidTvProfile).restartAfterMs + +internal enum class FirstFrameRecoveryStep { + RetryRequestedProfile, + ApplySafeVideoFallback, + ContinueBoundedTransportRecovery, +} + +internal fun firstFrameRecoveryStep( + transportHasStableMedia: Boolean, + reconnectAttempts: Int, + safeVideoFallbackApplied: Boolean, +): FirstFrameRecoveryStep = when { + transportHasStableMedia -> FirstFrameRecoveryStep.ContinueBoundedTransportRecovery + reconnectAttempts == 0 -> FirstFrameRecoveryStep.RetryRequestedProfile + !safeVideoFallbackApplied -> FirstFrameRecoveryStep.ApplySafeVideoFallback + else -> FirstFrameRecoveryStep.ContinueBoundedTransportRecovery +} + +private fun newStreamLivenessWatchdog(androidTvProfile: Boolean): StreamLivenessWatchdog { + val timing = streamRecoveryTiming(androidTvProfile) + return StreamLivenessWatchdog( + keyframeAfterMs = timing.keyframeAfterMs, + keyframeIntervalMs = timing.keyframeIntervalMs, + restartAfterMs = timing.restartAfterMs, + ) +} + +internal class FirstVideoFrameWatchdog( + private val timeoutMs: Long = FIRST_VIDEO_FRAME_TIMEOUT_MS, +) { + private var bytesWithoutFrameSinceMs: Long? = null + private var rendered = false + + @Synchronized + fun reset() { + bytesWithoutFrameSinceMs = null + rendered = false + } + + @Synchronized + fun markRendered() { + rendered = true + bytesWithoutFrameSinceMs = null + } + + @Synchronized + fun shouldRecover(nowMs: Long, bytesReceived: Long?, connected: Boolean): Boolean { + if (!connected || rendered || bytesReceived == null || bytesReceived <= 0L) { + if (!connected) bytesWithoutFrameSinceMs = null + return false + } + val startedAt = bytesWithoutFrameSinceMs ?: nowMs.also { bytesWithoutFrameSinceMs = it } + return nowMs - startedAt >= timeoutMs + } +} + +private class TouchMouseState { + private var activePointerId = -1 + private var downX = 0f + private var downY = 0f + private var downTimeMs = 0L + private var lastX = 0f + private var lastY = 0f + private var selecting = false + private var doubleTapDragCandidate = false + private var lastTapTimeMs = Long.MIN_VALUE + private var lastTapX = Float.NaN + private var lastTapY = Float.NaN + private var virtualCursorX = 0f + private var virtualCursorY = 0f + private var virtualCursorInitialized = false + private var twoFingerTapCandidate = false + + fun reset(client: NativeStreamClient?) { + if (selecting) client?.setTouchMouseButton(false) + activePointerId = -1 + selecting = false + doubleTapDragCandidate = false + virtualCursorInitialized = false + twoFingerTapCandidate = false + } + + fun handle( + event: MotionEvent, + enabled: Boolean, + client: NativeStreamClient, + ignoredPointerIds: Set, + directClick: Boolean = false, + width: Int = 0, + height: Int = 0, + stretchToFit: Boolean = false, + renderingAspectRatio: Float = 0f, + ): Boolean { + if (!enabled) { + reset(client) + return false + } + + if (directClick) { + val parts = client.settings.resolution.split("x") + val streamWidth = parts.getOrNull(0)?.toIntOrNull() ?: 1920 + val streamHeight = parts.getOrNull(1)?.toIntOrNull() ?: 1080 + + var videoWidth = width.toFloat() + var videoHeight = height.toFloat() + var offsetX = 0f + var offsetY = 0f + + if (!stretchToFit && width > 0 && height > 0) { + val streamAspectRatio = if (renderingAspectRatio > 0f) renderingAspectRatio else (streamWidth.toFloat() / streamHeight.toFloat()) + val screenAspectRatio = width.toFloat() / height.toFloat() + if (screenAspectRatio > streamAspectRatio) { + // Pillarboxed (black bars left/right) + videoWidth = height * streamAspectRatio + videoHeight = height.toFloat() + offsetX = (width - videoWidth) / 2f + } else if (screenAspectRatio < streamAspectRatio) { + // Letterboxed (black bars top/bottom) + videoWidth = width.toFloat() + videoHeight = width / streamAspectRatio + offsetY = (height - videoHeight) / 2f + } + } + + if (!virtualCursorInitialized) { + virtualCursorX = streamWidth / 2f + virtualCursorY = streamHeight / 2f + virtualCursorInitialized = true + } + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> { + val index = if (event.actionMasked == MotionEvent.ACTION_DOWN) 0 else event.actionIndex + if (index in 0 until event.pointerCount && event.getPointerId(index) !in ignoredPointerIds) { + val pointerId = event.getPointerId(index) + // Guard: only block if the *same* pointer is already being tracked (true dup). + // Allow a new pointer if the previous activePointerId is no longer present in the event. + if (activePointerId >= 0 && event.findPointerIndex(activePointerId) >= 0) { + // Active pointer still in contact — absorb this extra DOWN. + return true + } + // If we get here the old pointer was lifted without a UP event — reset first. + if (activePointerId >= 0) { + client.setTouchMouseButton(false) + } + + activePointerId = pointerId + val touchX = event.getX(index) + val touchY = event.getY(index) + val rx = touchX - offsetX + val ry = touchY - offsetY + val targetX = if (videoWidth > 0) (rx / videoWidth * streamWidth).coerceIn(0f, streamWidth.toFloat()) else 0f + val targetY = if (videoHeight > 0) (ry / videoHeight * streamHeight).coerceIn(0f, streamHeight.toFloat()) else 0f + + val dx = targetX - virtualCursorX + val dy = targetY - virtualCursorY + + val idx = dx.roundToInt() + val idy = dy.roundToInt() + + if (idx != 0 || idy != 0) { + client.sendRawMouseMove(idx, idy) + virtualCursorX = (virtualCursorX + idx).coerceIn(0f, streamWidth.toFloat()) + virtualCursorY = (virtualCursorY + idy).coerceIn(0f, streamHeight.toFloat()) + } + + client.setTouchMouseButton(true) + } + return true + } + MotionEvent.ACTION_MOVE -> { + if (activePointerId >= 0) { + val index = event.findPointerIndex(activePointerId) + if (index >= 0) { + val touchX = event.getX(index) + val touchY = event.getY(index) + val rx = touchX - offsetX + val ry = touchY - offsetY + val targetX = if (videoWidth > 0) (rx / videoWidth * streamWidth).coerceIn(0f, streamWidth.toFloat()) else 0f + val targetY = if (videoHeight > 0) (ry / videoHeight * streamHeight).coerceIn(0f, streamHeight.toFloat()) else 0f + + val dx = targetX - virtualCursorX + val dy = targetY - virtualCursorY + + val idx = dx.roundToInt() + val idy = dy.roundToInt() + + if (idx != 0 || idy != 0) { + client.sendRawMouseMove(idx, idy) + virtualCursorX = (virtualCursorX + idx).coerceIn(0f, streamWidth.toFloat()) + virtualCursorY = (virtualCursorY + idy).coerceIn(0f, streamHeight.toFloat()) + } + } + } + return true + } + MotionEvent.ACTION_UP -> { + // Final pointer lifted — always release the button. + client.setTouchMouseButton(false) + activePointerId = -1 + return true + } + MotionEvent.ACTION_POINTER_UP -> { + val releasedId = event.getPointerId(event.actionIndex) + if (releasedId == activePointerId) { + client.setTouchMouseButton(false) + activePointerId = -1 + } + return true + } + MotionEvent.ACTION_CANCEL -> { + client.setTouchMouseButton(false) + activePointerId = -1 + return true + } + } + return true + } + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + if (event.getPointerId(0) in ignoredPointerIds) { + reset(client) + return false + } + beginPointer(event, 0) + twoFingerTapCandidate = false + return true + } + MotionEvent.ACTION_POINTER_DOWN -> { + if (activePointerId < 0) { + val index = event.actionIndex + if (index in 0 until event.pointerCount && event.getPointerId(index) !in ignoredPointerIds) { + beginPointer(event, index) + } + } else { + val newIndex = event.actionIndex + val newPointerId = if (newIndex in 0 until event.pointerCount) event.getPointerId(newIndex) else -1 + if (newPointerId >= 0 && newPointerId !in ignoredPointerIds) { + var nonIgnoredCount = 0 + for (i in 0 until event.pointerCount) { + if (event.getPointerId(i) !in ignoredPointerIds) { + nonIgnoredCount++ + } + } + if (nonIgnoredCount == 2) { + twoFingerTapCandidate = true + } + } + } + return true + } + MotionEvent.ACTION_MOVE -> { + if (activePointerId < 0) { + val index = event.firstPointerIndexNotIn(ignoredPointerIds) + if (index >= 0) beginPointer(event, index) + return index >= 0 + } + val index = event.findPointerIndex(activePointerId) + if (index < 0) return true + val x = event.getX(index) + val y = event.getY(index) + val dx = x - lastX + val dy = y - lastY + if ( + doubleTapDragCandidate && + !selecting && + (abs(x - downX) > TOUCH_MOUSE_DRAG_START_SLOP_PX || abs(y - downY) > TOUCH_MOUSE_DRAG_START_SLOP_PX) + ) { + selecting = client.setTouchMouseButton(true) + doubleTapDragCandidate = false + if (selecting) { + NativeInputDiagnostics.add("touch double tap drag start") + } + } + sendMouseDelta(dx, dy, client) + lastX = x + lastY = y + return true + } + MotionEvent.ACTION_POINTER_UP -> { + val index = event.actionIndex + if (index in 0 until event.pointerCount && event.getPointerId(index) == activePointerId) { + finishPointer(event, index, client) + } + return true + } + MotionEvent.ACTION_UP -> { + val index = event.findPointerIndex(activePointerId).takeIf { it >= 0 } ?: event.firstPointerIndexNotIn(ignoredPointerIds) + if (index < 0) return false + finishPointer(event, index, client) + return true + } + MotionEvent.ACTION_CANCEL -> { + reset(client) + return true + } + } + return true + } + + private fun beginPointer(event: MotionEvent, index: Int) { + activePointerId = event.getPointerId(index) + downX = event.getX(index) + downY = event.getY(index) + downTimeMs = event.eventTime + lastX = downX + lastY = downY + selecting = false + doubleTapDragCandidate = isDoubleTap(event, index) + if (doubleTapDragCandidate) { + lastTapTimeMs = Long.MIN_VALUE + } + } + + private fun finishPointer(event: MotionEvent, index: Int, client: NativeStreamClient) { + val x = event.getX(index) + val y = event.getY(index) + val tapDistanceX = abs(x - downX) + val tapDistanceY = abs(y - downY) + val wasTap = activePointerId >= 0 && + event.eventTime - downTimeMs <= TOUCH_MOUSE_TAP_TIMEOUT_MS && + tapDistanceX <= TOUCH_MOUSE_TAP_SLOP_PX && + tapDistanceY <= TOUCH_MOUSE_TAP_SLOP_PX + activePointerId = -1 + doubleTapDragCandidate = false + if (selecting) { + client.setTouchMouseButton(false) + selecting = false + return + } + if (wasTap) { + if (twoFingerTapCandidate) { + NativeInputDiagnostics.add("touch 2-finger tap right click dx=${tapDistanceX.roundToInt()} dy=${tapDistanceY.roundToInt()}") + client.sendTouchMouseRightClick() + } else { + NativeInputDiagnostics.add("touch tap click dx=${tapDistanceX.roundToInt()} dy=${tapDistanceY.roundToInt()}") + client.sendTouchMouseClick() + } + lastTapTimeMs = event.eventTime + lastTapX = x + lastTapY = y + } + twoFingerTapCandidate = false + } + + private fun MotionEvent.firstPointerIndexNotIn(ignoredPointerIds: Set): Int { + for (index in 0 until pointerCount) { + if (getPointerId(index) !in ignoredPointerIds) return index + } + return -1 + } + + private fun isDoubleTap(event: MotionEvent, index: Int): Boolean { + if (lastTapTimeMs == Long.MIN_VALUE) return false + if (event.eventTime - lastTapTimeMs > TOUCH_MOUSE_DOUBLE_TAP_TIMEOUT_MS) return false + if (!lastTapX.isFinite() || !lastTapY.isFinite()) return false + return abs(event.getX(index) - lastTapX) <= TOUCH_MOUSE_DOUBLE_TAP_SLOP_PX && + abs(event.getY(index) - lastTapY) <= TOUCH_MOUSE_DOUBLE_TAP_SLOP_PX + } + + private fun sendMouseDelta( + dx: Float, + dy: Float, + client: NativeStreamClient, + partiallyReliable: Boolean = true, + ) { + val ix = dx.roundToInt() + val iy = dy.roundToInt() + if (ix != 0 || iy != 0) { + client.sendTouchMouseMove(ix, iy, partiallyReliable) + } + } + + companion object { + private const val TOUCH_MOUSE_DRAG_START_SLOP_PX = 10f + private const val TOUCH_MOUSE_TAP_SLOP_PX = 42f + private const val TOUCH_MOUSE_TAP_TIMEOUT_MS = 450L + private const val TOUCH_MOUSE_DOUBLE_TAP_TIMEOUT_MS = 320L + private const val TOUCH_MOUSE_DOUBLE_TAP_SLOP_PX = 36f + } +} + +class NativeStreamClient( + context: Context, + private val onState: (String) -> Unit, + private val onError: (String) -> Unit, + private val onSafeVideoFallbackApplied: (String) -> Unit = {}, + private val onSessionRecoveryRequired: (String) -> Unit = {}, + private val onFirstVideoFrameRendered: () -> Unit = {}, + private val onStats: (StreamRuntimeStats) -> Unit = {}, + private val onControllerMouseAssistChanged: (Boolean) -> Unit = {}, + private val onStreamStopped: () -> Unit = {}, +) { + private val appContext = context.applicationContext + private val initialAndroidTvProfile = isAndroidTvProfile(appContext) + private val eglBase: EglBase = EglBase.create() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val inputExecutor = java.util.concurrent.Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "opennow-input-sender").apply { + priority = Thread.MAX_PRIORITY + } + } + private val inputScope = CoroutineScope(SupervisorJob() + inputExecutor.asCoroutineDispatcher()) + private val inputEncoder = InputEncoder() + private val audioDeviceModule: AudioDeviceModule = + JavaAudioDeviceModule.builder(appContext) + .setUseLowLatency(shouldUseLowLatencyStreamAudio(initialAndroidTvProfile)) + .setAudioSource(MediaRecorder.AudioSource.VOICE_COMMUNICATION) + .setUseStereoInput(false) + .setUseStereoOutput(true) + .setUseHardwareAcousticEchoCanceler(true) + .setUseHardwareNoiseSuppressor(true) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_GAME) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(), + ) + .createAudioDeviceModule() + private var factory: PeerConnectionFactory? = null + private var peerConnection: PeerConnection? = null + private var signaling: GfnSignalingClient? = null + private var reliableInput: DataChannel? = null + private var partiallyReliableInput: DataChannel? = null + private var partiallyReliableGamepadMask = 0 + private var hapticsAdvertised: Boolean? = null + private var videoTrack: VideoTrack? = null + private var audioTrack: AudioTrack? = null + private var microphoneSource: AudioSource? = null + private var microphoneTrack: AudioTrack? = null + private var microphoneSender: RtpSender? = null + private var renderer: SurfaceViewRenderer? = null + private var rendererSharpnessDrawer: StreamSharpnessGlDrawer? = null + private var rendererSurfaceCallback: SurfaceHolder.Callback? = null + private var rendererSinkAttached = false + private var heartbeatJob: Job? = null + private var gamepadKeepaliveJob: Job? = null + private var statsJob: Job? = null + private var iceRecoveryJob: Job? = null + private var offerTimeoutJob: Job? = null + internal var settings: StreamSettings = StreamSettings() + private var session: SessionInfo? = null + private var transportGeneration = 0 + private var reconnectAttempts = 0 + private var videoSafeFallbackApplied = false + private var transportHasStableMedia = false + private var consecutiveTransportProgressSamples = 0 + private var sessionRecoveryRequested = false + private var lastIceState: PeerConnection.IceConnectionState? = null + private var audioMuted = false + private var microphoneMuted = false + private var virtualButtons = 0 + private var virtualLeftTrigger = 0 + private var virtualRightTrigger = 0 + private var virtualLeftStickActive = false + private var virtualLeftStickX = 0 + private var virtualLeftStickY = 0 + private var virtualRightStickActive = false + private var virtualRightStickX = 0 + private var virtualRightStickY = 0 + private var virtualControllerVisible = false + private var physicalControllerConnected = false + private var physicalControllerActive = false + private var activeControllerId = 0 + private val controllerSlots = linkedMapOf() + private val controllerAxisAvailability = mutableMapOf() + private var physicalButtons = 0 + private var physicalHatButtons = 0 + private var steamMenuChordButtons = 0 + private val physicalSteamOverlayChord = SteamOverlayChordState() + private val virtualSteamOverlayChord = SteamOverlayChordState() + private var physicalLeftTriggerButtonPressed = false + private var physicalRightTriggerButtonPressed = false + private var lastLeftTrigger = 0 + private var lastRightTrigger = 0 + private var lastLeftStickX = 0 + private var lastLeftStickY = 0 + private var lastRightStickX = 0 + private var lastRightStickY = 0 + private var controllerMouseAutoArmOnStart = false + private var controllerMouseAssistActive = false + private var controllerMouseAssistAutoArmed = false + private var controllerMouseEmulationActive = false + private var controllerMouseMoveLogged = false + private var controllerMouseLeftButtonDown = false + private var controllerMouseRightButtonDown = false + private var mouseLastDeviceId = Int.MIN_VALUE + private var mouseLastSource = 0 + private var mouseLastX = 0f + private var mouseLastY = 0f + private var mousePositionValid = false + private var mouseSuppressNextAbsoluteDelta = false + private var inputDropLogged = false + private var externalMouseEventLogged = false + private var externalMouseMoveSentLogged = false + private var externalMouseAbsoluteJumpLogged = false + private var hardwareKeyboardEventLogged = false + private var physicalGamepadAxisLogged = false + private var lastStatsSample: StreamStatsSample? = null + private var androidTvProfile = initialAndroidTvProfile + private var livenessWatchdog = newStreamLivenessWatchdog(androidTvProfile) + private var firstVideoFrameWatchdog = FirstVideoFrameWatchdog( + timeoutMs = firstVideoFrameRecoveryTimeoutMs(androidTvProfile), + ) + private val textSendMutex = Mutex() + private var guideAutoReleaseJob: Job? = null + private var steamMenuChordJob: Job? = null + private var physicalSteamOverlayChordReleaseJob: Job? = null + private var virtualSteamOverlayChordReleaseJob: Job? = null + private val lastRumbleEffectAtMs = LongArray(GAMEPAD_MAX_CONTROLLERS) + private val hapticsSupportLogged = BooleanArray(GAMEPAD_MAX_CONTROLLERS) + private var lastHapticsWarningAtMs = 0L + private var phoneRumbleFallbackEnabled = true + private var phoneRumbleSupportLogged = false + private var released = false + private var controllerMouseLoopJob: Job? = null + private var physicalLeftStickX = 0f + private var physicalLeftStickY = 0f + private var physicalRightStickX = 0f + private var physicalRightStickY = 0f + + private data class RumbleEffectProfile( + val weakAmplitude: Int, + val strongAmplitude: Int, + val combinedAmplitude: Int, + ) { + val isStop: Boolean + get() = weakAmplitude <= 0 && strongAmplitude <= 0 && combinedAmplitude <= 0 + } + + private data class StreamStatsSample( + val atMs: Double, + val bytesReceived: Long, + val framesDecoded: Long, + val totalDecodeTime: Double, + val packetsLost: Long, + val packetsReceived: Long, + ) + + private data class RuntimeStatsSnapshot( + val stats: StreamRuntimeStats, + val bytesReceived: Long?, + val framesDecoded: Long?, + ) + + private fun recordStreamDiagnostic(message: String) { + NativeInputDiagnostics.add("stream $message") + } + + init { + WebRtcRuntime.ensureInitialized(appContext) + val lowLatencyEnabled = SettingsStore(appContext).settings.value.nativeLowLatencyDecoder + factory = PeerConnectionFactory.builder() + .setOptions(PeerConnectionFactory.Options()) + .setAudioDeviceModule(audioDeviceModule) + .setVideoDecoderFactory(OpenNowVideoDecoderFactory(eglBase.eglBaseContext, lowLatencyEnabled)) + .setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true)) + .createPeerConnectionFactory() + } + + fun createRenderer(context: Context, settings: StreamSettings): SurfaceViewRenderer = + SurfaceViewRenderer(context).also { + renderer?.let { oldRenderer -> + releaseRendererInternal(oldRenderer) + } + firstVideoFrameWatchdog.reset() + val rendererEvents = object : RendererCommon.RendererEvents { + override fun onFirstFrameRendered() { + firstVideoFrameWatchdog.markRendered() + NativeInputDiagnostics.add("video renderer first frame codec=${this@NativeStreamClient.settings.codec}") + scope.launch { onFirstVideoFrameRendered() } + } + + override fun onFrameResolutionChanged(videoWidth: Int, videoHeight: Int, rotation: Int) { + NativeInputDiagnostics.add("video renderer resolution=${videoWidth}x$videoHeight rotation=$rotation") + } + } + val sharpnessDrawer = if (settings.streamSharpeningEnabled) { + StreamSharpnessGlDrawer().also { drawer -> + drawer.amount = streamSharpnessShaderStrength(true, settings.streamSharpeningAmount) + } + } else { + null + } + rendererSharpnessDrawer = sharpnessDrawer + if (sharpnessDrawer != null) { + it.init(eglBase.eglBaseContext, rendererEvents, EglBase.CONFIG_PLAIN, sharpnessDrawer) + } else { + it.init(eglBase.eglBaseContext, rendererEvents) + } + it.setEnableHardwareScaler(true) + it.setMirror(false) + // Do not give SurfaceViewRenderer an opaque View background. Its decoded + // frames are presented by a separate Surface layer, so a normal View + // background can cover every rendered frame on physical devices. The + // Compose stream container already supplies the black pre-frame backdrop. + it.setStreamScaling() + renderer = it + rendererSurfaceCallback = object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) { + attachRendererSinkIfAvailable(it) + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + attachRendererSinkIfAvailable(it) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + detachRendererSink(it) + } + }.also(it.holder::addCallback) + attachRendererSinkIfAvailable(it) + } + + fun releaseRenderer(candidate: SurfaceViewRenderer) { + if (renderer !== candidate) return + releaseRendererInternal(candidate) + renderer = null + rendererSharpnessDrawer = null + } + + private fun attachRendererSinkIfAvailable(candidate: SurfaceViewRenderer) { + if (renderer !== candidate || rendererSinkAttached || candidate.holder.surface?.isValid != true) return + val track = videoTrack ?: return + firstVideoFrameWatchdog.reset() + track.addSink(candidate) + rendererSinkAttached = true + recordStreamDiagnostic("video renderer sink attached") + } + + private fun detachRendererSink(candidate: SurfaceViewRenderer) { + if (renderer !== candidate || !rendererSinkAttached) return + videoTrack?.removeSink(candidate) + rendererSinkAttached = false + recordStreamDiagnostic("video renderer sink detached surface=${candidate.holder.surface?.isValid == true}") + } + + private fun releaseRendererInternal(candidate: SurfaceViewRenderer) { + prepareRendererForRelease(candidate) + candidate.release() + } + + private fun prepareRendererForRelease(candidate: SurfaceViewRenderer) { + detachRendererSink(candidate) + rendererSurfaceCallback?.let(candidate.holder::removeCallback) + rendererSurfaceCallback = null + candidate.hideSurfaceBeforeRelease() + } + + private fun SurfaceViewRenderer.hideSurfaceBeforeRelease() { + // SurfaceView frames are composited in a separate native layer. Hide that + // layer before tearing down WebRTC so a stale/pre-frame buffer cannot remain + // above the next Compose screen while SurfaceFlinger processes the detach. + alpha = 0f + visibility = View.GONE + } + + fun updateRendererSettings(settings: StreamSettings) { + this.settings = this.settings.copy( + mouseSensitivity = settings.mouseSensitivity, + mouseAcceleration = settings.mouseAcceleration, + streamSharpeningEnabled = settings.streamSharpeningEnabled, + streamSharpeningAmount = settings.streamSharpeningAmount, + ) + rendererSharpnessDrawer?.amount = streamSharpnessShaderStrength(settings.streamSharpeningEnabled, settings.streamSharpeningAmount) + renderer?.setStreamScaling() + } + + private fun SurfaceViewRenderer.setStreamScaling() { + // Keep the complete decoded frame inside the SurfaceView. Phone edge-to-edge + // presentation is applied by scaling the View itself, never by cropping video. + setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT) + } + + fun updateHapticsSettings(phoneFallbackEnabled: Boolean) { + if (phoneRumbleFallbackEnabled == phoneFallbackEnabled) return + phoneRumbleFallbackEnabled = phoneFallbackEnabled + if (!phoneFallbackEnabled) { + cancelPhoneRumble() + } + updateHapticsAdvertisement(force = true) + } + + fun updateControllerMouseAssistAutoArm(enabled: Boolean) { + controllerMouseAutoArmOnStart = enabled + if (!enabled) { + setControllerMouseAssistActive(false) + } + } + + fun updateAndroidTvProfile(enabled: Boolean) { + if (androidTvProfile == enabled) return + androidTvProfile = enabled + livenessWatchdog = newStreamLivenessWatchdog(enabled) + firstVideoFrameWatchdog = FirstVideoFrameWatchdog( + timeoutMs = firstVideoFrameRecoveryTimeoutMs(enabled), + ) + recordStreamDiagnostic("recovery profile=${if (enabled) "android-tv" else "mobile"}") + } + + fun setControllerMouseAssistEnabled(enabled: Boolean) { + setControllerMouseAssistActive(enabled) + } + + fun setControllerMouseEmulationActive(enabled: Boolean) { + if (controllerMouseEmulationActive == enabled) return + if (!enabled) { + // Release any held mouse buttons so state stays clean. + releaseControllerMouseButtons() + // Zero both physical and virtual left-stick memory so neither controller path + // delivers stale deflection to the game after mode is disabled. + lastLeftStickX = 0 + lastLeftStickY = 0 + virtualLeftStickActive = false + virtualLeftStickX = 0 + virtualLeftStickY = 0 + } + controllerMouseEmulationActive = enabled + updateControllerMouseLoop() + // Push a fresh gamepad state immediately so the zeroed stick is sent before any next frame. + sendCurrentGamepadState() + NativeInputDiagnostics.add("controller mouse emulation ${if (enabled) "enabled" else "disabled"}") + } + + private fun startControllerMouseLoop() { + if (controllerMouseLoopJob?.isActive == true) return + controllerMouseLoopJob = scope.launch { + val currentJob = coroutineContext[Job] + while (currentJob?.isActive == true) { + // Poll/send mouse updates at 60Hz (approx 16ms delay; physical caches are cleared on disconnect/reset) + delay(16L) + if (controllerMouseEmulationActive) { + sendControllerMouseMove(physicalLeftStickX, physicalLeftStickY) + } + if (controllerMouseAssistActive) { + sendControllerMouseMove(physicalRightStickX, physicalRightStickY) + } + } + } + } + + private fun updateControllerMouseLoop() { + if (shouldRunControllerMouseLoop(controllerMouseAssistActive, controllerMouseEmulationActive)) { + startControllerMouseLoop() + } else { + stopControllerMouseLoop() + } + } + + private fun stopControllerMouseLoop() { + controllerMouseLoopJob?.cancel() + controllerMouseLoopJob = null + physicalLeftStickX = 0f + physicalLeftStickY = 0f + physicalRightStickX = 0f + physicalRightStickY = 0f + } + + fun start(session: SessionInfo, settings: StreamSettings) { + if (released) return + this.session = session + this.settings = settings + transportGeneration += 1 + reconnectAttempts = 0 + videoSafeFallbackApplied = false + sessionRecoveryRequested = false + lastStatsSample = null + livenessWatchdog.reset() + firstVideoFrameWatchdog.reset() + onStats(StreamRuntimeStats()) + audioDeviceModule.setSpeakerMute(audioMuted) + audioDeviceModule.setMicrophoneMute( + settings.microphoneMode == MicrophoneMode.Disabled || microphoneMuted, + ) + closeTransport(clearInputState = false) + armControllerMouseAssistForSession() + recordStreamDiagnostic( + "start session=${streamDiagnosticId(session.sessionId)} status=${session.status} server=${session.serverIp.take(96)} signaling=${signalingUrlForDiagnostics(session.signalingUrl, session.sessionId)} settings=${settings.resolution}/${settings.fps}/${settings.codec} bitrate=${settings.maxBitrateMbps} microphone=${settings.microphoneMode.name}", + ) + startTransport(session, settings, transportGeneration) + updateControllerMouseLoop() + } + + fun stop() { + stopControllerMouseLoop() + transportGeneration += 1 + reconnectAttempts = 0 + sessionRecoveryRequested = false + livenessWatchdog.reset() + firstVideoFrameWatchdog.reset() + closeTransport(clearInputState = true) + emitState("Stopped") + } + + fun release() { + if (released) return + released = true + if (androidTvProfile) { + val activeRenderer = renderer + activeRenderer?.let(::prepareRendererForRelease) + renderer = null + rendererSharpnessDrawer = null + stop() + scope.launch { + delay(ANDROID_TV_CODEC_RELEASE_SETTLE_MS) + finishRelease(activeRenderer) + } + return + } + stop() + renderer?.let { activeRenderer -> + releaseRendererInternal(activeRenderer) + } + renderer = null + rendererSharpnessDrawer = null + finishRelease() + } + + private fun finishRelease(preparedRenderer: SurfaceViewRenderer? = null) { + inputScope.cancel() + inputExecutor.shutdown() + preparedRenderer?.release() + factory?.dispose() + factory = null + audioDeviceModule.release() + eglBase.release() + scope.cancel() + } + + private fun resetInputState() { + virtualButtons = 0 + virtualLeftTrigger = 0 + virtualRightTrigger = 0 + virtualLeftStickActive = false + virtualLeftStickX = 0 + virtualLeftStickY = 0 + virtualRightStickActive = false + virtualRightStickX = 0 + virtualRightStickY = 0 + virtualControllerVisible = false + physicalControllerConnected = false + physicalControllerActive = false + physicalButtons = 0 + physicalHatButtons = 0 + steamMenuChordButtons = 0 + physicalSteamOverlayChord.reset() + virtualSteamOverlayChord.reset() + physicalLeftTriggerButtonPressed = false + physicalRightTriggerButtonPressed = false + guideAutoReleaseJob?.cancel() + guideAutoReleaseJob = null + steamMenuChordJob?.cancel() + steamMenuChordJob = null + physicalSteamOverlayChordReleaseJob?.cancel() + physicalSteamOverlayChordReleaseJob = null + virtualSteamOverlayChordReleaseJob?.cancel() + virtualSteamOverlayChordReleaseJob = null + stopAllGamepadRumble() + lastLeftTrigger = 0 + lastRightTrigger = 0 + lastLeftStickX = 0 + lastLeftStickY = 0 + lastRightStickX = 0 + lastRightStickY = 0 + physicalLeftStickX = 0f + physicalLeftStickY = 0f + physicalRightStickX = 0f + physicalRightStickY = 0f + controllerMouseAssistActive = false + controllerMouseAssistAutoArmed = false + controllerMouseEmulationActive = false + controllerMouseMoveLogged = false + controllerMouseLeftButtonDown = false + controllerMouseRightButtonDown = false + activeControllerId = 0 + controllerSlots.clear() + controllerAxisAvailability.clear() + mousePositionValid = false + mouseSuppressNextAbsoluteDelta = false + inputDropLogged = false + externalMouseEventLogged = false + externalMouseMoveSentLogged = false + externalMouseAbsoluteJumpLogged = false + hardwareKeyboardEventLogged = false + physicalGamepadAxisLogged = false + inputEncoder.resetGamepadSequences() + emitControllerMouseAssistChanged(false) + } + + fun dispatchKey(event: KeyEvent): Boolean { + if (event.isGamepadEvent() && dispatchGamepadKey(event)) { + return true + } + val key = InputEncoder.mapKeyEvent(event) + val hardwareKeyboard = event.isHardwareKeyboardSource() + if (hardwareKeyboard && !hardwareKeyboardEventLogged) { + hardwareKeyboardEventLogged = true + NativeInputDiagnostics.add("hardware keyboard event action=${event.action} key=${event.keyCode} scan=${event.scanCode} source=${event.source} device=${event.deviceId} mapped=${key != null}") + } + val packet = key?.let { if (event.action == KeyEvent.ACTION_DOWN) inputEncoder.encodeKeyDown(it) else inputEncoder.encodeKeyUp(it) } + if (packet == null) { + if (hardwareKeyboard && (event.action == KeyEvent.ACTION_DOWN || event.action == KeyEvent.ACTION_UP)) { + NativeInputDiagnostics.add("hardware keyboard consumed unmapped key=${event.keyCode} action=${event.action}") + return true + } + return false + } + val sent = sendReliableInput(packet) + if (hardwareKeyboard && !sent) { + NativeInputDiagnostics.add("hardware keyboard consumed without send key=${event.keyCode} reliable=${reliableInput?.state()} partial=${partiallyReliableInput?.state()}") + } + return sent || hardwareKeyboard + } + + fun dispatchMotion(event: MotionEvent): Boolean { + if (event.isGamepadMotionEvent()) { + return dispatchJoystick(event) + } + if (event.isMouseLikePointer()) { + return dispatchMouseLikePointer(event) + } + return false + } + + fun sendRawMouseMove(dx: Int, dy: Int, partiallyReliable: Boolean = false): Boolean { + return sendInput( + inputEncoder.encodeMouseMove(dx, dy), + partiallyReliable = partiallyReliable, + ) + } + + fun sendTouchMouseMove(dx: Int, dy: Int, partiallyReliable: Boolean = true): Boolean { + var adjustedDx = dx * settings.mouseSensitivity + var adjustedDy = dy * settings.mouseSensitivity + if (settings.mouseAcceleration > 1) { + val speed = sqrt(adjustedDx * adjustedDx + adjustedDy * adjustedDy) + val strength = (settings.mouseAcceleration - 1f) / 149f + val accelFactor = 1f + min(0.6f * strength, (speed / 50f) * strength) + adjustedDx *= accelFactor + adjustedDy *= accelFactor + } + return sendInput( + inputEncoder.encodeMouseMove(adjustedDx.roundToInt(), adjustedDy.roundToInt()), + partiallyReliable = partiallyReliable, + ) + } + + private fun dispatchMouseLikePointer(event: MotionEvent): Boolean { + if (!externalMouseEventLogged) { + externalMouseEventLogged = true + val relativeDx = if (Build.VERSION.SDK_INT >= 26) event.getAxisValue(MotionEvent.AXIS_RELATIVE_X) else 0f + val relativeDy = if (Build.VERSION.SDK_INT >= 26) event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y) else 0f + NativeInputDiagnostics.add( + "external mouse event action=${event.actionMasked} source=${event.source} device=${event.deviceId} buttons=${event.buttonState} relativeDx=$relativeDx relativeDy=$relativeDy", + ) + } + when (event.actionMasked) { + MotionEvent.ACTION_HOVER_MOVE, + MotionEvent.ACTION_MOVE, + -> { + val relativeDx = if (Build.VERSION.SDK_INT >= 26) event.getAxisValue(MotionEvent.AXIS_RELATIVE_X) else 0f + val relativeDy = if (Build.VERSION.SDK_INT >= 26) event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y) else 0f + if (abs(relativeDx) >= 0.5f || abs(relativeDy) >= 0.5f) { + val sent = sendTouchMouseMove(relativeDx.roundToInt(), relativeDy.roundToInt()) + if (sent && !externalMouseMoveSentLogged) { + externalMouseMoveSentLogged = true + NativeInputDiagnostics.add("external mouse move sent source=${event.source} device=${event.deviceId} mode=relative") + } + mousePositionValid = false + } else if (event.isRelativeMousePointer()) { + val positionDx = event.x + val positionDy = event.y + if (abs(positionDx) >= 0.5f || abs(positionDy) >= 0.5f) { + val sent = sendTouchMouseMove(positionDx.roundToInt(), positionDy.roundToInt()) + if (sent && !externalMouseMoveSentLogged) { + externalMouseMoveSentLogged = true + NativeInputDiagnostics.add("external mouse move sent source=${event.source} device=${event.deviceId} mode=relativePosition") + } + } + mousePositionValid = false + } else if (mousePositionValid && mouseLastDeviceId == event.deviceId && mouseLastSource == event.source) { + val dx = event.x - mouseLastX + val dy = event.y - mouseLastY + if (abs(dx) >= 0.5f || abs(dy) >= 0.5f) { + val discontinuous = mouseSuppressNextAbsoluteDelta || + abs(dx) > EXTERNAL_MOUSE_ABSOLUTE_DELTA_LIMIT_PX || + abs(dy) > EXTERNAL_MOUSE_ABSOLUTE_DELTA_LIMIT_PX + if (discontinuous) { + if (!externalMouseAbsoluteJumpLogged) { + externalMouseAbsoluteJumpLogged = true + NativeInputDiagnostics.add("external mouse absolute delta rebased source=${event.source} device=${event.deviceId} dx=${dx.roundToInt()} dy=${dy.roundToInt()}") + } + } else { + val sent = sendTouchMouseMove(dx.roundToInt(), dy.roundToInt()) + if (sent && !externalMouseMoveSentLogged) { + externalMouseMoveSentLogged = true + NativeInputDiagnostics.add("external mouse move sent source=${event.source} device=${event.deviceId} mode=absoluteDelta") + } + } + mouseSuppressNextAbsoluteDelta = false + } + } else { + mouseSuppressNextAbsoluteDelta = false + } + if (!event.isRelativeMousePointer()) { + rememberMousePosition(event) + } + } + MotionEvent.ACTION_DOWN -> { + mouseSuppressNextAbsoluteDelta = true + rememberMousePosition(event) + sendReliableInput(inputEncoder.encodeMouseButton(InputEncoder.INPUT_MOUSE_BUTTON_DOWN, event.primaryMouseButton())) + } + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL, + -> { + mousePositionValid = false + mouseSuppressNextAbsoluteDelta = true + sendReliableInput(inputEncoder.encodeMouseButton(InputEncoder.INPUT_MOUSE_BUTTON_UP, event.primaryMouseButton())) + } + MotionEvent.ACTION_BUTTON_PRESS -> { + mouseSuppressNextAbsoluteDelta = true + rememberMousePosition(event) + val handled = sendReliableInput(inputEncoder.encodeMouseButton(InputEncoder.INPUT_MOUSE_BUTTON_DOWN, event.actionButton.toGfnMouseButton())) + if (!handled) { + NativeInputDiagnostics.add("external mouse button consumed without send action=press button=${event.actionButton} reliable=${reliableInput?.state()} partial=${partiallyReliableInput?.state()}") + } + return true + } + MotionEvent.ACTION_BUTTON_RELEASE -> { + mousePositionValid = false + mouseSuppressNextAbsoluteDelta = true + val handled = sendReliableInput(inputEncoder.encodeMouseButton(InputEncoder.INPUT_MOUSE_BUTTON_UP, event.actionButton.toGfnMouseButton())) + if (!handled) { + NativeInputDiagnostics.add("external mouse button consumed without send action=release button=${event.actionButton} reliable=${reliableInput?.state()} partial=${partiallyReliableInput?.state()}") + } + return true + } + MotionEvent.ACTION_SCROLL -> { + val vertical = event.getAxisValue(MotionEvent.AXIS_VSCROLL) + if (abs(vertical) >= 0.01f) { + sendReliableInput(inputEncoder.encodeMouseWheel((vertical * 120).roundToInt())) + } + } + } + return true + } + + private fun rememberMousePosition(event: MotionEvent) { + mouseLastDeviceId = event.deviceId + mouseLastSource = event.source + mouseLastX = event.x + mouseLastY = event.y + mousePositionValid = true + } + + fun sendTouchMouseClick(delayBeforeDownMs: Long = 0L) { + scope.launch { + if (delayBeforeDownMs > 0) { + delay(delayBeforeDownMs) + } + if (!setTouchMouseButton(true)) return@launch + delay(160L) + setTouchMouseButton(false) + } + } + + fun sendTouchMouseRightClick() { + scope.launch { + if (!sendMouseButton(button = 3, pressed = true, source = "touch mouse right click")) return@launch + delay(160L) + sendMouseButton(button = 3, pressed = false, source = "touch mouse right click") + } + } + + fun sendKeyCode(keyCode: Int) { + val down = KeyEvent(SystemClock.uptimeMillis(), SystemClock.uptimeMillis(), KeyEvent.ACTION_DOWN, keyCode, 0) + val up = KeyEvent(SystemClock.uptimeMillis(), SystemClock.uptimeMillis(), KeyEvent.ACTION_UP, keyCode, 0) + dispatchKey(down) + dispatchKey(up) + } + + fun sendText(text: String) { + if (text.isEmpty()) return + val textToSend = text.take(STREAM_TEXT_SEND_MAX_CHARS) + scope.launch { + textSendMutex.withLock { + textToSend.forEach { char -> + sendTextChar(char) + } + } + } + } + + private fun sendKeyboardPayload(payload: InputEncoder.KeyboardPayload, isDown: Boolean): Boolean = + sendReliableInput(if (isDown) inputEncoder.encodeKeyDown(payload) else inputEncoder.encodeKeyUp(payload)) + + private suspend fun sendTextChar(char: Char) { + val spec = InputEncoder.mapTextCharToKeySpec(char) ?: return + if (spec.shift) { + sendKeyboardPayloadWithRetry(InputEncoder.shiftLeftPayload(modifiers = 0x01), isDown = true) + } + val modifiers = if (spec.shift) 0x01 else 0 + sendKeyboardPayloadWithRetry(spec.toKeyboardPayload(modifiers), isDown = true) + sendKeyboardPayloadWithRetry(spec.toKeyboardPayload(modifiers), isDown = false) + if (spec.shift) { + sendKeyboardPayloadWithRetry(InputEncoder.shiftLeftPayload(modifiers = 0), isDown = false) + } + delay(STREAM_TEXT_KEY_DELAY_MS) + } + + private suspend fun sendKeyboardPayloadWithRetry(payload: InputEncoder.KeyboardPayload, isDown: Boolean): Boolean { + repeat(STREAM_TEXT_SEND_ATTEMPTS) { attempt -> + if (sendKeyboardPayload(payload, isDown)) { + delay(STREAM_TEXT_PACKET_DELAY_MS) + return true + } + if (attempt < STREAM_TEXT_SEND_ATTEMPTS - 1) { + delay(STREAM_TEXT_RETRY_DELAY_MS) + } + } + NativeInputDiagnostics.add( + "overlay keyboard dropped key=${payload.keycode} action=${if (isDown) "down" else "up"} reliable=${reliableInput?.state()} partial=${partiallyReliableInput?.state()}", + ) + return false + } + + fun setAudioMuted(muted: Boolean) { + audioMuted = muted + audioDeviceModule.setSpeakerMute(muted) + audioTrack?.setEnabled(!muted) + } + + fun setMicrophoneEnabled(enabled: Boolean) { + microphoneMuted = !enabled + audioDeviceModule.setMicrophoneMute(!enabled) + microphoneTrack?.setEnabled(enabled) + recordStreamDiagnostic("microphone ${if (enabled) "enabled" else "muted"}") + } + + fun setTouchMouseButton(pressed: Boolean): Boolean { + return sendMouseButton(button = 1, pressed = pressed, source = "touch mouse") + } + + private fun sendMouseButton(button: Int, pressed: Boolean, source: String): Boolean { + val packet = inputEncoder.encodeMouseButton( + if (pressed) InputEncoder.INPUT_MOUSE_BUTTON_DOWN else InputEncoder.INPUT_MOUSE_BUTTON_UP, + button, + ) + val reliableSent = sendInput(packet, partiallyReliable = false) + val partialSent = sendInput(packet, partiallyReliable = true) + NativeInputDiagnostics.add( + "$source button=$button ${if (pressed) "down" else "up"} reliableSent=$reliableSent partialSent=$partialSent reliable=${reliableInput?.state()} partial=${partiallyReliableInput?.state()}", + ) + return reliableSent || partialSent + } + + private fun armControllerMouseAssistForSession() { + if (!controllerMouseAutoArmOnStart) return + controllerMouseAssistActive = true + controllerMouseAssistAutoArmed = true + controllerMouseMoveLogged = false + emitControllerMouseAssistChanged(true) + NativeInputDiagnostics.add("controller mouse assist auto-armed for Android TV") + } + + private fun setControllerMouseAssistActive(active: Boolean, autoArmed: Boolean = false) { + if (controllerMouseAssistActive == active && controllerMouseAssistAutoArmed == (autoArmed && active)) return + if (!active) releaseControllerMouseButtons() + controllerMouseAssistActive = active + controllerMouseAssistAutoArmed = autoArmed && active + updateControllerMouseLoop() + controllerMouseMoveLogged = false + sendCurrentGamepadState() + emitControllerMouseAssistChanged(active) + NativeInputDiagnostics.add("controller mouse assist ${if (active) "enabled" else "disabled"} auto=$controllerMouseAssistAutoArmed") + } + + private fun emitControllerMouseAssistChanged(active: Boolean) { + scope.launch { onControllerMouseAssistChanged(active) } + } + + private fun releaseControllerMouseButtons() { + if (controllerMouseLeftButtonDown) { + controllerMouseLeftButtonDown = false + sendMouseButton(button = 1, pressed = false, source = "controller mouse") + } + if (controllerMouseRightButtonDown) { + controllerMouseRightButtonDown = false + sendMouseButton(button = 3, pressed = false, source = "controller mouse") + } + } + + private fun setControllerMouseButton(button: Int, pressed: Boolean): Boolean { + when (button) { + 1 -> { + if (controllerMouseLeftButtonDown == pressed) return true + controllerMouseLeftButtonDown = pressed + } + 3 -> { + if (controllerMouseRightButtonDown == pressed) return true + controllerMouseRightButtonDown = pressed + } + else -> return false + } + val sent = sendMouseButton(button = button, pressed = pressed, source = "controller mouse") + if (!pressed && controllerMouseAssistAutoArmed && button == 1) { + setControllerMouseAssistActive(false) + } + return sent + } + + fun setVirtualButton(buttonMask: Int, pressed: Boolean) { + // When left-stick mouse emulation is active, intercept A (left click) and B (right click). + if (controllerMouseEmulationActive) { + val mouseButton = AndroidControllerMouseAssist.mouseButtonForGamepad(buttonMask) + if (mouseButton != null) { + setControllerMouseButton(mouseButton, pressed) + return + } + } + virtualButtons = if (pressed) virtualButtons or buttonMask else virtualButtons and buttonMask.inv() + val steamOverlayChordActivated = virtualSteamOverlayChord.update(virtualButtons) + sendCurrentGamepadState() + if (steamOverlayChordActivated) { + scheduleVirtualSteamOverlayChordRelease() + } + } + + fun openSteamMenu() { + steamMenuChordJob?.cancel() + val controllerId = activeControllerId + steamMenuChordButtons = SteamMenuChord.buttons(aPressed = false) + sendCurrentGamepadState(controllerId) + steamMenuChordJob = scope.launch { + delay(STEAM_MENU_MODIFIER_DELAY_MS) + steamMenuChordButtons = SteamMenuChord.buttons(aPressed = true) + sendCurrentGamepadState(controllerId) + delay(GAMEPAD_GUIDE_AUTO_RELEASE_MS) + steamMenuChordButtons = SteamMenuChord.buttons(aPressed = false) + sendCurrentGamepadState(controllerId) + delay(STEAM_MENU_MODIFIER_DELAY_MS) + steamMenuChordButtons = 0 + sendCurrentGamepadState(controllerId) + NativeInputDiagnostics.add("Steam Menu sent Guide+A chord slot=$controllerId") + } + } + + fun setVirtualTrigger(left: Boolean, pressed: Boolean) { + if (left) { + virtualLeftTrigger = if (pressed) 255 else 0 + } else { + virtualRightTrigger = if (pressed) 255 else 0 + } + sendCurrentGamepadState() + } + + fun setVirtualLeftStick(x: Float, y: Float) { + val scale = radialDeadzoneScale(x, y, deadzone = 0.08f) + val normalizedX = x * scale + val normalizedY = y * scale + if (controllerMouseEmulationActive) { + // Redirect left-stick input to mouse movement; keep virtual stick zeroed so the game + // receives no stick deflection from the touch controller either. + sendControllerMouseMove(normalizedX, normalizedY) + virtualLeftStickActive = false + virtualLeftStickX = 0 + virtualLeftStickY = 0 + sendCurrentGamepadState() + return + } + virtualLeftStickActive = normalizedX != 0f || normalizedY != 0f + virtualLeftStickX = normalizeToInt16(normalizedX) + virtualLeftStickY = normalizeToInt16(-normalizedY) + sendCurrentGamepadState() + } + + fun setVirtualRightStick(x: Float, y: Float) { + val scale = radialDeadzoneScale(x, y, deadzone = 0.08f) + val normalizedX = x * scale + val normalizedY = y * scale + virtualRightStickActive = normalizedX != 0f || normalizedY != 0f + virtualRightStickX = normalizeToInt16(normalizedX) + virtualRightStickY = normalizeToInt16(-normalizedY) + sendCurrentGamepadState() + } + + fun setVirtualControllerVisible(visible: Boolean) { + if (virtualControllerVisible == visible) return + virtualControllerVisible = visible + sendCurrentGamepadState() + } + + private fun startTransport(session: SessionInfo, settings: StreamSettings, generation: Int) { + inputDropLogged = false + lastIceState = null + lastStatsSample = null + transportHasStableMedia = false + consecutiveTransportProgressSamples = 0 + firstVideoFrameWatchdog.reset() + emitStats(StreamRuntimeStats()) + audioDeviceModule.setSpeakerMute(audioMuted) + audioDeviceModule.setMicrophoneMute( + settings.microphoneMode == MicrophoneMode.Disabled || microphoneMuted, + ) + recordStreamDiagnostic( + "transport start generation=$generation reconnectAttempts=$reconnectAttempts session=${streamDiagnosticId(session.sessionId)} iceServers=${session.iceServers.size} media=${session.mediaConnectionInfo?.let { "${it.ip}:${it.port}" } ?: "unknown"}", + ) + emitState(if (reconnectAttempts > 0) "Reconnecting signaling" else "Connecting signaling") + signaling = GfnSignalingClient(session, settings = settings) { event -> + handleSignaling(event, generation) + }.also { it.connect() } + } + + private fun closeTransport(clearInputState: Boolean, cancelRecovery: Boolean = true) { + if (peerConnection != null || signaling != null || reliableInput != null || partiallyReliableInput != null) { + recordStreamDiagnostic("transport close clearInput=$clearInputState cancelRecovery=$cancelRecovery lastIce=${lastIceState?.name ?: "none"}") + } + if (cancelRecovery) { + iceRecoveryJob?.cancel() + iceRecoveryJob = null + } + heartbeatJob?.cancel() + gamepadKeepaliveJob?.cancel() + statsJob?.cancel() + offerTimeoutJob?.cancel() + heartbeatJob = null + gamepadKeepaliveJob = null + statsJob = null + offerTimeoutJob = null + lastStatsSample = null + lastIceState = null + livenessWatchdog.reset() + signaling?.disconnect() + signaling = null + reliableInput = null + partiallyReliableInput = null + partiallyReliableGamepadMask = 0 + hapticsAdvertised = null + if (clearInputState) resetInputState() + if (rendererSinkAttached) { + videoTrack?.removeSink(renderer) + rendererSinkAttached = false + } + releaseMicrophoneTrack() + videoTrack = null + audioTrack = null + peerConnection?.close() + peerConnection?.dispose() + peerConnection = null + } + + private fun handleSignaling(event: SignalingEvent, generation: Int) { + if (generation != transportGeneration) return + when (event) { + SignalingEvent.Connected -> { + recordStreamDiagnostic("signaling connected generation=$generation") + emitState("Waiting for offer") + startOfferTimeout(generation) + } + is SignalingEvent.Disconnected -> { + recordStreamDiagnostic("signaling disconnected ${event.reason}") + when (signalingFailureDisposition(event.reason, normalClosureMeansSessionEnded = true)) { + SignalingFailureDisposition.SessionEnded -> { + recordStreamDiagnostic("Signaling disconnected normally. Stopping stream.") + stop() + scope.launch { onStreamStopped() } + } + SignalingFailureDisposition.RecoverSession -> { + recordStreamDiagnostic("Signaling endpoint is stale. Recovering cloud session.") + requestSessionRecovery("Signaling endpoint became unavailable while connecting to the cloud session.") + } + SignalingFailureDisposition.RetryTransport -> + scheduleTransportReconnect("Signaling disconnected: ${event.reason}", SIGNALING_RECONNECT_DELAY_MS, generation) + } + } + is SignalingEvent.Error -> { + recordStreamDiagnostic("signaling error ${event.message}") + when (signalingFailureDisposition(event.message)) { + SignalingFailureDisposition.SessionEnded -> { + recordStreamDiagnostic("Signaling error indicates session terminated. Stopping stream.") + stop() + scope.launch { onStreamStopped() } + } + SignalingFailureDisposition.RecoverSession -> { + recordStreamDiagnostic("Signaling endpoint is stale. Recovering cloud session.") + requestSessionRecovery("Signaling endpoint became unavailable while connecting to the cloud session.") + } + SignalingFailureDisposition.RetryTransport -> + scheduleTransportReconnect("Signaling failed: ${event.message}", SIGNALING_RECONNECT_DELAY_MS, generation) + } + } + is SignalingEvent.Log -> recordStreamDiagnostic(event.message) + is SignalingEvent.RemoteIce -> { + val added = peerConnection?.addIceCandidate(event.candidate) + recordStreamDiagnostic("remote ICE add requested accepted=${added ?: false} pcReady=${peerConnection != null} ${event.candidate.diagnosticSummary()}") + } + is SignalingEvent.Offer -> handleOffer(event.sdp, generation) + } + } + + private fun handleOffer(rawOffer: String, generation: Int) { + val currentSession = session ?: return + offerTimeoutJob?.cancel() + offerTimeoutJob = null + recordStreamDiagnostic(sdpDiagnosticSummary("raw offer", rawOffer)) + val fixed = prepareRemoteOffer(rawOffer, currentSession) + val preferred = SdpTools.preferCodec(fixed, settings) + if (fixed != rawOffer) { + recordStreamDiagnostic(sdpDiagnosticSummary("fixed offer", fixed)) + } + if (preferred != fixed) { + recordStreamDiagnostic(sdpDiagnosticSummary("preferred offer", preferred)) + } + val pc = ensurePeerConnection(currentSession, generation) + ensureInputDataChannels(pc, preferred) + inputEncoder.setProtocolVersion(SdpTools.parseInputProtocolVersion(preferred)) + partiallyReliableGamepadMask = SdpTools.parsePartiallyReliableGamepadMask(preferred) + recordStreamDiagnostic("offer input protocol=${SdpTools.parseInputProtocolVersion(preferred)} partialGamepadMask=$partiallyReliableGamepadMask") + pc.setRemoteDescription( + object : SimpleSdpObserver() { + override fun onSetSuccess() { + if (generation != transportGeneration || peerConnection !== pc) return + recordStreamDiagnostic("remote description set") + // WebRTC disposes previously returned transceiver wrappers whenever + // getTransceivers() refreshes its cache. Share one snapshot so the + // microphone sender remains valid through transport teardown. + val transceivers = pc.transceivers + applyVideoCodecPreferences(transceivers) + attachMicrophoneTrack(pc, transceivers) + pc.createAnswer( + object : SimpleSdpObserver() { + override fun onCreateSuccess(description: SessionDescription?) { + if (generation != transportGeneration) return + val rawDescription = description ?: run { + failStream("WebRTC returned an empty answer", generation) + return + } + val munged = SdpTools.mungeAnswerSdp(rawDescription.description, settings.maxBitrateMbps * 1000) + recordStreamDiagnostic(sdpDiagnosticSummary("created answer", munged)) + if (settings.codec != VideoCodec.H264 && !SdpTools.negotiatesCodec(munged, settings.codec)) { + NativeInputDiagnostics.add("local answer did not negotiate requested codec=${settings.codec}; requesting safe fallback") + if ( + requestSafeVideoFallback( + message = "${settings.codec} was requested but WebRTC did not negotiate it; restarting with safe H264 profile", + diagnosticReason = "codec negotiation", + ) + ) { + return + } + failStream("${settings.codec} requested but not negotiated in local SDP", generation) + return + } + val answer = SessionDescription(SessionDescription.Type.ANSWER, munged) + pc.setLocalDescription( + object : SimpleSdpObserver() { + override fun onSetSuccess() { + if (generation != transportGeneration) return + val nvst = SdpTools.buildNvstSdp( + offerSdp = preferred, + settings = settings, + localAnswer = munged, + ) + signaling?.sendAnswer(munged, nvst) + recordStreamDiagnostic("local description set and answer sent") + emitState("Streaming") + startHeartbeat() + startGamepadKeepalive() + startStatsPolling() + } + + override fun onSetFailure(error: String?) { + recordStreamDiagnostic("local description failed error=${error.orEmpty()}") + failStream(error ?: "Failed to set local description", generation) + } + }, + answer, + ) + } + + override fun onCreateFailure(error: String?) { + recordStreamDiagnostic("answer create failed error=${error.orEmpty()}") + failStream(error ?: "Failed to create WebRTC answer", generation) + } + }, + MediaConstraints(), + ) + } + + override fun onSetFailure(error: String?) { + recordStreamDiagnostic("remote description failed error=${error.orEmpty()}") + failStream(error ?: "Failed to apply server offer", generation) + } + }, + SessionDescription(SessionDescription.Type.OFFER, preferred), + ) + } + + private fun prepareRemoteOffer(rawOffer: String, session: SessionInfo): String { + var prepared = SdpTools.fixServerEndpoint(rawOffer, session.serverIp, session.mediaConnectionInfo) + if (settings.codec == VideoCodec.H265) { + val maxLevels = h265ReceiverMaxLevelsByProfile() + if (maxLevels.isNotEmpty()) { + val rewritten = SdpTools.rewriteH265LevelIdByProfile(prepared, maxLevels) + if (rewritten.replacements > 0) { + NativeInputDiagnostics.add("h265 level-id clamped replacements=${rewritten.replacements} maxLevels=$maxLevels") + prepared = rewritten.sdp + } + } + if (!supportsH265TierFlagOne()) { + val rewritten = SdpTools.rewriteH265TierFlag(prepared, 0) + if (rewritten.replacements > 0) { + NativeInputDiagnostics.add("h265 tier-flag rewritten replacements=${rewritten.replacements}") + prepared = rewritten.sdp + } + } + } + return prepared + } + + private fun applyVideoCodecPreferences(transceivers: List) { + val preferences = receiverCodecPreferences(settings.codec) + if (preferences.isEmpty()) return + val transceiver = transceivers.firstOrNull { + it.mediaType == MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO || + it.receiver?.track()?.kind() == MediaStreamTrack.VIDEO_TRACK_KIND + } ?: return + val result = transceiver.setCodecPreferences(preferences) + if (result.isSuccess) { + NativeInputDiagnostics.add("codec preferences applied codec=${settings.codec} count=${preferences.size}") + } else { + NativeInputDiagnostics.add("codec preferences failed codec=${settings.codec} error=${result.error()?.message.orEmpty()}") + } + } + + @Synchronized + private fun attachMicrophoneTrack( + pc: PeerConnection, + transceivers: List, + ) { + val permissionGranted = ContextCompat.checkSelfPermission( + appContext, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + if (!shouldCaptureMicrophone(settings.microphoneMode, permissionGranted)) { + recordStreamDiagnostic( + "microphone not attached mode=${settings.microphoneMode.name} permission=$permissionGranted", + ) + return + } + + releaseMicrophoneTrack() + val audioConstraints = MediaConstraints().apply { + optional.add(MediaConstraints.KeyValuePair("googEchoCancellation", "true")) + optional.add(MediaConstraints.KeyValuePair("googAutoGainControl", "true")) + optional.add(MediaConstraints.KeyValuePair("googNoiseSuppression", "true")) + optional.add(MediaConstraints.KeyValuePair("googHighpassFilter", "true")) + } + val source = requireNotNull(factory).createAudioSource(audioConstraints) + val track = requireNotNull(factory).createAudioTrack(MICROPHONE_TRACK_ID, source) + track.setEnabled(!microphoneMuted) + + val audioTransceivers = transceivers.filter { + it.mediaType == MediaStreamTrack.MediaType.MEDIA_TYPE_AUDIO || + it.receiver?.track()?.kind() == MediaStreamTrack.AUDIO_TRACK_KIND + } + val transceiver = audioTransceivers.firstOrNull { it.mid == GFN_MICROPHONE_MID } + ?: audioTransceivers.firstOrNull { + it.direction == RtpTransceiver.RtpTransceiverDirection.SEND_ONLY && + it.sender?.track() == null + } + ?: audioTransceivers.firstOrNull { it.sender?.track() == null } + + val sender = if (transceiver != null) { + when (transceiver.direction) { + RtpTransceiver.RtpTransceiverDirection.RECV_ONLY -> + transceiver.setDirection(RtpTransceiver.RtpTransceiverDirection.SEND_RECV) + RtpTransceiver.RtpTransceiverDirection.INACTIVE -> + transceiver.setDirection(RtpTransceiver.RtpTransceiverDirection.SEND_ONLY) + else -> Unit + } + transceiver.sender.apply { + setStreams(listOf(MICROPHONE_STREAM_ID)) + }.takeIf { it.setTrack(track, false) } + } else { + pc.addTrack(track, listOf(MICROPHONE_STREAM_ID)) + } + + if (sender == null) { + track.dispose() + source.dispose() + recordStreamDiagnostic("microphone sender attachment failed") + return + } + microphoneSource = source + microphoneTrack = track + microphoneSender = sender + audioDeviceModule.setMicrophoneMute(microphoneMuted) + recordStreamDiagnostic( + "microphone track attached mid=${transceiver?.mid ?: "new"} direction=${transceiver?.direction?.name ?: "new"} muted=$microphoneMuted", + ) + } + + @Synchronized + private fun releaseMicrophoneTrack() { + val sender = microphoneSender + val track = microphoneTrack + val source = microphoneSource + microphoneSender = null + microphoneTrack = null + microphoneSource = null + + try { + sender?.setTrack(null, false) + } catch (error: IllegalStateException) { + if (!isDisposedRtpSenderFailure(error)) throw error + recordStreamDiagnostic("microphone sender was already disposed during transport close") + } finally { + if (track?.isDisposed == false) track.dispose() + source?.dispose() + } + } + + private fun receiverCodecPreferences(codec: VideoCodec): List { + val receiverCaps = runCatching { + requireNotNull(factory).getRtpReceiverCapabilities(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO).codecs + }.getOrNull().orEmpty() + val target = codec.webRtcCodecName() + val preferred = receiverCaps + .filter { it.openNowCodecName() == target } + .let { caps -> if (codec == VideoCodec.H265) caps.sortedBy { it.h265ProfilePriority(settings.prefersTenBitVideo()) } else caps } + if (preferred.isEmpty()) return emptyList() + val auxiliary = receiverCaps.filter { it.openNowCodecName() in WEBRTC_AUXILIARY_VIDEO_CODECS } + return (preferred + auxiliary).distinctBy { it.preferenceKey() } + } + + private fun h265ReceiverMaxLevelsByProfile(): Map = + receiverH265Capabilities() + .mapNotNull { capability -> + val profile = capability.codecParameterInt("profile-id") ?: return@mapNotNull null + val level = capability.codecParameterInt("level-id") ?: return@mapNotNull null + profile to level + } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, levels) -> levels.maxOrNull() ?: 0 } + .filterValues { it > 0 } + + private fun supportsH265TierFlagOne(): Boolean = + receiverH265Capabilities().any { it.codecParameterInt("tier-flag") == 1 } + + private fun receiverH265Capabilities(): List = + runCatching { + requireNotNull(factory).getRtpReceiverCapabilities(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO).codecs + }.getOrNull().orEmpty() + .filter { it.openNowCodecName() == VideoCodec.H265.webRtcCodecName() } + + private fun startOfferTimeout(generation: Int) { + offerTimeoutJob?.cancel() + offerTimeoutJob = scope.launch { + delay(OFFER_TIMEOUT_MS) + if (generation != transportGeneration || peerConnection != null) return@launch + offerTimeoutJob = null + NativeInputDiagnostics.add("video offer timeout codec=${settings.codec} resolution=${settings.resolution} bitrate=${settings.maxBitrateMbps}") + if ( + requestSafeVideoFallback( + message = "Timed out waiting for video offer; restarting with safe H264 profile", + diagnosticReason = "offer timeout", + ) + ) { + return@launch + } + restartTransport("Timed out waiting for video offer") + } + } + + private fun ensurePeerConnection(session: SessionInfo, generation: Int): PeerConnection { + peerConnection?.let { return it } + val ice = session.iceServers.map { + PeerConnection.IceServer.builder(it.urls).apply { + if (it.username != null) setUsername(it.username) + if (it.credential != null) setPassword(it.credential) + }.createIceServer() + } + val config = PeerConnection.RTCConfiguration(ice).apply { + sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN + continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY + tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.ENABLED + bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE + rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE + } + recordStreamDiagnostic( + "peer connection create generation=$generation iceServers=${ice.size} iceUrls=${session.iceServers.flatMap { it.urls }.joinToString(limit = 8) { url -> url.substringBefore('?').take(120) }}", + ) + val pc = requireNotNull(factory).createPeerConnection(config, object : PeerConnection.Observer { + override fun onSignalingChange(state: PeerConnection.SignalingState?) { + recordStreamDiagnostic("webrtc signaling state=${state?.name ?: "null"}") + } + override fun onIceConnectionChange(state: PeerConnection.IceConnectionState?) { + handleIceConnectionChange(state, generation) + } + override fun onIceConnectionReceivingChange(receiving: Boolean) { + recordStreamDiagnostic("ice receiving=$receiving generation=$generation") + } + override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) { + recordStreamDiagnostic("ice gathering state=${state?.name ?: "null"} generation=$generation") + } + override fun onIceCandidate(candidate: IceCandidate?) { + if (candidate != null) { + recordStreamDiagnostic("local ICE candidate gathered ${candidate.diagnosticSummary()}") + signaling?.sendIceCandidate(candidate) + } else { + recordStreamDiagnostic("local ICE candidate gathering complete") + } + } + override fun onIceCandidatesRemoved(candidates: Array?) { + recordStreamDiagnostic("ice candidates removed count=${candidates?.size ?: 0}") + } + override fun onAddStream(stream: MediaStream?) { + recordStreamDiagnostic("media stream added video=${stream?.videoTracks?.size ?: 0} audio=${stream?.audioTracks?.size ?: 0}") + stream?.videoTracks?.firstOrNull()?.let(::attachVideo) + stream?.audioTracks?.firstOrNull()?.let { + audioTrack = it + it.setEnabled(!audioMuted) + } + } + override fun onRemoveStream(stream: MediaStream?) { + recordStreamDiagnostic("media stream removed video=${stream?.videoTracks?.size ?: 0} audio=${stream?.audioTracks?.size ?: 0}") + } + override fun onDataChannel(channel: DataChannel?) { + if (channel != null) attachDataChannel(channel) + } + override fun onRenegotiationNeeded() { + recordStreamDiagnostic("renegotiation needed") + } + override fun onAddTrack(receiver: RtpReceiver?, streams: Array?) { + val track = receiver?.track() + recordStreamDiagnostic("track added kind=${track?.kind().orEmpty()} streams=${streams?.size ?: 0}") + if (track is VideoTrack) attachVideo(track) + if (track is AudioTrack) { + audioTrack = track + track.setEnabled(!audioMuted) + } + } + override fun onTrack(transceiver: RtpTransceiver?) { + val track = transceiver?.receiver?.track() + recordStreamDiagnostic("transceiver track kind=${track?.kind().orEmpty()} media=${transceiver?.mediaType?.name ?: "unknown"}") + if (track is VideoTrack) attachVideo(track) + if (track is AudioTrack) { + audioTrack = track + track.setEnabled(!audioMuted) + } + } + }) ?: error("Failed to create PeerConnection") + peerConnection = pc + recordStreamDiagnostic("peer connection ready generation=$generation") + return pc + } + + private fun handleIceConnectionChange(state: PeerConnection.IceConnectionState?, generation: Int) { + scope.launch { + if (generation != transportGeneration) return@launch + val previous = lastIceState + lastIceState = state + recordStreamDiagnostic("ice connection ${previous?.name ?: "none"} -> ${state?.name ?: "null"} generation=$generation") + when (state) { + PeerConnection.IceConnectionState.CONNECTED, + PeerConnection.IceConnectionState.COMPLETED, + -> { + iceRecoveryJob?.cancel() + iceRecoveryJob = null + livenessWatchdog.markConnected(SystemClock.elapsedRealtime()) + if (reconnectAttempts > 0) { + signaling?.requestKeyframe( + reason = "transport_reconnect", + backlogFrames = 0, + attempt = reconnectAttempts, + ) + recordStreamDiagnostic("reconnect keyframe requested attempt=$reconnectAttempts generation=$generation") + } + emitState("Streaming") + } + PeerConnection.IceConnectionState.DISCONNECTED -> { + emitState("ICE_DISCONNECTED") + scheduleTransportReconnect("ICE disconnected", ICE_DISCONNECTED_GRACE_MS, generation) + } + PeerConnection.IceConnectionState.FAILED -> { + emitState("ICE_FAILED") + scheduleTransportReconnect("ICE failed", ICE_FAILED_RECONNECT_DELAY_MS, generation) + } + PeerConnection.IceConnectionState.CHECKING, + PeerConnection.IceConnectionState.NEW, + -> emitState(state.toIceStatusLabel()) + PeerConnection.IceConnectionState.CLOSED -> Unit + null -> Unit + } + } + } + + private fun scheduleTransportReconnect(reason: String, delayMs: Long, generation: Int) { + if (generation != transportGeneration || iceRecoveryJob?.isActive == true) { + recordStreamDiagnostic("reconnect not scheduled reason=$reason generation=$generation activeJob=${iceRecoveryJob?.isActive == true}") + return + } + recordStreamDiagnostic("reconnect scheduled reason=$reason delayMs=$delayMs generation=$generation") + iceRecoveryJob = scope.launch { + if (delayMs > 0) delay(delayMs) + if (generation != transportGeneration) return@launch + if (reason == "ICE disconnected" && lastIceState != PeerConnection.IceConnectionState.DISCONNECTED) return@launch + restartTransport(reason) + } + } + + private fun restartTransport(reason: String) { + val currentSession = session ?: return + val currentSettings = settings + val hadStableMedia = transportHasStableMedia + if ( + reconnectAttempts >= 1 && + !transportHasStableMedia && + requestSafeVideoFallback( + message = "$reason. Restarting the local transport with safe H264 profile.", + diagnosticReason = "transport reconnect", + restartWhenAlreadySafe = true, + ) + ) { + return + } + if (reconnectAttempts >= MAX_TRANSPORT_RECONNECT_ATTEMPTS) { + recordStreamDiagnostic("reconnect limit reached reason=$reason attempts=$reconnectAttempts") + requestSessionRecovery("$reason. Stream reconnect failed after $MAX_TRANSPORT_RECONNECT_ATTEMPTS attempts.") + return + } + reconnectAttempts += 1 + transportGeneration += 1 + val generation = transportGeneration + recordStreamDiagnostic("transport restart reason=$reason attempt=$reconnectAttempts generation=$generation") + emitState("Reconnecting stream ($reconnectAttempts/$MAX_TRANSPORT_RECONNECT_ATTEMPTS)") + closeTransport(clearInputState = false, cancelRecovery = false) + val codecSettleDelayMs = advancedCodecRestartSettleDelayMs( + codec = currentSettings.codec, + hadStableMedia = hadStableMedia, + ) + if (codecSettleDelayMs == 0L) { + iceRecoveryJob = null + startTransport(currentSession, currentSettings, generation) + return + } + recordStreamDiagnostic( + "waiting ${codecSettleDelayMs}ms for ${currentSettings.codec} decoder release before transport restart generation=$generation", + ) + iceRecoveryJob = scope.launch { + delay(codecSettleDelayMs) + if (generation != transportGeneration || session?.sessionId != currentSession.sessionId) return@launch + iceRecoveryJob = null + startTransport(currentSession, currentSettings, generation) + } + } + + private fun requestSessionRecovery(message: String) { + if (sessionRecoveryRequested) return + sessionRecoveryRequested = true + transportGeneration += 1 + closeTransport(clearInputState = false) + recordStreamDiagnostic("session recovery requested message=$message") + emitState("Recovering cloud session") + emitSessionRecoveryRequired(message) + } + + private fun failStream(message: String, generation: Int? = null) { + if (generation != null && generation != transportGeneration) return + transportGeneration += 1 + recordStreamDiagnostic("stream failed message=$message") + closeTransport(clearInputState = true) + emitError(message) + } + + private fun emitState(message: String) { + scope.launch { onState(message) } + } + + private fun emitError(message: String) { + scope.launch { onError(message) } + } + + private fun emitSafeVideoFallbackApplied(message: String) { + scope.launch { onSafeVideoFallbackApplied(message) } + } + + private fun emitSessionRecoveryRequired(message: String) { + scope.launch { onSessionRecoveryRequired(message) } + } + + private fun PeerConnection.IceConnectionState.toIceStatusLabel(): String = "ICE_${name}" + + private fun emitStats(stats: StreamRuntimeStats) { + scope.launch { onStats(stats) } + } + + private fun ensureInputDataChannels(pc: PeerConnection, offerSdp: String) { + if (reliableInput == null) { + val reliableInit = DataChannel.Init().apply { + ordered = true + } + pc.createDataChannel("input_channel_v1", reliableInit)?.let(::attachDataChannel) + } + + if (partiallyReliableInput == null) { + val thresholdMs = SdpTools.parsePartialReliableThresholdMs(offerSdp) + val partialInit = DataChannel.Init().apply { + ordered = false + maxRetransmitTimeMs = thresholdMs + } + pc.createDataChannel("input_channel_partially_reliable", partialInit)?.let(::attachDataChannel) + } + } + + private fun attachVideo(track: VideoTrack) { + val currentTrack = videoTrack + if (currentTrack?.id() == track.id() && currentTrack.state() != MediaStreamTrack.State.ENDED) { + currentTrack.setEnabled(true) + renderer?.let(::attachRendererSinkIfAvailable) + return + } + if (rendererSinkAttached) { + currentTrack?.removeSink(renderer) + rendererSinkAttached = false + } + videoTrack = track + track.setEnabled(true) + renderer?.let(::attachRendererSinkIfAvailable) + recordStreamDiagnostic("video track attached id=${track.id()} state=${track.state()?.name ?: "unknown"} renderer=${renderer != null} sink=$rendererSinkAttached") + } + + private fun attachDataChannel(channel: DataChannel) { + val label = channel.label() + val normalizedLabel = label.lowercase(Locale.US) + val role = InputDataChannelLabels.classify(label) + NativeInputDiagnostics.add("data channel attached label=$normalizedLabel role=$role state=${channel.state()}") + when (role) { + InputDataChannelRole.Reliable -> reliableInput = channel + InputDataChannelRole.PartiallyReliable -> partiallyReliableInput = channel + InputDataChannelRole.Other -> return + } + channel.registerObserver(object : DataChannel.Observer { + override fun onBufferedAmountChange(previousAmount: Long) = Unit + override fun onStateChange() { + NativeInputDiagnostics.add("input channel state label=$normalizedLabel state=${channel.state()}") + if (channel.state() == DataChannel.State.OPEN) { + inputDropLogged = false + NativeInputDiagnostics.add("input channel open label=$normalizedLabel") + updateHapticsAdvertisement(force = true) + schedulePrimeConnectedGamepadState(reason = "channel open $normalizedLabel") + } + } + override fun onMessage(buffer: DataChannel.Buffer) { + handleInputChannelMessage(buffer) + } + }) + } + + private fun handleInputChannelMessage(buffer: DataChannel.Buffer) { + val bytes = buffer.data.duplicate().let { data -> + ByteArray(data.remaining()).also(data::get) + } + if (bytes.isEmpty()) return + if (handleInputHandshakeMessage(ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN))) return + HapticsPacketParser.parse(bytes)?.let { command -> + applyGamepadRumble(command.controllerId, command.weakMagnitude, command.strongMagnitude) + } + } + + private fun handleInputHandshakeMessage(data: ByteBuffer): Boolean { + val size = data.remaining() + val firstWord = if (size >= 2) { + data.getShort(0).toInt() and 0xffff + } else { + data.get(0).toInt() and 0xff + } + val version = when { + firstWord == INPUT_HANDSHAKE_MAGIC_WORD -> { + if (size >= 4) { + data.getShort(2).toInt() and 0xffff + } else { + DEFAULT_INPUT_PROTOCOL_VERSION + } + } + (data.get(0).toInt() and 0xff) == INPUT_HANDSHAKE_MARKER -> firstWord + else -> return false + }.coerceAtLeast(1) + + inputEncoder.setProtocolVersion(version) + inputEncoder.resetGamepadSequences() + NativeInputDiagnostics.add("input handshake protocol=$version bytes=$size") + updateHapticsAdvertisement(force = true) + schedulePrimeConnectedGamepadState(reason = "input handshake") + return true + } + + private fun startHeartbeat() { + heartbeatJob?.cancel() + heartbeatJob = scope.launch { + while (true) { + delay(1000) + sendReliableInput(inputEncoder.encodeHeartbeat()) + } + } + } + + private fun startGamepadKeepalive() { + gamepadKeepaliveJob?.cancel() + gamepadKeepaliveJob = scope.launch { + var connectedScanCountdown = 0 + primeConnectedGamepadState(reason = "keepalive start") + while (true) { + delay(100L) + connectedScanCountdown -= 1 + if (connectedScanCountdown <= 0) { + connectedScanCountdown = 10 + refreshConnectedPhysicalControllers() + } + if (hasAnyControllerState()) { + sendCurrentGamepadState() + } + updateHapticsAdvertisement() + } + } + } + + private fun startStatsPolling() { + statsJob?.cancel() + statsJob = scope.launch { + while (true) { + pollRuntimeStats() + delay(1000L) + } + } + } + + private fun pollRuntimeStats() { + val pc = peerConnection ?: return + val generation = transportGeneration + pc.getStats(RTCStatsCollectorCallback { report -> + val snapshot = buildRuntimeStatsSnapshot(report.timestampUs / 1000.0, report.statsMap.values) + scope.launch { + if (generation != transportGeneration) return@launch + handleMediaLiveness(snapshot) + onStats(snapshot.stats) + } + }) + } + + private fun buildRuntimeStatsSnapshot(timestampMs: Double, stats: Collection): RuntimeStatsSnapshot { + val inboundVideo = stats.firstOrNull { stat -> + val members = stat.members + stat.type == "inbound-rtp" && + (members["kind"] == "video" || members["mediaType"] == "video") + } + val activePair = stats.firstOrNull { stat -> + val members = stat.members + stat.type == "candidate-pair" && + members["state"] == "succeeded" && + members["nominated"] == true + } + val codecId = inboundVideo?.members?.get("codecId") as? String + val codec = codecId + ?.let { id -> stats.firstOrNull { stat -> stat.id == id } } + ?.members + ?.get("mimeType") + ?.let(::formatStatsCodec) + + val members = inboundVideo?.members.orEmpty() + val bytesReceived = members["bytesReceived"].statsLong() + val framesDecoded = members["framesDecoded"].statsLong() + val explicitFps = members["framesPerSecond"].statsDouble() + val width = members["frameWidth"].statsLong() + val height = members["frameHeight"].statsLong() + val totalDecodeTime = members["totalDecodeTime"].statsDouble() ?: 0.0 + val jitterMs = members["jitter"].statsDouble()?.let { (it * 1000.0).coerceAtLeast(0.0) } + val packetsLost = members["packetsLost"].statsLong() ?: 0L + val packetsReceived = members["packetsReceived"].statsLong() ?: 0L + + val previous = lastStatsSample + val elapsedSeconds = previous?.let { (timestampMs - it.atMs) / 1000.0 }?.takeIf { it > 0.0 } + val bitrateKbps = if (previous != null && bytesReceived != null && elapsedSeconds != null) { + (((bytesReceived - previous.bytesReceived).coerceAtLeast(0) * 8.0) / elapsedSeconds / 1000.0) + .roundToInt() + .coerceAtLeast(0) + } else { + null + } + val derivedFps = if (previous != null && framesDecoded != null && elapsedSeconds != null) { + ((framesDecoded - previous.framesDecoded).coerceAtLeast(0) / elapsedSeconds).roundToInt() + } else { + null + } + + val decodeMs = if (previous != null && framesDecoded != null && framesDecoded > previous.framesDecoded) { + val deltaDecodeTime = totalDecodeTime - previous.totalDecodeTime + val deltaFrames = framesDecoded - previous.framesDecoded + if (deltaFrames > 0) { + (deltaDecodeTime / deltaFrames * 1000.0).coerceIn(0.1, 50.0) + } else { + null + } + } else { + null + } + + val packetLossPct = if (previous != null) { + val deltaLost = packetsLost - previous.packetsLost + val deltaReceived = packetsReceived - previous.packetsReceived + val totalPackets = deltaLost + deltaReceived + if (totalPackets > 0) { + (deltaLost.toDouble() / totalPackets.toDouble() * 100.0).coerceIn(0.0, 100.0) + } else { + 0.0 + } + } else { + null + } + val packetsLostDelta = previous?.let { (packetsLost - it.packetsLost).coerceAtLeast(0L) } + val packetsReceivedDelta = previous?.let { (packetsReceived - it.packetsReceived).coerceAtLeast(0L) } + + if (bytesReceived != null || framesDecoded != null) { + lastStatsSample = StreamStatsSample( + atMs = timestampMs, + bytesReceived = bytesReceived ?: previous?.bytesReceived ?: 0L, + framesDecoded = framesDecoded ?: previous?.framesDecoded ?: 0L, + totalDecodeTime = totalDecodeTime, + packetsLost = packetsLost, + packetsReceived = packetsReceived, + ) + } + + val pingMs = activePair?.members?.get("currentRoundTripTime") + .statsDouble() + ?.let { (it * 1000.0).roundToInt().coerceAtLeast(0) } + val resolution = if (width != null && height != null && width > 0 && height > 0) { + "${width}x$height" + } else { + null + } + + return RuntimeStatsSnapshot( + stats = StreamRuntimeStats( + bitrateKbps = bitrateKbps, + pingMs = pingMs, + fps = explicitFps?.roundToInt()?.takeIf { it > 0 } ?: derivedFps?.takeIf { it > 0 }, + resolution = resolution, + codec = codec, + decodeMs = decodeMs, + jitterMs = jitterMs, + packetLossPct = packetLossPct, + packetsLostDelta = packetsLostDelta, + packetsReceivedDelta = packetsReceivedDelta, + ), + bytesReceived = bytesReceived, + framesDecoded = framesDecoded, + ) + } + + private fun handleMediaLiveness(snapshot: RuntimeStatsSnapshot) { + val connected = lastIceState == PeerConnection.IceConnectionState.CONNECTED || + lastIceState == PeerConnection.IceConnectionState.COMPLETED + val action = livenessWatchdog.observe( + SystemClock.elapsedRealtime(), + snapshot.bytesReceived, + snapshot.framesDecoded, + connected, + ) + updateTransportRecoveryProgress(livenessWatchdog.latestObservationProgressed) + if ( + rendererSinkAttached && + firstVideoFrameWatchdog.shouldRecover(SystemClock.elapsedRealtime(), snapshot.bytesReceived, connected) + ) { + when (firstFrameRecoveryStep(transportHasStableMedia, reconnectAttempts, videoSafeFallbackApplied)) { + FirstFrameRecoveryStep.RetryRequestedProfile -> { + NativeInputDiagnostics.add( + "first frame timeout requested profile retry codec=${settings.codec} resolution=${settings.resolution}", + ) + restartTransport("First video frame timed out") + return + } + FirstFrameRecoveryStep.ApplySafeVideoFallback -> { + if ( + requestSafeVideoFallback( + message = "Video packets arrived but no frame rendered; restarting with safe H264 profile", + diagnosticReason = "first frame timeout", + restartWhenAlreadySafe = true, + ) + ) { + return + } + } + FirstFrameRecoveryStep.ContinueBoundedTransportRecovery -> Unit + } + } + when (action) { + StreamLivenessAction.None -> Unit + is StreamLivenessAction.RequestKeyframe -> { + signaling?.requestKeyframe( + reason = "media_stall", + backlogFrames = 0, + attempt = action.attempt, + ) + emitState("Recovering video") + NativeInputDiagnostics.add("media stall keyframe requested stalledMs=${action.stalledMs} attempt=${action.attempt}") + } + is StreamLivenessAction.RestartTransport -> { + if ( + !transportHasStableMedia && + requestSafeVideoFallback( + message = "Decoder stalled; restarting with safe H264 profile", + diagnosticReason = "media stall", + ) + ) { + return + } + NativeInputDiagnostics.add("media stall transport restart stalledMs=${action.stalledMs}") + restartTransport("Media stalled for ${action.stalledMs / 1000}s") + } + } + } + + private fun updateTransportRecoveryProgress(progressed: Boolean) { + if (!progressed) { + consecutiveTransportProgressSamples = 0 + return + } + firstVideoFrameWatchdog.markRendered() + consecutiveTransportProgressSamples += 1 + if (consecutiveTransportProgressSamples < STABLE_TRANSPORT_PROGRESS_SAMPLES) return + + if (!transportHasStableMedia && reconnectAttempts > 0) { + recordStreamDiagnostic( + "transport media stable; reconnect budget reset attempts=$reconnectAttempts generation=$transportGeneration", + ) + } + transportHasStableMedia = true + reconnectAttempts = 0 + } + + private fun requestSafeVideoFallback( + message: String, + diagnosticReason: String, + restartWhenAlreadySafe: Boolean = false, + ): Boolean { + val currentSession = session ?: return false + val fallback = settings.androidSafeVideoFallback() + val alreadySafe = settings == fallback + if (videoSafeFallbackApplied || (alreadySafe && !restartWhenAlreadySafe)) return false + videoSafeFallbackApplied = true + settings = fallback + reconnectAttempts = (reconnectAttempts + 1).coerceAtMost(MAX_TRANSPORT_RECONNECT_ATTEMPTS) + NativeInputDiagnostics.add( + "$diagnosticReason ${if (alreadySafe) "safe profile transport restart" else "safe video fallback"} codec=${fallback.codec} resolution=${fallback.resolution} fps=${fallback.fps} bitrate=${fallback.maxBitrateMbps}", + ) + transportGeneration += 1 + val generation = transportGeneration + closeTransport(clearInputState = false) + firstVideoFrameWatchdog.reset() + recordStreamDiagnostic( + "safe video transport restart generation=$generation session=${streamDiagnosticId(currentSession.sessionId)} codec=${fallback.codec} reason=$diagnosticReason", + ) + emitState("Reconnecting stream with safe H264 profile") + emitSafeVideoFallbackApplied(message) + startTransport(currentSession, fallback, generation) + return true + } + + private fun formatStatsCodec(value: Any?): String? { + val raw = value?.toString()?.substringAfter("/", value.toString())?.trim()?.uppercase(Locale.US) ?: return null + return when (raw) { + "AVC", "H264", "H.264" -> "H264" + "HEVC", "H265", "H.265" -> "H265" + "AV01", "AV1" -> "AV1" + else -> raw.takeIf { it.isNotBlank() } + } + } + + private fun dispatchJoystick(event: MotionEvent): Boolean { + val controllerId = controllerIdFor(event) + activeControllerId = controllerId + if (!physicalControllerActive) { + NativeInputDiagnostics.add("physical gamepad motion source=${event.source} device=${event.deviceId} slot=$controllerId") + } + physicalControllerConnected = true + physicalControllerActive = true + val axes = AndroidGamepadAxisMapping.resolve(event.rawGamepadAxes(), event.axisAvailability()) + if (!physicalGamepadAxisLogged) { + physicalGamepadAxisLogged = true + val raw = event.rawGamepadAxes() + NativeInputDiagnostics.add( + "physical gamepad axes left=${axes.leftSource} right=${axes.rightSource} hatAsLeft=${axes.hatUsedAsLeftStick} " + + "x=${raw.x.formatAxis()} y=${raw.y.formatAxis()} z=${raw.z.formatAxis()} rz=${raw.rz.formatAxis()} " + + "rx=${raw.rx.formatAxis()} ry=${raw.ry.formatAxis()} hatX=${raw.hatX.formatAxis()} hatY=${raw.hatY.formatAxis()}", + ) + } + val hasAnalogL = event.device?.getMotionRange(MotionEvent.AXIS_LTRIGGER) != null || + event.device?.getMotionRange(MotionEvent.AXIS_BRAKE) != null + val lt = if (hasAnalogL) { + max(event.getAxisValue(MotionEvent.AXIS_LTRIGGER), normalizeTriggerAxis(event.getAxisValue(MotionEvent.AXIS_BRAKE))) + } else { + if (physicalLeftTriggerButtonPressed) 1f else 0f + } + + val hasAnalogR = event.device?.getMotionRange(MotionEvent.AXIS_RTRIGGER) != null || + event.device?.getMotionRange(MotionEvent.AXIS_GAS) != null + val rt = if (hasAnalogR) { + max(event.getAxisValue(MotionEvent.AXIS_RTRIGGER), normalizeTriggerAxis(event.getAxisValue(MotionEvent.AXIS_GAS))) + } else { + if (physicalRightTriggerButtonPressed) 1f else 0f + } + val leftScale = radialDeadzoneScale(axes.leftX, axes.leftY) + val rightScale = radialDeadzoneScale(axes.rightX, axes.rightY) + val leftX = axes.leftX * leftScale + val leftY = axes.leftY * leftScale + val rightX = axes.rightX * rightScale + val rightY = axes.rightY * rightScale + physicalHatButtons = if (axes.hatUsedAsLeftStick) 0 else event.hatDpadButtons() + lastLeftTrigger = normalizeToUint8(lt) + lastRightTrigger = normalizeToUint8(rt) + // When left-stick mouse emulation is active, keep lastLeftStick{X,Y} = 0 so the game + // receives no stick deflection. The actual motion is forwarded as mouse delta instead. + if (controllerMouseEmulationActive) { + lastLeftStickX = 0 + lastLeftStickY = 0 + } else { + lastLeftStickX = normalizeToInt16(leftX) + lastLeftStickY = normalizeToInt16(-leftY) + } + lastRightStickX = normalizeToInt16(rightX) + lastRightStickY = normalizeToInt16(-rightY) + physicalLeftStickX = leftX + physicalLeftStickY = leftY + physicalRightStickX = rightX + physicalRightStickY = rightY + return sendCurrentGamepadState(controllerId = controllerId) + } + + private fun dispatchGamepadKey(event: KeyEvent): Boolean { + if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return false + val pressed = event.action == KeyEvent.ACTION_DOWN + val controllerInputDevice = event.isControllerInputDevice() + val mask = GamepadButtonMapping.maskForKeyCode( + event.keyCode, + controllerActivation = controllerInputDevice, + ) + if (mask != null) { + activeControllerId = controllerIdFor(event) + if (!physicalControllerActive) { + NativeInputDiagnostics.add("physical gamepad key source=${event.source} device=${event.deviceId} slot=$activeControllerId key=${event.keyCode}") + } + physicalControllerConnected = true + physicalControllerActive = true + if (handleControllerMouseEmulationButton(mask, pressed)) { + return true + } + if (handleControllerMouseButton(mask, pressed)) { + return true + } + physicalButtons = if (pressed) physicalButtons or mask else physicalButtons and mask.inv() + val steamOverlayChordActivated = physicalSteamOverlayChord.update(physicalButtons) + val sent = sendCurrentGamepadState(controllerId = activeControllerId) + updateGuideAutoRelease(mask, pressed, activeControllerId) + if (steamOverlayChordActivated) { + schedulePhysicalSteamOverlayChordRelease(activeControllerId) + } + return sent + } + when (event.keyCode) { + KeyEvent.KEYCODE_BUTTON_L2 -> { + activeControllerId = controllerIdFor(event) + if (!physicalControllerActive) { + NativeInputDiagnostics.add("physical gamepad key source=${event.source} device=${event.deviceId} slot=$activeControllerId key=${event.keyCode}") + } + physicalControllerConnected = true + physicalControllerActive = true + physicalLeftTriggerButtonPressed = pressed + val hasAnalogTrigger = event.device?.getMotionRange(MotionEvent.AXIS_LTRIGGER) != null || + event.device?.getMotionRange(MotionEvent.AXIS_BRAKE) != null + if (!hasAnalogTrigger) { + lastLeftTrigger = if (pressed) 255 else 0 + } + val mouseSent = handleControllerMouseTrigger(left = true, pressed = pressed) + if (mouseSent) { + return true + } + return if (!hasAnalogTrigger) { + sendCurrentGamepadState(controllerId = activeControllerId) + } else { + true + } + } + KeyEvent.KEYCODE_BUTTON_R2 -> { + activeControllerId = controllerIdFor(event) + if (!physicalControllerActive) { + NativeInputDiagnostics.add("physical gamepad key source=${event.source} device=${event.deviceId} slot=$activeControllerId key=${event.keyCode}") + } + physicalControllerConnected = true + physicalControllerActive = true + physicalRightTriggerButtonPressed = pressed + val hasAnalogTrigger = event.device?.getMotionRange(MotionEvent.AXIS_RTRIGGER) != null || + event.device?.getMotionRange(MotionEvent.AXIS_GAS) != null + if (!hasAnalogTrigger) { + lastRightTrigger = if (pressed) 255 else 0 + } + val mouseSent = handleControllerMouseTrigger(left = false, pressed = pressed) + if (mouseSent) { + return true + } + return if (!hasAnalogTrigger) { + sendCurrentGamepadState(controllerId = activeControllerId) + } else { + true + } + } + } + return false + } + + private fun sendControllerMouseMove(stickX: Float, stickY: Float): Boolean { + if (!controllerMouseAssistActive && !controllerMouseEmulationActive) return false + val delta = AndroidControllerMouseAssist.mouseDelta(stickX, stickY) ?: return false + val sent = sendTouchMouseMove(delta.dx, delta.dy) + if (sent && !controllerMouseMoveLogged) { + controllerMouseMoveLogged = true + NativeInputDiagnostics.add("controller mouse move sent dx=${delta.dx} dy=${delta.dy} auto=$controllerMouseAssistAutoArmed emulation=$controllerMouseEmulationActive") + } + return sent + } + + private fun handleControllerMouseButton(buttonMask: Int, pressed: Boolean): Boolean { + if (!controllerMouseAssistActive && !controllerMouseEmulationActive) return false + val mouseButton = AndroidControllerMouseAssist.mouseButtonForGamepad(buttonMask) ?: return false + setControllerMouseButton(mouseButton, pressed) + return true + } + + /** When emulation mode is on, intercept Gamepad A as a left mouse click (button 1). */ + private fun handleControllerMouseEmulationButton(buttonMask: Int, pressed: Boolean): Boolean { + if (!controllerMouseEmulationActive) return false + if (buttonMask != GamepadButtonMapping.A) return false + setControllerMouseButton(1, pressed) + return true + } + + private fun handleControllerMouseTrigger(left: Boolean, pressed: Boolean): Boolean { + if (!controllerMouseAssistActive) return false + val mouseButton = AndroidControllerMouseAssist.mouseButtonForTrigger(left) ?: return false + return setControllerMouseButton(mouseButton, pressed) + } + + private fun sendCurrentGamepadState(controllerId: Int = activeControllerId): Boolean { + val partiallyReliable = canSendGamepadPartiallyReliable(controllerId) + val packet = inputEncoder.encodeGamepadState( + controllerId = controllerId, + buttons = + physicalSteamOverlayChord.effectiveButtons(physicalButtons) or + physicalHatButtons or + virtualSteamOverlayChord.effectiveButtons(virtualButtons) or + steamMenuChordButtons, + leftTrigger = max(lastLeftTrigger, virtualLeftTrigger), + rightTrigger = max(lastRightTrigger, virtualRightTrigger), + leftStickX = effectiveLeftStickX(), + leftStickY = effectiveLeftStickY(), + rightStickX = effectiveRightStickX(), + rightStickY = effectiveRightStickY(), + bitmap = currentGamepadBitmap(controllerId), + partiallyReliable = partiallyReliable, + ) + return sendInput(packet, partiallyReliable = partiallyReliable, fallbackToReliable = !partiallyReliable) + } + + private fun updateGuideAutoRelease(mask: Int, pressed: Boolean, controllerId: Int) { + if (mask != GamepadButtonMapping.GUIDE) return + guideAutoReleaseJob?.cancel() + if (!pressed) { + guideAutoReleaseJob = null + return + } + guideAutoReleaseJob = scope.launch { + delay(GAMEPAD_GUIDE_AUTO_RELEASE_MS) + if ((physicalButtons and GamepadButtonMapping.GUIDE) == 0) return@launch + physicalButtons = physicalButtons and GamepadButtonMapping.GUIDE.inv() + sendCurrentGamepadState(controllerId = controllerId) + NativeInputDiagnostics.add("physical gamepad guide auto-release slot=$controllerId") + } + } + + private fun schedulePhysicalSteamOverlayChordRelease(controllerId: Int) { + physicalSteamOverlayChordReleaseJob?.cancel() + physicalSteamOverlayChordReleaseJob = scope.launch { + delay(GAMEPAD_GUIDE_AUTO_RELEASE_MS) + if (!physicalSteamOverlayChord.releaseChord()) return@launch + sendCurrentGamepadState(controllerId = controllerId) + NativeInputDiagnostics.add("physical View+Start sent Steam Menu Home+A chord slot=$controllerId") + } + } + + private fun scheduleVirtualSteamOverlayChordRelease() { + virtualSteamOverlayChordReleaseJob?.cancel() + virtualSteamOverlayChordReleaseJob = scope.launch { + delay(GAMEPAD_GUIDE_AUTO_RELEASE_MS) + if (!virtualSteamOverlayChord.releaseChord()) return@launch + sendCurrentGamepadState() + NativeInputDiagnostics.add("touch View+Start sent Steam Menu Home+A chord") + } + } + + private fun effectiveLeftStickX(): Int = if (virtualLeftStickActive) virtualLeftStickX else lastLeftStickX + private fun effectiveLeftStickY(): Int = if (virtualLeftStickActive) virtualLeftStickY else lastLeftStickY + private fun effectiveRightStickX(): Int = + if (virtualRightStickActive) virtualRightStickX else if (controllerMouseAssistActive) 0 else lastRightStickX + + private fun effectiveRightStickY(): Int = + if (virtualRightStickActive) virtualRightStickY else if (controllerMouseAssistActive) 0 else lastRightStickY + + private fun hasAnyControllerState(): Boolean = + physicalControllerConnected || + physicalControllerActive || + virtualControllerVisible || + physicalButtons != 0 || + physicalHatButtons != 0 || + virtualButtons != 0 || + steamMenuChordButtons != 0 || + lastLeftTrigger != 0 || + lastRightTrigger != 0 || + virtualLeftTrigger != 0 || + virtualRightTrigger != 0 || + lastLeftStickX != 0 || + lastLeftStickY != 0 || + lastRightStickX != 0 || + lastRightStickY != 0 || + virtualLeftStickActive || + virtualRightStickActive + + private fun sendInput(bytes: ByteArray, partiallyReliable: Boolean): Boolean = + sendInput(bytes, partiallyReliable, fallbackToReliable = true) + + private fun sendReliableInput(bytes: ByteArray): Boolean { + if (sendInput(bytes, partiallyReliable = false)) return true + val sentPartial = sendInput(bytes, partiallyReliable = true, fallbackToReliable = false) + if (sentPartial) { + NativeInputDiagnostics.add("reliable input used partial fallback reliable=${reliableInput?.state()} partial=${partiallyReliableInput?.state()} bytes=${bytes.size}") + } + return sentPartial + } + + private fun sendInput(bytes: ByteArray, partiallyReliable: Boolean, fallbackToReliable: Boolean): Boolean { + val channel = if (partiallyReliable && partiallyReliableInput?.state() == DataChannel.State.OPEN) { + partiallyReliableInput + } else if (partiallyReliable && !fallbackToReliable) { + null + } else { + reliableInput + } + if (channel?.state() != DataChannel.State.OPEN) { + if (!inputDropLogged) { + inputDropLogged = true + NativeInputDiagnostics.add( + "input dropped noOpenChannel requestedPartial=$partiallyReliable reliable=${reliableInput?.state()} partial=${partiallyReliableInput?.state()} bytes=${bytes.size}", + ) + } + return false + } + if (channel.bufferedAmount() > 65536) { + return false + } + inputScope.launch { + runCatching { + channel.send(DataChannel.Buffer(java.nio.ByteBuffer.wrap(bytes), true)) + } + } + return true + } + + private fun clearPhysicalControllerInputState() { + physicalControllerActive = false + physicalButtons = 0 + physicalHatButtons = 0 + physicalSteamOverlayChord.reset() + physicalSteamOverlayChordReleaseJob?.cancel() + physicalSteamOverlayChordReleaseJob = null + physicalLeftTriggerButtonPressed = false + physicalRightTriggerButtonPressed = false + lastLeftTrigger = 0 + lastRightTrigger = 0 + lastLeftStickX = 0 + lastLeftStickY = 0 + lastRightStickX = 0 + lastRightStickY = 0 + physicalLeftStickX = 0f + physicalLeftStickY = 0f + physicalRightStickX = 0f + physicalRightStickY = 0f + } + + private fun refreshConnectedPhysicalControllers() { + controllerAxisAvailability.clear() + val availableDeviceIds = InputDevice.getDeviceIds() + val connectedDevices = mutableListOf() + availableDeviceIds.forEach { deviceId -> + val device = InputDevice.getDevice(deviceId) ?: return@forEach + if (AndroidControllerInput.isControllerDevice(device)) { + connectedDevices += device + } + } + val removedControllerSlots = AndroidControllerSlotRegistry.retainConnected( + controllerSlots = controllerSlots, + connectedDeviceIds = availableDeviceIds.toSet(), + ) + if (removedControllerSlots.isNotEmpty()) { + NativeInputDiagnostics.add( + "physical gamepad slots released=${removedControllerSlots.entries.joinToString { "${it.key}:${it.value}" }}", + ) + } + val activeControllerDisconnected = activeControllerId in removedControllerSlots.values + val connected = connectedDevices.isNotEmpty() + val connectionChanged = connected != physicalControllerConnected + if (connectionChanged) { + NativeInputDiagnostics.add( + "physical gamepad connected=$connected devices=${connectedDevices.joinToString { "${it.id}:${it.name}" }}", + ) + } + physicalControllerConnected = connected + if (connected && !physicalControllerActive && controllerSlots.isEmpty()) { + activeControllerId = controllerIdFor(connectedDevices.first().id) + } + if (physicalControllerActive && (!connected || activeControllerDisconnected)) { + clearPhysicalControllerInputState() + if (connected) { + activeControllerId = controllerIdFor(connectedDevices.first().id) + } + sendCurrentGamepadState() + } + updateHapticsAdvertisement(force = connectionChanged) + } + + private fun schedulePrimeConnectedGamepadState(reason: String) { + scope.launch { + primeConnectedGamepadState(reason) + } + } + + private fun primeConnectedGamepadState(reason: String) { + refreshConnectedPhysicalControllers() + if (!hasAnyControllerState()) return + val sent = sendCurrentGamepadState() + NativeInputDiagnostics.add( + "gamepad state prime reason=$reason sent=$sent connected=$physicalControllerConnected active=$physicalControllerActive slot=$activeControllerId reliable=${reliableInput?.state()} partial=${partiallyReliableInput?.state()}", + ) + } + + private fun updateHapticsAdvertisement(force: Boolean = false) { + if (reliableInput?.state() != DataChannel.State.OPEN) return + if (!force && hapticsAdvertised != null) return + val enabled = hapticsOutputAvailable() + if (hapticsAdvertised == enabled) return + if (sendReliableInput(inputEncoder.encodeHapticsEnabled(enabled))) { + hapticsAdvertised = enabled + NativeInputDiagnostics.add("gamepad haptics advertised enabled=$enabled force=$force") + } + } + + private fun hapticsOutputAvailable(): Boolean = + hapticControllerDevices().isNotEmpty() || hasPhoneRumbleFallback() + + private fun hapticControllerDevices(): List = + buildList { + InputDevice.getDeviceIds().forEach { deviceId -> + val device = InputDevice.getDevice(deviceId) ?: return@forEach + if (!AndroidControllerInput.isControllerDevice(device)) return@forEach + if (device.hasControllerRumble()) add(device) + } + } + + private fun findHapticControllerDevice(controllerId: Int): InputDevice? { + val devices = hapticControllerDevices() + if (devices.isEmpty()) return null + devices.firstOrNull { controllerSlots[it.id] == controllerId }?.let { return it } + if (controllerId in 0 until GAMEPAD_MAX_CONTROLLERS) { + devices.getOrNull(controllerId)?.let { return it } + } + return devices.singleOrNull() + } + + @Suppress("DEPRECATION") + private fun applyGamepadRumble(controllerId: Int, weakMagnitude16: Int, strongMagnitude16: Int) { + val slot = controllerId.coerceIn(0, GAMEPAD_MAX_CONTROLLERS - 1) + val profile = buildRumbleEffectProfile(weakMagnitude16, strongMagnitude16) + val isStop = profile.isStop + val now = SystemClock.elapsedRealtime() + if (!isStop && lastRumbleEffectAtMs[slot] != 0L && now - lastRumbleEffectAtMs[slot] <= RUMBLE_THROTTLE_MS) { + return + } + val device = findHapticControllerDevice(slot) + val usePhoneFallback = device == null && hasPhoneRumbleFallback() + if (device == null && !usePhoneFallback) { + logHapticsWarning("input haptics no vibrator controller=$controllerId phoneFallback=$phoneRumbleFallbackEnabled") + return + } + lastRumbleEffectAtMs[slot] = if (isStop) 0L else now + + if (isStop) { + device?.let(::cancelControllerRumble) + cancelPhoneRumble() + return + } + if (device != null && !hapticsSupportLogged[slot]) { + hapticsSupportLogged[slot] = true + NativeInputDiagnostics.add("gamepad haptics available controller=$slot device=${device.id}:${device.name}") + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (device != null) { + vibrateController(device, profile) + } else { + vibratePhoneRumble(profile) + } + } else { + @Suppress("DEPRECATION") + if (device != null) { + device.vibrator.vibrate(RUMBLE_EFFECT_MS) + } else { + vibratePhoneRumbleLegacy() + } + } + } + + private fun stopAllGamepadRumble() { + hapticControllerDevices().forEach { device -> + cancelControllerRumble(device) + } + cancelPhoneRumble() + for (index in 0 until GAMEPAD_MAX_CONTROLLERS) { + lastRumbleEffectAtMs[index] = 0L + hapticsSupportLogged[index] = false + } + phoneRumbleSupportLogged = false + lastHapticsWarningAtMs = 0L + } + + private fun logHapticsWarning(message: String) { + val now = SystemClock.elapsedRealtime() + if (now - lastHapticsWarningAtMs < HAPTICS_LOG_INTERVAL_MS) return + lastHapticsWarningAtMs = now + NativeInputDiagnostics.add(message) + } + + private fun buildRumbleEffectProfile(weakMagnitude16: Int, strongMagnitude16: Int): RumbleEffectProfile { + val weak = weakMagnitude16.coerceIn(0, 65535) / 65535f + val strong = strongMagnitude16.coerceIn(0, 65535) / 65535f + val combined = (strong * 0.78f + weak * 0.48f).coerceIn(0f, 1f) + return RumbleEffectProfile( + weakAmplitude = rumbleAmplitude(weak, weight = 0.72f), + strongAmplitude = rumbleAmplitude(strong, weight = 1f), + combinedAmplitude = rumbleAmplitude(combined, weight = 1f), + ) + } + + private fun rumbleAmplitude(value: Float, weight: Float): Int { + val scaled = (value.coerceIn(0f, 1f) * weight.coerceIn(0f, 1f) * 255f).roundToInt() + return if (scaled <= 0) 0 else scaled.coerceIn(1, 255) + } + + @Suppress("DEPRECATION") + private fun InputDevice.hasControllerRumble(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val manager = vibratorManager + if (manager.vibratorIds.any { manager.getVibrator(it).hasVibrator() }) return true + } + return vibrator.hasVibrator() + } + + private fun vibrateController(device: InputDevice, profile: RumbleEffectProfile) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val manager = device.vibratorManager + val vibratorIds = manager.vibratorIds.filter { manager.getVibrator(it).hasVibrator() } + if (vibratorIds.size >= 2) { + val combination = CombinedVibration.startParallel() + var addedEffect = false + if (profile.strongAmplitude > 0) { + combination.addVibrator(vibratorIds[0], createRumbleEffect(profile.strongAmplitude)) + addedEffect = true + } + if (profile.weakAmplitude > 0) { + combination.addVibrator(vibratorIds[1], createRumbleEffect(profile.weakAmplitude)) + addedEffect = true + } + if (addedEffect) { + manager.vibrate(combination.combine()) + return + } + } + if (vibratorIds.isNotEmpty() && profile.combinedAmplitude > 0) { + manager.vibrate(CombinedVibration.createParallel(createRumbleEffect(profile.combinedAmplitude))) + return + } + } + @Suppress("DEPRECATION") + device.vibrator.vibrate(createRumbleEffect(profile.combinedAmplitude)) + } + + @Suppress("DEPRECATION") + private fun cancelControllerRumble(device: InputDevice) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val manager = device.vibratorManager + if (manager.vibratorIds.isNotEmpty()) { + manager.cancel() + return + } + } + device.vibrator.cancel() + } + + private fun hasPhoneRumbleFallback(): Boolean { + if (!phoneRumbleFallbackEnabled) return false + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val manager = appContext.getSystemService(VibratorManager::class.java) ?: return false + manager.vibratorIds.any { manager.getVibrator(it).hasVibrator() } + } else { + @Suppress("DEPRECATION") + (appContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator)?.hasVibrator() == true + } + } + + private fun createRumbleEffect(amplitude: Int): VibrationEffect = + VibrationEffect.createOneShot(RUMBLE_EFFECT_MS, amplitude.coerceIn(1, 255)) + + private fun vibratePhoneRumble(profile: RumbleEffectProfile) { + if (!phoneRumbleSupportLogged) { + phoneRumbleSupportLogged = true + NativeInputDiagnostics.add("gamepad haptics using phone fallback") + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val manager = appContext.getSystemService(VibratorManager::class.java) + if (manager != null && manager.vibratorIds.any { manager.getVibrator(it).hasVibrator() }) { + manager.vibrate(CombinedVibration.createParallel(createRumbleEffect(profile.combinedAmplitude))) + return + } + } + @Suppress("DEPRECATION") + (appContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator)?.vibrate(createRumbleEffect(profile.combinedAmplitude)) + } + + @Suppress("DEPRECATION") + private fun vibratePhoneRumbleLegacy() { + if (!phoneRumbleSupportLogged) { + phoneRumbleSupportLogged = true + NativeInputDiagnostics.add("gamepad haptics using phone fallback") + } + (appContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator)?.vibrate(RUMBLE_EFFECT_MS) + } + + private fun cancelPhoneRumble() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + appContext.getSystemService(VibratorManager::class.java)?.cancel() + return + } + @Suppress("DEPRECATION") + (appContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator)?.cancel() + } + + private fun controllerIdFor(event: KeyEvent): Int = controllerIdFor(event.deviceId) + private fun controllerIdFor(event: MotionEvent): Int = controllerIdFor(event.deviceId) + + private fun controllerIdFor(deviceId: Int): Int { + val assignment = AndroidControllerSlotRegistry.assign( + controllerSlots = controllerSlots, + deviceId = deviceId, + connectedDeviceIds = InputDevice.getDeviceIds().toSet(), + maxControllers = GAMEPAD_MAX_CONTROLLERS, + ) + if (physicalControllerActive && activeControllerId in assignment.removedDevices.values) { + clearPhysicalControllerInputState() + } + if (assignment.removedDevices.isNotEmpty()) { + NativeInputDiagnostics.add( + "physical gamepad slots reconciled removed=${assignment.removedDevices.entries.joinToString { "${it.key}:${it.value}" }} " + + "device=$deviceId slot=${assignment.slot}", + ) + } + return assignment.slot + } + + private fun currentGamepadBitmap(controllerId: Int): Int { + val connected = physicalControllerConnected || + physicalControllerActive || + virtualControllerVisible || + virtualButtons != 0 || + virtualLeftTrigger != 0 || + virtualRightTrigger != 0 || + virtualLeftStickActive || + virtualRightStickActive + if (!connected) return 0 + val id = controllerId.coerceIn(0, 3) + return (1 shl id) or (1 shl (id + 8)) + } + + private fun canSendGamepadPartiallyReliable(controllerId: Int): Boolean { + if (partiallyReliableInput?.state() != DataChannel.State.OPEN) return false + val mask = 1 shl (controllerId and 0x1f) + return (partiallyReliableGamepadMask and mask) != 0 + } + + private fun MotionEvent.isFromSource(source: Int): Boolean = (this.source and source) == source + private fun MotionEvent.isMouseLikePointer(): Boolean { + val controllerSource = isFromSource(InputDevice.SOURCE_JOYSTICK) || isFromSource(InputDevice.SOURCE_GAMEPAD) + return isFromSource(InputDevice.SOURCE_MOUSE) || + isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE) || + (isFromSource(InputDevice.SOURCE_TOUCHPAD) && !controllerSource) + } + + private fun MotionEvent.isRelativeMousePointer(): Boolean = + isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE) + + private fun MotionEvent.primaryMouseButton(): Int = + when { + actionButton != 0 -> actionButton.toGfnMouseButton() + buttonState != 0 -> buttonState.toGfnMouseButton() + else -> 1 + } + + private fun MotionEvent.hatDpadButtons(): Int { + var mask = 0 + val hatX = getAxisValue(MotionEvent.AXIS_HAT_X) + val hatY = getAxisValue(MotionEvent.AXIS_HAT_Y) + if (hatY <= -0.5f) mask = mask or GamepadButtonMapping.DPAD_UP + if (hatY >= 0.5f) mask = mask or GamepadButtonMapping.DPAD_DOWN + if (hatX <= -0.5f) mask = mask or GamepadButtonMapping.DPAD_LEFT + if (hatX >= 0.5f) mask = mask or GamepadButtonMapping.DPAD_RIGHT + return mask + } + + private fun MotionEvent.rawGamepadAxes(): AndroidGamepadRawAxes = + AndroidGamepadRawAxes( + x = getAxisValue(MotionEvent.AXIS_X), + y = getAxisValue(MotionEvent.AXIS_Y), + z = getAxisValue(MotionEvent.AXIS_Z), + rz = getAxisValue(MotionEvent.AXIS_RZ), + rx = getAxisValue(MotionEvent.AXIS_RX), + ry = getAxisValue(MotionEvent.AXIS_RY), + hatX = getAxisValue(MotionEvent.AXIS_HAT_X), + hatY = getAxisValue(MotionEvent.AXIS_HAT_Y), + ) + + private fun MotionEvent.axisAvailability(): AndroidGamepadAxisAvailability { + val cacheKey = deviceId + if (cacheKey >= 0) { + controllerAxisAvailability[cacheKey]?.let { return it } + } + return AndroidGamepadAxisAvailability( + x = hasMotionAxis(MotionEvent.AXIS_X), + y = hasMotionAxis(MotionEvent.AXIS_Y), + z = hasMotionAxis(MotionEvent.AXIS_Z), + rz = hasMotionAxis(MotionEvent.AXIS_RZ), + rx = hasMotionAxis(MotionEvent.AXIS_RX), + ry = hasMotionAxis(MotionEvent.AXIS_RY), + hatX = hasMotionAxis(MotionEvent.AXIS_HAT_X), + hatY = hasMotionAxis(MotionEvent.AXIS_HAT_Y), + ).also { availability -> + if (cacheKey >= 0) { + controllerAxisAvailability[cacheKey] = availability + } + } + } + + private fun MotionEvent.hasMotionAxis(axis: Int): Boolean { + val inputDevice = device ?: return false + return inputDevice.getMotionRange(axis, source) != null || + inputDevice.getMotionRange(axis) != null + } + + private fun KeyEvent.isGamepadEvent(): Boolean { + val controllerInputDevice = isControllerInputDevice() + return (controllerInputDevice && + (GamepadButtonMapping.maskForKeyCode(keyCode, controllerActivation = true) != null || + AndroidControllerInput.isPrimaryActivationKey(keyCode) || + keyCode == KeyEvent.KEYCODE_BUTTON_L2 || + keyCode == KeyEvent.KEYCODE_BUTTON_R2)) || + GamepadButtonMapping.isControllerButtonKeyCode(keyCode) + } + + private fun KeyEvent.isHardwareKeyboardSource(): Boolean = + !isControllerInputDevice() && + ((source and InputDevice.SOURCE_KEYBOARD) == InputDevice.SOURCE_KEYBOARD || + InputDevice.getDevice(deviceId)?.keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC) + + private fun MotionEvent.isGamepadMotionEvent(): Boolean = + isFromSource(InputDevice.SOURCE_JOYSTICK) || + isFromSource(InputDevice.SOURCE_GAMEPAD) || + (AndroidControllerInput.isControllerEvent(source, deviceId) && !isMouseLikePointer()) + + private fun KeyEvent.isControllerInputDevice(): Boolean = + AndroidControllerInput.isControllerEvent(source, deviceId) + + private fun Int.toGfnMouseButton(): Int = when { + this and MotionEvent.BUTTON_PRIMARY != 0 -> 1 + this and MotionEvent.BUTTON_TERTIARY != 0 -> 2 + this and MotionEvent.BUTTON_SECONDARY != 0 -> 3 + this and MotionEvent.BUTTON_BACK != 0 -> 4 + this and MotionEvent.BUTTON_FORWARD != 0 -> 5 + else -> 1 + } + + private companion object { + private const val ANDROID_TV_CODEC_RELEASE_SETTLE_MS = 180L + private const val EXTERNAL_MOUSE_ABSOLUTE_DELTA_LIMIT_PX = 240f + private const val GAMEPAD_MAX_CONTROLLERS = 4 + private const val RUMBLE_EFFECT_MS = 90L + private const val RUMBLE_THROTTLE_MS = 35L + private const val HAPTICS_LOG_INTERVAL_MS = 5000L + } + + private fun radialDeadzoneScale(x: Float, y: Float, deadzone: Float = 0.15f): Float { + val magnitude = kotlin.math.sqrt((x * x + y * y).toDouble()).toFloat() + if (magnitude < deadzone) return 0f + val scaled = ((magnitude - deadzone) / (1f - deadzone)).coerceIn(0f, 1f) + return scaled / magnitude + } + + private fun normalizeToInt16(value: Float): Int = (value.coerceIn(-1f, 1f) * 32767).roundToInt().coerceIn(-32768, 32767) + private fun normalizeToUint8(value: Float): Int = (value.coerceIn(0f, 1f) * 255).roundToInt().coerceIn(0, 255) + private fun normalizeTriggerAxis(value: Float): Float = if (value < 0f) ((value + 1f) / 2f).coerceIn(0f, 1f) else value.coerceIn(0f, 1f) + private fun Float.formatAxis(): String = String.format(Locale.US, "%.3f", this) +} + +open class SimpleSdpObserver : SdpObserver { + override fun onCreateSuccess(description: SessionDescription?) = Unit + override fun onSetSuccess() = Unit + override fun onCreateFailure(error: String?) = Unit + override fun onSetFailure(error: String?) = Unit +} + +object SdpTools { + data class RewriteResult(val sdp: String, val replacements: Int) + + fun fixServerIp(sdp: String, serverIp: String): String = + fixServerEndpoint(sdp, serverIp, mediaConnectionInfo = null) + + fun fixServerEndpoint(sdp: String, serverIp: String, mediaConnectionInfo: MediaConnectionInfo?): String { + val signalingIp = extractPublicIp(serverIp) ?: return sdp + val mediaIp = mediaConnectionInfo?.ip?.let(::extractPublicIp) ?: signalingIp + val mediaPort = mediaConnectionInfo?.port?.takeIf { it in 1..65535 } + return sdp + .replace(Regex("c=IN IP4 ([^\\r\\n]+)")) { match -> + val address = match.groupValues[1] + if (shouldRewriteRemoteEndpoint(address, mediaConnectionInfo != null)) "c=IN IP4 $mediaIp" else match.value + } + .replace(Regex("(a=candidate:\\S+\\s+\\d+\\s+\\w+\\s+\\d+\\s+)([^\\s]+)\\s+(\\d+)(\\s+)")) { match -> + val address = match.groupValues[2] + val port = match.groupValues[3] + if (shouldRewriteRemoteEndpoint(address, mediaConnectionInfo != null)) { + "${match.groupValues[1]}$mediaIp ${mediaPort ?: port}${match.groupValues[4]}" + } else { + match.value + } + } + } + + fun preferCodec(sdp: String, settings: StreamSettings): String = + preferCodec(sdp, settings.codec, settings.prefersTenBitVideo()) + + fun preferCodec(sdp: String, codec: VideoCodec): String = + preferCodec(sdp, codec, preferTenBit = codec != VideoCodec.H265) + + private fun preferCodec(sdp: String, codec: VideoCodec, preferTenBit: Boolean): String { + val target = when (codec) { + VideoCodec.H264 -> "H264" + VideoCodec.H265 -> "H265" + VideoCodec.AV1 -> "AV1" + } + val lineEnding = if (sdp.contains("\r\n")) "\r\n" else "\n" + val lines = sdp.split(Regex("\\r?\\n")) + var inVideo = false + val codecByPt = mutableMapOf() + val rtxApt = mutableMapOf() + val fmtpByPt = mutableMapOf() + lines.forEach { line -> + if (line.startsWith("m=video")) inVideo = true else if (line.startsWith("m=") && inVideo) inVideo = false + if (inVideo && line.startsWith("a=rtpmap:")) { + val rest = line.substringAfter(":") + val pt = rest.substringBefore(" ") + val name = rest.substringAfter(" ").substringBefore("/").uppercase(Locale.US).let { if (it == "HEVC") "H265" else it } + codecByPt[pt] = name + } + if (inVideo && line.startsWith("a=fmtp:")) { + val rest = line.substringAfter(":") + val pt = rest.substringBefore(" ") + val params = rest.substringAfter(" ", "") + fmtpByPt[pt] = params + Regex("(?:^|;)\\s*apt=(\\d+)").find(params)?.groupValues?.getOrNull(1)?.let { rtxApt[pt] = it } + } + } + val preferred = codecByPt.filterValues { it == target }.keys.toMutableList() + if (preferred.isEmpty()) return sdp + if (codec == VideoCodec.H265) { + preferred.sortBy { pt -> h265ProfilePriority(fmtpByPt[pt], preferTenBit) } + } + val allowed = preferred.toMutableSet() + rtxApt.forEach { (rtx, apt) -> + if (apt in preferred && codecByPt[rtx] == "RTX") allowed += rtx + } + val output = mutableListOf() + inVideo = false + lines.forEach { line -> + if (line.startsWith("m=video")) { + inVideo = true + val parts = line.split(Regex("\\s+")) + val ordered = preferred + parts.drop(3).filter { it in allowed && it !in preferred } + output += if (ordered.isNotEmpty()) (parts.take(3) + ordered).joinToString(" ") else line + return@forEach + } + if (line.startsWith("m=") && inVideo) inVideo = false + if (inVideo && (line.startsWith("a=rtpmap:") || line.startsWith("a=fmtp:") || line.startsWith("a=rtcp-fb:"))) { + val pt = line.substringAfter(":").substringBefore(" ") + if (pt !in allowed) return@forEach + } + output += line + } + return output.joinToString(lineEnding) + } + + fun rewriteH265TierFlag(sdp: String, tierFlag: Int): RewriteResult { + val payloads = h265PayloadTypes(sdp) + if (payloads.isEmpty()) return RewriteResult(sdp, 0) + val lineEnding = if (sdp.contains("\r\n")) "\r\n" else "\n" + var replacements = 0 + val output = sdp.split(Regex("\\r?\\n")).map { line -> + if (!line.startsWith("a=fmtp:")) return@map line + val pt = line.substringAfter(":").substringBefore(" ") + if (pt !in payloads) return@map line + val next = line.replace(Regex("tier-flag=1", RegexOption.IGNORE_CASE), "tier-flag=$tierFlag") + if (next != line) replacements += 1 + next + } + return RewriteResult(output.joinToString(lineEnding), replacements) + } + + fun rewriteH265LevelIdByProfile(sdp: String, maxLevelByProfile: Map): RewriteResult { + val payloads = h265PayloadTypes(sdp) + if (payloads.isEmpty() || maxLevelByProfile.isEmpty()) return RewriteResult(sdp, 0) + val lineEnding = if (sdp.contains("\r\n")) "\r\n" else "\n" + var replacements = 0 + val output = sdp.split(Regex("\\r?\\n")).map { line -> + if (!line.startsWith("a=fmtp:")) return@map line + val rest = line.substringAfter(":") + val pt = rest.substringBefore(" ") + val params = rest.substringAfter(" ", "") + if (pt !in payloads || params.isBlank()) return@map line + val profile = Regex("(?:^|;)\\s*profile-id=(\\d+)", RegexOption.IGNORE_CASE) + .find(params) + ?.groupValues + ?.getOrNull(1) + ?.toIntOrNull() + ?: return@map line + val level = Regex("(?:^|;)\\s*level-id=(\\d+)", RegexOption.IGNORE_CASE) + .find(params) + ?.groupValues + ?.getOrNull(1) + ?.toIntOrNull() + ?: return@map line + val maxLevel = maxLevelByProfile[profile] ?: return@map line + if (level <= maxLevel) return@map line + val next = line.replace(Regex("(level-id=)(\\d+)", RegexOption.IGNORE_CASE), "$1$maxLevel") + if (next != line) replacements += 1 + next + } + return RewriteResult(output.joinToString(lineEnding), replacements) + } + + fun negotiatesCodec(sdp: String, codec: VideoCodec): Boolean { + val target = when (codec) { + VideoCodec.H264 -> "H264" + VideoCodec.H265 -> "H265" + VideoCodec.AV1 -> "AV1" + } + var inVideo = false + sdp.split(Regex("\\r?\\n")).forEach { line -> + if (line.startsWith("m=video")) { + inVideo = true + return@forEach + } + if (line.startsWith("m=") && inVideo) { + inVideo = false + } + if (!inVideo || !line.startsWith("a=rtpmap:")) return@forEach + val codecName = line.substringAfter(" ") + .substringBefore("/") + .uppercase(Locale.US) + .let { if (it == "HEVC") "H265" else it } + if (codecName == target) return true + } + return false + } + + private fun h265PayloadTypes(sdp: String): Set { + var inVideo = false + val payloads = mutableSetOf() + sdp.split(Regex("\\r?\\n")).forEach { line -> + if (line.startsWith("m=video")) { + inVideo = true + return@forEach + } + if (line.startsWith("m=") && inVideo) { + inVideo = false + } + if (!inVideo || !line.startsWith("a=rtpmap:")) return@forEach + val rest = line.substringAfter(":") + val pt = rest.substringBefore(" ") + val codecName = rest.substringAfter(" ") + .substringBefore("/") + .uppercase(Locale.US) + .let { if (it == "HEVC") "H265" else it } + if (pt.isNotBlank() && codecName == "H265") payloads += pt + } + return payloads + } + + private fun h265ProfilePriority(fmtp: String?, preferTenBit: Boolean): Int { + val profileId = Regex("(?:^|;)\\s*profile-id=(\\d+)") + .find(fmtp.orEmpty()) + ?.groupValues + ?.getOrNull(1) + return if (preferTenBit) { + when (profileId) { + "2" -> 0 + "1" -> 1 + else -> 2 + } + } else { + when (profileId) { + "1" -> 0 + null -> 1 + "2" -> 2 + else -> 3 + } + } + } + + private fun StreamSettings.prefersTenBitVideo(): Boolean = + hdrEnabled || + colorQuality == ColorQuality.TenBit420 || + colorQuality == ColorQuality.TenBit444 + + fun mungeAnswerSdp(sdp: String, maxBitrateKbps: Int): String { + val lineEnding = if (sdp.contains("\r\n")) "\r\n" else "\n" + val out = mutableListOf() + val lines = sdp.split(Regex("\\r?\\n")) + lines.forEachIndexed { index, line -> + val rewritten = if (line.startsWith("a=fmtp:") && line.contains("minptime=") && !line.contains("stereo=1")) "$line;stereo=1" else line + out += rewritten + if ((line.startsWith("m=video") || line.startsWith("m=audio")) && !lines.getOrNull(index + 1).orEmpty().startsWith("b=")) { + out += if (line.startsWith("m=video")) "b=AS:$maxBitrateKbps" else "b=AS:128" + } + } + return out.joinToString(lineEnding) + } + + fun parseInputProtocolVersion(sdp: String): Int = + Regex("a=ri\\.version:(\\d+)").find(sdp)?.groupValues?.getOrNull(1)?.toIntOrNull() + ?: DEFAULT_INPUT_PROTOCOL_VERSION + + fun parsePartialReliableThresholdMs(sdp: String): Int = + Regex("a=ri\\.partialReliableThresholdMs:(\\d+)") + .find(sdp) + ?.groupValues + ?.getOrNull(1) + ?.toIntOrNull() + ?.coerceIn(1, 5000) + ?: 30 + + fun parsePartiallyReliableGamepadMask(sdp: String): Int = + parseRiIntegerAttribute( + sdp, + "ri.enablePartiallyReliableTransferGamepad", + PARTIALLY_RELIABLE_GAMEPAD_MASK_ALL, + ) + + fun buildNvstSdp(offerSdp: String, settings: StreamSettings, localAnswer: String): String { + val (width, height) = streamResolutionPixels(settings) + val ufrag = Regex("a=ice-ufrag:([^\\r\\n]+)").find(localAnswer)?.groupValues?.getOrNull(1)?.trim().orEmpty() + val pwd = Regex("a=ice-pwd:([^\\r\\n]+)").find(localAnswer)?.groupValues?.getOrNull(1)?.trim().orEmpty() + val fingerprint = Regex("a=fingerprint:sha-256 ([^\\r\\n]+)").find(localAnswer)?.groupValues?.getOrNull(1)?.trim().orEmpty() + val threshold = Regex("a=ri\\.partialReliableThresholdMs:(\\d+)").find(offerSdp)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: 30 + val bitDepth = if (settings.hdrEnabled || settings.colorQuality == ColorQuality.TenBit420 || settings.colorQuality == ColorQuality.TenBit444) 10 else 8 + val maxBitrate = settings.maxBitrateMbps * 1000 + val minBitrate = max(5000, (maxBitrate * 0.35f).roundToInt()) + val initialBitrate = max(minBitrate, (maxBitrate * 0.7f).roundToInt()) + val isHighFps = settings.fps >= 90 + val is120Fps = settings.fps == 120 + val is240Fps = settings.fps >= 240 + val isAv1 = settings.codec == VideoCodec.AV1 + return buildList { + add("v=0") + add("o=SdpTest test_id_13 14 IN IPv4 127.0.0.1") + add("s=-") + add("t=0 0") + add("a=general.icePassword:$pwd") + add("a=general.iceUserNameFragment:$ufrag") + add("a=general.dtlsFingerprint:$fingerprint") + add("m=video 0 RTP/AVP") + add("a=msid:fbc-video-0") + add("a=vqos.fec.rateDropWindow:10") + add("a=vqos.fec.minRequiredFecPackets:2") + add("a=vqos.fec.repairMinPercent:5") + add("a=vqos.fec.repairPercent:5") + add("a=vqos.fec.repairMaxPercent:35") + add("a=vqos.dynamicStreamingMode:0") + add("a=vqos.drc.enable:0") + add("a=video.dx9EnableNv12:1") + add("a=video.dx9EnableHdr:${if (settings.hdrEnabled) 1 else 0}") + add("a=vqos.qpg.enable:1") + add("a=vqos.resControl.qp.qpg.featureSetting:7") + add("a=bwe.useOwdCongestionControl:1") + add("a=video.enableRtpNack:1") + add("a=vqos.bw.txRxLag.minFeedbackTxDeltaMs:200") + add("a=vqos.drc.bitrateIirFilterFactor:18") + add("a=video.packetSize:1140") + add("a=packetPacing.minNumPacketsPerGroup:15") + if (isHighFps) { + add("a=vqos.dfc.enable:1") + add("a=vqos.dfc.decodeFpsAdjPercent:85") + add("a=vqos.dfc.targetDownCooldownMs:250") + add("a=vqos.dfc.dfcAlgoVersion:${if (is120Fps || is240Fps) 2 else 1}") + add("a=vqos.dfc.minTargetFps:${if (is120Fps || is240Fps) 100 else 60}") + add("a=vqos.resControl.dfc.useClientFpsPerf:0") + add("a=vqos.dfc.adjustResAndFps:0") + add("a=bwe.iirFilterFactor:8") + add("a=video.encoderFeatureSetting:47") + add("a=video.encoderPreset:6") + add("a=vqos.resControl.cpmRtc.badNwSkipFramesCount:600") + add("a=vqos.resControl.cpmRtc.decodeTimeThresholdMs:9") + add("a=video.fbcDynamicFpsGrabTimeoutMs:${if (is120Fps) 6 else 18}") + add("a=vqos.resControl.cpmRtc.serverResolutionUpdateCoolDownCount:${if (is120Fps) 6000 else 12000}") + } else { + add("a=vqos.dfc.enable:0") + add("a=vqos.dfc.adjustResAndFps:0") + } + if (is240Fps) { + add("a=video.enableNextCaptureMode:1") + add("a=vqos.maxStreamFpsEstimate:${settings.fps}") + add("a=video.videoSplitEncodeStripsPerFrame:3") + add("a=video.updateSplitEncodeStateDynamically:1") + add("a=vqos.rtcPreemptiveIdrSettings.minBurstNackSize:65535") + add("a=vqos.rtcPreemptiveIdrSettings.minNackPacketCaptureAgeMs:65535") + } + add("a=vqos.adjustStreamingFpsDuringOutOfFocus:0") + add("a=vqos.resControl.cpmRtc.ignoreOutOfFocusWindowState:1") + add("a=vqos.resControl.perfHistory.rtcIgnoreOutOfFocusWindowState:1") + add("a=vqos.resControl.cpmRtc.featureMask:0") + add("a=vqos.resControl.cpmRtc.enable:0") + add("a=vqos.resControl.cpmRtc.minResolutionPercent:100") + add("a=vqos.resControl.cpmRtc.resolutionChangeHoldonMs:999999") + add("a=packetPacing.numGroups:${if (is120Fps) 3 else 5}") + add("a=packetPacing.maxDelayUs:1000") + add("a=packetPacing.minNumPacketsFrame:10") + add("a=video.rtpNackQueueLength:1024") + add("a=video.rtpNackQueueMaxPackets:512") + add("a=video.rtpNackMaxPacketCount:25") + add("a=vqos.drc.qpMaxResThresholdAdj:4") + add("a=vqos.grc.qpMaxResThresholdAdj:4") + add("a=vqos.drc.iirFilterFactor:100") + if (isAv1) { + add("a=vqos.drc.minQpHeadroom:20") + add("a=vqos.drc.lowerQpThreshold:100") + add("a=vqos.drc.upperQpThreshold:200") + add("a=vqos.drc.minAdaptiveQpThreshold:180") + add("a=vqos.drc.qpCodecThresholdAdj:0") + add("a=vqos.drc.qpMaxResThresholdAdj:20") + add("a=vqos.dfc.minQpHeadroom:20") + add("a=vqos.dfc.qpLowerLimit:100") + add("a=vqos.dfc.qpMaxUpperLimit:200") + add("a=vqos.dfc.qpMinUpperLimit:180") + add("a=vqos.dfc.qpMaxResThresholdAdj:20") + add("a=vqos.dfc.qpCodecThresholdAdj:0") + add("a=vqos.grc.minQpHeadroom:20") + add("a=vqos.grc.lowerQpThreshold:100") + add("a=vqos.grc.upperQpThreshold:200") + add("a=vqos.grc.minAdaptiveQpThreshold:180") + add("a=vqos.grc.qpMaxResThresholdAdj:20") + add("a=vqos.grc.qpCodecThresholdAdj:0") + add("a=video.minQp:25") + add("a=video.enableAv1RcPrecisionFactor:1") + } + add("a=video.clientViewportWd:$width") + add("a=video.clientViewportHt:$height") + add("a=video.maxFPS:${settings.fps}") + add("a=video.initialBitrateKbps:$initialBitrate") + add("a=video.initialPeakBitrateKbps:$maxBitrate") + add("a=vqos.bw.maximumBitrateKbps:$maxBitrate") + add("a=vqos.bw.minimumBitrateKbps:$minBitrate") + add("a=vqos.bw.peakBitrateKbps:$maxBitrate") + add("a=vqos.bw.serverPeakBitrateKbps:$maxBitrate") + add("a=vqos.bw.enableBandwidthEstimation:1") + add("a=vqos.bw.disableBitrateLimit:0") + add("a=vqos.grc.maximumBitrateKbps:$maxBitrate") + add("a=vqos.grc.enable:0") + add("a=video.maxNumReferenceFrames:4") + add("a=video.mapRtpTimestampsToFrames:1") + add("a=video.encoderCscMode:3") + add("a=video.dynamicRangeMode:0") + add("a=video.bitDepth:$bitDepth") + // Keep the encoded geometry fixed for every codec. AV1 value 1 was + // added during the June SDP expansion and permits the horizontal + // scaling seen as 1366x768 -> 1230x768 in affected sessions. + add("a=video.scalingFeature1:0") + add("a=video.prefilterParams.prefilterModel:0") + add("m=audio 0 RTP/AVP") + add("a=msid:audio") + add("m=mic 0 RTP/AVP") + add("a=msid:mic") + add("a=rtpmap:0 PCMU/8000") + add("m=application 0 RTP/AVP") + add("a=msid:input_1") + add("a=ri.partialReliableThresholdMs:$threshold") + add("a=ri.hidDeviceMask:4294967295") + add("a=ri.enablePartiallyReliableTransferGamepad:15") + add("a=ri.enablePartiallyReliableTransferHid:4294967295") + add("") + }.joinToString("\n") + } + + private fun extractPublicIp(hostOrIp: String): String? { + if (Regex("^\\d{1,3}(\\.\\d{1,3}){3}$").matches(hostOrIp)) return hostOrIp + val first = hostOrIp.substringBefore(".") + val parts = first.split("-") + return if (parts.size == 4 && parts.all { it.all(Char::isDigit) }) parts.joinToString(".") else null + } + + private fun shouldRewriteRemoteEndpoint(address: String, hasMediaEndpoint: Boolean): Boolean { + val remoteAddress = parseIpv4Address(address) ?: return false + if (remoteAddress.inetAddress.isAnyLocalAddress) return true + return hasMediaEndpoint && remoteAddress.isUnroutable() + } + + private data class RemoteIpv4Address( + val octets: List, + val inetAddress: InetAddress, + ) { + fun isUnroutable(): Boolean = + inetAddress.isLoopbackAddress || + inetAddress.isSiteLocalAddress || + inetAddress.isLinkLocalAddress || + inetAddress.isMulticastAddress || + isCarrierGradeNatAddress(octets) + } + + private fun parseIpv4Address(address: String): RemoteIpv4Address? { + val octets = address.split(".").map { it.toIntOrNull() ?: return null } + if (octets.size != 4 || octets.any { it !in 0..255 }) return null + val inetAddress = InetAddress.getByAddress(octets.map { it.toByte() }.toByteArray()) + return RemoteIpv4Address(octets, inetAddress) + } + + private fun isCarrierGradeNatAddress(octets: List): Boolean { + return octets[0] == 100 && octets[1] in 64..127 + } + + private fun parseRiIntegerAttribute(sdp: String, attribute: String, fallback: Int): Int { + val escaped = Regex.escape(attribute) + val raw = Regex("a=$escaped:([^\\r\\n]+)", RegexOption.IGNORE_CASE) + .find(sdp) + ?.groupValues + ?.getOrNull(1) + ?.trim() + ?: return fallback + val parsed = if (raw.startsWith("0x", ignoreCase = true)) { + raw.drop(2).toIntOrNull(16) + } else { + raw.toIntOrNull() + } + return parsed ?: fallback + } + + private const val PARTIALLY_RELIABLE_GAMEPAD_MASK_ALL = 0x0f +} + +class InputEncoder { + private var protocolVersion = 3 + private val gamepadSequences = mutableMapOf() + + fun setProtocolVersion(version: Int) { + protocolVersion = version.coerceAtLeast(1) + } + + fun resetGamepadSequences() { + gamepadSequences.clear() + } + + fun encodeHeartbeat(): ByteArray = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(INPUT_HEARTBEAT).array() + + fun encodeKeyDown(key: KeyboardPayload): ByteArray = encodeKey(INPUT_KEY_DOWN, key) + fun encodeKeyUp(key: KeyboardPayload): ByteArray = encodeKey(INPUT_KEY_UP, key) + + fun encodeMouseMove(dx: Int, dy: Int): ByteArray { + val bytes = ByteArray(22) + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putInt(INPUT_MOUSE_REL) + ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + .putShort(4, dx.coerceIn(-32768, 32767).toShort()) + .putShort(6, dy.coerceIn(-32768, 32767).toShort()) + .putShort(8, 0.toShort()) + .putInt(10, 0) + .putLong(14, timestampUs()) + return wrapMouseMove(bytes) + } + + fun encodeMouseButton(type: Int, button: Int): ByteArray { + val bytes = ByteArray(18) + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putInt(type) + bytes[4] = button.coerceIn(1, 5).toByte() + bytes[5] = 0 + ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).putInt(6, 0).putLong(10, timestampUs()) + return wrapSingle(bytes) + } + + fun encodeMouseWheel(delta: Int): ByteArray { + val bytes = ByteArray(22) + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putInt(INPUT_MOUSE_WHEEL) + ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + .putShort(4, 0.toShort()) + .putShort(6, delta.coerceIn(-32768, 32767).toShort()) + .putShort(8, 0.toShort()) + .putInt(10, 0) + .putLong(14, timestampUs()) + return wrapSingle(bytes) + } + + fun encodeHapticsEnabled(enabled: Boolean): ByteArray { + val bytes = ByteArray(6) + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putInt(INPUT_HAPTICS_ENABLED) + ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).putShort(4, (if (enabled) 1 else 0).toShort()) + return wrapSingle(bytes) + } + + fun encodeGamepadState( + controllerId: Int, + buttons: Int, + leftTrigger: Int, + rightTrigger: Int, + leftStickX: Int, + leftStickY: Int, + rightStickX: Int, + rightStickY: Int, + bitmap: Int, + partiallyReliable: Boolean, + timestampUs: Long = timestampUs(), + ): ByteArray { + val bytes = ByteArray(38) + val le = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + le.putInt(0, INPUT_GAMEPAD) + le.putShort(4, 26.toShort()) + le.putShort(6, (controllerId and 0x03).toShort()) + le.putShort(8, bitmap.toShort()) + le.putShort(10, 20.toShort()) + le.putShort(12, buttons.toShort()) + le.putShort(14, ((leftTrigger and 0xff) or ((rightTrigger and 0xff) shl 8)).toShort()) + le.putShort(16, leftStickX.toShort()) + le.putShort(18, leftStickY.toShort()) + le.putShort(20, rightStickX.toShort()) + le.putShort(22, rightStickY.toShort()) + le.putShort(24, 0.toShort()) + le.putShort(26, 85.toShort()) + le.putShort(28, 0.toShort()) + le.putLong(30, timestampUs) + return if (partiallyReliable) wrapGamepadPartiallyReliable(bytes, controllerId) else wrapGamepadReliable(bytes) + } + + private fun encodeKey(type: Int, key: KeyboardPayload): ByteArray { + val bytes = ByteArray(18) + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putInt(type) + ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + .putShort(4, key.keycode.toShort()) + .putShort(6, key.modifiers.toShort()) + .putShort(8, key.scancode.toShort()) + .putLong(10, key.timestampUs) + return wrapSingle(bytes) + } + + private fun wrapSingle(payload: ByteArray): ByteArray { + if (protocolVersion <= 2) return payload + return ByteArray(10 + payload.size).also { + it[0] = 0x23 + ByteBuffer.wrap(it).order(ByteOrder.BIG_ENDIAN).putLong(1, timestampUs()) + it[9] = 0x22 + payload.copyInto(it, 10) + } + } + + private fun wrapMouseMove(payload: ByteArray): ByteArray { + if (protocolVersion <= 2) return payload + return ByteArray(12 + payload.size).also { + it[0] = 0x23 + ByteBuffer.wrap(it).order(ByteOrder.BIG_ENDIAN).putLong(1, timestampUs()) + it[9] = 0x21 + ByteBuffer.wrap(it).order(ByteOrder.BIG_ENDIAN).putShort(10, payload.size.toShort()) + payload.copyInto(it, 12) + } + } + + private fun wrapGamepadReliable(payload: ByteArray): ByteArray { + if (protocolVersion <= 2) return payload + return ByteArray(12 + payload.size).also { + it[0] = 0x23 + ByteBuffer.wrap(it).order(ByteOrder.BIG_ENDIAN).putLong(1, timestampUs()) + it[9] = 0x21 + ByteBuffer.wrap(it).order(ByteOrder.BIG_ENDIAN).putShort(10, payload.size.toShort()) + payload.copyInto(it, 12) + } + } + + private fun wrapGamepadPartiallyReliable(payload: ByteArray, index: Int): ByteArray { + if (protocolVersion <= 2) return payload + val seq = gamepadSequences[index] ?: 1 + gamepadSequences[index] = (seq + 1) and 0xffff + return ByteArray(16 + payload.size).also { + it[0] = 0x23 + val be = ByteBuffer.wrap(it).order(ByteOrder.BIG_ENDIAN) + be.putLong(1, timestampUs()) + it[9] = 0x26 + it[10] = (index and 0xff).toByte() + be.putShort(11, seq.toShort()) + it[13] = 0x21 + be.putShort(14, payload.size.toShort()) + payload.copyInto(it, 16) + } + } + + data class KeyboardPayload( + val keycode: Int, + val scancode: Int, + val modifiers: Int, + val timestampUs: Long = timestampUs(), + ) + + data class TextKeySpec( + val keycode: Int, + val scancode: Int, + val shift: Boolean = false, + ) { + fun toKeyboardPayload(modifiers: Int): KeyboardPayload = + KeyboardPayload(keycode, scancode, modifiers) + } + + companion object { + const val INPUT_HEARTBEAT = 2 + const val INPUT_KEY_DOWN = 3 + const val INPUT_KEY_UP = 4 + const val INPUT_MOUSE_REL = 7 + const val INPUT_MOUSE_BUTTON_DOWN = 8 + const val INPUT_MOUSE_BUTTON_UP = 9 + const val INPUT_MOUSE_WHEEL = 10 + const val INPUT_GAMEPAD = 12 + const val INPUT_HAPTICS_ENABLED = 13 + + fun mapKeyEvent(event: KeyEvent): KeyboardPayload? { + if (event.action != KeyEvent.ACTION_DOWN && event.action != KeyEvent.ACTION_UP) return null + return mapKeyboardPayload( + keyCode = event.keyCode, + unicode = event.unicodeChar, + scanCode = event.scanCode, + shift = event.isShiftPressed, + ctrl = event.isCtrlPressed, + alt = event.isAltPressed, + meta = event.isMetaPressed, + capsLock = event.isCapsLockOn, + numLock = event.isNumLockOn, + ) + } + + internal fun mapKeyboardPayload( + keyCode: Int, + unicode: Int, + scanCode: Int, + shift: Boolean = false, + ctrl: Boolean = false, + alt: Boolean = false, + meta: Boolean = false, + capsLock: Boolean = false, + numLock: Boolean = false, + timestampUs: Long = timestampUs(), + ): KeyboardPayload? { + val vk = virtualKey(keyCode, unicode) + val resolvedScanCode = if (scanCode > 0) scanCode else fallbackScanCode(keyCode) + if (vk == null || resolvedScanCode == null) return null + var modifiers = 0 + if (shift) modifiers = modifiers or 0x01 + if (ctrl) modifiers = modifiers or 0x02 + if (alt) modifiers = modifiers or 0x04 + if (meta) modifiers = modifiers or 0x08 + if (capsLock) modifiers = modifiers or 0x10 + if (numLock) modifiers = modifiers or 0x20 + return KeyboardPayload(vk, resolvedScanCode, modifiers, timestampUs) + } + + internal fun mapTextCharToKeySpec(char: Char): TextKeySpec? { + val mapped = when (char) { + in 'a'..'z' -> textKeySpecFromAndroidKeyCode(KeyEvent.KEYCODE_A + (char - 'a')) + in 'A'..'Z' -> textKeySpecFromAndroidKeyCode(KeyEvent.KEYCODE_A + (char - 'A'), shift = true) + in '0'..'9' -> textKeySpecFromAndroidKeyCode(KeyEvent.KEYCODE_0 + (char - '0')) + '\n', '\r' -> textKeySpecFromAndroidKeyCode(KeyEvent.KEYCODE_ENTER) + else -> textBaseKeyCodes[char]?.let(::textKeySpecFromAndroidKeyCode) + ?: textShiftedKeyCodes[char]?.let { textKeySpecFromAndroidKeyCode(it, shift = true) } + } + return mapped + } + + internal fun shiftLeftPayload(modifiers: Int): KeyboardPayload = + KeyboardPayload(0xa0, fallbackScanCode(KeyEvent.KEYCODE_SHIFT_LEFT) ?: 0x002a, modifiers) + + private fun textKeySpecFromAndroidKeyCode(keyCode: Int, shift: Boolean = false): TextKeySpec? { + val payload = mapKeyboardPayload( + keyCode = keyCode, + unicode = 0, + scanCode = 0, + shift = shift, + timestampUs = 0L, + ) ?: return null + return TextKeySpec(payload.keycode, payload.scancode, shift) + } + + private val textBaseKeyCodes = mapOf( + ' ' to KeyEvent.KEYCODE_SPACE, + '-' to KeyEvent.KEYCODE_MINUS, + '=' to KeyEvent.KEYCODE_EQUALS, + '[' to KeyEvent.KEYCODE_LEFT_BRACKET, + ']' to KeyEvent.KEYCODE_RIGHT_BRACKET, + '\\' to KeyEvent.KEYCODE_BACKSLASH, + ';' to KeyEvent.KEYCODE_SEMICOLON, + '\'' to KeyEvent.KEYCODE_APOSTROPHE, + ',' to KeyEvent.KEYCODE_COMMA, + '.' to KeyEvent.KEYCODE_PERIOD, + '/' to KeyEvent.KEYCODE_SLASH, + '`' to KeyEvent.KEYCODE_GRAVE, + ) + + private val textShiftedKeyCodes = mapOf( + '!' to KeyEvent.KEYCODE_1, + '@' to KeyEvent.KEYCODE_2, + '#' to KeyEvent.KEYCODE_3, + '$' to KeyEvent.KEYCODE_4, + '%' to KeyEvent.KEYCODE_5, + '^' to KeyEvent.KEYCODE_6, + '&' to KeyEvent.KEYCODE_7, + '*' to KeyEvent.KEYCODE_8, + '(' to KeyEvent.KEYCODE_9, + ')' to KeyEvent.KEYCODE_0, + '_' to KeyEvent.KEYCODE_MINUS, + '+' to KeyEvent.KEYCODE_EQUALS, + '{' to KeyEvent.KEYCODE_LEFT_BRACKET, + '}' to KeyEvent.KEYCODE_RIGHT_BRACKET, + '|' to KeyEvent.KEYCODE_BACKSLASH, + ':' to KeyEvent.KEYCODE_SEMICOLON, + '"' to KeyEvent.KEYCODE_APOSTROPHE, + '<' to KeyEvent.KEYCODE_COMMA, + '>' to KeyEvent.KEYCODE_PERIOD, + '?' to KeyEvent.KEYCODE_SLASH, + '~' to KeyEvent.KEYCODE_GRAVE, + ) + + private fun virtualKey(keyCode: Int, unicode: Int): Int? = + when (keyCode) { + KeyEvent.KEYCODE_ENTER -> 0x0d + KeyEvent.KEYCODE_ESCAPE -> 0x1b + KeyEvent.KEYCODE_DEL -> 0x08 + KeyEvent.KEYCODE_TAB -> 0x09 + KeyEvent.KEYCODE_SPACE -> 0x20 + KeyEvent.KEYCODE_DPAD_LEFT -> 0x25 + KeyEvent.KEYCODE_DPAD_UP -> 0x26 + KeyEvent.KEYCODE_DPAD_RIGHT -> 0x27 + KeyEvent.KEYCODE_DPAD_DOWN -> 0x28 + KeyEvent.KEYCODE_PAGE_UP -> 0x21 + KeyEvent.KEYCODE_PAGE_DOWN -> 0x22 + KeyEvent.KEYCODE_FORWARD_DEL -> 0x2e + KeyEvent.KEYCODE_INSERT -> 0x2d + KeyEvent.KEYCODE_MOVE_HOME -> 0x24 + KeyEvent.KEYCODE_MOVE_END -> 0x23 + KeyEvent.KEYCODE_SHIFT_LEFT, + KeyEvent.KEYCODE_SHIFT_RIGHT, + -> 0x10 + KeyEvent.KEYCODE_CTRL_LEFT, + KeyEvent.KEYCODE_CTRL_RIGHT, + -> 0x11 + KeyEvent.KEYCODE_ALT_LEFT, + KeyEvent.KEYCODE_ALT_RIGHT, + -> 0x12 + KeyEvent.KEYCODE_CAPS_LOCK -> 0x14 + KeyEvent.KEYCODE_NUM_LOCK -> 0x90 + KeyEvent.KEYCODE_SCROLL_LOCK -> 0x91 + KeyEvent.KEYCODE_MINUS -> 0xbd + KeyEvent.KEYCODE_EQUALS -> 0xbb + KeyEvent.KEYCODE_LEFT_BRACKET -> 0xdb + KeyEvent.KEYCODE_RIGHT_BRACKET -> 0xdd + KeyEvent.KEYCODE_BACKSLASH -> 0xdc + KeyEvent.KEYCODE_SEMICOLON -> 0xba + KeyEvent.KEYCODE_APOSTROPHE -> 0xde + KeyEvent.KEYCODE_COMMA -> 0xbc + KeyEvent.KEYCODE_PERIOD -> 0xbe + KeyEvent.KEYCODE_SLASH -> 0xbf + KeyEvent.KEYCODE_GRAVE -> 0xc0 + in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> 0x41 + (keyCode - KeyEvent.KEYCODE_A) + in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> 0x30 + (keyCode - KeyEvent.KEYCODE_0) + in KeyEvent.KEYCODE_NUMPAD_0..KeyEvent.KEYCODE_NUMPAD_9 -> 0x60 + (keyCode - KeyEvent.KEYCODE_NUMPAD_0) + in KeyEvent.KEYCODE_F1..KeyEvent.KEYCODE_F12 -> 0x70 + (keyCode - KeyEvent.KEYCODE_F1) + else -> unicode.takeIf { it in 1..255 }?.let { Character.toUpperCase(it.toChar()).code } + } + + private fun fallbackScanCode(keyCode: Int): Int? = + when (keyCode) { + KeyEvent.KEYCODE_A -> 0x001e + KeyEvent.KEYCODE_B -> 0x0030 + KeyEvent.KEYCODE_C -> 0x002e + KeyEvent.KEYCODE_D -> 0x0020 + KeyEvent.KEYCODE_E -> 0x0012 + KeyEvent.KEYCODE_F -> 0x0021 + KeyEvent.KEYCODE_G -> 0x0022 + KeyEvent.KEYCODE_H -> 0x0023 + KeyEvent.KEYCODE_I -> 0x0017 + KeyEvent.KEYCODE_J -> 0x0024 + KeyEvent.KEYCODE_K -> 0x0025 + KeyEvent.KEYCODE_L -> 0x0026 + KeyEvent.KEYCODE_M -> 0x0032 + KeyEvent.KEYCODE_N -> 0x0031 + KeyEvent.KEYCODE_O -> 0x0018 + KeyEvent.KEYCODE_P -> 0x0019 + KeyEvent.KEYCODE_Q -> 0x0010 + KeyEvent.KEYCODE_R -> 0x0013 + KeyEvent.KEYCODE_S -> 0x001f + KeyEvent.KEYCODE_T -> 0x0014 + KeyEvent.KEYCODE_U -> 0x0016 + KeyEvent.KEYCODE_V -> 0x002f + KeyEvent.KEYCODE_W -> 0x0011 + KeyEvent.KEYCODE_X -> 0x002d + KeyEvent.KEYCODE_Y -> 0x0015 + KeyEvent.KEYCODE_Z -> 0x002c + KeyEvent.KEYCODE_1 -> 0x0002 + KeyEvent.KEYCODE_2 -> 0x0003 + KeyEvent.KEYCODE_3 -> 0x0004 + KeyEvent.KEYCODE_4 -> 0x0005 + KeyEvent.KEYCODE_5 -> 0x0006 + KeyEvent.KEYCODE_6 -> 0x0007 + KeyEvent.KEYCODE_7 -> 0x0008 + KeyEvent.KEYCODE_8 -> 0x0009 + KeyEvent.KEYCODE_9 -> 0x000a + KeyEvent.KEYCODE_0 -> 0x000b + KeyEvent.KEYCODE_NUMPAD_7 -> 0x0047 + KeyEvent.KEYCODE_NUMPAD_8 -> 0x0048 + KeyEvent.KEYCODE_NUMPAD_9 -> 0x0049 + KeyEvent.KEYCODE_NUMPAD_4 -> 0x004b + KeyEvent.KEYCODE_NUMPAD_5 -> 0x004c + KeyEvent.KEYCODE_NUMPAD_6 -> 0x004d + KeyEvent.KEYCODE_NUMPAD_1 -> 0x004f + KeyEvent.KEYCODE_NUMPAD_2 -> 0x0050 + KeyEvent.KEYCODE_NUMPAD_3 -> 0x0051 + KeyEvent.KEYCODE_NUMPAD_0 -> 0x0052 + KeyEvent.KEYCODE_ENTER -> 0x001c + KeyEvent.KEYCODE_NUMPAD_ENTER -> 0x011c + KeyEvent.KEYCODE_ESCAPE -> 0x0001 + KeyEvent.KEYCODE_SPACE -> 0x0039 + KeyEvent.KEYCODE_TAB -> 0x000f + KeyEvent.KEYCODE_DEL -> 0x000e + KeyEvent.KEYCODE_DPAD_LEFT -> 0x014b + KeyEvent.KEYCODE_DPAD_UP -> 0x0148 + KeyEvent.KEYCODE_DPAD_RIGHT -> 0x014d + KeyEvent.KEYCODE_DPAD_DOWN -> 0x0150 + KeyEvent.KEYCODE_PAGE_UP -> 0x0149 + KeyEvent.KEYCODE_PAGE_DOWN -> 0x0151 + KeyEvent.KEYCODE_FORWARD_DEL -> 0x0153 + KeyEvent.KEYCODE_INSERT -> 0x0152 + KeyEvent.KEYCODE_MOVE_HOME -> 0x0147 + KeyEvent.KEYCODE_MOVE_END -> 0x014f + KeyEvent.KEYCODE_SHIFT_LEFT -> 0x002a + KeyEvent.KEYCODE_SHIFT_RIGHT -> 0x0036 + KeyEvent.KEYCODE_CTRL_LEFT -> 0x001d + KeyEvent.KEYCODE_CTRL_RIGHT -> 0x011d + KeyEvent.KEYCODE_ALT_LEFT -> 0x0038 + KeyEvent.KEYCODE_ALT_RIGHT -> 0x0138 + KeyEvent.KEYCODE_CAPS_LOCK -> 0x003a + KeyEvent.KEYCODE_NUM_LOCK -> 0x0145 + KeyEvent.KEYCODE_SCROLL_LOCK -> 0x0046 + KeyEvent.KEYCODE_MINUS -> 0x000c + KeyEvent.KEYCODE_EQUALS -> 0x000d + KeyEvent.KEYCODE_LEFT_BRACKET -> 0x001a + KeyEvent.KEYCODE_RIGHT_BRACKET -> 0x001b + KeyEvent.KEYCODE_BACKSLASH -> 0x002b + KeyEvent.KEYCODE_SEMICOLON -> 0x0027 + KeyEvent.KEYCODE_APOSTROPHE -> 0x0028 + KeyEvent.KEYCODE_COMMA -> 0x0033 + KeyEvent.KEYCODE_PERIOD -> 0x0034 + KeyEvent.KEYCODE_SLASH -> 0x0035 + KeyEvent.KEYCODE_GRAVE -> 0x0029 + else -> null + } + } +} + +private fun timestampUs(): Long = SystemClock.elapsedRealtimeNanos() / 1000L + +internal fun shouldUseLowLatencyStreamAudio(androidTvProfile: Boolean): Boolean = !androidTvProfile + +internal fun shouldRunControllerMouseLoop( + controllerMouseAssistActive: Boolean, + controllerMouseEmulationActive: Boolean, +): Boolean = controllerMouseAssistActive || controllerMouseEmulationActive + +internal fun shouldCaptureMicrophone( + mode: MicrophoneMode, + permissionGranted: Boolean, +): Boolean = mode != MicrophoneMode.Disabled && permissionGranted + +internal fun isDisposedRtpSenderFailure(error: IllegalStateException): Boolean = + error.message == "RtpSender has been disposed." + +internal fun advancedCodecRestartSettleDelayMs(codec: VideoCodec, hadStableMedia: Boolean): Long = + if (hadStableMedia && codec != VideoCodec.H264) ANDROID_CODEC_RESTART_SETTLE_MS else 0L + +private const val GFN_MICROPHONE_MID = "3" +private const val MICROPHONE_STREAM_ID = "mic" +private const val MICROPHONE_TRACK_ID = "mic" +private const val DEFAULT_INPUT_PROTOCOL_VERSION = 2 +private const val INPUT_HANDSHAKE_MARKER = 0x0e +private const val INPUT_HANDSHAKE_MAGIC_WORD = 526 +private const val ICE_DISCONNECTED_GRACE_MS = 3500L +private const val ICE_FAILED_RECONNECT_DELAY_MS = 250L +private const val SIGNALING_RECONNECT_DELAY_MS = 1000L +private const val ANDROID_CODEC_RESTART_SETTLE_MS = 180L +private const val MAX_TRANSPORT_RECONNECT_ATTEMPTS = 3 +private const val OFFER_TIMEOUT_MS = 12_000L +private const val MEDIA_STALL_KEYFRAME_AFTER_MS = 5_000L +private const val MEDIA_STALL_KEYFRAME_INTERVAL_MS = 2_500L +private const val MEDIA_STALL_RESTART_AFTER_MS = 10_000L +// Low-power TV MediaCodec implementations can open an advanced decoder several +// seconds before they produce their first frame. Keep the pre-TV-optimization +// startup window so a slow H.265/AV1 decoder is not mistaken for a dead one and +// immediately replaced by the safe-codec profile. +private const val TV_MEDIA_STALL_KEYFRAME_AFTER_MS = 5_000L +private const val TV_MEDIA_STALL_KEYFRAME_INTERVAL_MS = 2_500L +private const val TV_MEDIA_STALL_RESTART_AFTER_MS = 14_000L +private const val FIRST_VIDEO_FRAME_TIMEOUT_MS = 8_000L +private const val STABLE_TRANSPORT_PROGRESS_SAMPLES = 3 +private const val GAMEPAD_GUIDE_AUTO_RELEASE_MS = 160L +private const val STEAM_MENU_MODIFIER_DELAY_MS = 40L +private const val STREAM_TEXT_SEND_MAX_CHARS = 4096 +private const val STREAM_TEXT_SEND_ATTEMPTS = 3 +private const val STREAM_TEXT_PACKET_DELAY_MS = 4L +private const val STREAM_TEXT_KEY_DELAY_MS = 10L +private const val STREAM_TEXT_RETRY_DELAY_MS = 16L +private const val BYTES_PER_MEBIBYTE = 1024L * 1024L +private const val LOW_POWER_TV_MEMORY_LIMIT_BYTES = 3L * 1024L * BYTES_PER_MEBIBYTE + +private fun Any?.statsDouble(): Double? = + when (this) { + is Number -> toDouble() + is String -> toDoubleOrNull() + else -> null + } + +private fun Any?.statsLong(): Long? = + when (this) { + is Number -> toLong() + is String -> toLongOrNull() + else -> null + } diff --git a/android/app/src/main/res/drawable-nodpi/catalog_colorful_abstract_background.jpg b/android/app/src/main/res/drawable-nodpi/catalog_colorful_abstract_background.jpg new file mode 100644 index 000000000..4769ea300 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/catalog_colorful_abstract_background.jpg differ diff --git a/android/app/src/main/res/drawable-nodpi/catalog_default_background.png b/android/app/src/main/res/drawable-nodpi/catalog_default_background.png new file mode 100644 index 000000000..b814d6958 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/catalog_default_background.png differ diff --git a/android/app/src/main/res/drawable/ic_arrow_back.xml b/android/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 000000000..c1d24c8be --- /dev/null +++ b/android/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_arrow_up.xml b/android/app/src/main/res/drawable/ic_arrow_up.xml new file mode 100644 index 000000000..59605489b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_arrow_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_chevron_right.xml b/android/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 000000000..afafd9942 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_clear.xml b/android/app/src/main/res/drawable/ic_clear.xml new file mode 100644 index 000000000..194fa93eb --- /dev/null +++ b/android/app/src/main/res/drawable/ic_clear.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_help.xml b/android/app/src/main/res/drawable/ic_help.xml new file mode 100644 index 000000000..945800aa9 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_help.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_keyboard.xml b/android/app/src/main/res/drawable/ic_keyboard.xml new file mode 100644 index 000000000..0145e8044 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_keyboard.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_mic.xml b/android/app/src/main/res/drawable/ic_mic.xml new file mode 100644 index 000000000..37939c87b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_mic.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_save.xml b/android/app/src/main/res/drawable/ic_save.xml new file mode 100644 index 000000000..e5e76c0bc --- /dev/null +++ b/android/app/src/main/res/drawable/ic_save.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_save_filled.xml b/android/app/src/main/res/drawable/ic_save_filled.xml new file mode 100644 index 000000000..fa736fab2 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_save_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_search.xml b/android/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 000000000..1de4f7cd0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_amazon.xml b/android/app/src/main/res/drawable/ic_store_amazon.xml new file mode 100644 index 000000000..9826e24c7 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_amazon.xml @@ -0,0 +1,7 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_battlenet.xml b/android/app/src/main/res/drawable/ic_store_battlenet.xml new file mode 100644 index 000000000..c2d50e89b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_battlenet.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_ea.xml b/android/app/src/main/res/drawable/ic_store_ea.xml new file mode 100644 index 000000000..444ac6b62 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_ea.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_epic.xml b/android/app/src/main/res/drawable/ic_store_epic.xml new file mode 100644 index 000000000..38da7c7bf --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_epic.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_gog.xml b/android/app/src/main/res/drawable/ic_store_gog.xml new file mode 100644 index 000000000..89fab7bb6 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_gog.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_google_play.xml b/android/app/src/main/res/drawable/ic_store_google_play.xml new file mode 100644 index 000000000..3abd1bae0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_google_play.xml @@ -0,0 +1,7 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_hoyo.xml b/android/app/src/main/res/drawable/ic_store_hoyo.xml new file mode 100644 index 000000000..8337fad87 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_hoyo.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_microsoft.xml b/android/app/src/main/res/drawable/ic_store_microsoft.xml new file mode 100644 index 000000000..62f95ef78 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_microsoft.xml @@ -0,0 +1,7 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_riot.xml b/android/app/src/main/res/drawable/ic_store_riot.xml new file mode 100644 index 000000000..bb1792b0b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_riot.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_rockstar.xml b/android/app/src/main/res/drawable/ic_store_rockstar.xml new file mode 100644 index 000000000..eed8b9e59 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_rockstar.xml @@ -0,0 +1,7 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_steam.xml b/android/app/src/main/res/drawable/ic_store_steam.xml new file mode 100644 index 000000000..244ea3f62 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_steam.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_ubisoft.xml b/android/app/src/main/res/drawable/ic_store_ubisoft.xml new file mode 100644 index 000000000..502155b2e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_ubisoft.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_store_xbox.xml b/android/app/src/main/res/drawable/ic_store_xbox.xml new file mode 100644 index 000000000..618d0b999 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_store_xbox.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_tab_library.xml b/android/app/src/main/res/drawable/ic_tab_library.xml new file mode 100644 index 000000000..bebcf3be9 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_tab_library.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_tab_settings.xml b/android/app/src/main/res/drawable/ic_tab_settings.xml new file mode 100644 index 000000000..0dc925109 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_tab_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_tab_store.xml b/android/app/src/main/res/drawable/ic_tab_store.xml new file mode 100644 index 000000000..7d9ac9efb --- /dev/null +++ b/android/app/src/main/res/drawable/ic_tab_store.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_tab_stream.xml b/android/app/src/main/res/drawable/ic_tab_stream.xml new file mode 100644 index 000000000..9c89a82a5 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_tab_stream.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_volume_off.xml b/android/app/src/main/res/drawable/ic_volume_off.xml new file mode 100644 index 000000000..a56690272 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_volume_off.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable/opennow_banner.png b/android/app/src/main/res/drawable/opennow_banner.png new file mode 100644 index 000000000..b86426566 Binary files /dev/null and b/android/app/src/main/res/drawable/opennow_banner.png differ diff --git a/android/app/src/main/res/drawable/opennow_icon.png b/android/app/src/main/res/drawable/opennow_icon.png new file mode 100644 index 000000000..005fbec75 Binary files /dev/null and b/android/app/src/main/res/drawable/opennow_icon.png differ diff --git a/android/app/src/main/res/drawable/opennow_logo_mark.png b/android/app/src/main/res/drawable/opennow_logo_mark.png new file mode 100644 index 000000000..adbdff2b7 Binary files /dev/null and b/android/app/src/main/res/drawable/opennow_logo_mark.png differ diff --git a/android/app/src/main/res/raw/nerd_queue_ready.mp3 b/android/app/src/main/res/raw/nerd_queue_ready.mp3 new file mode 100644 index 000000000..9637e553a Binary files /dev/null and b/android/app/src/main/res/raw/nerd_queue_ready.mp3 differ diff --git a/android/app/src/main/res/raw/nerd_stream_intro.mp3 b/android/app/src/main/res/raw/nerd_stream_intro.mp3 new file mode 100644 index 000000000..b559df0c0 Binary files /dev/null and b/android/app/src/main/res/raw/nerd_stream_intro.mp3 differ diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..69e3bfd52 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,139 @@ + + + OpenNOW + Starting OpenNOW + Sign in with %1$s + Sign in on another device with %1$s + Use this code to sign in + %1$s + Waiting for sign-in + Code expires in %1$d:%2$02d + Store + Search + Library + Settings + Search games + Search settings + Clear search + Voice search + %1$d games + No games loaded + No library games match + Clear search to show all games in your library. + Clear filters to show all games in your library. + Clear search or filters to show all games in your library. + No store games match + Clear search to show more games. + Clear filters to show more games. + Clear search or filters to show more games. + Jump back in + Coming next + Play + Continue + Resume + Save + Saved + Favorite + Unfavorite + Cancel + Clear filters + Back to top + Auto + Coming soon + Choose launcher + Launchers + Default + Selected + Available launcher + Don\'t ask me again - make this the default store + Proceeding with default store: %1$s + Tip: long press Play to choose a different store later. + Long press Play to choose a store + Stream + Interface + Advanced tools + Thanks + Resolution + Aspect ratio + Stream preset + Recommended + Custom + Low (data saver) + Medium + High + FPS + Bitrate Mbps + Codec + Color + HDR (Performance & Ultimate) + Region + Session proxy + Routes GFN session creation and queue polling through this proxy. Leave off for direct requests. + Proxy URL + Copy codec diagnostic + Copied codec diagnostic + Codec probe has not run yet. + Enable session proxy? + GFN session creation, queue polling, resume, stop, and queue ad update requests will be routed through the proxy you enter. + A bad or blocked proxy can break launch, queue progress, active session resume, or session cleanup. + Only use a proxy you trust. The proxy operator may be able to observe request timing, destination hosts, and sensitive session traffic metadata. + Enable proxy + L4S + Requests NVIDIA\'s low-latency, low-loss transport path when the server and network support it. Leave off if your network gets unstable. + Cloud G-Sync / VRR request + Asks the cloud session to use variable refresh timing when supported by your device, display, plan, and the GFN session. + Microphone + Sends your default Android microphone to the streamed game. You can mute it from Stream Controls. + Microphone permission was not granted. OpenNOW will keep microphone streaming off. + Use system colors + Accent + Launch page + Store + Library + Disable update checking + Advanced options + Shows experimental catalog and tuning options. The Advanced diagnostics tab stays available. + Expressive card styling + Uses brighter card surfaces and softer corners. Turn it off for a flatter, quieter Material style. + Catalog background + Shows a background image behind Store and Library on handheld screens. + Background image + Custom image + Built-in background + Colorful abstract (default) + Original OpenNOW + Choose image + Use default + Screen edge padding + Compact game cards + Show store labels + Game card size + Hide stream buttons + Show stats on launch + Stats overlay position + Hide server selector + Button press tones + Plays a short UI tone for controller navigation and on-screen control presses. + Play intro music + Intro music starts + Muted + Playing + Play music when queue finishes + Mute music + Stretch stream to fit + Smart session timer + Thanks to the people helping improve OpenNOW for everyone. + DarkevilPT + Community support + Donate + Donate link copied + OpenNOW + Pixel blue + Hot pink + Lime + Coral + Violet + Native streamer (Experimental) + Intercepts the hardware decoder to inject low-latency vendor properties. May be unstable. + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000..7b6e0b05c --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,14 @@ + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..3c611eb3f --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,13 @@ + + + + + + + + + + 127.0.0.1 + localhost + + diff --git a/android/app/src/main/res/xml/update_file_paths.xml b/android/app/src/main/res/xml/update_file_paths.xml new file mode 100644 index 000000000..6655b88f6 --- /dev/null +++ b/android/app/src/main/res/xml/update_file_paths.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/app/src/playBundle/AndroidManifest.xml b/android/app/src/playBundle/AndroidManifest.xml new file mode 100644 index 000000000..6d64f732c --- /dev/null +++ b/android/app/src/playBundle/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AndroidAuthRefreshTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidAuthRefreshTest.kt new file mode 100644 index 000000000..7767e8328 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidAuthRefreshTest.kt @@ -0,0 +1,84 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidAuthRefreshTest { + @Test + fun legacySessionTriesBothKnownAuthClients() { + assertEquals( + listOf("browser-client", "device-client"), + authenticationRefreshClientIds( + savedClientId = null, + browserClientId = "browser-client", + deviceClientId = "device-client", + ), + ) + } + + @Test + fun savedAuthClientIsTriedFirstWithoutDuplicates() { + assertEquals( + listOf("device-client", "browser-client"), + authenticationRefreshClientIds( + savedClientId = "device-client", + browserClientId = "browser-client", + deviceClientId = "device-client", + ), + ) + } + + @Test + fun freshAccessAndClientTokensDoNotNeedBackgroundRefresh() { + val now = 1_000_000L + val tokens = tokens( + expiresAt = now + TOKEN_REFRESH_WINDOW_MS + 1L, + clientTokenExpiresAt = now + CLIENT_TOKEN_REFRESH_WINDOW_MS + 1L, + ) + + assertFalse(tokens.needsBackgroundRefresh(now)) + } + + @Test + fun accessTokenInsideRefreshWindowNeedsBackgroundRefresh() { + val now = 1_000_000L + val tokens = tokens( + expiresAt = now + TOKEN_REFRESH_WINDOW_MS - 1L, + clientTokenExpiresAt = now + CLIENT_TOKEN_REFRESH_WINDOW_MS + 1L, + ) + + assertTrue(tokens.needsBackgroundRefresh(now)) + } + + @Test + fun missingOrExpiringClientTokenNeedsBackgroundRefresh() { + val now = 1_000_000L + val missingClientToken = tokens( + expiresAt = now + TOKEN_REFRESH_WINDOW_MS + 1L, + clientToken = null, + clientTokenExpiresAt = null, + ) + val expiringClientToken = tokens( + expiresAt = now + TOKEN_REFRESH_WINDOW_MS + 1L, + clientTokenExpiresAt = now + CLIENT_TOKEN_REFRESH_WINDOW_MS - 1L, + ) + + assertTrue(missingClientToken.needsBackgroundRefresh(now)) + assertTrue(expiringClientToken.needsBackgroundRefresh(now)) + } + + private fun tokens( + expiresAt: Long, + clientToken: String? = "client-token", + clientTokenExpiresAt: Long?, + ): AuthTokens = AuthTokens( + accessToken = "access-token", + refreshToken = "refresh-token", + idToken = "id-token", + expiresAt = expiresAt, + clientToken = clientToken, + clientTokenExpiresAt = clientTokenExpiresAt, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AndroidQueueAdsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidQueueAdsTest.kt new file mode 100644 index 000000000..f106c7702 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidQueueAdsTest.kt @@ -0,0 +1,305 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidQueueAdsTest { + @Test + fun mergeQueueSessionStatePreservesAdsWhenServerTemporarilyOmitsThem() { + val previous = session( + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ), + ) + val next = session( + queuePosition = 4, + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + serverSentEmptyAds = true, + ), + ) + + val merged = mergeQueueSessionState(previous, next) + + assertEquals(4, merged.queuePosition) + assertEquals(listOf("ad-1"), sessionAdItems(merged.adState).map { it.adId }) + } + + @Test + fun mergeQueueSessionStateDoesNotRestoreAdsForReadySession() { + val previous = session( + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ), + ) + val ready = session( + status = 2, + adState = SessionAdState( + isAdsRequired = false, + sessionAdsRequired = false, + serverSentEmptyAds = true, + ), + ) + + val merged = mergeQueueSessionState(previous, ready) + + assertEquals(2, merged.status) + assertEquals(emptyList(), sessionAdItems(merged.adState)) + } + + @Test + fun mergeQueueSessionStatePreservesAdsForReadyStatusWithoutStreamEndpoint() { + val previous = session( + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ), + ) + val partialReady = session( + status = 2, + serverIp = "", + signalingServer = "", + signalingUrl = "", + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + serverSentEmptyAds = true, + ), + ) + + val merged = mergeQueueSessionState(previous, partialReady) + + assertEquals(2, merged.status) + assertEquals(listOf("ad-1"), sessionAdItems(merged.adState).map { it.adId }) + } + + @Test + fun mergeQueueAdStateDoesNotRestoreAfterExplicitLocalClear() { + val previous = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ) + val next = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + serverSentEmptyAds = false, + ) + + val merged = mergeQueueAdState(previous, next) + + assertEquals(emptyList(), sessionAdItems(merged)) + } + + @Test + fun mergeQueueAdStateKeepsMissingAdStateStableDuringQueue() { + val previous = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ) + + val merged = mergeQueueAdState(previous, null) + + assertEquals(listOf("ad-1"), sessionAdItems(merged).map { it.adId }) + } + + @Test + fun mergeQueueAdStateCanClearMissingAdStateAfterFinishReport() { + val previous = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ) + + val merged = mergeQueueAdState(previous, null, preserveMissingAdState = false) + + assertNull(merged) + } + + @Test + fun mergeQueueAdStateDoesNotRestoreServerEmptyAdsAfterFinishReport() { + val previous = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ) + val next = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + serverSentEmptyAds = true, + ) + + val merged = mergeQueueAdState(previous, next, preserveMissingAdState = false) + + assertEquals(emptyList(), sessionAdItems(merged)) + } + + @Test + fun mergeQueueSessionStateDoesNotRestoreServerEmptyAdsAfterFinishReport() { + val previous = session( + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ), + ) + val next = session( + queuePosition = 5, + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + serverSentEmptyAds = true, + ), + ) + + val merged = mergeQueueSessionState(previous, next, preserveMissingAdState = false) + + assertEquals(5, merged.queuePosition) + assertEquals(emptyList(), sessionAdItems(merged.adState)) + } + + @Test + fun removeSessionAdItemDropsOnlyCompletedAd() { + val state = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1"), ad("ad-2")), + ads = listOf(ad("ad-1"), ad("ad-2")), + ) + + val updated = removeSessionAdItem(state, "ad-1") + + assertEquals(listOf("ad-2"), sessionAdItems(updated).map { it.adId }) + } + + @Test + fun mergeQueueSessionStatePreservesRemainingAdsAfterCompletedAdIsRemoved() { + val previous = session( + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1"), ad("ad-2")), + ads = listOf(ad("ad-1"), ad("ad-2")), + ), + ) + val locallyAdvanced = removeSessionAdItem(previous, "ad-1") + val serverOmittedAds = session( + queuePosition = 5, + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + serverSentEmptyAds = true, + ), + ) + + val merged = mergeQueueSessionState(locallyAdvanced, serverOmittedAds) + + assertEquals(5, merged.queuePosition) + assertEquals(listOf("ad-2"), sessionAdItems(merged.adState).map { it.adId }) + } + + @Test + fun mergeQueueSessionStateDoesNotRestoreLastCompletedAdAfterLocalRemoval() { + val previous = session( + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ), + ) + val locallyCompleted = removeSessionAdItem(previous, "ad-1") + val serverOmittedAds = session( + queuePosition = 5, + adState = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + serverSentEmptyAds = true, + ), + ) + + val merged = mergeQueueSessionState(locallyCompleted, serverOmittedAds) + + assertEquals(5, merged.queuePosition) + assertEquals(emptyList(), sessionAdItems(merged.adState)) + } + + @Test + fun nextSessionAdIdOnlyAdvancesToADifferentAd() { + val ads = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1"), ad("ad-2")), + ads = listOf(ad("ad-1"), ad("ad-2")), + ) + + assertEquals("ad-2", nextSessionAdId(ads, "ad-1")) + assertNull(nextSessionAdId(ads, "ad-2")) + assertNull(nextSessionAdId(ads.copy(sessionAds = listOf(ad("ad-1")), ads = emptyList()), "ad-1")) + assertEquals("ad-2", nextSessionAdId(ads.copy(sessionAds = listOf(ad("ad-2")), ads = emptyList()), "ad-1")) + } + + @Test + fun mergeQueueAdStateReturnsNullWhenNoPriorAdStateExists() { + assertNull(mergeQueueAdState(null, null)) + } + + @Test + fun shouldWaitForQueueAdPlaybackRequiresPlayableAdItem() { + val waiting = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + sessionAds = listOf(ad("ad-1")), + ads = listOf(ad("ad-1")), + ) + val emptyRequired = SessionAdState( + isAdsRequired = true, + sessionAdsRequired = true, + serverSentEmptyAds = true, + ) + + assertEquals(true, shouldWaitForQueueAdPlayback(waiting)) + assertEquals(false, shouldWaitForQueueAdPlayback(emptyRequired)) + } + + private fun ad(id: String): SessionAdInfo = + SessionAdInfo(adId = id, mediaUrl = "https://example.invalid/$id.mp4") + + private fun session( + status: Int = 1, + queuePosition: Int? = 7, + adState: SessionAdState? = null, + serverIp: String = "np.example.invalid", + signalingServer: String = "np.example.invalid:443", + signalingUrl: String = "wss://np.example.invalid/nvst/", + ): SessionInfo = + SessionInfo( + sessionId = "session-1", + status = status, + queuePosition = queuePosition, + adState = adState, + zone = "prod", + streamingBaseUrl = "https://np.example.invalid", + serverIp = serverIp, + signalingServer = signalingServer, + signalingUrl = signalingUrl, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AndroidStreamKeepAliveNotifierTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidStreamKeepAliveNotifierTest.kt new file mode 100644 index 000000000..448e5b2eb --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidStreamKeepAliveNotifierTest.kt @@ -0,0 +1,70 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidStreamKeepAliveNotifierTest { + @Test + fun keepsReadyStreamAlive() { + val state = OpenNowUiState( + page = AppPage.Stream, + streamStatus = "streaming", + streamSession = readySession(), + ) + + assertTrue(shouldKeepAndroidStreamAlive(state)) + } + + @Test + fun doesNotKeepQueueOrExitedStreamAlive() { + assertFalse( + shouldKeepAndroidStreamAlive( + OpenNowUiState(page = AppPage.Stream, streamStatus = "queueing"), + ), + ) + assertFalse( + shouldKeepAndroidStreamAlive( + OpenNowUiState(page = AppPage.Home, streamStatus = "streaming", streamSession = readySession()), + ), + ) + } + + @Test + fun capturesExactReadySessionForTaskRemovalCleanup() { + val session = readySession() + val settings = StreamSettings(resolution = "2560x1440", fps = 120) + val request = activeStreamShutdownRequest( + OpenNowUiState( + page = AppPage.Stream, + streamStatus = "streaming", + streamSession = session, + activeStreamSettings = settings, + ), + ) + + assertNotNull(request) + assertEquals(session, request?.session) + assertEquals(settings, request?.settings) + assertNull( + activeStreamShutdownRequest( + OpenNowUiState( + page = AppPage.Home, + streamStatus = "streaming", + streamSession = session, + ), + ), + ) + } + + private fun readySession(): SessionInfo = SessionInfo( + sessionId = "session-id", + status = 2, + serverIp = "example.invalid", + signalingServer = "example.invalid", + signalingUrl = "wss://example.invalid", + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AndroidStreamOrientationTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidStreamOrientationTest.kt new file mode 100644 index 000000000..ac99bea91 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidStreamOrientationTest.kt @@ -0,0 +1,81 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidStreamOrientationTest { + @Test + fun locksPhoneLandscapeOnlyAfterQueueCompletes() { + val readySession = readySession() + + assertFalse( + shouldLockPhoneStreamLandscape( + state = OpenNowUiState( + page = AppPage.Stream, + streamStatus = "queue", + streamSession = readySession, + ), + smallestScreenWidthDp = 390, + ), + ) + assertTrue( + shouldLockPhoneStreamLandscape( + state = OpenNowUiState( + page = AppPage.Stream, + streamStatus = "connecting", + streamSession = readySession, + ), + smallestScreenWidthDp = 390, + ), + ) + } + + @Test + fun keepsPhonesUnlockedUntilSessionIsStreamReady() { + assertFalse( + shouldLockPhoneStreamLandscape( + state = OpenNowUiState( + page = AppPage.Stream, + streamStatus = "connecting", + streamSession = readySession(status = 1), + ), + smallestScreenWidthDp = 390, + ), + ) + } + + @Test + fun doesNotLockTabletOrTvLandscape() { + val state = OpenNowUiState( + page = AppPage.Stream, + streamStatus = "streaming", + streamSession = readySession(), + ) + + assertFalse(shouldLockPhoneStreamLandscape(state, smallestScreenWidthDp = 600)) + assertFalse( + shouldLockPhoneStreamLandscape( + state = state.copy(codecReport = runtimeCodecReport(androidTvProfile = true)), + smallestScreenWidthDp = 390, + ), + ) + } + + private fun readySession(status: Int = 2): SessionInfo = + SessionInfo( + sessionId = "session", + status = status, + serverIp = "203.0.113.10", + signalingServer = "signal.example.com", + signalingUrl = "wss://signal.example.com", + ) + + private fun runtimeCodecReport(androidTvProfile: Boolean): RuntimeCodecReport = + RuntimeCodecReport( + capabilities = emptyList(), + nativeRuntimeSummary = "", + androidTvProfile = androidTvProfile, + lowPowerGpuProfile = false, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AndroidTvUiBehaviorTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidTvUiBehaviorTest.kt new file mode 100644 index 000000000..00d38b711 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AndroidTvUiBehaviorTest.kt @@ -0,0 +1,297 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidTvUiBehaviorTest { + @Test + fun gameDetailsPreferCleanShortDescription() { + val game = GameInfo( + id = "fortnite", + title = "Fortnite", + description = "Clean short description", + longDescription = "Provider long description", + ) + + assertEquals("Clean short description", gameDescriptionForDetails(game)) + } + + @Test + fun restoresTvNavigationFocusOnlyWhenLeavingStream() { + assertTrue( + shouldRestoreTvNavigationFocus( + previouslyInStream = true, + currentlyInStream = false, + tvProfile = true, + ), + ) + assertFalse( + shouldRestoreTvNavigationFocus( + previouslyInStream = true, + currentlyInStream = false, + tvProfile = false, + ), + ) + assertFalse( + shouldRestoreTvNavigationFocus( + previouslyInStream = false, + currentlyInStream = false, + tvProfile = true, + ), + ) + } + + @Test + fun catalogWallpaperIsOptInOnBothTvAndMobile() { + val defaults = AppSettings() + + assertFalse(shouldShowCatalogWallpaper(defaults)) + assertTrue(shouldShowCatalogWallpaper(defaults.copy(nerdCatalogBackground = true))) + } + + @Test + fun mobileCatalogCardsUseFullQualityGameBoxArtWithoutTitleOverlay() { + val gameBoxArt = "https://img.nvidiagrid.net/apps/123/ZZ/GAME_BOX_ART_01_example.jpg" + val game = GameInfo( + id = "game", + title = "Game", + imageUrl = gameBoxArt, + tvCardImageUrl = gameBoxArt, + ) + + assertEquals(gameBoxArt, catalogCardImageUrl(game, tvProfile = false)) + assertFalse(shouldOverlayCatalogCardTitle(tvProfile = false)) + assertTrue(shouldShowCatalogCardActions(tvProfile = false, controllerActionMode = false)) + } + + @Test + fun mobileCatalogCardsRejectStaleTvBannerCacheEntries() { + val game = GameInfo( + id = "game", + title = "Game", + imageUrl = "https://img.nvidiagrid.net/apps/123/ZZ/TV_BANNER_01_example.jpg", + ) + + assertNull(catalogCardImageUrl(game, tvProfile = false)) + } + + @Test + fun tvCatalogCardsKeepLowBandwidthArtworkAndTitleOverlay() { + val game = GameInfo( + id = "game", + title = "Game", + imageUrl = "https://img.nvidiagrid.net/apps/key-art.jpg", + tvCardImageUrl = "https://img.nvidiagrid.net/apps/box-art.jpg;f=webp;w=1920", + ) + + assertEquals( + "https://img.nvidiagrid.net/apps/box-art.jpg;f=webp;w=272", + catalogCardImageUrl(game, tvProfile = true), + ) + assertTrue(shouldOverlayCatalogCardTitle(tvProfile = true)) + assertFalse(shouldShowCatalogCardActions(tvProfile = true, controllerActionMode = false)) + } + + @Test + fun tvAndPhysicalControllerCardsUseEnhancedFocusFrame() { + assertTrue( + shouldShowEnhancedControllerFocus( + focused = true, + tvProfile = true, + controllerActionMode = false, + ), + ) + assertTrue( + shouldShowEnhancedControllerFocus( + focused = true, + tvProfile = false, + controllerActionMode = true, + ), + ) + assertFalse( + shouldShowEnhancedControllerFocus( + focused = true, + tvProfile = false, + controllerActionMode = false, + ), + ) + assertFalse( + shouldShowEnhancedControllerFocus( + focused = false, + tvProfile = true, + controllerActionMode = false, + ), + ) + } + + @Test + fun controllerFocusPulseExpandsAndFadesOnlyAtTheOuterEdge() { + assertEquals(4f, controllerFocusPulseStrokeWidthDp(0f), 0f) + assertEquals(13f, controllerFocusPulseStrokeWidthDp(1f), 0f) + assertTrue(controllerFocusPulseAlpha(0f) > controllerFocusPulseAlpha(0.5f)) + assertEquals(0f, controllerFocusPulseAlpha(1f), 0f) + } + + @Test + fun tvGameDetailsInitiallyFocusPlayWhileTouchLayoutsKeepArtworkFocus() { + assertTrue(shouldInitiallyFocusGameDetailsPlay(tvProfile = true)) + assertFalse(shouldInitiallyFocusGameDetailsPlay(tvProfile = false)) + } + + @Test + fun focusedGameDetailsPlayButtonGetsAnUnmistakableLiftAndEdge() { + assertEquals(1.06f, gameDetailsPlayFocusScale(focused = true), 0f) + assertEquals(1f, gameDetailsPlayFocusScale(focused = false), 0f) + assertEquals(4f, gameDetailsPlayFocusBorderWidthDp(focused = true), 0f) + assertEquals(0f, gameDetailsPlayFocusBorderWidthDp(focused = false), 0f) + } + + @Test + fun whiteButtonFocusTreatmentRequiresTvOrPhysicalController() { + assertTrue( + shouldShowControllerFocus( + focused = true, + tvProfile = true, + physicalControllerConnected = false, + ), + ) + assertTrue( + shouldShowControllerFocus( + focused = true, + tvProfile = false, + physicalControllerConnected = true, + ), + ) + assertFalse( + shouldShowControllerFocus( + focused = true, + tvProfile = false, + physicalControllerConnected = false, + ), + ) + assertFalse( + shouldShowControllerFocus( + focused = false, + tvProfile = true, + physicalControllerConnected = true, + ), + ) + } + + @Test + fun tvCatalogCardsNeverShowStoreLabels() { + assertFalse(shouldShowGameStoreLabels(tvProfile = true, enabled = true)) + assertTrue(shouldShowGameStoreLabels(tvProfile = false, enabled = true)) + assertFalse(shouldShowGameStoreLabels(tvProfile = false, enabled = false)) + } + + @Test + fun tvNeverShowsTouchControlsWhileMobileBehaviorIsPreserved() { + assertFalse( + shouldShowAndroidTouchControls( + tvProfile = true, + touchInputEnabled = true, + touchControlsEnabled = true, + suppressedByPhysicalController = false, + ), + ) + assertTrue( + shouldShowAndroidTouchControls( + tvProfile = false, + touchInputEnabled = true, + touchControlsEnabled = true, + suppressedByPhysicalController = false, + ), + ) + assertFalse( + shouldShowAndroidTouchControls( + tvProfile = false, + touchInputEnabled = true, + touchControlsEnabled = true, + suppressedByPhysicalController = true, + ), + ) + } + + @Test + fun tvUsesStableAudioBufferingWhileMobileKeepsLowLatencyAudio() { + assertFalse(shouldUseLowLatencyStreamAudio(androidTvProfile = true)) + assertTrue(shouldUseLowLatencyStreamAudio(androidTvProfile = false)) + } + + @Test + fun tvSafeAreaStartsInset() { + assertEquals(16f, AppSettings().tvSafeAreaPaddingDp, 0f) + } + + @Test + fun screenEdgePaddingOnlyAppliesToTvOutsideStream() { + val settings = AppSettings(tvSafeAreaPaddingDp = 20f) + + assertEquals(20f, appContentEdgePaddingDp(settings, inStream = false, tvProfile = true), 0f) + assertEquals(0f, appContentEdgePaddingDp(settings, inStream = true, tvProfile = true), 0f) + assertEquals(0f, appContentEdgePaddingDp(settings, inStream = false, tvProfile = false), 0f) + } + + @Test + fun gameCardScaleChangesGridAndStoreRailDensity() { + assertEquals(7, scaledGameCardColumnCount(baseColumns = 5, cardScale = 0.75f, minimumColumns = 3)) + assertEquals(5, scaledGameCardColumnCount(baseColumns = 5, cardScale = 1f, minimumColumns = 3)) + assertEquals(4, scaledGameCardColumnCount(baseColumns = 5, cardScale = 1.4f, minimumColumns = 3)) + + val smallCards = storeRailVisibleCardCount(900f, 146f, 10f, 0.75f) + val largeCards = storeRailVisibleCardCount(900f, 146f, 10f, 1.4f) + assertTrue(smallCards > largeCards) + } + + @Test + fun localTvRemoteRequiresExplicitOptIn() { + assertFalse(AppSettings().localTvRemoteEnabled) + } + + @Test + fun localTvConnectionDotNeverLeaksIntoMobileChrome() { + assertTrue(shouldShowLocalTvConnectionDot(tvProfile = true, pairedDeviceName = "Pixel")) + assertFalse(shouldShowLocalTvConnectionDot(tvProfile = false, pairedDeviceName = "Pixel")) + assertFalse(shouldShowLocalTvConnectionDot(tvProfile = true, pairedDeviceName = null)) + } + + @Test + fun tvSettingsNeverAddsASecondBackItemToTheRail() { + assertFalse( + shouldShowSettingsBackRail( + tvProfile = true, + settingsPageOpen = true, + horizontalChrome = true, + detailRouteOpen = true, + ), + ) + assertTrue( + shouldShowSettingsBackRail( + tvProfile = false, + settingsPageOpen = true, + horizontalChrome = true, + detailRouteOpen = true, + ), + ) + } + + @Test + fun batteryOptimizationIsHiddenOnlyWhenNoBatteryIsConfirmed() { + assertFalse(shouldShowBatteryOptimization(explicitBatteryPresent = false)) + assertTrue(shouldShowBatteryOptimization(explicitBatteryPresent = true)) + assertTrue(shouldShowBatteryOptimization(explicitBatteryPresent = null)) + } + + @Test + fun compactTvPairingHidesMainLoginContentOnlyWhenNeeded() { + assertTrue(shouldUseDedicatedTvPairingLayout(true, true, 640f, 360f)) + assertTrue(shouldUseDedicatedTvPairingLayout(true, true, 960f, 480f)) + assertFalse(shouldUseDedicatedTvPairingLayout(true, true, 960f, 540f)) + assertFalse(shouldUseDedicatedTvPairingLayout(false, true, 400f, 720f)) + assertFalse(shouldUseDedicatedTvPairingLayout(true, false, 640f, 360f)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AppSettingsDefaultsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AppSettingsDefaultsTest.kt new file mode 100644 index 000000000..c37698951 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AppSettingsDefaultsTest.kt @@ -0,0 +1,84 @@ +package com.opencloudgaming.opennow + +import kotlinx.serialization.decodeFromString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppSettingsDefaultsTest { + @Test + fun streamStatusBarDefaultsToConnectionEssentials() { + val metrics = AppSettings().streamStatsMetrics + + assertFalse(metrics.fps) + assertTrue(metrics.ping) + assertFalse(metrics.bitrate) + assertTrue(metrics.battery) + assertTrue(metrics.connection) + assertFalse(metrics.resolution) + assertFalse(metrics.codec) + assertTrue(metrics.location) + assertTrue(metrics.latency) + assertTrue(metrics.packetLoss) + assertEquals(6, metrics.enabledCount()) + } + + @Test + fun olderSavedSettingsReceiveStatusBarDefaults() { + val settings = OpenNowJson.decodeFromString("{}") + + assertEquals(StreamStatsMetrics(), settings.streamStatsMetrics) + assertEquals(CatalogBackgroundPreset.ColorfulAbstract, settings.catalogBackgroundPreset) + assertFalse(settings.analyticsConsentAsked) + assertTrue(settings.analyticsOptOut) + assertFalse(settings.analyticsSharingEnabled) + assertEquals(TouchJoystickMode.Fixed, settings.androidTouch.joystickMode) + assertEquals(0f, settings.androidTouch.joystickDeadZone, 0.0001f) + } + + @Test + fun defaultsUseRecommendedProfileAndKeepOptionalMusicOff() { + val settings = AppSettings() + + assertFalse(settings.nerdMode) + assertEquals(CatalogBackgroundPreset.ColorfulAbstract, settings.catalogBackgroundPreset) + assertTrue(settings.controllerUiSounds) + assertEquals(AppLaunchPage.Store, settings.launchPage) + assertEquals(StreamPreset.Recommended, settings.streamPreset) + assertFalse(settings.streamIntroMusic) + assertEquals(IntroMusicStartMode.Muted, settings.streamIntroStartMode) + assertFalse(settings.queueReadyMusic) + assertFalse(settings.analyticsConsentAsked) + assertTrue(settings.analyticsOptOut) + assertFalse(settings.analyticsSharingEnabled) + } + + @Test + fun phonePresentationDefaultsToNoCropStretchAndPreservesLaterOptOut() { + val migrated = AppSettings().withCurrentStreamPresentationDefaults(androidTvProfile = false) + + assertFalse(migrated.legacyCropStreamToFill) + assertTrue(migrated.stretchStreamToFit) + assertEquals(STREAM_PRESENTATION_PROFILE_VERSION, migrated.streamPresentationProfileVersion) + + val optedOut = migrated.copy(stretchStreamToFit = false) + assertEquals(optedOut, optedOut.withCurrentStreamPresentationDefaults(androidTvProfile = false)) + } + + @Test + fun tvPresentationKeepsAspectFitByDefault() { + val migrated = AppSettings().withCurrentStreamPresentationDefaults(androidTvProfile = true) + + assertFalse(migrated.legacyCropStreamToFill) + assertFalse(migrated.stretchStreamToFit) + } + + @Test + fun legacyAnalyticsPreferenceDoesNotOptInWithoutConsent() { + val settings = OpenNowJson.decodeFromString("""{"analyticsOptOut":false}""") + + assertFalse(settings.analyticsConsentAsked) + assertFalse(settings.analyticsSharingEnabled) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/AppUpdateTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/AppUpdateTest.kt new file mode 100644 index 000000000..c91ff2f3d --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/AppUpdateTest.kt @@ -0,0 +1,252 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppUpdateTest { + @Test + fun parsesManifestWithRelativeApkUrl() { + val candidate = parseAndroidUpdateCandidate( + "https://updates.example.com/android/opennow.json", + """ + { + "versionCode": 7, + "versionName": "0.5.2", + "apkUrl": "OpenNOW-0.5.2.apk", + "sha256": "SHA256: AA BB CC", + "releaseNotes": "Native Android update" + } + """.trimIndent(), + ) + + assertEquals("https://updates.example.com/android/opennow.json", candidate?.sourceUrl) + assertEquals("https://updates.example.com/android/OpenNOW-0.5.2.apk", candidate?.apkUrl) + assertEquals("0.5.2", candidate?.versionName) + assertEquals(7L, candidate?.versionCode) + assertEquals("aabbcc", candidate?.sha256) + assertEquals("Native Android update", candidate?.releaseNotes) + } + + @Test + fun parsesNestedAndroidManifest() { + val candidate = parseAndroidUpdateCandidate( + "https://updates.example.com/releases/latest.json", + """ + { + "android": { + "version_code": 8, + "version_name": "0.5.3", + "download_url": "https://cdn.example.com/OpenNOW-0.5.3.apk", + "release_notes": "Fix haptics\\nImprove updater\\r\\nClean up stream UI" + } + } + """.trimIndent(), + ) + + assertEquals("https://cdn.example.com/OpenNOW-0.5.3.apk", candidate?.apkUrl) + assertEquals("0.5.3", candidate?.versionName) + assertEquals(8L, candidate?.versionCode) + assertEquals("Fix haptics\nImprove updater\nClean up stream UI", candidate?.releaseNotes) + } + + @Test + fun parsesGithubReleaseApkAsset() { + val candidate = parseAndroidUpdateCandidate( + "https://api.github.com/repos/OpenCloudGaming/OpenNOW/releases/latest", + """ + { + "tag_name": "v0.5.4", + "body": "Release notes", + "assets": [ + { + "name": "OpenNOW-desktop.zip", + "browser_download_url": "https://github.com/example/desktop.zip" + }, + { + "name": "OpenNOW-android.apk", + "browser_download_url": "https://github.com/example/OpenNOW-android.apk", + "digest": "sha256:012345" + } + ] + } + """.trimIndent(), + ) + + assertEquals("https://github.com/example/OpenNOW-android.apk", candidate?.apkUrl) + assertEquals("0.5.4", candidate?.versionName) + assertEquals("012345", candidate?.sha256) + assertEquals("Release notes", candidate?.releaseNotes) + } + + @Test + fun parsesPrintedWasteManifestShape() { + val candidate = parseAndroidUpdateCandidate( + ANDROID_UPDATE_SOURCE_URL, + """ + { + "id": "5b000fc7-4f4c-464d-862a-ce9409c61081", + "appSlug": "opennow", + "appName": "OpenNOW", + "platform": "android", + "channel": "stable", + "versionCode": 6, + "versionName": "0.5.1", + "artifactUrl": "https://api.printedwaste.com/release-files/opennow/app-release.apk", + "url": "https://api.printedwaste.com/release-files/opennow/app-release.apk", + "sha256": "8bfd318dad6b2590e23d39237d02fb7bfec9fde1d09b6f08aff368ba5e9b073c", + "releaseNotes": "- Autoupdating\n- Filter fixies\n- Optimizations", + "mandatory": false, + "draft": false, + "apkUrl": "https://api.printedwaste.com/release-files/opennow/app-release.apk" + } + """.trimIndent(), + ) + + assertEquals(ANDROID_UPDATE_SOURCE_URL, candidate?.sourceUrl) + assertEquals("https://api.printedwaste.com/release-files/opennow/app-release.apk", candidate?.apkUrl) + assertEquals("0.5.1", candidate?.versionName) + assertEquals(6L, candidate?.versionCode) + assertEquals("8bfd318dad6b2590e23d39237d02fb7bfec9fde1d09b6f08aff368ba5e9b073c", candidate?.sha256) + assertEquals("- Autoupdating\n- Filter fixies\n- Optimizations", candidate?.releaseNotes) + } + + @Test + fun rejectsManifestWithoutApkUrl() { + val candidate = parseAndroidUpdateCandidate( + "https://updates.example.com/opennow.json", + """{"versionCode": 7, "versionName": "0.5.2"}""", + ) + + assertNull(candidate) + } + + @Test + fun normalizesHttpsSourceWithoutScheme() { + assertEquals("https://updates.example.com/opennow.json", normalizeAndroidUpdateSourceUrl("updates.example.com/opennow.json")) + } + + @Test(expected = IllegalStateException::class) + fun rejectsNonLoopbackHttpSource() { + normalizeAndroidUpdateSourceUrl("http://updates.example.com/opennow.json") + } + + @Test + fun updateChecksAreBlockedAcrossLocalStreamLifecycle() { + assertFalse(OpenNowUiState().isAndroidUpdateCheckBlockedByStream()) + assertTrue(OpenNowUiState(streamStatus = "queue").isAndroidUpdateCheckBlockedByStream()) + assertTrue(OpenNowUiState(streamStatus = "connecting").isAndroidUpdateCheckBlockedByStream()) + assertTrue(OpenNowUiState(streamStatus = "streaming").isAndroidUpdateCheckBlockedByStream()) + assertTrue( + OpenNowUiState( + streamStatus = "idle", + activeStreamSettings = StreamSettings(), + ).isAndroidUpdateCheckBlockedByStream(), + ) + } + + @Test + fun updateNoticeKeyIsStableAcrossAvailableAndDownloadedStates() { + val available = AndroidUpdateState( + status = AndroidUpdateStatus.Available, + availableVersionName = "0.5.4", + availableVersionCode = 9, + ) + val downloaded = available.copy(status = AndroidUpdateStatus.Downloaded) + + assertEquals(androidUpdateNoticeKey(available), androidUpdateNoticeKey(downloaded)) + assertEquals(null, available.visibleNoticeKey(androidUpdateNoticeKey(downloaded))) + assertEquals(androidUpdateNoticeKey(available), available.visibleNoticeKey(null)) + } + + @Test + fun googlePlayInstallSourceDisablesApkUpdater() { + val update = AndroidUpdateState( + status = AndroidUpdateStatus.Available, + installSource = AndroidAppInstallSource(setOf(GOOGLE_PLAY_STORE_PACKAGE)), + availableVersionName = "0.6.7", + availableVersionCode = 22, + ) + + assertTrue(update.installSource.isGooglePlay) + assertFalse(update.apkUpdatesAllowed) + assertFalse(update.canCheck) + assertFalse(update.canDownload) + assertFalse(update.canInstall) + assertFalse(update.shouldRunAutomaticCheck()) + assertNull(androidUpdateNoticeKey(update)) + } + + @Test + fun sideloadInstallSourceAllowsApkUpdaterWhenBuildSupportsIt() { + val update = AndroidUpdateState( + status = AndroidUpdateStatus.Available, + installSource = AndroidAppInstallSource(installerPackageNames = emptySet(), apkUpdatesSupportedByBuild = true), + availableVersionName = "0.6.7", + availableVersionCode = 22, + ) + + assertFalse(update.installSource.isGooglePlay) + assertTrue(update.apkUpdatesAllowed) + assertTrue(update.canCheck) + assertTrue(update.canDownload) + assertEquals("Sideloaded", update.installSource.displayName) + assertEquals("code:22|name:0.6.7", androidUpdateNoticeKey(update)) + } + + @Test + fun playReleaseBuildDisablesApkUpdaterEvenWhenSideloaded() { + val update = AndroidUpdateState( + status = AndroidUpdateStatus.Available, + installSource = AndroidAppInstallSource(installerPackageNames = emptySet(), apkUpdatesSupportedByBuild = false), + availableVersionName = "0.6.7", + availableVersionCode = 22, + ) + + assertFalse(update.installSource.isGooglePlay) + assertFalse(update.apkUpdatesAllowed) + assertFalse(update.canDownload) + assertFalse(update.shouldRunAutomaticCheck()) + assertEquals("APK self-updates are disabled in this Play release.", androidUpdateUnavailableMessage(update.installSource)) + } + + @Test + fun googlePlayInstallerPackageMatchingIsStable() { + assertTrue(isGooglePlayInstallerPackage(" com.android.vending ")) + assertTrue(isGooglePlayInstallerPackage("COM.ANDROID.VENDING")) + assertFalse(isGooglePlayInstallerPackage("com.android.packageinstaller")) + assertFalse(isGooglePlayInstallerPackage(null)) + } + + @Test + fun installSourceLabelsPlayStoreAndApkDistribution() { + val play = AndroidAppInstallSource(setOf(GOOGLE_PLAY_STORE_PACKAGE)) + val sideload = AndroidAppInstallSource(installerPackageNames = emptySet()) + val packageInstaller = AndroidAppInstallSource(setOf("com.google.android.packageinstaller")) + + assertEquals("play-store", play.distributionKind) + assertEquals("apk", sideload.distributionKind) + assertEquals("apk", packageInstaller.distributionKind) + assertEquals("Google Play", play.displayName) + assertEquals("Sideloaded", sideload.displayName) + } + + @Test + fun debugHeaderIncludesBuildAndDistribution() { + val update = AndroidUpdateState( + currentVersionName = "0.7.5", + currentVersionCode = 30, + installSource = AndroidAppInstallSource( + installerPackageNames = setOf(GOOGLE_PLAY_STORE_PACKAGE), + apkUpdatesSupportedByBuild = false, + ), + ) + + assertEquals( + "app.version=0.7.5 build=30 variant=release distribution=play-store installSource=Google Play buildDistribution=play-release apkUpdatesAllowed=false", + update.debugHeaderLine(debugBuild = false), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/BugReportsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/BugReportsTest.kt new file mode 100644 index 000000000..d12388f3a --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/BugReportsTest.kt @@ -0,0 +1,90 @@ +package com.opencloudgaming.opennow + +import okio.Buffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BugReportsTest { + @Test + fun buildsPrintedWasteMultipartReportWithRedactedLogAttachment() { + val request = buildAndroidBugReportRequest( + AndroidBugReport( + title = " Stream froze ", + description = " Video stopped after reconnecting. ", + versionName = "0.9.0", + versionCode = "45", + metadata = """{"device":"Pixel 9","sessionId":"[redacted]"}""", + files = listOf( + AndroidBugReportAttachment( + fileName = "opennow.log", + contentType = "text/plain; charset=utf-8", + bytes = "sessionId=[redacted]".toByteArray(), + ), + ), + ), + ) + + val buffer = Buffer() + requireNotNull(request.body).writeTo(buffer) + val multipart = buffer.readUtf8() + + assertEquals(ANDROID_BUG_REPORT_ENDPOINT, request.url.toString()) + assertEquals("POST", request.method) + assertTrue(multipart.contains("name=\"title\"\r\n\r\nStream froze")) + assertTrue(multipart.contains("name=\"description\"\r\n\r\nVideo stopped after reconnecting.")) + assertTrue(multipart.contains("name=\"versionName\"\r\n\r\n0.9.0")) + assertTrue(multipart.contains("name=\"versionCode\"\r\n\r\n45")) + assertTrue(multipart.contains("name=\"platform\"\r\n\r\nandroid")) + assertTrue(multipart.contains("name=\"files\"; filename=\"opennow.log\"")) + assertTrue(multipart.contains("sessionId=[redacted]")) + } + + @Test + fun metadataOnlyIdentifiesTheAdvancedDebugLogExport() { + val fileName = "opennow-android-logs-20260718-123456.txt" + val metadata = buildAndroidBugReportMetadata(fileName) + + assertTrue(metadata.contains("\"source\":\"settings-advanced-debug-logs\"")) + assertTrue(metadata.contains("\"attachment\":\"$fileName\"")) + assertFalse(metadata.contains("device")) + assertFalse(metadata.contains("sessionId")) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsMoreThanFiveFiles() { + val files = (1..6).map { index -> + AndroidBugReportAttachment("$index.log", "text/plain", byteArrayOf()) + } + buildAndroidBugReportRequest( + AndroidBugReport("Title", "Description", "0.9.0", "45", "{}", files), + ) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsFilesLargerThanTenMib() { + buildAndroidBugReportRequest( + AndroidBugReport( + "Title", + "Description", + "0.9.0", + "45", + "{}", + listOf( + AndroidBugReportAttachment( + "too-large.log", + "text/plain", + ByteArray(ANDROID_BUG_REPORT_MAX_FILE_BYTES.toInt() + 1), + ), + ), + ), + ) + } + + @Test + fun parsesServerReferenceWhenPresent() { + assertEquals("report-123", parseAndroidBugReportReference("""{"id":"report-123"}""")) + assertEquals(null, parseAndroidBugReportReference("""{"ok":true}""")) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/CatalogBackgroundTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/CatalogBackgroundTest.kt new file mode 100644 index 000000000..a7adf505c --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/CatalogBackgroundTest.kt @@ -0,0 +1,68 @@ +package com.opencloudgaming.opennow + +import java.io.File +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CatalogBackgroundTest { + @Test + fun `blank custom source selects the chosen built in background`() { + assertEquals( + CatalogWallpaperSelection.BuiltIn(CatalogBackgroundPreset.ColorfulAbstract), + catalogWallpaperSelection(CatalogBackgroundPreset.ColorfulAbstract, null), + ) + assertEquals( + CatalogWallpaperSelection.BuiltIn(CatalogBackgroundPreset.Original), + catalogWallpaperSelection(CatalogBackgroundPreset.Original, " "), + ) + } + + @Test + fun `custom source replaces the bundled default`() { + assertEquals( + CatalogWallpaperSelection.Custom("file:///data/user/0/opennow/files/custom"), + catalogWallpaperSelection( + CatalogBackgroundPreset.ColorfulAbstract, + " file:///data/user/0/opennow/files/custom ", + ), + ) + } + + @Test + fun `managed background cleanup cannot escape app files directory`() { + val filesDir = Files.createTempDirectory("catalog-background-test").toFile() + val outsideDir = Files.createTempDirectory("catalog-background-outside").toFile() + try { + assertTrue( + isManagedCatalogBackgroundImageFile( + filesDir, + File(filesDir, CATALOG_BACKGROUND_IMAGE_FILE_PREFIX), + ), + ) + assertTrue( + isManagedCatalogBackgroundImageFile( + filesDir, + File(filesDir, "$CATALOG_BACKGROUND_IMAGE_FILE_PREFIX-unique"), + ), + ) + assertFalse( + isManagedCatalogBackgroundImageFile( + filesDir, + File(filesDir, "unrelated-image"), + ), + ) + assertFalse( + isManagedCatalogBackgroundImageFile( + filesDir, + File(outsideDir, "$CATALOG_BACKGROUND_IMAGE_FILE_PREFIX-unique"), + ), + ) + } finally { + filesDir.deleteRecursively() + outsideDir.deleteRecursively() + } + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/CellularNetworkStatusTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/CellularNetworkStatusTest.kt new file mode 100644 index 000000000..8f2e20e89 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/CellularNetworkStatusTest.kt @@ -0,0 +1,24 @@ +package com.opencloudgaming.opennow + +import android.telephony.TelephonyDisplayInfo +import android.telephony.TelephonyManager +import org.junit.Assert.assertEquals +import org.junit.Test + +class CellularNetworkStatusTest { + @Test + fun carrierOverridesUseDisplayGeneration() { + assertEquals("5G+", cellularGenerationLabel(TelephonyManager.NETWORK_TYPE_LTE, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_ADVANCED)) + assertEquals("5G", cellularGenerationLabel(TelephonyManager.NETWORK_TYPE_LTE, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NR_NSA)) + assertEquals("LTE+", cellularGenerationLabel(TelephonyManager.NETWORK_TYPE_LTE, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_LTE_CA)) + } + + @Test + fun baseRadioTypesUseCompactLabels() { + assertEquals("5G", cellularGenerationLabel(TelephonyManager.NETWORK_TYPE_NR, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE)) + assertEquals("LTE", cellularGenerationLabel(TelephonyManager.NETWORK_TYPE_LTE, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE)) + assertEquals("H+", cellularGenerationLabel(TelephonyManager.NETWORK_TYPE_HSPAP, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE)) + assertEquals("3G", cellularGenerationLabel(TelephonyManager.NETWORK_TYPE_UMTS, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE)) + assertEquals("E", cellularGenerationLabel(TelephonyManager.NETWORK_TYPE_EDGE, TelephonyDisplayInfo.OVERRIDE_NETWORK_TYPE_NONE)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/DeviceLoginLayoutTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/DeviceLoginLayoutTest.kt new file mode 100644 index 000000000..c5185106b --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/DeviceLoginLayoutTest.kt @@ -0,0 +1,52 @@ +package com.opencloudgaming.opennow + +import android.content.res.Configuration +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DeviceLoginLayoutTest { + @Test + fun usesSideBySideLayoutWhenHandheldIsLandscape() { + assertTrue( + shouldUseSideBySideDeviceLoginLayout( + orientation = Configuration.ORIENTATION_LANDSCAPE, + preferLandscapeLayout = false, + availableWidthDp = 640, + ), + ) + } + + @Test + fun keepsPortraitDeviceLoginStacked() { + assertFalse( + shouldUseSideBySideDeviceLoginLayout( + orientation = Configuration.ORIENTATION_PORTRAIT, + preferLandscapeLayout = false, + availableWidthDp = 640, + ), + ) + } + + @Test + fun keepsCrampedLandscapeDeviceLoginStacked() { + assertFalse( + shouldUseSideBySideDeviceLoginLayout( + orientation = Configuration.ORIENTATION_LANDSCAPE, + preferLandscapeLayout = false, + availableWidthDp = 480, + ), + ) + } + + @Test + fun honorsExplicitLandscapePreference() { + assertTrue( + shouldUseSideBySideDeviceLoginLayout( + orientation = Configuration.ORIENTATION_PORTRAIT, + preferLandscapeLayout = true, + availableWidthDp = 320, + ), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/DiagnosticsSanitizationTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/DiagnosticsSanitizationTest.kt new file mode 100644 index 000000000..4b4da85b0 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/DiagnosticsSanitizationTest.kt @@ -0,0 +1,33 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DiagnosticsSanitizationTest { + @Test + fun exportRemovesNamesTokensIdsAndNetworkAddresses() { + val raw = """ + user=Jane Example tier=FREE provider=NVIDIA + sessionId=01234567-89ab-4cde-8fab-0123456789ab sessionStatus=READY serverIp=192.168.10.42 + Authorization: Bearer secret.jwt.value + {"displayName":"Jane Example","email":"jane@example.com","deviceId":"device-secret"} + ipv6=2001:db8::1234 + """.trimIndent() + + val sanitized = sanitizeDiagnosticExport(raw) + + listOf( + "Jane Example", + "jane@example.com", + "secret.jwt.value", + "01234567-89ab-4cde-8fab-0123456789ab", + "192.168.10.42", + "2001:db8::1234", + "device-secret", + ).forEach { sensitive -> assertFalse("Leaked $sensitive in $sanitized", sanitized.contains(sensitive)) } + assertTrue(sanitized.contains("tier=FREE")) + assertTrue(sanitized.contains("provider=NVIDIA")) + assertTrue(sanitized.contains("[redacted]")) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/DisplayRefreshRateTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/DisplayRefreshRateTest.kt new file mode 100644 index 000000000..443bcb08c --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/DisplayRefreshRateTest.kt @@ -0,0 +1,122 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DisplayRefreshRateTest { + @Test + fun choosesSmallestModeAtOrAboveRequestedFps() { + val selected = selectStreamDisplayMode( + supportedModes = listOf( + mode(id = 1, refreshRate = 72f), + mode(id = 2, refreshRate = 90f), + mode(id = 3, refreshRate = 120f), + ), + currentMode = mode(id = 1, refreshRate = 72f), + requestedFps = 90, + ) + + assertEquals(2, selected?.id) + } + + @Test + fun keepsCurrentHighRefreshModeWhenItAlreadyCoversStreamFps() { + val selected = selectStreamDisplayMode( + supportedModes = listOf( + mode(id = 1, refreshRate = 60f), + mode(id = 2, refreshRate = 120f), + ), + currentMode = mode(id = 2, refreshRate = 120f), + requestedFps = 60, + ) + + assertEquals(2, selected?.id) + } + + @Test + fun rejectsCadenceIncompatibleCurrentHighRefreshMode() { + val selected = selectStreamDisplayMode( + supportedModes = listOf( + mode(id = 1, refreshRate = 60f), + mode(id = 2, refreshRate = 90f), + mode(id = 3, refreshRate = 120f), + ), + currentMode = mode(id = 2, refreshRate = 90f), + requestedFps = 60, + ) + + assertEquals(1, selected?.id) + } + + @Test + fun prefersCadenceCompatibleModeOverCloserIncompatibleMode() { + val selected = selectStreamDisplayMode( + supportedModes = listOf( + mode(id = 1, refreshRate = 90f), + mode(id = 2, refreshRate = 120f), + mode(id = 3, refreshRate = 144f), + ), + currentMode = mode(id = 3, refreshRate = 144f), + requestedFps = 60, + ) + + assertEquals(2, selected?.id) + } + + @Test + fun usesHighestModeWhenRequestedFpsExceedsDisplaySupport() { + val selected = selectStreamDisplayMode( + supportedModes = listOf( + mode(id = 1, refreshRate = 60f), + mode(id = 2, refreshRate = 90f), + ), + currentMode = mode(id = 1, refreshRate = 60f), + requestedFps = 120, + ) + + assertEquals(2, selected?.id) + } + + @Test + fun prefersCurrentPhysicalResolutionWhenModesIncludeMultipleSizes() { + val selected = selectStreamDisplayMode( + supportedModes = listOf( + mode(id = 1, refreshRate = 90f, physicalWidth = 1920, physicalHeight = 1080), + mode(id = 2, refreshRate = 90f, physicalWidth = 2560, physicalHeight = 1440), + mode(id = 3, refreshRate = 120f, physicalWidth = 2560, physicalHeight = 1440), + ), + currentMode = mode(id = 4, refreshRate = 60f, physicalWidth = 2560, physicalHeight = 1440), + requestedFps = 90, + ) + + assertEquals(2, selected?.id) + } + + @Test + fun toleratesFractionalDisplayRatesNearRequestedFps() { + val selected = selectStreamDisplayMode( + supportedModes = listOf( + mode(id = 1, refreshRate = 72f), + mode(id = 2, refreshRate = 89.98f), + mode(id = 3, refreshRate = 119.88f), + ), + currentMode = mode(id = 1, refreshRate = 72f), + requestedFps = 90, + ) + + assertEquals(2, selected?.id) + } + + private fun mode( + id: Int, + refreshRate: Float, + physicalWidth: Int = 1920, + physicalHeight: Int = 1080, + ): DisplayRefreshMode = + DisplayRefreshMode( + id = id, + refreshRate = refreshRate, + physicalWidth = physicalWidth, + physicalHeight = physicalHeight, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/ExternalLaunchIntentTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/ExternalLaunchIntentTest.kt new file mode 100644 index 000000000..05d60b82b --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/ExternalLaunchIntentTest.kt @@ -0,0 +1,77 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ExternalLaunchIntentTest { + @Test + fun extractsLaunchIdFromCustomSchemePath() { + val id = externalLaunchIdFromParts( + extras = emptyList(), + scheme = "opennow", + host = "launch", + pathSegments = listOf("100362311"), + schemeSpecificPart = "//launch/100362311", + queryParameters = emptyMap(), + ) + + assertEquals("100362311", id) + } + + @Test + fun extractsLaunchIdFromOpaqueCustomScheme() { + val id = externalLaunchIdFromParts( + extras = emptyList(), + scheme = "opennow", + host = null, + pathSegments = emptyList(), + schemeSpecificPart = "launch/100362311", + queryParameters = emptyMap(), + ) + + assertEquals("100362311", id) + } + + @Test + fun queryParameterBeatsPathFallback() { + val id = externalLaunchIdFromParts( + extras = emptyList(), + scheme = "opennow", + host = "launch", + pathSegments = listOf("wrong"), + schemeSpecificPart = "//launch/wrong", + queryParameters = mapOf("appId" to "100362311"), + ) + + assertEquals("100362311", id) + } + + @Test + fun intentExtraBeatsUri() { + val id = externalLaunchIdFromParts( + extras = listOf("100362311"), + scheme = "opennow", + host = "launch", + pathSegments = listOf("wrong"), + schemeSpecificPart = "//launch/wrong", + queryParameters = emptyMap(), + ) + + assertEquals("100362311", id) + } + + @Test + fun ignoresNonOpenNowUriWithoutExtra() { + val id = externalLaunchIdFromParts( + extras = emptyList(), + scheme = "https", + host = "example.com", + pathSegments = listOf("launch", "100362311"), + schemeSpecificPart = "//example.com/launch/100362311", + queryParameters = mapOf("appId" to "100362311"), + ) + + assertNull(id) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/FirstVideoFrameWatchdogTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/FirstVideoFrameWatchdogTest.kt new file mode 100644 index 000000000..3c1842895 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/FirstVideoFrameWatchdogTest.kt @@ -0,0 +1,34 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FirstVideoFrameWatchdogTest { + @Test + fun recoversWhenPacketsArriveWithoutRenderedFrame() { + val watchdog = FirstVideoFrameWatchdog(timeoutMs = 8_000L) + + assertFalse(watchdog.shouldRecover(1_000L, bytesReceived = 1L, connected = true)) + assertFalse(watchdog.shouldRecover(8_999L, bytesReceived = 10_000L, connected = true)) + assertTrue(watchdog.shouldRecover(9_000L, bytesReceived = 20_000L, connected = true)) + } + + @Test + fun renderedFrameDisarmsRecovery() { + val watchdog = FirstVideoFrameWatchdog(timeoutMs = 1_000L) + + assertFalse(watchdog.shouldRecover(100L, bytesReceived = 1L, connected = true)) + watchdog.markRendered() + assertFalse(watchdog.shouldRecover(5_000L, bytesReceived = 50_000L, connected = true)) + } + + @Test + fun disconnectResetsPendingTimeout() { + val watchdog = FirstVideoFrameWatchdog(timeoutMs = 1_000L) + + assertFalse(watchdog.shouldRecover(100L, bytesReceived = 1L, connected = true)) + assertFalse(watchdog.shouldRecover(2_000L, bytesReceived = 1L, connected = false)) + assertFalse(watchdog.shouldRecover(2_100L, bytesReceived = 2L, connected = true)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/GfnApiTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/GfnApiTest.kt new file mode 100644 index 000000000..f0e82d6a0 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/GfnApiTest.kt @@ -0,0 +1,639 @@ +package com.opencloudgaming.opennow + +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GfnApiTest { + @Test + fun catalogArtworkUsesGameBoxArtForMobileAndTvCards() { + val artwork = catalogCardArtwork( + keyArt = "key-art", + gameBoxArt = "game-box-art", + heroImage = "hero-image", + tvBanner = "tv-banner", + ) + + assertEquals("game-box-art", artwork.mobileImageUrl) + assertEquals("game-box-art", artwork.tvImageUrl) + } + + @Test + fun catalogArtworkDoesNotFallBackToLandscapeArtOnMobile() { + val artwork = catalogCardArtwork( + keyArt = "key-art", + gameBoxArt = null, + heroImage = "hero-image", + tvBanner = "tv-banner", + ) + + assertNull(artwork.mobileImageUrl) + assertEquals("key-art", artwork.tvImageUrl) + } + + @Test + fun catalogScreenshotsPreserveDistinctNonBlankImages() { + val images = buildJsonObject { + putJsonArray("SCREENSHOTS") { + add(JsonPrimitive(" screenshot-one ")) + add(JsonPrimitive("")) + add(JsonPrimitive("screenshot-two")) + add(JsonPrimitive("screenshot-one")) + } + } + + assertEquals( + listOf("screenshot-one", "screenshot-two"), + catalogScreenshotUrls(images), + ) + } + + @Test + fun catalogDescriptionSupportsBrowseAndMetadataFieldNames() { + val browseApp = buildJsonObject { + put("shortDescription", JsonPrimitive("Browse description")) + } + val metadataApp = buildJsonObject { + put("description", JsonPrimitive("Metadata description")) + put("shortDescription", JsonPrimitive("Fallback description")) + } + + assertEquals("Browse description", catalogGameDescription(browseApp)) + assertEquals("Metadata description", catalogGameDescription(metadataApp)) + } + + @Test + fun canonicalizesOldGamesGraphQlHost() { + val url = canonicalizeGfnRequestUrl( + "https://games.geforcenow.com/graphql?requestType=panels%2FMainV2".toHttpUrl(), + ) + + assertEquals("https", url.scheme) + assertEquals("games.geforce.com", url.host) + assertEquals("/graphql", url.encodedPath) + assertEquals("panels/MainV2", url.queryParameter("requestType")) + } + + @Test + fun leavesCanonicalGamesGraphQlHostUnchanged() { + val source = "https://games.geforce.com/graphql".toHttpUrl() + + assertEquals(source, canonicalizeGfnRequestUrl(source)) + } + + @Test + fun claimRequestWithoutSettingsDoesNotRenegotiateMonitorSettings() { + val body = buildMinimalClaimRequestBody(appId = "123", deviceId = "device") + val sessionRequestData = body.getValue("sessionRequestData").jsonObject + val metadata = sessionRequestData.getValue("metaData").jsonArray + + assertEquals(2, body.getValue("action").jsonPrimitive.int) + assertEquals(123, sessionRequestData.getValue("appId").jsonPrimitive.int) + assertFalse(sessionRequestData.containsKey("clientRequestMonitorSettings")) + assertFalse(sessionRequestData.containsKey("requestedStreamingFeatures")) + assertTrue(metadata.none { item -> + item.jsonObject["key"]?.jsonPrimitive?.contentOrNull == "clientPhysicalResolution" + }) + } + + @Test + fun claimRequestCarriesCommonResolutionAspectAndCodecMatrix() { + val cases = STREAM_RESOLUTION_OPTIONS.map { option -> + Triple(option.value, option.aspectRatio, parseResolutionPixels(option.value)) + } + + for ((resolution, aspectRatio, pixels) in cases) { + for (codec in VideoCodec.entries) { + val settings = StreamSettings( + resolution = resolution, + aspectRatio = aspectRatio, + fps = 60, + maxBitrateMbps = 75, + codec = codec, + colorQuality = if (codec == VideoCodec.H264) ColorQuality.EightBit420 else ColorQuality.TenBit420, + ) + val body = buildMinimalClaimRequestBody(appId = "123", deviceId = "device", settings = settings) + val sessionRequestData = body.getValue("sessionRequestData").jsonObject + val metadata = sessionRequestData.getValue("metaData").jsonArray + val monitor = sessionRequestData.getValue("clientRequestMonitorSettings").jsonArray.single().jsonObject + val features = sessionRequestData.getValue("requestedStreamingFeatures").jsonObject + val signature = metadata.firstNotNullOfOrNull { item -> + item.jsonObject.takeIf { + it["key"]?.jsonPrimitive?.contentOrNull == OPENNOW_STREAM_SETTINGS_METADATA_KEY + }?.get("value")?.jsonPrimitive?.contentOrNull + } + val physicalResolution = metadata.firstNotNullOfOrNull { item -> + item.jsonObject.takeIf { + it["key"]?.jsonPrimitive?.contentOrNull == "clientPhysicalResolution" + }?.get("value")?.jsonPrimitive?.contentOrNull + }?.let { OpenNowJson.parseToJsonElement(it).jsonObject } + + assertEquals("$resolution $codec signature", streamSettingsSessionSignature(settings), signature) + assertEquals("$resolution $codec width", pixels.first, monitor.getValue("widthInPixels").jsonPrimitive.int) + assertEquals("$resolution $codec height", pixels.second, monitor.getValue("heightInPixels").jsonPrimitive.int) + assertEquals("$resolution $codec fps", 60, monitor.getValue("framesPerSecond").jsonPrimitive.int) + assertEquals("$resolution $codec bit depth", if (codec == VideoCodec.H264) 0 else 10, features.getValue("bitDepth").jsonPrimitive.int) + assertEquals(false, features.getValue("reflex").jsonPrimitive.boolean) + assertEquals(0, monitor.getValue("monitorId").jsonPrimitive.int) + assertEquals(0, monitor.getValue("positionX").jsonPrimitive.int) + assertEquals(0, monitor.getValue("positionY").jsonPrimitive.int) + assertEquals(100, monitor.getValue("dpi").jsonPrimitive.int) + assertEquals(pixels.first, physicalResolution?.getValue("horizontalPixels")?.jsonPrimitive?.int) + assertEquals(pixels.second, physicalResolution?.getValue("verticalPixels")?.jsonPrimitive?.int) + } + } + } + + @Test + fun ultrawideMetadataUsesRequestedViewportInsteadOfDifferentPanelGeometry() { + val settings = StreamSettings( + resolution = "1680x720", + aspectRatio = "21:9", + fps = 60, + codec = VideoCodec.H264, + colorQuality = ColorQuality.EightBit420, + ) + + val body = buildMinimalClaimRequestBody( + appId = "123", + deviceId = "device", + settings = settings, + physicalDisplayResolution = 1920 to 1080, + ) + val sessionRequestData = body.getValue("sessionRequestData").jsonObject + val monitor = sessionRequestData + .getValue("clientRequestMonitorSettings").jsonArray + .single().jsonObject + val physicalResolution = sessionRequestData + .getValue("metaData").jsonArray + .firstNotNullOf { item -> + item.jsonObject.takeIf { + it["key"]?.jsonPrimitive?.contentOrNull == "clientPhysicalResolution" + }?.get("value")?.jsonPrimitive?.contentOrNull + } + .let { OpenNowJson.parseToJsonElement(it).jsonObject } + + assertEquals(1680, monitor.getValue("widthInPixels").jsonPrimitive.int) + assertEquals(720, monitor.getValue("heightInPixels").jsonPrimitive.int) + assertEquals(100, monitor.getValue("dpi").jsonPrimitive.int) + assertEquals(0, monitor.getValue("monitorId").jsonPrimitive.int) + assertEquals(0, monitor.getValue("positionX").jsonPrimitive.int) + assertEquals(0, monitor.getValue("positionY").jsonPrimitive.int) + assertEquals(JsonNull, monitor.getValue("displayData")) + assertEquals(JsonNull, monitor.getValue("hdr10PlusGamingData")) + assertEquals("windows", sessionRequestData.getValue("clientPlatformName").jsonPrimitive.content) + assertEquals(1, sessionRequestData.getValue("appLaunchMode").jsonPrimitive.int) + assertEquals(true, sessionRequestData.getValue("enablePersistingInGameSettings").jsonPrimitive.boolean) + assertEquals(1680, physicalResolution.getValue("horizontalPixels").jsonPrimitive.int) + assertEquals(720, physicalResolution.getValue("verticalPixels").jsonPrimitive.int) + } + + @Test + fun larger4kPanelDoesNotOverrideRequested1440pViewportMetadata() { + val settings = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + fps = 120, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + ) + + val body = buildMinimalClaimRequestBody( + appId = "123", + deviceId = "device", + settings = settings, + physicalDisplayResolution = 3840 to 2160, + ) + val sessionRequestData = body.getValue("sessionRequestData").jsonObject + val physicalResolution = sessionRequestData + .getValue("metaData").jsonArray + .firstNotNullOf { item -> + item.jsonObject.takeIf { + it["key"]?.jsonPrimitive?.contentOrNull == "clientPhysicalResolution" + }?.get("value")?.jsonPrimitive?.contentOrNull + } + .let { OpenNowJson.parseToJsonElement(it).jsonObject } + + assertEquals(2560, physicalResolution.getValue("horizontalPixels").jsonPrimitive.int) + assertEquals(1440, physicalResolution.getValue("verticalPixels").jsonPrimitive.int) + } + + @Test + fun sessionMonitorSnapshotKeepsRequestedReturnedAndFinalModesSeparate() { + val session = buildJsonObject { + putJsonObject("sessionRequestData") { + putJsonArray("clientRequestMonitorSettings") { + add(buildJsonObject { + put("widthInPixels", JsonPrimitive(1680)) + put("heightInPixels", JsonPrimitive(720)) + put("framesPerSecond", JsonPrimitive(60)) + }) + } + } + putJsonArray("monitorSettings") { + add(buildJsonObject { + put("widthInPixels", JsonPrimitive(1366)) + put("heightInPixels", JsonPrimitive(768)) + put("framesPerSecond", JsonPrimitive(60)) + }) + } + putJsonObject("finalSelectedScreenResolution") { + put("horizontalPixels", JsonPrimitive(1230)) + put("verticalPixels", JsonPrimitive(768)) + } + } + + val snapshot = extractSessionMonitorSnapshot(session) + + assertEquals("1680x720", snapshot?.requestedResolution) + assertEquals(60, snapshot?.requestedFps) + assertEquals("1366x768", snapshot?.returnedResolution) + assertEquals(60, snapshot?.returnedFps) + assertEquals("1230x768", snapshot?.finalSelectedResolution) + } + + @Test + fun sessionMonitorSnapshotAcceptsStringFinalResolutionWithoutReplacingReturnedMode() { + val session = buildJsonObject { + putJsonArray("monitorSettings") { + add(buildJsonObject { + put("widthInPixels", JsonPrimitive(2560)) + put("heightInPixels", JsonPrimitive(1440)) + }) + } + put("finalSelectedScreenResolution", JsonPrimitive("1920x1080")) + } + + val snapshot = extractSessionMonitorSnapshot(session) + + assertEquals("2560x1440", snapshot?.returnedResolution) + assertEquals("1920x1080", snapshot?.finalSelectedResolution) + } + + @Test + fun cloudMatchUsesDesktopNativeClientIdentity() { + val headers = cloudMatchHeaders( + token = "token", + clientId = "client", + deviceId = "device", + includeOrigin = true, + ) + + assertEquals("NVIDIA-CLASSIC", headers["nv-client-streamer"]) + assertEquals("NATIVE", headers["nv-client-type"]) + assertEquals("WINDOWS", headers["nv-device-os"]) + assertEquals("DESKTOP", headers["nv-device-type"]) + assertTrue(headers["User-Agent"].orEmpty().contains("NVIDIACEFClient")) + assertEquals("https://play.geforcenow.com", headers["Origin"]) + } + + @Test + fun physicalResolutionMetadataDoesNotUndercutRequested1440pStream() { + val settings = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + fps = 60, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + ) + + val body = buildMinimalClaimRequestBody( + appId = "123", + deviceId = "device", + settings = settings, + physicalDisplayResolution = 1920 to 1080, + ) + val sessionRequestData = body.getValue("sessionRequestData").jsonObject + val monitor = sessionRequestData + .getValue("clientRequestMonitorSettings").jsonArray + .single().jsonObject + val physicalResolution = sessionRequestData + .getValue("metaData").jsonArray + .firstNotNullOf { item -> + item.jsonObject.takeIf { + it["key"]?.jsonPrimitive?.contentOrNull == "clientPhysicalResolution" + }?.get("value")?.jsonPrimitive?.contentOrNull + } + .let { OpenNowJson.parseToJsonElement(it).jsonObject } + + assertEquals(2560, monitor.getValue("widthInPixels").jsonPrimitive.int) + assertEquals(1440, monitor.getValue("heightInPixels").jsonPrimitive.int) + assertEquals(2560, physicalResolution.getValue("horizontalPixels").jsonPrimitive.int) + assertEquals(1440, physicalResolution.getValue("verticalPixels").jsonPrimitive.int) + } + + @Test + fun claimRequestExplicitlyMarksSdrColorMetadata() { + val settings = StreamSettings( + resolution = "1920x1080", + codec = VideoCodec.H265, + colorQuality = ColorQuality.EightBit420, + hdrEnabled = false, + ) + + val sessionRequestData = buildMinimalClaimRequestBody("123", "device", settings) + .getValue("sessionRequestData").jsonObject + val monitor = sessionRequestData + .getValue("clientRequestMonitorSettings").jsonArray.single().jsonObject + val features = sessionRequestData.getValue("requestedStreamingFeatures").jsonObject + + assertEquals(0, monitor.getValue("sdrHdrMode").jsonPrimitive.int) + assertEquals(JsonNull, monitor.getValue("displayData")) + assertEquals(JsonNull, monitor.getValue("hdr10PlusGamingData")) + assertEquals(0, features.getValue("bitDepth").jsonPrimitive.int) + assertEquals(false, features.getValue("trueHdr").jsonPrimitive.boolean) + assertEquals(2, features.getValue("sdrColorSpace").jsonPrimitive.int) + assertEquals(0, features.getValue("hdrColorSpace").jsonPrimitive.int) + assertEquals(0, sessionRequestData.getValue("sdrHdrMode").jsonPrimitive.int) + assertEquals(JsonNull, sessionRequestData.getValue("clientDisplayHdrCapabilities")) + } + + @Test + fun claimRequestExplicitlyMarksHdrColorMetadata() { + val settings = StreamSettings( + resolution = "1920x1080", + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + ) + + val sessionRequestData = buildMinimalClaimRequestBody("123", "device", settings) + .getValue("sessionRequestData").jsonObject + val monitor = sessionRequestData + .getValue("clientRequestMonitorSettings").jsonArray.single().jsonObject + val features = sessionRequestData.getValue("requestedStreamingFeatures").jsonObject + + assertEquals(1, monitor.getValue("sdrHdrMode").jsonPrimitive.int) + assertEquals(1000, monitor.getValue("displayData").jsonObject + .getValue("desiredContentMaxLuminance").jsonPrimitive.int) + assertEquals(true, features.getValue("trueHdr").jsonPrimitive.boolean) + assertEquals(10, features.getValue("bitDepth").jsonPrimitive.int) + assertEquals(2, features.getValue("sdrColorSpace").jsonPrimitive.int) + assertEquals(4, features.getValue("hdrColorSpace").jsonPrimitive.int) + assertEquals(1, sessionRequestData.getValue("sdrHdrMode").jsonPrimitive.int) + assertTrue(sessionRequestData.getValue("clientDisplayHdrCapabilities") is kotlinx.serialization.json.JsonObject) + } + + @Test + fun claimRequestCarriesRequested120FpsMonitorSetting() { + val settings = StreamSettings( + resolution = "1920x1080", + aspectRatio = "16:9", + fps = 120, + maxBitrateMbps = 75, + codec = VideoCodec.H264, + colorQuality = ColorQuality.EightBit420, + ) + + val body = buildMinimalClaimRequestBody(appId = "123", deviceId = "device", settings = settings) + val monitor = body + .getValue("sessionRequestData").jsonObject + .getValue("clientRequestMonitorSettings").jsonArray + .single().jsonObject + + assertEquals(120, monitor.getValue("framesPerSecond").jsonPrimitive.int) + assertEquals( + true, + body.getValue("sessionRequestData").jsonObject + .getValue("requestedStreamingFeatures").jsonObject + .getValue("reflex").jsonPrimitive.boolean, + ) + } + + @Test + fun claimRequestCarriesRequested240FpsMonitorSetting() { + val settings = StreamSettings( + resolution = "1920x1080", + aspectRatio = "16:9", + fps = 240, + maxBitrateMbps = 75, + codec = VideoCodec.AV1, + colorQuality = ColorQuality.EightBit420, + ) + + val body = buildMinimalClaimRequestBody(appId = "123", deviceId = "device", settings = settings) + val monitor = body + .getValue("sessionRequestData").jsonObject + .getValue("clientRequestMonitorSettings").jsonArray + .single().jsonObject + + assertEquals(240, monitor.getValue("framesPerSecond").jsonPrimitive.int) + } + + @Test + fun claimRequestDoesNotAdvertiseAv1Chroma444() { + val settings = StreamSettings( + resolution = "1920x1080", + aspectRatio = "16:9", + fps = 60, + codec = VideoCodec.AV1, + colorQuality = ColorQuality.EightBit444, + ) + + val body = buildMinimalClaimRequestBody(appId = "123", deviceId = "device", settings = settings) + val sessionRequestData = body.getValue("sessionRequestData").jsonObject + val features = sessionRequestData.getValue("requestedStreamingFeatures").jsonObject + val signature = sessionRequestData.getValue("metaData").jsonArray.firstNotNullOfOrNull { item -> + item.jsonObject.takeIf { + it["key"]?.jsonPrimitive?.contentOrNull == OPENNOW_STREAM_SETTINGS_METADATA_KEY + }?.get("value")?.jsonPrimitive?.contentOrNull + } + + assertEquals(0, features.getValue("chromaFormat").jsonPrimitive.int) + assertTrue(signature?.contains("color=EightBit420") == true) + } + + @Test + fun activeSessionMonitorSettingsPreferActualTopLevelMonitor() { + val session = OpenNowJson.parseToJsonElement( + """ + { + "sessionRequestData": { + "clientRequestMonitorSettings": [ + { "widthInPixels": 1680, "heightInPixels": 720, "framesPerSecond": 60 } + ] + }, + "monitorSettings": [ + { "widthInPixels": 1366, "heightInPixels": 768, "framesPerSecond": 60 } + ] + } + """.trimIndent(), + ).jsonObject + val monitor = requireNotNull(activeSessionMonitorSettings(session)) + + assertEquals(1366, monitor.getValue("widthInPixels").jsonPrimitive.int) + assertEquals(768, monitor.getValue("heightInPixels").jsonPrimitive.int) + } + + @Test + fun activeSessionMonitorSettingsFallsBackToTopLevelMonitor() { + val session = OpenNowJson.parseToJsonElement( + """ + { + "monitorSettings": [ + { "widthInPixels": 1366, "heightInPixels": 768, "framesPerSecond": 60 } + ] + } + """.trimIndent(), + ).jsonObject + val monitor = requireNotNull(activeSessionMonitorSettings(session)) + + assertEquals(1366, monitor.getValue("widthInPixels").jsonPrimitive.int) + assertEquals(768, monitor.getValue("heightInPixels").jsonPrimitive.int) + } + + @Test + fun activeSessionSettingsSignatureReadsSessionRequestMetadata() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60, maxBitrateMbps = 150, codec = VideoCodec.H265) + val signature = streamSettingsSessionSignature(settings) + val session = OpenNowJson.parseToJsonElement( + """ + { + "sessionRequestData": { + "metaData": [ + { "key": "$OPENNOW_STREAM_SETTINGS_METADATA_KEY", "value": "$signature" } + ] + } + } + """.trimIndent(), + ).jsonObject + + assertEquals(signature, activeSessionSettingsSignature(session)) + } + + @Test + fun providerLaunchBaseUsesSingleAdvertisedAllianceRegion() { + val base = providerLaunchBaseUrl( + providerBase = "https://prod.yes.geforcenow.nvidiagrid.net/", + regions = listOf(StreamRegion("MY YES", "https://my-yes.yes.geforcenow.nvidiagrid.net")), + ) + + assertEquals("https://my-yes.yes.geforcenow.nvidiagrid.net", base) + } + + @Test + fun providerLaunchBaseDoesNotGuessWhenProviderHasMultipleRegions() { + val base = providerLaunchBaseUrl( + providerBase = "https://prod.example.geforcenow.nvidiagrid.net/", + regions = listOf( + StreamRegion("A", "https://a.example.geforcenow.nvidiagrid.net"), + StreamRegion("B", "https://b.example.geforcenow.nvidiagrid.net"), + ), + ) + + assertEquals("https://prod.example.geforcenow.nvidiagrid.net", base) + } + + @Test + fun providerLaunchBaseDoesNotRewriteCloudmatchRoot() { + val base = providerLaunchBaseUrl( + providerBase = "https://prod.cloudmatchbeta.nvidiagrid.net/", + regions = listOf(StreamRegion("NP-AMS-06", "https://np-ams-06.cloudmatchbeta.nvidiagrid.net")), + ) + + assertEquals("https://prod.cloudmatchbeta.nvidiagrid.net", base) + } + + @Test + fun usableSessionHostRejectsPlaceholderAllianceHosts() { + assertNull(usableSessionHost(".yes.geforcenow.nvidiagrid.net")) + assertNull(usableSessionHost("bad..host")) + assertEquals("183-78-14-238.yes.geforcenow.nvidiagrid.net", usableSessionHost("183-78-14-238.yes.geforcenow.nvidiagrid.net")) + } + + @Test + fun diagnosticLogPayloadRedactsSensitiveJsonFields() { + val exported = sanitizeDiagnosticLogPayload( + """ + { + "session": { + "sessionId": "session-123", + "queuePosition": 4, + "iceServerConfiguration": { + "iceServers": [ + { + "urls": ["turn:example.invalid"], + "username": "ice-user", + "credential": "ice-secret" + } + ] + } + }, + "accessToken": "token-value", + "email": "player@example.invalid" + } + """.trimIndent(), + ) + + assertTrue(exported.contains("\"sessionId\": \"session-123\"")) + assertTrue(exported.contains("\"queuePosition\": 4")) + assertFalse(exported.contains("ice-secret")) + assertFalse(exported.contains("token-value")) + assertFalse(exported.contains("player@example.invalid")) + assertTrue(exported.contains("\"credential\": \"[redacted]\"")) + assertTrue(exported.contains("\"accessToken\": \"[redacted]\"")) + } + + @Test + fun diagnosticLogPayloadRedactsDeviceLoginAndDeviceIds() { + val exported = sanitizeDiagnosticLogPayload( + """ + { + "device_code": "device-secret", + "user_code": "ABCD-EFGH", + "verification_uri_complete": "https://login.example/activate?user_code=ABCD-EFGH", + "deviceHashId": "stable-device-id", + "statusCode": 1 + } + """.trimIndent(), + ) + + assertFalse(exported.contains("device-secret")) + assertFalse(exported.contains("ABCD-EFGH")) + assertFalse(exported.contains("stable-device-id")) + assertTrue(exported.contains("\"device_code\": \"[redacted]\"")) + assertTrue(exported.contains("\"user_code\": \"[redacted]\"")) + assertTrue(exported.contains("\"deviceHashId\": \"[redacted]\"")) + assertTrue(exported.contains("\"statusCode\": 1")) + } + + @Test + fun diagnosticUrlRedactsSensitiveQueryParameters() { + val exported = redactDiagnosticUrl("https://login.example/token?code=abc123&device_id=device-1&requestType=session") + + assertFalse(exported.contains("abc123")) + assertFalse(exported.contains("device-1")) + assertTrue(exported.contains("code=%5Bredacted%5D")) + assertTrue(exported.contains("device_id=%5Bredacted%5D")) + assertTrue(exported.contains("requestType=session")) + } + + @Test + fun diagnosticLogPayloadRedactsFormEncodedAuthFields() { + val exported = sanitizeDiagnosticLogPayload( + "grant_type=client_token&client_token=client-secret&client_id=public-client&sub=user-123", + ) + + assertFalse(exported.contains("client-secret")) + assertFalse(exported.contains("user-123")) + assertTrue(exported.contains("client_token=[redacted]")) + assertTrue(exported.contains("sub=[redacted]")) + assertTrue(exported.contains("client_id=public-client")) + } + +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/InitialStreamConnectionStatusTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/InitialStreamConnectionStatusTest.kt new file mode 100644 index 000000000..31f402eec --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/InitialStreamConnectionStatusTest.kt @@ -0,0 +1,37 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Test + +class InitialStreamConnectionStatusTest { + @Test + fun translatesInitialNativeStatesIntoUserFacingCopy() { + assertEquals("Preparing your stream", initialStreamConnectionStatus("Preparing").title) + assertEquals("Connecting to your game", initialStreamConnectionStatus("Connecting signaling").title) + assertEquals("Starting the video stream", initialStreamConnectionStatus("Waiting for offer").title) + assertEquals("Almost ready", initialStreamConnectionStatus("ICE_CHECKING").title) + assertEquals("Connection established", initialStreamConnectionStatus("Streaming").title) + } + + @Test + fun explainsAnInitialRetryWithoutTechnicalErrorText() { + val retry = initialStreamConnectionStatus("Reconnecting stream (1/3)") + + assertEquals("Retrying connection", retry.phase) + assertEquals("Connecting again", retry.title) + assertEquals( + "The initial connection did not finish, so OpenNOW is retrying it.", + retry.detail, + ) + } + + @Test + fun explainsSafeVideoFallback() { + val fallback = initialStreamConnectionStatus( + "Video packets arrived but no frame rendered. Restarting with safe H264 profile.", + ) + + assertEquals("Optimizing video", fallback.phase) + assertEquals("Trying a compatible video mode", fallback.title) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/InputDataChannelLabelsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/InputDataChannelLabelsTest.kt new file mode 100644 index 000000000..0d9ad443d --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/InputDataChannelLabelsTest.kt @@ -0,0 +1,14 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Test + +class InputDataChannelLabelsTest { + @Test + fun classifiesOnlyInputChannelsAsInputTransport() { + assertEquals(InputDataChannelRole.Reliable, InputDataChannelLabels.classify("input_channel_v1")) + assertEquals(InputDataChannelRole.PartiallyReliable, InputDataChannelLabels.classify("input_channel_partially_reliable")) + assertEquals(InputDataChannelRole.Other, InputDataChannelLabels.classify("control_channel")) + assertEquals(InputDataChannelRole.Other, InputDataChannelLabels.classify("remote_trace_channel")) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/InputEncoderGamepadTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/InputEncoderGamepadTest.kt new file mode 100644 index 000000000..1c1efb871 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/InputEncoderGamepadTest.kt @@ -0,0 +1,342 @@ +package com.opencloudgaming.opennow + +import android.view.InputDevice +import android.view.KeyEvent +import java.nio.ByteBuffer +import java.nio.ByteOrder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class InputEncoderGamepadTest { + @Test + fun encodesGuideButtonMaskForSteamOverlay() { + val encoder = InputEncoder().apply { setProtocolVersion(2) } + val payload = encoder.encodeGamepadState( + controllerId = 0, + buttons = 0x0400, + leftTrigger = 0, + rightTrigger = 0, + leftStickX = 0, + leftStickY = 0, + rightStickX = 0, + rightStickY = 0, + bitmap = 0x0101, + partiallyReliable = false, + timestampUs = 0L, + ) + val bytes = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN) + + assertEquals(InputEncoder.INPUT_GAMEPAD, bytes.getInt(0)) + assertEquals(0x0400, bytes.getShort(12).toInt() and 0xffff) + } + + @Test + fun mapsAndroidGamepadButtonsToXinputMasks() { + assertEquals(GamepadButtonMapping.GUIDE, GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_BUTTON_MODE)) + assertEquals(GamepadButtonMapping.START, GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_BUTTON_START)) + assertEquals(GamepadButtonMapping.BACK, GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_BUTTON_SELECT)) + assertEquals(GamepadButtonMapping.START, GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_MENU, controllerActivation = true)) + assertEquals(GamepadButtonMapping.BACK, GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_BACK, controllerActivation = true)) + assertNull(GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_MENU)) + assertNull(GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_BACK)) + assertEquals(GamepadButtonMapping.LEFT_THUMB, GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_BUTTON_THUMBL)) + assertEquals(GamepadButtonMapping.RIGHT_THUMB, GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_BUTTON_THUMBR)) + } + + @Test + fun buildsSteamMenuAsGuideHeldWithA() { + assertEquals(GamepadButtonMapping.GUIDE, SteamMenuChord.buttons(aPressed = false)) + assertEquals( + GamepadButtonMapping.GUIDE, + SteamMenuChord.buttons(aPressed = true), + ) + } + + @Test + fun viewAndStartChordSendsHomeAWithoutLeakingTopButtons() { + val chord = SteamOverlayChordState() + + assertFalse(chord.update(GamepadButtonMapping.BACK)) + assertEquals(GamepadButtonMapping.BACK, chord.effectiveButtons(GamepadButtonMapping.BACK)) + + val both = GamepadButtonMapping.BACK or GamepadButtonMapping.START + assertTrue(chord.update(both)) + assertEquals( + GamepadButtonMapping.GUIDE, + chord.effectiveButtons(both), + ) + assertTrue(chord.releaseChord()) + assertEquals(0, chord.effectiveButtons(both)) + + assertFalse(chord.update(0)) + assertFalse(chord.update(GamepadButtonMapping.START)) + assertEquals(GamepadButtonMapping.START, chord.effectiveButtons(GamepadButtonMapping.START)) + } + + @Test + fun classifiesControllerButtonKeyCodesWithoutDependingOnEventSource() { + assertTrue(GamepadButtonMapping.isControllerButtonKeyCode(KeyEvent.KEYCODE_BUTTON_MODE)) + assertTrue(GamepadButtonMapping.isControllerButtonKeyCode(KeyEvent.KEYCODE_BUTTON_START)) + assertTrue(GamepadButtonMapping.isControllerButtonKeyCode(KeyEvent.KEYCODE_BUTTON_SELECT)) + assertTrue(GamepadButtonMapping.isControllerButtonKeyCode(KeyEvent.KEYCODE_BUTTON_L2)) + assertTrue(GamepadButtonMapping.isControllerButtonKeyCode(KeyEvent.KEYCODE_BUTTON_R2)) + assertFalse(GamepadButtonMapping.isControllerButtonKeyCode(KeyEvent.KEYCODE_DPAD_CENTER)) + assertFalse(GamepadButtonMapping.isControllerButtonKeyCode(KeyEvent.KEYCODE_ENTER)) + } + + @Test + fun detectsControllerCapableAndroidSources() { + assertTrue(AndroidControllerInput.hasControllerSource(InputDevice.SOURCE_GAMEPAD or InputDevice.SOURCE_DPAD)) + assertTrue(AndroidControllerInput.hasControllerSource(InputDevice.SOURCE_JOYSTICK or InputDevice.SOURCE_DPAD)) + assertFalse(AndroidControllerInput.hasControllerSource(InputDevice.SOURCE_DPAD)) + assertFalse(AndroidControllerInput.hasControllerSource(InputDevice.SOURCE_KEYBOARD or InputDevice.SOURCE_DPAD)) + } + + @Test + fun reusesPrimarySlotWhenAndroidReassignsControllerDeviceId() { + val controllerSlots = linkedMapOf() + val initial = AndroidControllerSlotRegistry.assign( + controllerSlots = controllerSlots, + deviceId = 21, + connectedDeviceIds = setOf(21), + maxControllers = 4, + ) + + val reconnected = AndroidControllerSlotRegistry.assign( + controllerSlots = controllerSlots, + deviceId = 44, + connectedDeviceIds = setOf(44), + maxControllers = 4, + ) + + assertEquals(0, initial.slot) + assertEquals(mapOf(21 to 0), reconnected.removedDevices) + assertEquals(0, reconnected.slot) + assertEquals(mapOf(44 to 0), controllerSlots) + } + + @Test + fun disconnectScanReleasesControllerSlotBeforeReconnect() { + val controllerSlots = linkedMapOf(21 to 0) + + val removed = AndroidControllerSlotRegistry.retainConnected( + controllerSlots = controllerSlots, + connectedDeviceIds = emptySet(), + ) + val reconnected = AndroidControllerSlotRegistry.assign( + controllerSlots = controllerSlots, + deviceId = 44, + connectedDeviceIds = setOf(44), + maxControllers = 4, + ) + + assertEquals(mapOf(21 to 0), removed) + assertEquals(0, reconnected.slot) + assertEquals(mapOf(44 to 0), controllerSlots) + } + + @Test + fun reconnectReusesOnlyTheSlotVacatedByDisconnectedController() { + val controllerSlots = linkedMapOf(21 to 0, 32 to 1) + + val reconnected = AndroidControllerSlotRegistry.assign( + controllerSlots = controllerSlots, + deviceId = 44, + connectedDeviceIds = setOf(32, 44), + maxControllers = 4, + ) + + assertEquals(mapOf(21 to 0), reconnected.removedDevices) + assertEquals(0, reconnected.slot) + assertEquals(mapOf(32 to 1, 44 to 0), controllerSlots) + } + + @Test + fun recognizesStadiaControllerNamesWithDpadOnlySources() { + assertTrue(AndroidControllerInput.isKnownControllerName("Stadia Controller rev. A")) + assertTrue(AndroidControllerInput.isKnownControllerName("Google Stadia Controller")) + assertTrue(AndroidControllerInput.isControllerDevice(InputDevice.SOURCE_DPAD, "Stadia Controller")) + assertTrue(AndroidControllerInput.isControllerDevice(InputDevice.SOURCE_DPAD, "DualSense Wireless Controller")) + assertTrue(AndroidControllerInput.isControllerDevice(InputDevice.SOURCE_DPAD, "Xbox Wireless Controller")) + assertFalse(AndroidControllerInput.isControllerDevice(InputDevice.SOURCE_DPAD, "TV Remote")) + } + + @Test + fun resolvesHatOnlyControllerMotionAsLeftStick() { + val axes = AndroidGamepadAxisMapping.resolve( + raw = AndroidGamepadRawAxes(hatX = -1f, hatY = 0.75f), + available = AndroidGamepadAxisAvailability( + x = false, + y = false, + z = false, + rz = false, + rx = false, + ry = false, + hatX = true, + hatY = true, + ), + ) + + assertEquals(-1f, axes.leftX, 0.0001f) + assertEquals(0.75f, axes.leftY, 0.0001f) + assertEquals("hat", axes.leftSource) + assertTrue(axes.hatUsedAsLeftStick) + } + + @Test + fun keepsStandardControllerAxesOnExpectedSticks() { + val axes = AndroidGamepadAxisMapping.resolve( + AndroidGamepadRawAxes(x = 0.5f, y = -0.25f, z = 0.6f, rz = -0.7f, rx = -0.2f, ry = 0.1f), + ) + + assertEquals(0.5f, axes.leftX, 0.0001f) + assertEquals(-0.25f, axes.leftY, 0.0001f) + assertEquals(0.6f, axes.rightX, 0.0001f) + assertEquals(-0.7f, axes.rightY, 0.0001f) + assertEquals("x/y", axes.leftSource) + assertEquals("z/rz", axes.rightSource) + assertFalse(axes.hatUsedAsLeftStick) + } + + @Test + fun mapsControllerMouseAssistFromRightStick() { + val delta = requireNotNull(AndroidControllerMouseAssist.mouseDelta(0.75f, -0.5f)) + + assertTrue(delta.dx > 0) + assertTrue(delta.dy < 0) + assertNull(AndroidControllerMouseAssist.mouseDelta(0f, 0f)) + } + + @Test + fun mapsControllerMouseClicksWithoutTakingOverOtherGameplayButtons() { + assertNull(AndroidControllerMouseAssist.mouseButtonForGamepad(GamepadButtonMapping.RIGHT_THUMB)) + assertEquals(1, AndroidControllerMouseAssist.mouseButtonForGamepad(GamepadButtonMapping.A)) + assertEquals(3, AndroidControllerMouseAssist.mouseButtonForGamepad(GamepadButtonMapping.B)) + assertNull(AndroidControllerMouseAssist.mouseButtonForTrigger(left = true)) + assertNull(AndroidControllerMouseAssist.mouseButtonForTrigger(left = false)) + } + + @Test + fun classifiesMemoryConstrainedTvWithoutDowngradingSameMemoryMobile() { + val twoGiB = 2L * 1024L * 1024L * 1024L + + assertTrue(isLowPowerStreamingProfile(androidTvProfile = true, renderer = "amlogic", totalMemoryBytes = twoGiB)) + assertFalse(isLowPowerStreamingProfile(androidTvProfile = false, renderer = "adreno", totalMemoryBytes = twoGiB)) + assertFalse(isLowPowerStreamingProfile(androidTvProfile = true, renderer = "adreno", totalMemoryBytes = 4L * 1024L * 1024L * 1024L)) + } + + @Test + fun classifies32BitPhonesAndMemoryConstrainedTvsAsConstrainedRuntimes() { + val twoGiB = 2L * 1024L * 1024L * 1024L + val fourGiB = 4L * 1024L * 1024L * 1024L + + assertTrue(isConstrainedStreamingRuntime(androidTvProfile = false, is64BitRuntime = false, totalMemoryBytes = fourGiB)) + assertTrue(isConstrainedStreamingRuntime(androidTvProfile = true, is64BitRuntime = true, totalMemoryBytes = twoGiB)) + assertFalse(isConstrainedStreamingRuntime(androidTvProfile = false, is64BitRuntime = true, totalMemoryBytes = twoGiB)) + assertTrue( + isLowPowerStreamingProfile( + androidTvProfile = false, + renderer = "adreno", + totalMemoryBytes = fourGiB, + is64BitRuntime = false, + ), + ) + } + + @Test + fun mapsControllerActivationKeysToPrimaryGamepadButtonOnlyForControllers() { + assertEquals( + GamepadButtonMapping.A, + GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_DPAD_CENTER, controllerActivation = true), + ) + assertEquals( + GamepadButtonMapping.A, + GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_ENTER, controllerActivation = true), + ) + assertNull(GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_DPAD_CENTER)) + assertNull(GamepadButtonMapping.maskForKeyCode(KeyEvent.KEYCODE_ENTER)) + } + + @Test + fun normalizesControllerAForNativeUiActivation() { + assertEquals( + KeyEvent.KEYCODE_DPAD_CENTER, + NativeStreamInputRouter.normalizedAppUiKeyCode(KeyEvent.KEYCODE_BUTTON_A, streamUiActive = false), + ) + } + + @Test + fun consumesControllerBAsNativeUiBackNavigation() { + assertTrue( + NativeStreamInputRouter.isControllerAppBackKey( + keyCode = KeyEvent.KEYCODE_BUTTON_B, + controllerSource = false, + streamUiActive = false, + ), + ) + } + + @Test + fun reservesOnlyNonControllerMenuForStreamControls() { + assertTrue(NativeStreamInputRouter.shouldOpenStreamSystemMenuKey(KeyEvent.KEYCODE_MENU, controllerInputDevice = false)) + assertFalse(NativeStreamInputRouter.shouldOpenStreamSystemMenuKey(KeyEvent.KEYCODE_MENU, controllerInputDevice = true)) + assertFalse(NativeStreamInputRouter.shouldOpenStreamSystemMenuKey(KeyEvent.KEYCODE_BUTTON_START, controllerInputDevice = true)) + } + + @Test + fun reservesBackForStreamControlsWithoutTakingControllerButtonsOnMobile() { + assertTrue( + NativeStreamInputRouter.shouldHandleStreamExitKey( + KeyEvent.KEYCODE_BACK, + controllerInputDevice = false, + hardwareKeyboardSource = false, + ), + ) + assertFalse( + NativeStreamInputRouter.shouldHandleStreamExitKey( + KeyEvent.KEYCODE_BACK, + controllerInputDevice = true, + hardwareKeyboardSource = false, + ), + ) + assertTrue( + NativeStreamInputRouter.shouldHandleStreamExitKey( + KeyEvent.KEYCODE_BACK, + controllerInputDevice = true, + hardwareKeyboardSource = false, + androidTvProfile = true, + ), + ) + assertFalse( + NativeStreamInputRouter.shouldHandleStreamExitKey( + KeyEvent.KEYCODE_BUTTON_SELECT, + controllerInputDevice = false, + hardwareKeyboardSource = false, + ), + ) + assertFalse( + NativeStreamInputRouter.shouldHandleStreamExitKey( + KeyEvent.KEYCODE_BUTTON_B, + controllerInputDevice = false, + hardwareKeyboardSource = false, + ), + ) + } + + @Test + fun clampsStreamSharpnessShaderStrength() { + assertEquals(0f, streamSharpnessShaderStrength(enabled = false, amount = 1f), 0.0001f) + assertEquals(0f, streamSharpnessShaderStrength(enabled = true, amount = -1f), 0.0001f) + assertEquals(0.28f, streamSharpnessShaderStrength(enabled = true, amount = 2f), 0.0001f) + } + + @Test + fun controllerMouseLoopOnlyRunsWhileAMouseModeIsActive() { + assertFalse(shouldRunControllerMouseLoop(controllerMouseAssistActive = false, controllerMouseEmulationActive = false)) + assertTrue(shouldRunControllerMouseLoop(controllerMouseAssistActive = true, controllerMouseEmulationActive = false)) + assertTrue(shouldRunControllerMouseLoop(controllerMouseAssistActive = false, controllerMouseEmulationActive = true)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/InputEncoderKeyboardTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/InputEncoderKeyboardTest.kt new file mode 100644 index 000000000..27be645bf --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/InputEncoderKeyboardTest.kt @@ -0,0 +1,59 @@ +package com.opencloudgaming.opennow + +import android.view.KeyEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class InputEncoderKeyboardTest { + @Test + fun mapsNumberRowKeysWhenAndroidReportsNoScanCode() { + val one = InputEncoder.mapKeyboardPayload(keyCode = KeyEvent.KEYCODE_1, unicode = 0, scanCode = 0, timestampUs = 0L) + val zero = InputEncoder.mapKeyboardPayload(keyCode = KeyEvent.KEYCODE_0, unicode = 0, scanCode = 0, timestampUs = 0L) + + assertNotNull(one) + assertEquals(0x31, one?.keycode) + assertEquals(0x0002, one?.scancode) + assertNotNull(zero) + assertEquals(0x30, zero?.keycode) + assertEquals(0x000b, zero?.scancode) + } + + @Test + fun mapsNumpadDigitsWhenAndroidReportsNoScanCode() { + val numpadOne = InputEncoder.mapKeyboardPayload(keyCode = KeyEvent.KEYCODE_NUMPAD_1, unicode = 0, scanCode = 0, timestampUs = 0L) + val numpadZero = InputEncoder.mapKeyboardPayload(keyCode = KeyEvent.KEYCODE_NUMPAD_0, unicode = 0, scanCode = 0, timestampUs = 0L) + + assertNotNull(numpadOne) + assertEquals(0x61, numpadOne?.keycode) + assertEquals(0x004f, numpadOne?.scancode) + assertNotNull(numpadZero) + assertEquals(0x60, numpadZero?.keycode) + assertEquals(0x0052, numpadZero?.scancode) + } + + @Test + fun mapsOverlayTextCharactersLikeDesktopTextInput() { + val upperD = InputEncoder.mapTextCharToKeySpec('D') + val lowerA = InputEncoder.mapTextCharToKeySpec('a') + val space = InputEncoder.mapTextCharToKeySpec(' ') + val colon = InputEncoder.mapTextCharToKeySpec(':') + + assertNotNull(upperD) + assertEquals(0x44, upperD?.keycode) + assertEquals(0x0020, upperD?.scancode) + assertEquals(true, upperD?.shift) + assertNotNull(lowerA) + assertEquals(0x41, lowerA?.keycode) + assertEquals(0x001e, lowerA?.scancode) + assertEquals(false, lowerA?.shift) + assertNotNull(space) + assertEquals(0x20, space?.keycode) + assertEquals(0x0039, space?.scancode) + assertNotNull(colon) + assertEquals(0xba, colon?.keycode) + assertEquals(0x0027, colon?.scancode) + assertEquals(true, colon?.shift) + } + +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/InputHapticsParserTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/InputHapticsParserTest.kt new file mode 100644 index 000000000..2f4e2e005 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/InputHapticsParserTest.kt @@ -0,0 +1,76 @@ +package com.opencloudgaming.opennow + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class InputHapticsParserTest { + @Test + fun parsesLegacyHapticPacket() { + val packet = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN).apply { + putShort(267.toShort()) + putShort(1.toShort()) + putShort(6.toShort()) + putShort(2.toShort()) + putShort(0x4000.toShort()) + putShort(0x7fff.toShort()) + }.array() + + val command = HapticsPacketParser.parse(packet) + + assertEquals(2, command?.controllerId) + assertEquals(0x4000, command?.weakMagnitude) + assertEquals(0x7fff, command?.strongMagnitude) + } + + @Test + fun parsesWrappedOcHapticPacket() { + val packet = ByteArray(14) + packet[0] = 34 + ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN).putInt(1, 17) + packet[5] = 7 + packet[8] = 5 + packet[9] = 1 + packet[12] = 0x20 + packet[13] = 0x60 + + val command = HapticsPacketParser.parse(packet) + + assertEquals(1, command?.controllerId) + assertEquals(0x2000, command?.weakMagnitude) + assertEquals(0x6000, command?.strongMagnitude) + } + + @Test + fun parsesPacketCopiedFromDirectDataChannelBuffer() { + val dataChannelBuffer = ByteBuffer.allocateDirect(18).order(ByteOrder.LITTLE_ENDIAN).apply { + position(3) + putShort(267.toShort()) + putShort(1.toShort()) + putShort(6.toShort()) + putShort(3.toShort()) + putShort(0x2000.toShort()) + putShort(0x6000.toShort()) + limit(position()) + position(3) + } + val packet = dataChannelBuffer.duplicate().let { data -> + ByteArray(data.remaining()).also(data::get) + } + + val command = HapticsPacketParser.parse(packet) + + assertEquals(3, dataChannelBuffer.position()) + assertEquals(3, command?.controllerId) + assertEquals(0x2000, command?.weakMagnitude) + assertEquals(0x6000, command?.strongMagnitude) + } + + @Test + fun ignoresHandshakeAndInputWrappers() { + assertNull(HapticsPacketParser.parse(byteArrayOf(0x0e, 0x02, 0x03, 0x00))) + assertNull(HapticsPacketParser.parse(byteArrayOf(33, 0, 0, 0))) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/LaunchErrorsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/LaunchErrorsTest.kt new file mode 100644 index 000000000..5f058afad --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/LaunchErrorsTest.kt @@ -0,0 +1,50 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LaunchErrorsTest { + @Test + fun freeTierEntitlementFailureExplainsMembershipRequirement() { + val error = IllegalStateException( + "CloudMatch returned status 18: ENTITLEMENT_FAILURE_STATUS 8A910006", + ) + + assertEquals( + "Your GeForce NOW account is on the Free tier. This game requires a Priority or Ultimate membership.", + normalizeLaunchErrorMessage(error, "Subnautica 2"), + ) + } + + @Test + fun limitedModeCloudMatchStatusUsesGameTitle() { + val error = IllegalStateException( + "CloudMatch returned status 81: STREAMING_NOT_ALLOWED_IN_LIMITED_MODE 8A91000D", + ) + + assertEquals( + "Subnautica 2 is only available for Priority or Ultimate members", + normalizeLaunchErrorMessage(error, "Subnautica 2"), + ) + } + + @Test + fun limitedModeCloudMatchStatusFallsBackWithoutGameTitle() { + val error = IllegalStateException("CloudMatch returned status 81: STREAMING_NOT_ALLOWED_IN_LIMITED_MODE") + + assertEquals( + "This game is only available for Priority or Ultimate members", + normalizeLaunchErrorMessage(error), + ) + } + + @Test + fun maintenanceErrorsStillUseFriendlyCopy() { + val error = IllegalStateException("Game server is under maintenance") + + assertEquals( + "Game is patching or under maintenance. Try again when NVIDIA finishes updating it.", + normalizeLaunchErrorMessage(error, "Subnautica 2"), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/LaunchOwnershipTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/LaunchOwnershipTest.kt new file mode 100644 index 000000000..a0300a55a --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/LaunchOwnershipTest.kt @@ -0,0 +1,171 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LaunchOwnershipTest { + @Test + fun ownedStatusesMatchDesktopContract() { + assertTrue(isOwnedLibraryStatus("MANUAL")) + assertTrue(isOwnedLibraryStatus("PLATFORM_SYNC")) + assertTrue(isOwnedLibraryStatus("IN_LIBRARY")) + assertFalse(isOwnedLibraryStatus("NOT_OWNED")) + assertFalse(isOwnedLibraryStatus(null)) + } + + @Test + fun accountLinkedRequiresOwnedVariantOrLibraryGame() { + val unownedSteam = variant(store = "Steam", libraryStatus = "NOT_OWNED") + val ownedSteam = variant(store = "Steam", libraryStatus = "PLATFORM_SYNC") + val unownedEpic = variant(store = "Epic", libraryStatus = "NOT_OWNED") + + assertFalse(shouldLaunchWithAccountLinked(game(listOf(unownedSteam)), unownedSteam)) + assertFalse(shouldLaunchWithAccountLinked(game(listOf(unownedEpic)), unownedEpic)) + assertTrue(shouldLaunchWithAccountLinked(game(listOf(ownedSteam), isInLibrary = true), ownedSteam)) + assertTrue(shouldLaunchWithAccountLinked(game(listOf(unownedSteam, ownedSteam)), unownedSteam)) + } + + @Test + fun installToPlayDoesNotUseAccountLinkedEvenWhenOwned() { + val ownedSteam = variant(store = "Steam", libraryStatus = "IN_LIBRARY") + + assertFalse( + shouldLaunchWithAccountLinked( + game(listOf(ownedSteam), playType = "INSTALL_TO_PLAY", isInLibrary = true), + ownedSteam, + ), + ) + } + + @Test + fun launchableVariantsPreferOwnedStoreEntryOverUnownedDuplicate() { + val unowned = variant(id = "public-steam", store = "Steam", libraryStatus = "NOT_OWNED", librarySelected = true) + val owned = variant(id = "owned-steam", store = "Steam", libraryStatus = "PLATFORM_SYNC") + + val variants = launchableGameVariants(listOf(unowned, owned)) + + assertEquals(listOf("owned-steam"), variants.map { it.id }) + } + + @Test + fun mergesOwnedCatalogResultsIntoLibrary() { + val library = game( + variants = listOf(variant(id = "steam", store = "Steam", libraryStatus = "PLATFORM_SYNC")), + isInLibrary = true, + ) + val catalogOnlyOwned = game( + id = "subnautica-2", + uuid = "subnautica-2-uuid", + title = "Subnautica 2", + variants = listOf(variant(id = "subnautica-steam", store = "Steam", libraryStatus = "IN_LIBRARY")), + isInLibrary = true, + ) + val catalogUnowned = game( + id = "catalog-only", + uuid = "catalog-only-uuid", + title = "Catalog Only", + variants = listOf(variant(id = "catalog-steam", store = "Steam", libraryStatus = "NOT_OWNED")), + ) + + val merged = mergeKnownLibraryGames(listOf(library), listOf(catalogOnlyOwned, catalogUnowned)) + + assertEquals(listOf("Game", "Subnautica 2"), merged.map { it.title }) + } + + @Test + fun metadataEnrichmentPreservesPanelLibraryOwnership() { + val panelGame = game( + variants = listOf( + variant( + id = "steam", + store = "Steam", + libraryStatus = "MANUAL", + librarySelected = true, + ), + ), + isInLibrary = true, + ) + val metadataGame = game( + variants = listOf(variant(id = "steam", store = "Steam")), + ).copy( + description = "Enriched description", + imageUrl = "game-box-art", + screenshotUrls = listOf("screenshot-one", "screenshot-two"), + ) + val panelWithFallbackArtwork = panelGame.copy( + imageUrl = "panel-banner-fallback", + screenshotUrls = listOf("screenshot-one"), + ) + + val merged = mergePanelGameWithMetadata(panelWithFallbackArtwork, metadataGame) + + assertTrue(merged.isInLibrary) + assertEquals("MANUAL", merged.variants.single().libraryStatus) + assertEquals(true, merged.variants.single().librarySelected) + assertEquals("Enriched description", merged.description) + assertEquals("game-box-art", merged.imageUrl) + assertEquals(listOf("screenshot-one", "screenshot-two"), merged.screenshotUrls) + } + + @Test + fun libraryStoreFiltersOnlyUseOwnedStores() { + val game = game( + variants = listOf( + variant(id = "epic", store = "Epic", libraryStatus = "PLATFORM_SYNC"), + variant(id = "steam", store = "Steam", libraryStatus = "NOT_OWNED"), + variant(id = "xbox", store = "Xbox"), + ), + isInLibrary = true, + ) + + assertEquals(listOf("Epic"), libraryStoreDisplayNames(game)) + } + + @Test + fun libraryStoreFiltersFallBackToSelectedVariantForLegacyLibraryRows() { + val game = game( + variants = listOf( + variant(id = "steam", store = "Steam"), + variant(id = "xbox", store = "Xbox", librarySelected = true), + ), + isInLibrary = true, + selectedVariantIndex = 0, + ) + + assertEquals(listOf("Xbox"), libraryStoreDisplayNames(game)) + } + + private fun variant( + id: String = "variant", + store: String = "Steam", + libraryStatus: String? = null, + librarySelected: Boolean? = null, + ): GameVariant = + GameVariant( + id = id, + store = store, + librarySelected = librarySelected, + libraryStatus = libraryStatus, + ) + + private fun game( + variants: List, + id: String = "game", + uuid: String? = null, + title: String = "Game", + playType: String? = null, + isInLibrary: Boolean = false, + selectedVariantIndex: Int = 0, + ): GameInfo = + GameInfo( + id = id, + uuid = uuid, + title = title, + playType = playType, + isInLibrary = isInLibrary, + variants = variants, + selectedVariantIndex = selectedVariantIndex, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/LoginProviderTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/LoginProviderTest.kt new file mode 100644 index 000000000..8cbe329e1 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/LoginProviderTest.kt @@ -0,0 +1,26 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LoginProviderTest { + @Test + fun deviceCodeLoginIsAvailableForNvidiaOnly() { + val nvidia = LoginProvider( + idpId = "idp-nvidia", + code = "NVIDIA", + displayName = "NVIDIA", + streamingServiceUrl = "https://prod.cloudmatchbeta.nvidiagrid.net/", + ) + val alliance = LoginProvider( + idpId = "idp-alliance", + code = "YES", + displayName = "YES Malaysia", + streamingServiceUrl = "https://yes.geforcenow.nvidiagrid.net/", + ) + + assertTrue(nvidia.supportsDeviceCodeLogin) + assertFalse(alliance.supportsDeviceCodeLogin) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/ManualTokenSignInTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/ManualTokenSignInTest.kt new file mode 100644 index 000000000..a2da09116 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/ManualTokenSignInTest.kt @@ -0,0 +1,60 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ManualTokenSignInTest { + @Test + fun acceptsRawBearerAccessToken() { + val tokens = parseManualAuthTokens(" Bearer access-value ", currentTimeMs = 1_000L) + + assertEquals("access-value", tokens.accessToken) + assertEquals(86_401_000L, tokens.expiresAt) + assertNull(tokens.refreshToken) + } + + @Test + fun acceptsOAuthTokenResponseJson() { + val tokens = parseManualAuthTokens( + """ + { + "access_token": "access-value", + "refresh_token": "refresh-value", + "id_token": "id-value", + "client_token": "client-value", + "expires_in": 3600 + } + """.trimIndent(), + currentTimeMs = 10_000L, + ) + + assertEquals("access-value", tokens.accessToken) + assertEquals("refresh-value", tokens.refreshToken) + assertEquals("id-value", tokens.idToken) + assertEquals("client-value", tokens.clientToken) + assertEquals(3_610_000L, tokens.expiresAt) + } + + @Test + fun acceptsPersistedSessionTokenShape() { + val tokens = parseManualAuthTokens( + """ + { + "tokens": { + "accessToken": "access-value", + "refreshToken": "refresh-value", + "expiresAt": 2000000000, + "authClientId": "saved-client" + } + } + """.trimIndent(), + currentTimeMs = 10_000L, + ) + + assertEquals("access-value", tokens.accessToken) + assertEquals("refresh-value", tokens.refreshToken) + assertEquals(2_000_000_000_000L, tokens.expiresAt) + assertEquals("saved-client", tokens.authClientId) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/MicrophoneSupportTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/MicrophoneSupportTest.kt new file mode 100644 index 000000000..88164f0f8 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/MicrophoneSupportTest.kt @@ -0,0 +1,63 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MicrophoneSupportTest { + @Test + fun microphoneCaptureRequiresEnabledModeAndRuntimePermission() { + assertTrue( + shouldCaptureMicrophone( + mode = MicrophoneMode.VoiceActivity, + permissionGranted = true, + ), + ) + assertTrue( + shouldCaptureMicrophone( + mode = MicrophoneMode.PushToTalk, + permissionGranted = true, + ), + ) + assertFalse( + shouldCaptureMicrophone( + mode = MicrophoneMode.Disabled, + permissionGranted = true, + ), + ) + assertFalse( + shouldCaptureMicrophone( + mode = MicrophoneMode.VoiceActivity, + permissionGranted = false, + ), + ) + } + + @Test + fun videoPresetChangesPreserveMicrophonePreferences() { + val source = StreamSettings( + microphoneMode = MicrophoneMode.VoiceActivity, + microphoneDeviceId = "preferred-device", + ) + + val updated = StreamSettings(resolution = "1280x720") + .withMicrophoneSettingsFrom(source) + + assertTrue(updated.microphoneMode == MicrophoneMode.VoiceActivity) + assertTrue(updated.microphoneDeviceId == "preferred-device") + } + + @Test + fun microphoneCleanupOnlyIgnoresTheWebRtcDisposedSenderFailure() { + assertTrue( + isDisposedRtpSenderFailure( + IllegalStateException("RtpSender has been disposed."), + ), + ) + assertFalse( + isDisposedRtpSenderFailure( + IllegalStateException("RtpSender track update failed."), + ), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/NativeStreamReadinessTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/NativeStreamReadinessTest.kt new file mode 100644 index 000000000..9f1869414 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/NativeStreamReadinessTest.kt @@ -0,0 +1,43 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NativeStreamReadinessTest { + @Test + fun readyCloudSessionDoesNotStartNativeTransportBeforeClaimCompletes() { + val readySession = readySession() + + assertFalse( + OpenNowUiState( + streamStatus = "queue", + streamSession = readySession, + ).isNativeStreamReady(), + ) + assertTrue( + OpenNowUiState( + streamStatus = "connecting", + streamSession = readySession, + ).isNativeStreamReady(), + ) + } + + @Test + fun connectingStateStillRequiresAStreamReadySession() { + assertFalse( + OpenNowUiState( + streamStatus = "connecting", + streamSession = readySession().copy(status = 1), + ).isNativeStreamReady(), + ) + } + + private fun readySession(): SessionInfo = SessionInfo( + sessionId = "session-id", + status = 2, + serverIp = "stream.example.test", + signalingServer = "stream.example.test:443", + signalingUrl = "wss://stream.example.test/nvst/", + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/OpenNowAnalyticsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/OpenNowAnalyticsTest.kt new file mode 100644 index 000000000..ed2d0b18e --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/OpenNowAnalyticsTest.kt @@ -0,0 +1,99 @@ +package com.opencloudgaming.opennow + +import com.posthog.PostHogEvent +import com.posthog.android.PostHogAndroidConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class OpenNowAnalyticsTest { + @Test + fun appliesPrivacyFirstAnalyticsConfig() { + val config = PostHogAndroidConfig( + apiKey = "phc_test", + host = "https://us.i.posthog.com", + ).apply { + sessionReplay = false + sessionReplayConfig.screenshot = false + sessionReplayConfig.maskAllTextInputs = false + sessionReplayConfig.maskAllImages = false + sessionReplayConfig.captureLogcat = true + captureDeepLinks = true + } + + config.applyOpenNowSettings( + AppSettings( + analyticsConsentAsked = true, + analyticsOptOut = false, + ), + ) + + assertFalse(config.optOut) + assertTrue(config.captureApplicationLifecycleEvents) + assertFalse(config.captureDeepLinks) + assertTrue(config.captureScreenViews) + assertEquals(10, config.flushIntervalSeconds) + assertFalse(config.sessionReplay) + assertFalse(config.sessionReplayConfig.screenshot) + assertFalse(config.sessionReplayConfig.captureLogcat) + assertTrue(config.sessionReplayConfig.maskAllTextInputs) + assertTrue(config.sessionReplayConfig.maskAllImages) + assertTrue(config.errorTrackingConfig.autoCapture) + assertEquals(1, config.beforeSendList.size) + + val sanitizedCrash = config.beforeSendList.single().run( + PostHogEvent( + event = "\$exception", + distinctId = "anonymous", + properties = mutableMapOf( + "\$exception_list" to listOf( + mapOf( + "type" to "IllegalStateException", + "value" to "token=secret for player@example.invalid", + ), + ), + ), + ), + ) + val exception = (sanitizedCrash?.properties?.get("\$exception_list") as List<*>).single() as Map<*, *> + assertEquals("IllegalStateException", exception["type"]) + assertFalse(exception.containsKey("value")) + assertEquals(true, sanitizedCrash.properties?.get("\$geoip_disable")) + } + + @Test + fun analyticsStayOffUntilConsentIsRecorded() { + val config = PostHogAndroidConfig( + apiKey = "phc_test", + host = "https://us.i.posthog.com", + ) + + config.applyOpenNowSettings(AppSettings(analyticsOptOut = false, analyticsConsentAsked = false)) + + assertTrue(config.optOut) + } + + @Test + fun analyticsPropertiesDropFreeFormAndIdentifyingValues() { + val properties = sanitizedAnalyticsProperties( + mapOf( + "query" to "private search", + "error_message" to "token=secret from player@example.invalid", + "provider" to "NP-PCC", + "server" to "203.0.113.42", + "metadata" to "sessionId=private deviceName=Kiefers-Controller email=player@example.invalid", + ), + ) + + assertFalse(properties.containsKey("query")) + assertFalse(properties.containsKey("error_message")) + assertEquals("NP-PCC", properties["provider"]) + assertEquals("[redacted-ip]", properties["server"]) + val metadata = properties["metadata"] as String + assertFalse(metadata.contains("private")) + assertFalse(metadata.contains("Kiefers-Controller")) + assertFalse(metadata.contains("player@example.invalid")) + assertEquals(true, properties["\$geoip_disable"]) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/QueueLaunchStatusTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/QueueLaunchStatusTest.kt new file mode 100644 index 000000000..882344fc1 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/QueueLaunchStatusTest.kt @@ -0,0 +1,49 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class QueueLaunchStatusTest { + @Test + fun seatSetupStepFiveQueuePositionIsNotDisplayed() { + val session = session(queuePosition = 1, seatSetupStep = 5) + val state = OpenNowUiState( + streamStatus = "queue", + launchPhase = "Queue", + queuePosition = 1, + streamSession = session, + ) + + assertNull(queueDisplayPosition(session)) + assertNull(queueDisplayPosition(state)) + assertEquals("Starting session", queueLaunchStatusText(state)) + } + + @Test + fun seatSetupStepOneQueuePositionIsDisplayed() { + val session = session(queuePosition = 40, seatSetupStep = 1) + val state = OpenNowUiState( + streamStatus = "queue", + streamSession = session, + ) + + assertEquals(40, queueDisplayPosition(session)) + assertEquals(40, queueDisplayPosition(state)) + assertEquals("Queue position 40", queueLaunchStatusText(state)) + } + + private fun session( + queuePosition: Int?, + seatSetupStep: Int?, + ): SessionInfo = + SessionInfo( + sessionId = "session", + status = 1, + queuePosition = queuePosition, + seatSetupStep = seatSetupStep, + serverIp = "server", + signalingServer = "server:443", + signalingUrl = "wss://server:443/nvst/", + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/RapidTapTrackerTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/RapidTapTrackerTest.kt new file mode 100644 index 000000000..1b4a1ab3a --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/RapidTapTrackerTest.kt @@ -0,0 +1,29 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RapidTapTrackerTest { + @Test + fun triggersOnTenthTapWithinWindow() { + val tracker = RapidTapTracker() + + repeat(9) { index -> + assertFalse(tracker.recordTap(index * 500L)) + } + + assertTrue(tracker.recordTap(4_500L)) + assertFalse(tracker.recordTap(5_000L)) + } + + @Test + fun expiredTapsDoNotCountTowardSequence() { + val tracker = RapidTapTracker(requiredTapCount = 3, windowMs = 1_000L) + + assertFalse(tracker.recordTap(0L)) + assertFalse(tracker.recordTap(500L)) + assertFalse(tracker.recordTap(1_400L)) + assertTrue(tracker.recordTap(1_500L)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/SdpToolsTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/SdpToolsTest.kt new file mode 100644 index 000000000..f5daed4ab --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/SdpToolsTest.kt @@ -0,0 +1,297 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SdpToolsTest { + @Test + fun partiallyReliableGamepadMaskDefaultsToAllControllerSlots() { + assertEquals(0x0f, SdpTools.parsePartiallyReliableGamepadMask("v=0\n")) + } + + @Test + fun partiallyReliableGamepadMaskParsesDecimalAndHexAttributes() { + assertEquals( + 0x03, + SdpTools.parsePartiallyReliableGamepadMask("a=ri.enablePartiallyReliableTransferGamepad:3\n"), + ) + assertEquals( + 0x0f, + SdpTools.parsePartiallyReliableGamepadMask("a=ri.enablePartiallyReliableTransferGamepad:0x0f\n"), + ) + } + + @Test + fun prefersEightBitH265ProfileForNonHdrAndroidStream() { + val munged = SdpTools.preferCodec(h265Offer(), StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.EightBit420)) + + assertEquals("m=video 9 UDP/TLS/RTP/SAVPF 97 96", munged.lineSequence().first()) + } + + @Test + fun prefersTenBitH265ProfileForHdrAndroidStream() { + val munged = SdpTools.preferCodec( + h265Offer(), + StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.TenBit420, hdrEnabled = true), + ) + + assertEquals("m=video 9 UDP/TLS/RTP/SAVPF 96 97", munged.lineSequence().first()) + } + + @Test + fun rewritesH265TierFlagAndClampsLevelByProfile() { + val offer = """ + m=video 9 UDP/TLS/RTP/SAVPF 96 97 + a=rtpmap:96 H265/90000 + a=fmtp:96 profile-id=1;tier-flag=1;level-id=186 + a=rtpmap:97 H265/90000 + a=fmtp:97 profile-id=2;tier-flag=1;level-id=255 + """.trimIndent() + + val tier = SdpTools.rewriteH265TierFlag(offer, 0) + val level = SdpTools.rewriteH265LevelIdByProfile(tier.sdp, mapOf(1 to 153, 2 to 186)) + + assertEquals(2, tier.replacements) + assertEquals(2, level.replacements) + assertTrue(level.sdp.contains("a=fmtp:96 profile-id=1;tier-flag=0;level-id=153")) + assertTrue(level.sdp.contains("a=fmtp:97 profile-id=2;tier-flag=0;level-id=186")) + } + + @Test + fun detectsNegotiatedVideoCodecInLocalAnswer() { + val answer = """ + m=audio 9 UDP/TLS/RTP/SAVPF 111 + a=rtpmap:111 opus/48000/2 + m=video 9 UDP/TLS/RTP/SAVPF 96 + a=rtpmap:96 HEVC/90000 + """.trimIndent() + + assertTrue(SdpTools.negotiatesCodec(answer, VideoCodec.H265)) + assertFalse(SdpTools.negotiatesCodec(answer, VideoCodec.AV1)) + } + + @Test + fun fixesPlaceholderCandidatesWithSignalingEndpointWhenMediaEndpointIsMissing() { + val offer = """ + v=0 + c=IN IP4 0.0.0.0 + m=video 47998 UDP/TLS/RTP/SAVPF 96 + a=candidate:1 1 udp 2122260223 0.0.0.0 47998 typ host generation 0 + a=rtpmap:96 H264/90000 + """.trimIndent() + + val fixed = SdpTools.fixServerIp( + offer, + serverIp = "66-22-131-132.cloudmatchbeta.nvidiagrid.net", + ) + + assertTrue(fixed.contains("c=IN IP4 66.22.131.132")) + assertTrue(fixed.contains("a=candidate:1 1 udp 2122260223 66.22.131.132 47998 typ host generation 0")) + } + + @Test + fun fixesPlaceholderCandidatesWithCloudMatchMediaEndpoint() { + val offer = """ + v=0 + c=IN IP4 0.0.0.0 + m=video 47998 UDP/TLS/RTP/SAVPF 96 + a=candidate:1 1 udp 2122260223 0.0.0.0 47998 typ host generation 0 + a=rtpmap:96 H264/90000 + """.trimIndent() + + val fixed = SdpTools.fixServerEndpoint( + offer, + serverIp = "183-78-14-231.yes.geforcenow.nvidiagrid.net", + mediaConnectionInfo = MediaConnectionInfo("183-78-14-231.yes.geforcenow.nvidiagrid.net", 19353), + ) + + assertTrue(fixed.contains("c=IN IP4 183.78.14.231")) + assertTrue(fixed.contains("a=candidate:1 1 udp 2122260223 183.78.14.231 19353 typ host generation 0")) + } + + @Test + fun leavesPrivateCandidatesWithoutCloudMatchMediaEndpoint() { + val offer = """ + v=0 + c=IN IP4 10.0.175.0 + m=video 47998 UDP/TLS/RTP/SAVPF 96 + a=candidate:1 1 udp 2122260223 10.0.175.0 47998 typ host generation 0 + a=rtpmap:96 H264/90000 + """.trimIndent() + + val fixed = SdpTools.fixServerIp( + offer, + serverIp = "183-78-14-231.yes.geforcenow.nvidiagrid.net", + ) + + assertEquals(offer, fixed) + } + + @Test + fun fixesPrivateCandidatesWithCloudMatchMediaEndpoint() { + val offer = """ + v=0 + c=IN IP4 10.0.175.0 + m=video 47998 UDP/TLS/RTP/SAVPF 96 + a=candidate:1 1 udp 2122260223 10.0.175.0 47998 typ host generation 0 + a=rtpmap:96 H264/90000 + """.trimIndent() + + val fixed = SdpTools.fixServerEndpoint( + offer, + serverIp = "183-78-14-231.yes.geforcenow.nvidiagrid.net", + mediaConnectionInfo = MediaConnectionInfo("183.78.14.231", 14317), + ) + + assertTrue(fixed.contains("c=IN IP4 183.78.14.231")) + assertTrue(fixed.contains("a=candidate:1 1 udp 2122260223 183.78.14.231 14317 typ host generation 0")) + } + + @Test + fun leavesResolvedCandidatesOnTheirAdvertisedEndpoint() { + val offer = """ + v=0 + c=IN IP4 203.0.113.10 + m=video 47998 UDP/TLS/RTP/SAVPF 96 + a=candidate:1 1 udp 2122260223 203.0.113.10 47998 typ host generation 0 + a=rtpmap:96 H264/90000 + """.trimIndent() + + val fixed = SdpTools.fixServerEndpoint( + offer, + serverIp = "183-78-14-231.yes.geforcenow.nvidiagrid.net", + mediaConnectionInfo = MediaConnectionInfo("183-78-14-231.yes.geforcenow.nvidiagrid.net", 19353), + ) + + assertEquals(offer, fixed) + } + + @Test + fun nvstSdpUsesConfiguredResolutionViewport() { + val nvst = SdpTools.buildNvstSdp( + offerSdp = "a=ri.partialReliableThresholdMs:42", + settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", codec = VideoCodec.H265), + localAnswer = """ + a=ice-ufrag:testUfrag + a=ice-pwd:testPassword + a=fingerprint:sha-256 11:22:33 + """.trimIndent(), + ) + + assertTrue(nvst.contains("a=video.clientViewportWd:1680")) + assertTrue(nvst.contains("a=video.clientViewportHt:720")) + assertTrue(nvst.contains("a=vqos.resControl.cpmRtc.minResolutionPercent:100")) + assertTrue(nvst.contains("a=vqos.resControl.cpmRtc.resolutionChangeHoldonMs:999999")) + assertTrue(nvst.contains("a=video.scalingFeature1:0")) + assertFalse(nvst.contains("a=video.clientViewportWd:1920")) + } + + @Test + fun everyResolutionCodecAndSupportedFpsProducesFixedGeometrySdp() { + val modes = STREAM_RESOLUTION_OPTIONS.map { option -> + Triple(option.value, option.aspectRatio, parseResolutionPixels(option.value)) + } + + for ((resolution, aspectRatio, pixels) in modes) { + for (codec in VideoCodec.entries) { + for (fps in listOf(60, 120, 240)) { + val settings = StreamSettings( + resolution = resolution, + aspectRatio = aspectRatio, + fps = fps, + codec = codec, + colorQuality = if (codec == VideoCodec.H264) ColorQuality.EightBit420 else ColorQuality.TenBit420, + ) + val preferred = SdpTools.preferCodec(allCodecOffer(), settings) + val nvst = SdpTools.buildNvstSdp( + offerSdp = preferred, + settings = settings, + localAnswer = """ + a=ice-ufrag:testUfrag + a=ice-pwd:testPassword + a=fingerprint:sha-256 11:22:33 + """.trimIndent(), + ) + + val case = "$resolution $codec ${fps}fps" + assertTrue("$case was not preferred", SdpTools.negotiatesCodec(preferred, codec)) + assertTrue("$case width missing", nvst.contains("a=video.clientViewportWd:${pixels.first}")) + assertTrue("$case height missing", nvst.contains("a=video.clientViewportHt:${pixels.second}")) + assertTrue("$case fps missing", nvst.contains("a=video.maxFPS:$fps")) + assertTrue("$case scaling must remain disabled", nvst.contains("a=video.scalingFeature1:0")) + assertFalse("$case must not enable scaling", nvst.contains("a=video.scalingFeature1:1")) + } + } + } + } + + @Test + fun nvstSdpDisablesHdrForSdrStream() { + val nvst = buildNvstSdp(StreamSettings(hdrEnabled = false)) + + assertTrue(nvst.contains("a=video.dx9EnableHdr:0")) + assertFalse(nvst.contains("a=video.dx9EnableHdr:1")) + } + + @Test + fun nvstSdpEnablesHdrOnlyForHdrStream() { + val nvst = buildNvstSdp(StreamSettings(codec = VideoCodec.H265, hdrEnabled = true)) + + assertTrue(nvst.contains("a=video.dx9EnableHdr:1")) + assertFalse(nvst.contains("a=video.dx9EnableHdr:0")) + } + + @Test + fun nvstSdpCarriesRequested240FpsEstimate() { + val nvst = SdpTools.buildNvstSdp( + offerSdp = "a=ri.partialReliableThresholdMs:42", + settings = StreamSettings(resolution = "1920x1080", aspectRatio = "16:9", fps = 240, codec = VideoCodec.AV1), + localAnswer = """ + a=ice-ufrag:testUfrag + a=ice-pwd:testPassword + a=fingerprint:sha-256 11:22:33 + """.trimIndent(), + ) + + assertTrue(nvst.contains("a=video.maxFPS:240")) + assertTrue(nvst.contains("a=vqos.maxStreamFpsEstimate:240")) + } + + private fun buildNvstSdp(settings: StreamSettings): String = + SdpTools.buildNvstSdp( + offerSdp = "a=ri.partialReliableThresholdMs:42", + settings = settings, + localAnswer = """ + a=ice-ufrag:testUfrag + a=ice-pwd:testPassword + a=fingerprint:sha-256 11:22:33 + """.trimIndent(), + ) + + private fun h265Offer(): String = + """ + m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 + a=rtpmap:96 H265/90000 + a=fmtp:96 profile-id=2 + a=rtpmap:97 H265/90000 + a=fmtp:97 profile-id=1 + a=rtpmap:98 H264/90000 + """.trimIndent() + + private fun allCodecOffer(): String = + """ + m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 + a=rtpmap:96 H264/90000 + a=rtpmap:97 rtx/90000 + a=fmtp:97 apt=96 + a=rtpmap:98 H265/90000 + a=fmtp:98 profile-id=2;tier-flag=0;level-id=153 + a=rtpmap:99 rtx/90000 + a=fmtp:99 apt=98 + a=rtpmap:100 AV1/90000 + a=rtpmap:101 rtx/90000 + a=fmtp:101 apt=100 + """.trimIndent() +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/SessionReportTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/SessionReportTest.kt new file mode 100644 index 000000000..e3fe73989 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/SessionReportTest.kt @@ -0,0 +1,159 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionReportTest { + @Test + fun healthySessionScoresOneHundred() { + assertEquals( + 100, + sessionQualityScore( + averagePingMs = 25, + packetLossPct = 0.05, + averageJitterMs = 3.0, + averageFps = 60.0, + targetFps = 60, + averageDecodeMs = 4.0, + ), + ) + assertEquals(SessionReportRating.Excellent, sessionReportRating(100)) + } + + @Test + fun poorNetworkProducesLowScore() { + val score = sessionQualityScore( + averagePingMs = 210, + packetLossPct = 6.0, + averageJitterMs = 55.0, + averageFps = 35.0, + targetFps = 60, + averageDecodeMs = 28.0, + ) + + assertTrue(score < 20) + assertEquals(SessionReportRating.Poor, sessionReportRating(score)) + } + + @Test + fun accumulatorUsesPacketDeltasAndAddsContextualWifiAdvice() { + val settings = StreamSettings( + resolution = "1920x1080", + fps = 60, + maxBitrateMbps = 50, + codec = VideoCodec.H264, + colorQuality = ColorQuality.EightBit420, + ) + val accumulator = StreamSessionReportAccumulator( + launchProfile = StreamReportLaunchProfile( + gameTitle = "Test Game", + selectedSettings = settings, + eligibleSettings = settings, + initialSettings = settings, + ), + startedAtMs = 1_000L, + ) + + repeat(10) { + accumulator.record( + stats = StreamRuntimeStats( + bitrateKbps = 28_000, + pingMs = 95, + fps = 58, + resolution = "1920x1080", + codec = "H264", + decodeMs = 5.0, + jitterMs = 22.0, + packetLossPct = 99.0, + packetsLostDelta = 2, + packetsReceivedDelta = 98, + ), + network = AndroidRuntimeDiagnosticsSnapshot( + networkKind = AndroidNetworkKind.Wifi, + networkSignalBars = 3, + networkDownstreamKbps = 65_000, + wifiFrequencyMhz = 2_437, + wifiBand = AndroidWifiBand.TwoPointFourGhz, + ), + ) + } + + val report = accumulator.finish(11_000L) + assertNotNull(report) + report!! + assertEquals(10, report.sampleCount) + assertFalse(report.limitedData) + assertEquals(95, report.averagePingMs) + assertEquals(28_000, report.averageBitrateKbps) + assertEquals(2.0, report.packetLossPct ?: -1.0, 0.001) + assertEquals(AndroidWifiBand.TwoPointFourGhz, report.wifiBand) + assertTrue(report.recommendations.any { it.title == "Use 5 GHz or 6 GHz Wi-Fi" }) + assertTrue(report.recommendations.any { it.title == "Reduce packet loss" }) + } + + @Test + fun reportExplainsDeviceServerAndRecoveryDowngrades() { + val selected = StreamSettings( + resolution = "3840x2160", + fps = 120, + maxBitrateMbps = 75, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + ) + val eligible = selected.copy(resolution = "2560x1440", fps = 60, hdrEnabled = false) + val initial = eligible.copy(maxBitrateMbps = 35) + val safe = initial.copy(codec = VideoCodec.H264, colorQuality = ColorQuality.EightBit420) + val accumulator = StreamSessionReportAccumulator( + launchProfile = StreamReportLaunchProfile( + gameTitle = "Test Game", + selectedSettings = selected, + eligibleSettings = eligible, + initialSettings = initial, + ), + startedAtMs = 0L, + ) + accumulator.recordRecovery("H265 did not render a first frame", safe) + accumulator.recordActiveMode( + ActiveStreamModeStatus( + requestedResolution = "2560x1440", + displayedResolution = "1920x1080", + serverNegotiatedResolution = "1920x1080", + resolutionSource = StreamResolutionChangeSource.ServerNegotiatedFallback, + safeVideoRecoveryActive = true, + transportCodec = VideoCodec.H264, + ), + ) + accumulator.record( + StreamRuntimeStats( + bitrateKbps = 20_000, + pingMs = 30, + fps = 60, + resolution = "1920x1080", + codec = "H264", + decodeMs = 4.0, + jitterMs = 3.0, + packetLossPct = 0.0, + ), + ) + + val report = accumulator.finish(5_000L) + assertNotNull(report) + report!! + assertTrue(report.downgrades.any { it.title == "Account or session limit" }) + assertTrue(report.downgrades.any { it.title == "Device compatibility adjustment" }) + assertTrue(report.downgrades.any { it.title == "Safe video recovery" }) + assertTrue(report.downgrades.any { it.title == "Delivered resolution changed" }) + } + + @Test + fun wifiFrequencyIsClassifiedWithoutGuessing() { + assertEquals(AndroidWifiBand.TwoPointFourGhz, androidWifiBandForFrequency(2_412)) + assertEquals(AndroidWifiBand.FiveGhz, androidWifiBandForFrequency(5_220)) + assertEquals(AndroidWifiBand.SixGhz, androidWifiBandForFrequency(6_115)) + assertEquals(AndroidWifiBand.Unknown, androidWifiBandForFrequency(null)) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/SessionTimerAnchorStoreTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/SessionTimerAnchorStoreTest.kt new file mode 100644 index 000000000..a7c0e53c4 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/SessionTimerAnchorStoreTest.kt @@ -0,0 +1,48 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SessionTimerAnchorStoreTest { + @Test + fun reconnectKeepsPersistedStartForSameSession() { + assertEquals( + 1_000L, + resolveSessionTimerStartedAtMs( + sessionId = "session-a", + persistedSessionId = "session-a", + persistedStartedAtMs = 1_000L, + preferredStartedAtMs = null, + nowMs = 2_000L, + ), + ) + } + + @Test + fun newSessionUsesCurrentTimeInsteadOfPreviousSessionAnchor() { + assertEquals( + 2_000L, + resolveSessionTimerStartedAtMs( + sessionId = "session-b", + persistedSessionId = "session-a", + persistedStartedAtMs = 1_000L, + preferredStartedAtMs = null, + nowMs = 2_000L, + ), + ) + } + + @Test + fun recoveryCanCarryAnExistingInMemoryAnchor() { + assertEquals( + 1_250L, + resolveSessionTimerStartedAtMs( + sessionId = "session-a", + persistedSessionId = null, + persistedStartedAtMs = 0L, + preferredStartedAtMs = 1_250L, + nowMs = 2_000L, + ), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StoreRailTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StoreRailTest.kt new file mode 100644 index 000000000..5ab1dae85 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StoreRailTest.kt @@ -0,0 +1,45 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Test + +class StoreRailTest { + @Test + fun comingNextUsesOnlyGamesFromNvidiaNewOrUpdatedSections() { + val newGame = GameInfo( + id = "new-game", + title = "New Game", + catalogSectionTitle = "New games this week", + ) + val updatedGame = GameInfo( + id = "updated-game", + title = "Updated Game", + catalogSectionTitle = "Recently updated games", + ) + val featuredGame = GameInfo( + id = "featured-game", + title = "Featured Game", + catalogSectionTitle = "Featured", + ) + + val result = comingNextStoreGames( + games = listOf(featuredGame, newGame, updatedGame), + excludedGames = emptyList(), + ) + + assertEquals(listOf("new-game", "updated-game"), result.map(GameInfo::id)) + } + + @Test + fun comingNextDoesNotRepeatJumpBackInGames() { + val game = GameInfo( + id = "new-game", + title = "New Game", + catalogSectionTitle = "New games this week", + ) + + val result = comingNextStoreGames(games = listOf(game), excludedGames = listOf(game)) + + assertEquals(emptyList(), result) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamLivenessWatchdogTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamLivenessWatchdogTest.kt new file mode 100644 index 000000000..65a0cdee9 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamLivenessWatchdogTest.kt @@ -0,0 +1,148 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class StreamLivenessWatchdogTest { + @Test + fun advancedCodecRestartWaitsForDecoderReleaseAfterStablePlayback() { + assertEquals(180L, advancedCodecRestartSettleDelayMs(VideoCodec.AV1, hadStableMedia = true)) + assertEquals(180L, advancedCodecRestartSettleDelayMs(VideoCodec.H265, hadStableMedia = true)) + assertEquals(0L, advancedCodecRestartSettleDelayMs(VideoCodec.H264, hadStableMedia = true)) + assertEquals(0L, advancedCodecRestartSettleDelayMs(VideoCodec.AV1, hadStableMedia = false)) + } + + @Test + fun androidTvAllowsSlowHardwareDecoderStartupBeforeSafeFallback() { + val tv = streamRecoveryTiming(androidTvProfile = true) + val mobile = streamRecoveryTiming(androidTvProfile = false) + + assertEquals(5_000L, tv.keyframeAfterMs) + assertEquals(2_500L, tv.keyframeIntervalMs) + assertEquals(14_000L, tv.restartAfterMs) + assertEquals(5_000L, mobile.keyframeAfterMs) + assertEquals(2_500L, mobile.keyframeIntervalMs) + assertEquals(10_000L, mobile.restartAfterMs) + assertEquals(14_000L, firstVideoFrameRecoveryTimeoutMs(androidTvProfile = true)) + assertEquals(10_000L, firstVideoFrameRecoveryTimeoutMs(androidTvProfile = false)) + } + + @Test + fun firstFrameRecoveryRetriesRequestedProfileThenAppliesSafeFallbackOnlyOnce() { + assertEquals( + FirstFrameRecoveryStep.RetryRequestedProfile, + firstFrameRecoveryStep( + transportHasStableMedia = false, + reconnectAttempts = 0, + safeVideoFallbackApplied = false, + ), + ) + assertEquals( + FirstFrameRecoveryStep.ApplySafeVideoFallback, + firstFrameRecoveryStep( + transportHasStableMedia = false, + reconnectAttempts = 1, + safeVideoFallbackApplied = false, + ), + ) + assertEquals( + FirstFrameRecoveryStep.ContinueBoundedTransportRecovery, + firstFrameRecoveryStep( + transportHasStableMedia = false, + reconnectAttempts = 2, + safeVideoFallbackApplied = true, + ), + ) + } + + @Test + fun requestsKeyframesBeforeRestartingStalledMedia() { + val watchdog = StreamLivenessWatchdog( + keyframeAfterMs = 1_000L, + keyframeIntervalMs = 500L, + restartAfterMs = 3_000L, + ) + + watchdog.markConnected(0L) + + assertEquals(StreamLivenessAction.None, watchdog.observe(0L, bytesReceived = 10L, framesDecoded = 1L, connected = true)) + + val first = watchdog.observe(1_000L, bytesReceived = 10L, framesDecoded = 1L, connected = true) + assertTrue(first is StreamLivenessAction.RequestKeyframe) + assertEquals(1, (first as StreamLivenessAction.RequestKeyframe).attempt) + + assertEquals(StreamLivenessAction.None, watchdog.observe(1_200L, bytesReceived = 10L, framesDecoded = 1L, connected = true)) + + val second = watchdog.observe(1_500L, bytesReceived = 10L, framesDecoded = 1L, connected = true) + assertTrue(second is StreamLivenessAction.RequestKeyframe) + assertEquals(2, (second as StreamLivenessAction.RequestKeyframe).attempt) + + val restart = watchdog.observe(3_000L, bytesReceived = 10L, framesDecoded = 1L, connected = true) + assertTrue(restart is StreamLivenessAction.RestartTransport) + } + + @Test + fun progressClearsPendingStallRecovery() { + val watchdog = StreamLivenessWatchdog( + keyframeAfterMs = 1_000L, + keyframeIntervalMs = 500L, + restartAfterMs = 3_000L, + ) + + watchdog.markConnected(0L) + assertEquals(StreamLivenessAction.None, watchdog.observe(0L, bytesReceived = 10L, framesDecoded = 1L, connected = true)) + assertTrue(watchdog.observe(1_000L, bytesReceived = 10L, framesDecoded = 1L, connected = true) is StreamLivenessAction.RequestKeyframe) + assertEquals(StreamLivenessAction.None, watchdog.observe(1_200L, bytesReceived = 11L, framesDecoded = 2L, connected = true)) + assertEquals(StreamLivenessAction.None, watchdog.observe(1_900L, bytesReceived = 11L, framesDecoded = 2L, connected = true)) + } + + @Test + fun incomingBytesDoNotHideDecoderFrameStall() { + val watchdog = StreamLivenessWatchdog( + keyframeAfterMs = 1_000L, + keyframeIntervalMs = 500L, + restartAfterMs = 3_000L, + ) + + watchdog.markConnected(0L) + assertEquals(StreamLivenessAction.None, watchdog.observe(100L, bytesReceived = 10L, framesDecoded = 0L, connected = true)) + assertEquals(StreamLivenessAction.None, watchdog.observe(900L, bytesReceived = 100L, framesDecoded = 0L, connected = true)) + + val first = watchdog.observe(1_000L, bytesReceived = 200L, framesDecoded = 0L, connected = true) + assertTrue(first is StreamLivenessAction.RequestKeyframe) + } + + @Test + fun fallsBackToBytesWhenFrameCounterIsMissing() { + val watchdog = StreamLivenessWatchdog( + keyframeAfterMs = 1_000L, + keyframeIntervalMs = 500L, + restartAfterMs = 3_000L, + ) + + watchdog.markConnected(0L) + assertEquals(StreamLivenessAction.None, watchdog.observe(900L, bytesReceived = 10L, framesDecoded = null, connected = true)) + assertEquals(StreamLivenessAction.None, watchdog.observe(1_700L, bytesReceived = 20L, framesDecoded = null, connected = true)) + assertEquals(StreamLivenessAction.None, watchdog.observe(2_500L, bytesReceived = 30L, framesDecoded = null, connected = true)) + } + + @Test + fun reportsMediaProgressSeparatelyFromTransportConnectivity() { + val watchdog = StreamLivenessWatchdog( + keyframeAfterMs = 1_000L, + keyframeIntervalMs = 500L, + restartAfterMs = 3_000L, + ) + + watchdog.markConnected(0L) + watchdog.observe(100L, bytesReceived = 10L, framesDecoded = 0L, connected = true) + assertEquals(false, watchdog.latestObservationProgressed) + + watchdog.observe(200L, bytesReceived = 20L, framesDecoded = 1L, connected = true) + assertEquals(true, watchdog.latestObservationProgressed) + + watchdog.observe(300L, bytesReceived = 30L, framesDecoded = 1L, connected = true) + assertEquals(false, watchdog.latestObservationProgressed) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamResolutionTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamResolutionTest.kt new file mode 100644 index 000000000..6bb4914c5 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamResolutionTest.kt @@ -0,0 +1,704 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StreamResolutionTest { + @Test + fun smartSessionLimitsMatchMembershipTier() { + val free = smartSessionLimitFor(SubscriptionInfo(membershipTier = "FREE"), null) + val performance = smartSessionLimitFor(SubscriptionInfo(membershipTier = "PERFORMANCE"), null) + val ultimate = smartSessionLimitFor(SubscriptionInfo(membershipTier = "ULTIMATE"), null) + + assertEquals(1, free.limitHours) + assertEquals(SessionTimerMode.Countdown, free.mode) + assertEquals(6, performance.limitHours) + assertEquals(SessionTimerMode.Stopwatch, performance.mode) + assertEquals(8, ultimate.limitHours) + assertEquals(SessionTimerMode.Stopwatch, ultimate.mode) + } + + @Test + fun paidMonthlyUsageFallsBackToHundredHourLimit() { + val subscription = SubscriptionInfo(membershipTier = "ULTIMATE", usedHours = 37.25) + + assertEquals(100.0, monthlyHourLimitFor(subscription, null) ?: 0.0, 0.001) + assertEquals(62.75, monthlyHoursRemainingFor(subscription, null) ?: 0.0, 0.001) + } + + @Test + fun sessionWarningsFireAtMostRelevantCrossedThreshold() { + assertEquals(null, sessionWarningThresholdCrossed(null, 30 * 60)) + assertEquals(30 * 60, sessionWarningThresholdCrossed(30 * 60 + 1, 30 * 60)) + assertEquals(5 * 60, sessionWarningThresholdCrossed(10 * 60 + 1, 5 * 60 - 1)) + assertEquals(null, sessionWarningThresholdCrossed(5 * 60, 5 * 60 - 1)) + } + + @Test + fun streamResolutionPixelsKeepsSelected1080pFor16By9() { + val settings = StreamSettings(resolution = "1920x1080", aspectRatio = "16:9") + + assertEquals(1920 to 1080, streamResolutionPixels(settings)) + } + + @Test + fun runtimeResolutionMismatchIsDiagnosticForServerFallbackModes() { + assertEquals( + StreamResolutionMismatch(actualResolution = "1152x720", expectedResolution = "1280x720"), + streamRuntimeResolutionMismatch( + StreamSettings(resolution = "1280x720", aspectRatio = "16:9"), + "1152x720", + ), + ) + assertEquals( + StreamResolutionMismatch(actualResolution = "1366x768", expectedResolution = "1680x720"), + streamRuntimeResolutionMismatch( + StreamSettings(resolution = "1680x720", aspectRatio = "21:9"), + "1366x768", + ), + ) + } + + @Test + fun runtimeGameResolutionChangeRemainsDiagnosticOnly() { + val mismatch = streamRuntimeResolutionMismatch( + StreamSettings(resolution = "1920x1080", aspectRatio = "16:9"), + actualResolution = "1280x720", + serverNegotiatedResolution = "1920x1080", + ) + + assertEquals( + StreamResolutionMismatch( + actualResolution = "1280x720", + expectedResolution = "1920x1080", + ), + mismatch, + ) + assertEquals(false, mismatch?.isServerNegotiatedFallback) + } + + @Test + fun runtimeResolutionMismatchMarksServerNegotiatedFallback() { + val mismatch = streamRuntimeResolutionMismatch( + StreamSettings(resolution = "1680x720", aspectRatio = "21:9"), + actualResolution = "1366x768", + serverNegotiatedResolution = "1366x768", + ) + + assertEquals( + StreamResolutionMismatch( + actualResolution = "1366x768", + expectedResolution = "1680x720", + serverNegotiatedResolution = "1366x768", + ), + mismatch, + ) + assertEquals(true, mismatch?.isServerNegotiatedFallback) + } + + @Test + fun runtimeResolutionMismatchIgnoresExactAndMissingStats() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9") + + assertEquals(null, streamRuntimeResolutionMismatch(settings, null)) + assertEquals(null, streamRuntimeResolutionMismatch(settings, "1680x720")) + assertEquals(null, streamRuntimeResolutionMismatch(settings, "2x2")) + } + + @Test + fun activeStreamModeSeparatesServerFallbackFromLaterProviderOrGameChange() { + val requested = StreamSettings( + resolution = "1680x720", + aspectRatio = "21:9", + codec = VideoCodec.AV1, + ) + + val serverFallback = activeStreamModeStatus( + requestedSettings = requested, + transportSettings = requested, + decodedResolution = "1366x768", + serverNegotiatedResolution = "1366x768", + ) + val laterModeChange = activeStreamModeStatus( + requestedSettings = requested, + transportSettings = requested, + decodedResolution = "1230x768", + serverNegotiatedResolution = "1366x768", + ) + val finalServerMode = activeStreamModeStatus( + requestedSettings = requested, + transportSettings = requested, + decodedResolution = "1230x768", + serverNegotiatedResolution = "1366x768", + serverFinalSelectedResolution = "1230x768", + ) + + assertEquals(StreamResolutionChangeSource.ServerNegotiatedFallback, serverFallback?.resolutionSource) + assertEquals("1366x768", serverFallback?.displayedResolution) + assertEquals(false, serverFallback?.safeVideoRecoveryActive) + assertEquals(StreamResolutionChangeSource.ProviderOrGameModeChange, laterModeChange?.resolutionSource) + assertEquals("1230x768", laterModeChange?.displayedResolution) + assertEquals(false, laterModeChange?.safeVideoRecoveryActive) + assertEquals(StreamResolutionChangeSource.ServerNegotiatedFallback, finalServerMode?.resolutionSource) + assertEquals("1230x768", finalServerMode?.serverFinalSelectedResolution) + } + + @Test + fun activeStreamModeSurfacesClientSafeRecoveryWithoutInventingResolutionChange() { + val requested = StreamSettings( + resolution = "3840x2160", + aspectRatio = "16:9", + fps = 120, + maxBitrateMbps = 150, + codec = VideoCodec.AV1, + colorQuality = ColorQuality.TenBit420, + ) + val recovery = requested.androidSafeVideoFallback() + + val status = activeStreamModeStatus( + requestedSettings = requested, + transportSettings = recovery, + decodedResolution = "3840x2160", + serverNegotiatedResolution = "3840x2160", + ) + + assertEquals(null, status?.resolutionSource) + assertEquals(true, status?.safeVideoRecoveryActive) + assertEquals(VideoCodec.H264, status?.transportCodec) + assertEquals("3840x2160", status?.requestedResolution) + assertEquals("3840x2160", status?.displayedResolution) + } + + @Test + fun streamRendererAspectRatioIgnoresStartupPlaceholderResolution() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9") + + assertEquals(1680f / 720f, streamRendererAspectRatio(settings, "2x2"), 0.0001f) + } + + @Test + fun streamRendererAspectRatioUsesStableDecodedFallbackToPreserveFullFrame() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9") + + assertEquals(1366f / 768f, streamRendererAspectRatio(settings, "1366x768"), 0.0001f) + } + + @Test + fun streamRendererAspectRatioUsesServerNegotiatedFallbackResolution() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9") + + assertEquals( + 1366f / 768f, + streamRendererAspectRatio( + settings = settings, + decodedResolution = "1366x768", + serverNegotiatedResolution = "1366x768", + ), + 0.0001f, + ) + } + + @Test + fun widePhoneStretchScalesOnlyWidthWithoutCropping() { + val scale = streamStretchScale( + enabled = true, + viewportAspectRatio = 2400f / 1080f, + streamAspectRatio = 1280f / 720f, + ) + + assertEquals(1.25f, scale.first, 0.0001f) + assertEquals(1f, scale.second, 0.0001f) + } + + @Test + fun pinchZoomIsDisabledWhileTouchControllerIsVisible() { + assertFalse( + streamPinchZoomEnabled( + touchMouseEnabled = true, + touchControllerVisible = true, + ), + ) + assertTrue( + streamPinchZoomEnabled( + touchMouseEnabled = true, + touchControllerVisible = false, + ), + ) + assertFalse( + streamPinchZoomEnabled( + touchMouseEnabled = false, + touchControllerVisible = false, + ), + ) + } + + @Test + fun streamResolutionPixelsMaps1080pTierToUltrawideMode() { + val settings = StreamSettings(resolution = "1920x1080", aspectRatio = "21:9") + + assertEquals(2560 to 1080, streamResolutionPixels(settings)) + } + + @Test + fun persistedUnsupportedAspectFallsBackToSupportedSixteenByNineMode() { + val adjusted = StreamSettings(resolution = "1600x720", aspectRatio = "20:9") + .withResolutionAllowed(SubscriptionInfo(membershipTier = "FREE"), null) + + assertEquals("16:9", adjusted.aspectRatio) + assertEquals("1280x720", adjusted.resolution) + } + + @Test + fun freePlanResolutionNormalizationUses720pUltrawideFallback() { + val freeSubscription = SubscriptionInfo(membershipTier = "FREE") + + assertEquals( + "1680x720", + normalizeStreamResolutionForAspectAndPlan("1920x1080", "21:9", freeSubscription, null), + ) + assertEquals( + "1680x720", + StreamSettings(resolution = "2560x1080", aspectRatio = "21:9") + .withResolutionAllowed(freeSubscription, null) + .resolution, + ) + } + + @Test + fun customResolutionPixelsArePreservedForLaunch() { + val settings = StreamSettings(resolution = "1728x720", aspectRatio = "21:9") + + assertEquals(1728 to 720, streamResolutionPixels(settings)) + } + + @Test + fun freePlanKeepsCustomResolutionInsidePlanBounds() { + val adjusted = StreamSettings(resolution = "1728x720", aspectRatio = "21:9") + .withResolutionAllowed(SubscriptionInfo(membershipTier = "FREE"), null) + + assertEquals("1728x720", adjusted.resolution) + assertEquals("21:9", adjusted.aspectRatio) + } + + @Test + fun freePlanClampsCustomResolutionOutsidePlanBounds() { + val adjusted = StreamSettings(resolution = "5120x2160", aspectRatio = "21:9") + .withResolutionAllowed(SubscriptionInfo(membershipTier = "FREE"), null) + + assertEquals("1680x720", adjusted.resolution) + assertEquals("21:9", adjusted.aspectRatio) + } + + @Test + fun freePlanResolutionGuardFallsBackWhenAspectHasNoAvailableMode() { + val adjusted = StreamSettings(resolution = "3840x1080", aspectRatio = "32:9") + .withResolutionAllowed(SubscriptionInfo(membershipTier = "FREE"), null) + + assertEquals("1920x1080", adjusted.resolution) + assertEquals("16:9", adjusted.aspectRatio) + } + + @Test + fun streamResolutionPixelsMaps1440pTierToUltrawideMode() { + val settings = StreamSettings(resolution = "2560x1440", aspectRatio = "21:9") + + assertEquals(3440 to 1440, streamResolutionPixels(settings)) + } + + @Test + fun streamResolutionPixelsKeepsSelected4kFor16By9() { + val settings = StreamSettings(resolution = "3840x2160", aspectRatio = "16:9") + + assertEquals(3840 to 2160, streamResolutionPixels(settings)) + } + + @Test + fun streamResolutionPixelsMapsSelectedTierForTallerAspectRatio() { + val settings = StreamSettings(resolution = "1920x1080", aspectRatio = "16:10") + + assertEquals(1920 to 1200, streamResolutionPixels(settings)) + } + + @Test + fun streamResolutionPixelsKeepsExactStoredUltrawideMode() { + val settings = StreamSettings(resolution = "3440x1440", aspectRatio = "21:9") + + assertEquals(3440 to 1440, streamResolutionPixels(settings)) + } + + @Test + fun streamResolutionOptionsIncludeAndroidSupportedModes() { + assertEquals( + listOf("1280x720", "1366x768", "1600x900", "1920x1080", "2560x1440", "3840x2160", "5120x2880"), + streamResolutionOptionsForAspect("16:9"), + ) + assertEquals(listOf("1024x768", "1112x834", "1600x1200"), streamResolutionOptionsForAspect("4:3")) + assertEquals(listOf("1280x1024"), streamResolutionOptionsForAspect("5:4")) + assertEquals(emptyList(), streamResolutionOptionsForAspect("20:9")) + assertEquals(listOf("1680x720", "2560x1080", "3440x1440", "5120x2160"), streamResolutionOptionsForAspect("21:9")) + assertEquals(listOf("3840x1080", "5120x1440"), streamResolutionOptionsForAspect("32:9")) + } + + @Test + fun streamResolutionPixelsMaps4kTierToSupported16By10Mode() { + val settings = StreamSettings(resolution = "3840x2160", aspectRatio = "16:10") + + assertEquals(3456 to 2160, streamResolutionPixels(settings)) + } + + @Test + fun streamResolutionChoicesGatePriorityAndUltimateModes() { + val freeSubscription = SubscriptionInfo(membershipTier = "FREE") + val prioritySubscription = SubscriptionInfo(membershipTier = "PRIORITY") + val ultimateSubscription = SubscriptionInfo(membershipTier = "ULTIMATE") + val fhd = streamResolutionChoicesForAspect("16:9").first { it.value == "1920x1080" } + val whd = streamResolutionChoicesForAspect("21:9").first { it.value == "1680x720" } + val wfhd = streamResolutionChoicesForAspect("21:9").first { it.value == "2560x1080" } + val qhd = streamResolutionChoicesForAspect("16:9").first { it.value == "2560x1440" } + val fourK = streamResolutionChoicesForAspect("16:9").first { it.value == "3840x2160" } + val fiveK = streamResolutionChoicesForAspect("16:9").first { it.value == "5120x2880" } + + assertEquals(true, fhd.isAvailableFor(freeSubscription, null)) + assertEquals(true, whd.isAvailableFor(freeSubscription, null)) + assertEquals(false, wfhd.isAvailableFor(freeSubscription, null)) + assertEquals(false, qhd.isAvailableFor(freeSubscription, null)) + assertEquals(true, fhd.isAvailableFor(prioritySubscription, null)) + assertEquals(true, whd.isAvailableFor(prioritySubscription, null)) + assertEquals(true, wfhd.isAvailableFor(prioritySubscription, null)) + assertEquals(true, qhd.isAvailableFor(prioritySubscription, null)) + assertEquals(false, fourK.isAvailableFor(prioritySubscription, null)) + assertEquals(false, fiveK.isAvailableFor(prioritySubscription, null)) + assertEquals(true, fourK.isAvailableFor(ultimateSubscription, null)) + assertEquals(true, fiveK.isAvailableFor(ultimateSubscription, null)) + } + + @Test + fun streamFpsCapsFollowMembershipPlan() { + val requested = StreamSettings(fps = 360) + + assertEquals(60, requested.withFpsAllowed(SubscriptionInfo(membershipTier = "FREE"), null).fps) + assertEquals(60, requested.withFpsAllowed(SubscriptionInfo(membershipTier = "PERFORMANCE"), null).fps) + assertEquals(240, requested.withFpsAllowed(SubscriptionInfo(membershipTier = "ULTIMATE"), null).fps) + assertEquals(240, maxStreamFpsFor(null, "ULTIMATE")) + } + + @Test + fun fpsPlanCapsDoNotChangeSelectedResolution() { + val freeRequested = StreamSettings(resolution = "1920x1200", aspectRatio = "16:10", fps = 240) + val ultimateRequested = StreamSettings(resolution = "3840x2160", aspectRatio = "16:9", fps = 360) + + assertEquals( + freeRequested.copy(fps = 60), + freeRequested.withFpsAllowed(SubscriptionInfo(membershipTier = "FREE"), null), + ) + assertEquals( + ultimateRequested.copy(fps = 240), + ultimateRequested.withFpsAllowed(SubscriptionInfo(membershipTier = "ULTIMATE"), null), + ) + } + + @Test + fun fpsPlanCapsPreserveEveryKnownResolutionAndCodec() { + val freeSubscription = SubscriptionInfo(membershipTier = "FREE") + val ultimateSubscription = SubscriptionInfo(membershipTier = "ULTIMATE") + + STREAM_RESOLUTION_OPTIONS.forEach { option -> + VideoCodec.entries.forEach { codec -> + val requested = StreamSettings( + resolution = option.value, + aspectRatio = option.aspectRatio, + fps = 360, + codec = codec, + ) + + assertEquals( + "Free ${option.value} $codec", + requested.copy(fps = 60), + requested.withFpsAllowed(freeSubscription, null), + ) + assertEquals( + "Ultimate ${option.value} $codec", + requested.copy(fps = 240), + requested.withFpsAllowed(ultimateSubscription, null), + ) + } + } + } + + @Test + fun hdrIsAvailableForPerformanceAndUltimatePlans() { + val requested = StreamSettings(codec = VideoCodec.H265, hdrEnabled = true) + + assertEquals(false, requested.withHdrAllowed(SubscriptionInfo(membershipTier = "FREE"), null).hdrEnabled) + assertEquals(true, requested.withHdrAllowed(SubscriptionInfo(membershipTier = "PERFORMANCE"), null).hdrEnabled) + assertEquals(true, requested.withHdrAllowed(SubscriptionInfo(membershipTier = "PRIORITY"), null).hdrEnabled) + assertEquals(true, requested.withHdrAllowed(SubscriptionInfo(membershipTier = "ULTIMATE"), null).hdrEnabled) + assertEquals(true, hasHdrStreamingPlan(null, "PERFORMANCE")) + } + + @Test + fun authenticatedUltimateTierWinsWhenSubscriptionPayloadDefaultsToFree() { + val incompleteSubscription = SubscriptionInfo(membershipTier = "FREE") + val fourK = streamResolutionChoicesForAspect("16:9").first { it.value == "3840x2160" } + val requested = StreamSettings(resolution = "3840x2160", aspectRatio = "16:9", fps = 240) + + assertEquals(true, fourK.isAvailableFor(incompleteSubscription, "ULTIMATE")) + assertEquals(240, maxStreamFpsFor(incompleteSubscription, "ULTIMATE")) + assertEquals( + requested, + requested + .withResolutionAllowed(incompleteSubscription, "ULTIMATE") + .withFpsAllowed(incompleteSubscription, "ULTIMATE"), + ) + } + + @Test + fun entitledResolutionDoesNotBypassMembershipPlanGate() { + val subscription = SubscriptionInfo( + membershipTier = "FREE", + entitledResolutions = listOf(EntitledResolution(width = 3840, height = 2160, fps = 60)), + ) + val fourK = streamResolutionChoicesForAspect("16:9").first { it.value == "3840x2160" } + + assertEquals(false, fourK.isAvailableFor(subscription, null)) + } + + @Test + fun activeSessionRejectsUnexpectedResolutionBeforeReuse() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val stale = activeSession( + resolution = "1680x1050", + fps = 60, + settingsSignature = streamSettingsSessionSignature(settings), + ) + + assertEquals(false, stale.matchesStreamSettings(settings)) + } + + @Test + fun activeSessionRejectsServerFallbackResolutionBeforeReuse() { + val ultrawide = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val hd = StreamSettings(resolution = "1280x720", aspectRatio = "16:9", fps = 60) + + assertEquals( + false, + activeSession( + resolution = "1366x768", + fps = 60, + settingsSignature = streamSettingsSessionSignature(ultrawide), + ).matchesStreamSettings(ultrawide), + ) + assertEquals( + false, + activeSession( + resolution = "1280x720", + fps = 60, + settingsSignature = streamSettingsSessionSignature(ultrawide), + ).matchesStreamSettings(ultrawide), + ) + assertEquals( + false, + activeSession( + resolution = "1152x720", + fps = 60, + settingsSignature = streamSettingsSessionSignature(hd), + ).matchesStreamSettings(hd), + ) + } + + @Test + fun activeSessionMatchesRequestedUltrawideResolutionBeforeReuse() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val active = activeSession( + resolution = "1680x720", + fps = 60, + settingsSignature = streamSettingsSessionSignature(settings), + ) + + assertEquals(true, active.matchesStreamSettings(settings)) + } + + @Test + fun activeSessionWithoutOpenNowSettingsSignatureIsNotReusedForLaunch() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val active = activeSession(resolution = "1680x720", fps = 60) + + assertEquals(false, active.matchesStreamSettings(settings)) + } + + @Test + fun activeSessionWithDifferentOpenNowSettingsSignatureIsNotReusedForLaunch() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60, codec = VideoCodec.H265, maxBitrateMbps = 150) + val otherSettings = settings.copy(codec = VideoCodec.H264, maxBitrateMbps = 75) + val active = activeSession( + resolution = "1680x720", + fps = 60, + settingsSignature = streamSettingsSessionSignature(otherSettings), + ) + + assertEquals(false, active.matchesStreamSettings(settings)) + } + + @Test + fun recoveryReclaimsExactUltrawideSessionAfterLocalCodecFallback() { + val original = StreamSettings( + resolution = "1680x720", + aspectRatio = "21:9", + fps = 60, + codec = VideoCodec.AV1, + ) + val safeFallback = original.androidSafeVideoFallback() + val running = activeSession( + sessionId = "running-session", + resolution = "1680x720", + fps = 60, + settingsSignature = streamSettingsSessionSignature(original), + ) + + assertEquals(false, running.matchesStreamSettings(safeFallback)) + assertEquals( + "running-session", + activeSessionRecoveryCandidate( + sessions = listOf(running), + previousSessionId = "running-session", + launchAppId = running.appId, + settings = safeFallback, + )?.sessionId, + ) + } + + @Test + fun recoveryDoesNotReclaimExactSessionWithDifferentGeometry() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val stale = activeSession( + sessionId = "running-session", + resolution = "1920x1080", + fps = 60, + settingsSignature = streamSettingsSessionSignature(settings), + ) + + assertEquals( + null, + activeSessionRecoveryCandidate( + sessions = listOf(stale), + previousSessionId = "running-session", + launchAppId = null, + settings = settings, + ), + ) + } + + @Test + fun recoveryKeepsStrictSignatureMatchingForOtherSessions() { + val original = StreamSettings( + resolution = "1680x720", + aspectRatio = "21:9", + fps = 60, + codec = VideoCodec.AV1, + ) + val safeFallback = original.androidSafeVideoFallback() + val otherSession = activeSession( + sessionId = "other-session", + resolution = "1680x720", + fps = 60, + settingsSignature = streamSettingsSessionSignature(original), + ) + + assertEquals( + null, + activeSessionRecoveryCandidate( + sessions = listOf(otherSession), + previousSessionId = "previous-session", + launchAppId = otherSession.appId, + settings = safeFallback, + ), + ) + } + + @Test + fun activeSessionWithUnknownMonitorModeIsNotReusedForLaunch() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val active = activeSession( + resolution = null, + fps = null, + settingsSignature = streamSettingsSessionSignature(settings), + ) + + assertEquals(false, active.matchesStreamSettings(settings)) + } + + @Test + fun activeSessionWithUnknownRefreshRateIsNotReusedForLaunch() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val active = activeSession( + resolution = "1680x720", + fps = null, + settingsSignature = streamSettingsSessionSignature(settings), + ) + + assertEquals(false, active.matchesStreamSettings(settings)) + } + + @Test + fun activeSessionConflictPrefersSameRequestedAppBeforePolling() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val otherReady = activeSession( + sessionId = "other-ready", + appId = 200, + resolution = "1680x720", + fps = 60, + settingsSignature = streamSettingsSessionSignature(settings), + ) + val sameAppLaunching = activeSession( + sessionId = "same-launching", + appId = 100, + status = 1, + resolution = "1680x720", + fps = 60, + settingsSignature = streamSettingsSessionSignature(settings), + ) + + assertEquals( + "same-launching", + activeSessionLaunchConflict(listOf(otherReady, sameAppLaunching), launchAppId = 100, settings = settings)?.sessionId, + ) + } + + @Test + fun activeSessionConflictStillReturnsMismatchedExistingSessionForUserChoice() { + val settings = StreamSettings(resolution = "1680x720", aspectRatio = "21:9", fps = 60) + val mismatched = activeSession( + sessionId = "mismatched", + appId = 100, + resolution = "1920x1080", + fps = 60, + settingsSignature = streamSettingsSessionSignature(StreamSettings(resolution = "1920x1080", aspectRatio = "16:9", fps = 60)), + ) + + assertEquals( + "mismatched", + activeSessionLaunchConflict(listOf(mismatched), launchAppId = 100, settings = settings)?.sessionId, + ) + } + + private fun activeSession( + sessionId: String = "session", + appId: Int = 100, + status: Int = 2, + resolution: String?, + fps: Int?, + settingsSignature: String? = null, + ): ActiveSessionInfo = + ActiveSessionInfo( + sessionId = sessionId, + appId = appId, + status = status, + serverIp = "127.0.0.1", + signalingUrl = "wss://127.0.0.1/nvst/", + resolution = resolution, + fps = fps, + settingsSignature = settingsSignature, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamSettingsDeviceAdjustmentTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamSettingsDeviceAdjustmentTest.kt new file mode 100644 index 000000000..55174e18a --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamSettingsDeviceAdjustmentTest.kt @@ -0,0 +1,994 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StreamSettingsDeviceAdjustmentTest { + @Test + fun preservesSelectedH265WhenWebRtcHardwareDecoderExists() { + val adjusted = StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.TenBit420) + .adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + } + + @Test + fun preservesSelectedAv1WhenWebRtcHardwareDecoderExists() { + val adjusted = StreamSettings(codec = VideoCodec.AV1, colorQuality = ColorQuality.TenBit420) + .adjustedForDevice( + codecReport( + VideoCodec.AV1, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.AV1, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + } + + @Test + fun av1DropsChroma444BeforeLaunch() { + val adjusted = StreamSettings(codec = VideoCodec.AV1, colorQuality = ColorQuality.EightBit444) + .adjustedForDevice( + codecReport( + VideoCodec.AV1, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.AV1, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + } + + @Test + fun av1HdrPreservesTenBit420Profile() { + val adjusted = StreamSettings(codec = VideoCodec.AV1, colorQuality = ColorQuality.TenBit444, hdrEnabled = true) + .adjustedForDevice( + codecReport( + VideoCodec.AV1, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.AV1, adjusted.codec) + assertEquals(ColorQuality.TenBit420, adjusted.colorQuality) + assertEquals(true, adjusted.hdrEnabled) + } + + @Test + fun androidSettingsAvailabilityAllowsCodecsAndWithholdsChroma444() { + assertTrue(VideoCodec.AV1.availableForAndroidSettings()) + assertTrue(VideoCodec.H264.availableForAndroidSettings()) + assertTrue(VideoCodec.H265.availableForAndroidSettings()) + assertFalse(ColorQuality.EightBit444.availableForCodec(VideoCodec.H265)) + assertFalse(ColorQuality.TenBit444.availableForCodec(VideoCodec.H265)) + assertTrue(ColorQuality.EightBit420.availableForCodec(VideoCodec.H265)) + assertTrue(ColorQuality.TenBit420.availableForCodec(VideoCodec.H265)) + } + + @Test + fun chroma444SettingsNormalizeTo420ForAndroid() { + val adjusted = StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.TenBit444) + .withCodecColorCompatibility() + + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.TenBit420, adjusted.colorQuality) + } + + @Test + fun streamPresetsApplyExpectedAndroidProfiles() { + val base = StreamSettings(aspectRatio = "21:9", resolution = "1680x720", codec = VideoCodec.AV1, colorQuality = ColorQuality.EightBit444) + + val low = base.applyingStreamPreset(StreamPreset.LowDataSaver) + assertEquals("1680x720", low.resolution) + assertEquals("21:9", low.aspectRatio) + assertEquals(30, low.fps) + assertEquals(12, low.maxBitrateMbps) + assertEquals(VideoCodec.AV1, low.codec) + assertEquals(ColorQuality.EightBit420, low.colorQuality) + + val medium = base.applyingStreamPreset(StreamPreset.Medium) + assertEquals("2560x1080", medium.resolution) + assertEquals(60, medium.fps) + assertEquals(35, medium.maxBitrateMbps) + + val high = base.applyingStreamPreset(StreamPreset.High) + assertEquals("3440x1440", high.resolution) + assertEquals(240, high.fps) + assertEquals(75, high.maxBitrateMbps) + } + + @Test + fun preservesUltimate240FpsAt4kForStableAndroidProfile() { + val adjusted = StreamSettings(resolution = "3840x2160", aspectRatio = "16:9", fps = 240, codec = VideoCodec.AV1) + .adjustedForDevice( + codecReport( + VideoCodec.AV1, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals("3840x2160", adjusted.resolution) + assertEquals(240, adjusted.fps) + } + + @Test + fun preservesTenBitWhenHdrIsEnabled() { + val adjusted = StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.TenBit420, hdrEnabled = true) + .adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.TenBit420, adjusted.colorQuality) + } + + @Test + fun fallsBackToH264WhenSelectedDecoderHasNoHardwarePath() { + val adjusted = StreamSettings(codec = VideoCodec.AV1, colorQuality = ColorQuality.TenBit420, maxBitrateMbps = 90) + .adjustedForDevice(codecReport(VideoCodec.AV1, hardwareDecoder = false, realtimeSafe = false)) + + assertEquals(VideoCodec.H264, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals(35, adjusted.maxBitrateMbps) + } + + @Test + fun fallsBackToH264WhenH265WebRtcDecoderIsSoftwareOnly() { + val adjusted = StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.TenBit420, maxBitrateMbps = 90) + .adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = false, + ), + ) + + assertEquals(VideoCodec.H264, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals(35, adjusted.maxBitrateMbps) + } + + @Test + fun preservesSelectedResolutionWhenFallingBackToH264() { + val adjusted = StreamSettings( + resolution = "1680x720", + aspectRatio = "21:9", + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + maxBitrateMbps = 90, + ) + .adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = false, + ), + ) + + assertEquals(VideoCodec.H264, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals("1680x720", adjusted.resolution) + assertEquals("21:9", adjusted.aspectRatio) + assertEquals(35, adjusted.maxBitrateMbps) + } + + @Test + fun fallsBackToH264WhenH265OnlyHasPlatformHardwareDecoder() { + val adjusted = StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.TenBit420, maxBitrateMbps = 90) + .adjustedForDevice(codecReport(VideoCodec.H265, hardwareDecoder = true, realtimeSafe = true)) + + assertEquals(VideoCodec.H264, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals(35, adjusted.maxBitrateMbps) + } + + @Test + fun preservesH265WhenWebRtcHardwareDecoderWorksButNativeProbeFails() { + val adjusted = StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.TenBit420, maxBitrateMbps = 90) + .adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + nativeDecoderAvailable = false, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals(75, adjusted.maxBitrateMbps) + } + + @Test + fun changingResolutionDoesNotForceAWebRtcHardwareCodecBackToH264() { + val report = codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + nativeDecoderAvailable = false, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ) + + listOf("1280x720", "1920x1080", "2560x1440", "3840x2160").forEach { resolution -> + val adjusted = StreamSettings( + resolution = resolution, + aspectRatio = "16:9", + codec = VideoCodec.H265, + ).adjustedForDevice(report) + + assertEquals("$resolution should retain the selected codec", VideoCodec.H265, adjusted.codec) + } + } + + @Test + fun selectsAv1WhenNativeAndWebRtcHardwarePathsExist() { + val adjusted = StreamSettings(codec = VideoCodec.AV1, colorQuality = ColorQuality.TenBit420) + .adjustedForDevice( + codecReport( + VideoCodec.AV1, + hardwareDecoder = true, + realtimeSafe = true, + nativeDecoderAvailable = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.AV1, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + } + + @Test + fun keepsSelectedCodecOnLowPowerDevicesWhenWebRtcHardwareDecoderExists() { + val adjusted = StreamSettings(codec = VideoCodec.H265, colorQuality = ColorQuality.TenBit420, maxBitrateMbps = 90) + .adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals(25, adjusted.maxBitrateMbps) + } + + @Test + fun preservesHighRefreshRateForSupportedAndroidStreams() { + val adjusted = StreamSettings(codec = VideoCodec.AV1, fps = 120, maxBitrateMbps = 90) + .adjustedForDevice( + codecReport( + VideoCodec.AV1, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.AV1, adjusted.codec) + assertEquals(120, adjusted.fps) + assertEquals(75, adjusted.maxBitrateMbps) + } + + @Test + fun capsH264AndroidBandwidthAtUserSafeCeiling() { + val adjusted = StreamSettings(codec = VideoCodec.H264, maxBitrateMbps = 150) + .adjustedForDevice(codecReport(VideoCodec.H264, hardwareDecoder = true, realtimeSafe = true)) + + assertEquals(VideoCodec.H264, adjusted.codec) + assertEquals(75, adjusted.maxBitrateMbps) + } + + @Test + fun stabilizesExtremeH264CloudMatchProfileBeforeLaunch() { + val adjusted = StreamSettings( + resolution = "5120x1440", + aspectRatio = "32:9", + fps = 240, + maxBitrateMbps = 150, + codec = VideoCodec.H264, + colorQuality = ColorQuality.TenBit444, + hdrEnabled = true, + enableL4S = true, + enableCloudGsync = true, + streamSharpeningEnabled = true, + ).adjustedForDevice(codecReport(VideoCodec.H264, hardwareDecoder = true, realtimeSafe = true)) + + assertEquals("5120x1440", adjusted.resolution) + assertEquals("32:9", adjusted.aspectRatio) + assertEquals(240, adjusted.fps) + assertEquals(75, adjusted.maxBitrateMbps) + assertEquals(VideoCodec.H264, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals(false, adjusted.hdrEnabled) + assertEquals(false, adjusted.enableCloudGsync) + assertEquals(true, adjusted.enableL4S) + } + + @Test + fun stabilizesExtremeH265CloudMatchProfileWithoutDroppingAdvancedFeatures() { + val adjusted = StreamSettings( + resolution = "5120x2160", + aspectRatio = "21:9", + fps = 240, + maxBitrateMbps = 150, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + enableL4S = true, + enableCloudGsync = true, + streamSharpeningEnabled = true, + ).adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + nativeDecoderAvailable = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals("5120x2160", adjusted.resolution) + assertEquals("21:9", adjusted.aspectRatio) + assertEquals(240, adjusted.fps) + assertEquals(75, adjusted.maxBitrateMbps) + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.TenBit420, adjusted.colorQuality) + assertEquals(true, adjusted.hdrEnabled) + assertEquals(true, adjusted.enableCloudGsync) + assertEquals(true, adjusted.enableL4S) + } + + @Test + fun safeVideoFallbackChangesCodecWithoutChangingRequested4kGeometry() { + val fallback = StreamSettings( + resolution = "3840x2160", + aspectRatio = "16:9", + fps = 120, + maxBitrateMbps = 150, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + enableCloudGsync = true, + streamSharpeningEnabled = true, + ).androidSafeVideoFallback() + + assertEquals("3840x2160", fallback.resolution) + assertEquals("16:9", fallback.aspectRatio) + assertEquals(60, fallback.fps) + assertEquals(75, fallback.maxBitrateMbps) + assertEquals(VideoCodec.H264, fallback.codec) + assertEquals(ColorQuality.EightBit420, fallback.colorQuality) + assertEquals(false, fallback.hdrEnabled) + assertEquals(false, fallback.enableCloudGsync) + assertEquals(false, fallback.streamSharpeningEnabled) + } + + @Test + fun safeVideoFallbackPreservesLaunchResolutionInsideH264Bounds() { + val fallback = StreamSettings( + resolution = "1680x720", + aspectRatio = "21:9", + fps = 60, + maxBitrateMbps = 75, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + enableCloudGsync = true, + streamSharpeningEnabled = true, + ).androidSafeVideoFallback() + + assertEquals("1680x720", fallback.resolution) + assertEquals("21:9", fallback.aspectRatio) + assertEquals(60, fallback.fps) + assertEquals(75, fallback.maxBitrateMbps) + assertEquals(VideoCodec.H264, fallback.codec) + assertEquals(ColorQuality.EightBit420, fallback.colorQuality) + } + + @Test + fun safeVideoFallbackPreservesEveryKnownResolutionAndAspect() { + STREAM_RESOLUTION_OPTIONS.forEach { option -> + val fallback = StreamSettings( + resolution = option.value, + aspectRatio = option.aspectRatio, + fps = 240, + maxBitrateMbps = 150, + codec = VideoCodec.AV1, + colorQuality = ColorQuality.TenBit420, + ).androidSafeVideoFallback() + + assertEquals(option.value, fallback.resolution) + assertEquals(option.aspectRatio, fallback.aspectRatio) + assertEquals(VideoCodec.H264, fallback.codec) + } + } + + @Test + fun constrainedTvFirstFrameRecoveryChangesCodecOnceWithoutReducing1440p() { + val launch = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + fps = 60, + maxBitrateMbps = 75, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + ).adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + tv = true, + constrainedRuntime = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + val recovery = launch.androidSafeVideoFallback() + + assertEquals("2560x1440", launch.resolution) + assertEquals(VideoCodec.H265, launch.codec) + assertEquals("2560x1440", recovery.resolution) + assertEquals("16:9", recovery.aspectRatio) + assertEquals(VideoCodec.H264, recovery.codec) + assertEquals(recovery, recovery.androidSafeVideoFallback()) + } + + @Test + fun usesSafeH264ProfileForLowPowerAndroidTv() { + val adjusted = StreamSettings( + resolution = "3840x2160", + aspectRatio = "16:9", + fps = 120, + maxBitrateMbps = 90, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + streamSharpeningEnabled = true, + ).adjustedForDevice(codecReport(VideoCodec.H265, hardwareDecoder = true, realtimeSafe = true, lowPower = true, tv = true)) + + assertEquals(VideoCodec.H264, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals("1920x1080", adjusted.resolution) + assertEquals("16:9", adjusted.aspectRatio) + assertEquals(60, adjusted.fps) + assertEquals(25, adjusted.maxBitrateMbps) + assertEquals(false, adjusted.streamSharpeningEnabled) + } + + @Test + fun preservesHighCustomProfileOn32BitPhone() { + val adjusted = StreamSettings( + resolution = "3840x2160", + aspectRatio = "16:9", + fps = 120, + maxBitrateMbps = 75, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + enableCloudGsync = true, + streamSharpeningEnabled = true, + ).adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + constrainedRuntime = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + maxSupportedWidth = 3840, + maxSupportedHeight = 2160, + ), + ) + + assertEquals("3840x2160", adjusted.resolution) + assertEquals("16:9", adjusted.aspectRatio) + assertEquals(120, adjusted.fps) + assertEquals(75, adjusted.maxBitrateMbps) + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.TenBit420, adjusted.colorQuality) + assertEquals(true, adjusted.hdrEnabled) + assertEquals(true, adjusted.enableCloudGsync) + assertEquals(true, adjusted.streamSharpeningEnabled) + } + + @Test + fun preservesHighCustomProfileOnMemoryConstrainedTv() { + val adjusted = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + fps = 60, + maxBitrateMbps = 75, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + enableCloudGsync = true, + streamSharpeningEnabled = true, + ).adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + tv = true, + constrainedRuntime = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + maxSupportedWidth = 3840, + maxSupportedHeight = 2160, + ), + ) + + assertEquals("2560x1440", adjusted.resolution) + assertEquals(60, adjusted.fps) + assertEquals(75, adjusted.maxBitrateMbps) + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.TenBit420, adjusted.colorQuality) + assertEquals(true, adjusted.hdrEnabled) + assertEquals(true, adjusted.enableCloudGsync) + assertEquals(true, adjusted.streamSharpeningEnabled) + } + + @Test + fun warnsAboutDemandingSettingsWithoutChangingThem() { + val settings = StreamSettings( + resolution = "1920x1080", + aspectRatio = "16:9", + fps = 60, + maxBitrateMbps = 35, + hdrEnabled = true, + enableCloudGsync = true, + streamSharpeningEnabled = true, + ) + val report = codecReport( + VideoCodec.H264, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + constrainedRuntime = true, + ) + + assertEquals( + listOf( + "1920x1080 resolution", + "60 FPS", + "35 Mbps bitrate", + "HDR", + "Cloud G-Sync", + "stream sharpening", + ), + settings.lowPowerPerformanceWarningReasons(report), + ) + } + + @Test + fun recommendedLowPowerProfileDoesNotWarn() { + val settings = StreamSettings( + resolution = "1280x720", + aspectRatio = "16:9", + fps = 30, + maxBitrateMbps = 12, + ) + val report = codecReport( + VideoCodec.H264, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + constrainedRuntime = true, + ) + + assertTrue(settings.lowPowerPerformanceWarningReasons(report).isEmpty()) + } + + @Test + fun preservesHardwareH265ForLowPowerAndroidTvInsideSafeLimits() { + val adjusted = StreamSettings( + resolution = "3840x2160", + aspectRatio = "16:9", + fps = 120, + maxBitrateMbps = 90, + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + hdrEnabled = true, + enableCloudGsync = true, + streamSharpeningEnabled = true, + ).adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + tv = true, + nativeDecoderAvailable = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals("1920x1080", adjusted.resolution) + assertEquals(60, adjusted.fps) + assertEquals(25, adjusted.maxBitrateMbps) + assertEquals(false, adjusted.hdrEnabled) + assertEquals(false, adjusted.enableCloudGsync) + assertEquals(false, adjusted.streamSharpeningEnabled) + } + + @Test + fun lowPowerAndroidTvKeeps1440pWhenHardwareDecoderExplicitlySupportsIt() { + val adjusted = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + maxBitrateMbps = 75, + fps = 120, + streamSharpeningEnabled = true, + ).adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + tv = true, + nativeDecoderAvailable = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + maxSupportedWidth = 3840, + maxSupportedHeight = 2160, + ), + ) + + assertEquals("2560x1440", adjusted.resolution) + assertEquals("16:9", adjusted.aspectRatio) + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(60, adjusted.fps) + assertEquals(25, adjusted.maxBitrateMbps) + assertEquals(false, adjusted.streamSharpeningEnabled) + } + + @Test + fun lowPowerAndroidTvKeeps1440pWhenHardwareDecoderLimitsAreMissing() { + val adjusted = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + maxBitrateMbps = 75, + fps = 60, + ).adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + tv = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals("2560x1440", adjusted.resolution) + assertEquals("16:9", adjusted.aspectRatio) + assertEquals(VideoCodec.H265, adjusted.codec) + assertEquals(60, adjusted.fps) + assertEquals(25, adjusted.maxBitrateMbps) + } + + @Test + fun lowPowerAndroidTvKeeps1440pWhenHardwareDecoderProbeUnderreportsLimits() { + val adjusted = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + maxBitrateMbps = 75, + fps = 60, + ).adjustedForDevice( + codecReport( + VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + lowPower = true, + tv = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + maxSupportedWidth = 1920, + maxSupportedHeight = 1080, + ), + ) + + assertEquals("2560x1440", adjusted.resolution) + assertEquals("16:9", adjusted.aspectRatio) + assertEquals(VideoCodec.H265, adjusted.codec) + } + + @Test + fun keepsLowPowerAndroidTvUltrawideWithinDecoderBounds() { + val adjusted = StreamSettings( + resolution = "3440x1440", + aspectRatio = "21:9", + codec = VideoCodec.H265, + colorQuality = ColorQuality.TenBit420, + ).adjustedForDevice(codecReport(VideoCodec.H265, hardwareDecoder = true, realtimeSafe = true, lowPower = true, tv = true)) + + assertEquals(VideoCodec.H264, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + assertEquals("2560x1080", adjusted.resolution) + assertEquals("21:9", adjusted.aspectRatio) + } + + @Test + fun disablesRendererSharpeningForAndroidTvLaunchProfiles() { + val adjusted = StreamSettings( + codec = VideoCodec.AV1, + maxBitrateMbps = 75, + streamSharpeningEnabled = true, + ).adjustedForDevice( + codecReport( + VideoCodec.AV1, + hardwareDecoder = true, + realtimeSafe = true, + tv = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.AV1, adjusted.codec) + assertEquals(35, adjusted.maxBitrateMbps) + assertEquals(false, adjusted.streamSharpeningEnabled) + } + + @Test + fun preservesAv1WhenWebRtcHardwarePathExistsAndPlatformProbeMissesIt() { + val adjusted = StreamSettings(codec = VideoCodec.AV1, colorQuality = ColorQuality.TenBit420) + .adjustedForDevice( + codecReport( + VideoCodec.AV1, + decoderAvailable = false, + hardwareDecoder = false, + realtimeSafe = false, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ) + + assertEquals(VideoCodec.AV1, adjusted.codec) + assertEquals(ColorQuality.EightBit420, adjusted.colorQuality) + } + + @Test + fun usesAv1HardwareFallbackWhenH264ProbeFails() { + val report = RuntimeCodecReport( + capabilities = listOf( + CodecCapability( + codec = VideoCodec.H264, + decoderAvailable = true, + encoderAvailable = false, + hardwareDecoder = true, + hardwareEncoder = false, + realtimeSafe = true, + webRtcDecoderAvailable = false, + ), + CodecCapability( + codec = VideoCodec.AV1, + decoderAvailable = false, + encoderAvailable = false, + hardwareDecoder = false, + hardwareEncoder = false, + realtimeSafe = false, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + ), + ), + nativeRuntimeSummary = "{}", + androidTvProfile = false, + lowPowerGpuProfile = false, + ) + + val adjusted = StreamSettings(codec = VideoCodec.H264, colorQuality = ColorQuality.EightBit420) + .adjustedForDevice(report) + + assertEquals(VideoCodec.AV1, adjusted.codec) + } + + @Test + fun capsResolutionToDecoderCapabilities() { + val report = RuntimeCodecReport( + capabilities = listOf( + CodecCapability( + codec = VideoCodec.H264, + decoderAvailable = true, + encoderAvailable = false, + hardwareDecoder = true, + hardwareEncoder = false, + realtimeSafe = true, + maxSupportedWidth = 1920, + maxSupportedHeight = 1080, + ) + ), + nativeRuntimeSummary = "{}", + androidTvProfile = false, + lowPowerGpuProfile = false, + ) + + val settings = StreamSettings( + resolution = "3440x1440", + aspectRatio = "21:9", + codec = VideoCodec.H264, + ) + + val adjusted = settings.adjustedForDevice(report) + assertEquals("2560x1080", adjusted.resolution) + } + + @Test + fun preserves1440pByUsingAnotherHardwareCodecBeforeReducingResolution() { + val report = RuntimeCodecReport( + capabilities = listOf( + CodecCapability( + codec = VideoCodec.H264, + decoderAvailable = true, + encoderAvailable = false, + hardwareDecoder = true, + hardwareEncoder = false, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + maxSupportedWidth = 1920, + maxSupportedHeight = 1080, + ), + CodecCapability( + codec = VideoCodec.H265, + decoderAvailable = true, + encoderAvailable = false, + hardwareDecoder = true, + hardwareEncoder = false, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + maxSupportedWidth = 3840, + maxSupportedHeight = 2160, + ), + ), + nativeRuntimeSummary = "{}", + androidTvProfile = false, + lowPowerGpuProfile = false, + ) + + val adjusted = StreamSettings( + resolution = "2560x1440", + aspectRatio = "16:9", + codec = VideoCodec.H264, + colorQuality = ColorQuality.EightBit420, + ).adjustedForDevice(report) + + assertEquals("2560x1440", adjusted.resolution) + assertEquals("16:9", adjusted.aspectRatio) + assertEquals(VideoCodec.H265, adjusted.codec) + } + + @Test + fun testAdjustedForDeviceCapsFpsBasedOnResolutionCapabilities() { + val report = codecReport( + codec = VideoCodec.H265, + hardwareDecoder = true, + realtimeSafe = true, + webRtcDecoderAvailable = true, + webRtcHardwareDecoderAvailable = true, + maxSupportedWidth = 3840, + maxSupportedHeight = 2160, + maxFpsByResolution = mapOf( + "3456x2160" to 36, + "2560x1600" to 120, + ) + ) + + val settings = StreamSettings( + resolution = "3456x2160", + aspectRatio = "16:10", + fps = 120, + codec = VideoCodec.H265, + ) + + val adjusted = settings.adjustedForDevice(report) + assertEquals("3456x2160", adjusted.resolution) + assertEquals(30, adjusted.fps) + } + + @Test + fun decoderFpsCapUsesCloudStreamCadence() { + assertEquals(30, cloudStreamFpsAtOrBelow(requestedFps = 120, decoderMaxFps = 36)) + assertEquals(60, cloudStreamFpsAtOrBelow(requestedFps = 120, decoderMaxFps = 89)) + assertEquals(120, cloudStreamFpsAtOrBelow(requestedFps = 120, decoderMaxFps = 144)) + } + + @Test + fun decoderFpsCapDoesNotChangeSettingsWithoutProbeData() { + assertEquals(120, cloudStreamFpsAtOrBelow(requestedFps = 120, decoderMaxFps = null)) + } + + private fun codecReport( + codec: VideoCodec, + decoderAvailable: Boolean = true, + hardwareDecoder: Boolean, + realtimeSafe: Boolean, + lowPower: Boolean = false, + tv: Boolean = false, + constrainedRuntime: Boolean = false, + nativeDecoderAvailable: Boolean? = null, + webRtcDecoderAvailable: Boolean? = null, + webRtcHardwareDecoderAvailable: Boolean? = null, + maxSupportedWidth: Int? = null, + maxSupportedHeight: Int? = null, + maxFpsByResolution: Map = emptyMap(), + ): RuntimeCodecReport = + RuntimeCodecReport( + capabilities = listOf( + CodecCapability( + codec = codec, + decoderAvailable = decoderAvailable, + encoderAvailable = false, + hardwareDecoder = hardwareDecoder, + hardwareEncoder = false, + realtimeSafe = realtimeSafe, + nativeDecoderAvailable = nativeDecoderAvailable, + webRtcDecoderAvailable = webRtcDecoderAvailable, + webRtcHardwareDecoderAvailable = webRtcHardwareDecoderAvailable, + maxSupportedWidth = maxSupportedWidth, + maxSupportedHeight = maxSupportedHeight, + maxFpsByResolution = maxFpsByResolution, + ), + ), + nativeRuntimeSummary = "{}", + androidTvProfile = tv, + lowPowerGpuProfile = lowPower, + constrainedRuntimeProfile = constrainedRuntime, + ) +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamSignalingFailureTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamSignalingFailureTest.kt new file mode 100644 index 000000000..742c1cfb5 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamSignalingFailureTest.kt @@ -0,0 +1,34 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Test + +class StreamSignalingFailureTest { + @Test + fun staleSignalingEndpointRequestsSessionRecovery() { + assertEquals( + SignalingFailureDisposition.RecoverSession, + signalingFailureDisposition("Expected HTTP 101 response but was '404 Not Found' http=404"), + ) + } + + @Test + fun goneSessionAndNormalServerCloseRemainTerminal() { + assertEquals( + SignalingFailureDisposition.SessionEnded, + signalingFailureDisposition("http=410 Gone"), + ) + assertEquals( + SignalingFailureDisposition.SessionEnded, + signalingFailureDisposition("code=1000", normalClosureMeansSessionEnded = true), + ) + } + + @Test + fun transientSignalingFailureRetriesTransport() { + assertEquals( + SignalingFailureDisposition.RetryTransport, + signalingFailureDisposition("socket timeout"), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/StreamSystemUiTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/StreamSystemUiTest.kt new file mode 100644 index 000000000..895c7af37 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/StreamSystemUiTest.kt @@ -0,0 +1,33 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StreamSystemUiTest { + @Test + fun leavesTransientThreeButtonNavigationVisibleLongEnoughToUse() { + assertFalse( + shouldPeriodicallyEnforceStreamSystemUi( + streamActive = true, + navigationBarsVisible = true, + ), + ) + } + + @Test + fun keepsFullscreenEnforcementForHiddenNavigationBars() { + assertTrue( + shouldPeriodicallyEnforceStreamSystemUi( + streamActive = true, + navigationBarsVisible = false, + ), + ) + assertFalse( + shouldPeriodicallyEnforceStreamSystemUi( + streamActive = false, + navigationBarsVisible = false, + ), + ) + } +} diff --git a/android/app/src/test/java/com/opencloudgaming/opennow/TouchOverlayLayoutTest.kt b/android/app/src/test/java/com/opencloudgaming/opennow/TouchOverlayLayoutTest.kt new file mode 100644 index 000000000..c2c880d16 --- /dev/null +++ b/android/app/src/test/java/com/opencloudgaming/opennow/TouchOverlayLayoutTest.kt @@ -0,0 +1,44 @@ +package com.opencloudgaming.opennow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TouchOverlayLayoutTest { + @Test + fun keepsLandscapeTopControlsBelowPhoneTopInformationBand() { + val clearance = landscapeTouchTopControlClearanceDp(viewportHeightDp = 390f, controlScale = 1f) + + assertTrue(clearance >= 40f) + } + + @Test + fun scalesLandscapeTopClearanceForLargerControlsWithoutRunningAway() { + val normal = landscapeTouchTopControlClearanceDp(viewportHeightDp = 430f, controlScale = 1f) + val large = landscapeTouchTopControlClearanceDp(viewportHeightDp = 800f, controlScale = 1.5f) + + assertTrue(large > normal) + assertEquals(76f, large, 0.001f) + } + + @Test + fun keepsTinyLandscapeScreensUsable() { + val clearance = landscapeTouchTopControlClearanceDp(viewportHeightDp = 300f, controlScale = 0.6f) + + assertEquals(30f, clearance, 0.001f) + } + + @Test + fun touchJoystickDeadZoneKeepsCenterStableAndPreservesFullRange() { + assertEquals(0f, applyTouchJoystickDeadZone(0.05f, 0.08f), 0.0001f) + assertEquals(0f, applyTouchJoystickDeadZone(-0.05f, 0.08f), 0.0001f) + assertEquals(1f, applyTouchJoystickDeadZone(1f, 0.08f), 0.0001f) + assertEquals(-1f, applyTouchJoystickDeadZone(-1f, 0.08f), 0.0001f) + } + + @Test + fun touchJoystickDeadZoneRescalesInputBeyondCenter() { + assertEquals(0.5f, applyTouchJoystickDeadZone(0.54f, 0.08f), 0.0001f) + assertEquals(-0.5f, applyTouchJoystickDeadZone(-0.54f, 0.08f), 0.0001f) + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 000000000..2b534f016 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "9.2.1" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.3.10" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.3.10" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 000000000..68e9427a0 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official +org.gradle.configuration-cache=true diff --git a/android/gradle/gradle-daemon-jvm.properties b/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 000000000..baa28d154 --- /dev/null +++ b/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..d997cfc60 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..c61a118f7 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 000000000..739907dfd --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# 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. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# 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/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 000000000..e509b2dd8 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%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 +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 000000000..17895f6af --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "OpenNOWAndroid" +include(":app") diff --git a/opennow-stable/src/main/gfn/games.test.ts b/opennow-stable/src/main/gfn/games.test.ts index 6a1e0ec80..0df82f9b5 100644 --- a/opennow-stable/src/main/gfn/games.test.ts +++ b/opennow-stable/src/main/gfn/games.test.ts @@ -145,3 +145,31 @@ test("does not duplicate primary catalog store variants from public data", () => assert.deepEqual(game?.variants.map((variant) => variant.store), ["Steam"]); }); + +test("does not add combined primary public store strings as launcher variants", () => { + const [game] = mergePublicGameVariants( + [ + { + id: "m1", + title: "M1", + selectedVariantIndex: 0, + variants: [ + { id: "steam", store: "Steam", supportedControls: [] }, + { id: "epic", store: "Epic", supportedControls: [] }, + ], + availableStores: ["Steam", "Epic"], + }, + ], + [ + publicGameToGameInfo({ + id: 123, + title: "M1", + store: "EPIC,STEAM", + status: "AVAILABLE", + }), + ], + ); + + assert.deepEqual(game?.variants.map((variant) => variant.store), ["Steam", "Epic"]); + assert.deepEqual(game?.availableStores, ["Steam", "Epic"]); +}); diff --git a/opennow-stable/src/main/gfn/games.ts b/opennow-stable/src/main/gfn/games.ts index c19194e1f..abc38cb5d 100644 --- a/opennow-stable/src/main/gfn/games.ts +++ b/opennow-stable/src/main/gfn/games.ts @@ -8,7 +8,7 @@ import type { } from "@shared/gfn"; import { isOwnedLibraryStatus } from "@shared/gfn"; import { cacheManager } from "../services/cacheManager"; -import { fetchPublicGamesUncached, mergePublicGameVariants } from "./publicGames"; +import { fetchPublicGamesUncached, hasSamePublicGameTitle, mergePublicGameVariants } from "./publicGames"; import { buildGfnGraphQlHeaders, buildGfnLcarsHeaders, @@ -788,19 +788,22 @@ ${appFields} cursor = endCursor; } - let games = dedupeGames(collectedApps.map(appToGame)); + const catalogGames = dedupeGames(collectedApps.map(appToGame)); const publicGames = await fetchPublicGames(); + let games = mergePublicGameVariants(catalogGames, publicGames); if (searchQuery.length > 0) { const publicSearchMatches = publicGames.filter((game) => matchesPublicGameSearch(game, searchQuery)); - games = dedupeGames([...games, ...publicSearchMatches]); + const publicOnlySearchMatches = publicSearchMatches.filter( + (publicGame) => !catalogGames.some((catalogGame) => hasSamePublicGameTitle(catalogGame, publicGame)), + ); + games = dedupeGames([...games, ...publicOnlySearchMatches]); } - const gamesWithPublicVariants = mergePublicGameVariants(games, publicGames); return { - games: gamesWithPublicVariants, + games, numberReturned, - numberSupported: Math.max(numberSupported, gamesWithPublicVariants.length), - totalCount: Math.max(totalCount, gamesWithPublicVariants.length), + numberSupported: Math.max(numberSupported, games.length), + totalCount: Math.max(totalCount, games.length), hasNextPage, endCursor: endCursor || undefined, searchQuery, diff --git a/opennow-stable/src/main/gfn/publicGames.ts b/opennow-stable/src/main/gfn/publicGames.ts index d94846965..2ea493e8c 100644 --- a/opennow-stable/src/main/gfn/publicGames.ts +++ b/opennow-stable/src/main/gfn/publicGames.ts @@ -22,6 +22,18 @@ const PRIMARY_CATALOG_STORE_KEYS = new Set([ "MICROSOFT_STORE", ]); +function splitPublicStoreKeys(store: string): string[] { + return store + .split(",") + .map((part) => normalizeGameStore(part.trim())) + .filter((part) => part.length > 0); +} + +function isPrimaryCatalogStoreValue(store: string): boolean { + const storeKeys = splitPublicStoreKeys(store); + return storeKeys.length > 0 && storeKeys.every((storeKey) => PRIMARY_CATALOG_STORE_KEYS.has(storeKey)); +} + export function inferPublicGameStore(item: RawPublicGame): string { const explicitStore = item.store?.trim(); if (explicitStore) { @@ -79,6 +91,11 @@ function normalizeTitleKey(title: string): string { .trim(); } +export function hasSamePublicGameTitle(left: GameInfo, right: GameInfo): boolean { + const leftKey = normalizeTitleKey(left.title); + return leftKey.length > 0 && leftKey === normalizeTitleKey(right.title); +} + function mergeSearchText(left?: string, right?: string): string | undefined { const merged = [left, right] .filter((value): value is string => typeof value === "string" && value.trim().length > 0) @@ -92,7 +109,7 @@ function getSupplementalPublicVariants(game: GameInfo, publicGame: GameInfo): Ga return publicGame.variants.filter((variant) => { const storeKey = normalizeGameStore(variant.store); - return !PRIMARY_CATALOG_STORE_KEYS.has(storeKey) && !existingStores.has(storeKey); + return !isPrimaryCatalogStoreValue(variant.store) && !existingStores.has(storeKey); }); } diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index 6455e04b1..743fdd25e 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -95,6 +95,8 @@ import { StreamLoading } from "./components/StreamLoading"; import { ControllerStreamLoading } from "./components/controllerMode/ControllerStreamLoading"; import { StreamView } from "./components/StreamView"; import { QueueServerSelectModal } from "./components/QueueServerSelectModal"; +import { getStoreDisplayName, getStoreIconComponent } from "./components/GameCard"; +import { getStoreOptions as getLaunchStoreOptions } from "./lib/gameCardStores"; const DEFAULT_STREAM_PREFERENCES = getDefaultStreamPreferences(); @@ -291,7 +293,9 @@ export function App(): JSX.Element { const [removeAccountConfirmOpen, setRemoveAccountConfirmOpen] = useState(false); const [logoutConfirmOpen, setLogoutConfirmOpen] = useState(false); const [launchError, setLaunchError] = useState(null); + const [launchStorePickerGame, setLaunchStorePickerGame] = useState(null); const [queueModalGame, setQueueModalGame] = useState(null); + const [queueModalVariantId, setQueueModalVariantId] = useState(null); const [queueModalData, setQueueModalData] = useState(null); const [sessionStartedAtMs, setSessionStartedAtMs] = useState(null); const [remoteStreamWarning, setRemoteStreamWarning] = useState(null); @@ -2602,7 +2606,7 @@ export function App(): JSX.Element { }, [attemptSessionRecovery, diagnosticsStore, handleControllerMetaToggle, refreshNavbarActiveSession, resetLaunchRuntime, scheduleStableRecoveryReset, settings, streamMicLevel, streamVolume, t]); // Play game handler - const handlePlayGame = useCallback(async (game: GameInfo, options?: { bypassGuards?: boolean; streamingBaseUrl?: string }) => { + const handlePlayGame = useCallback(async (game: GameInfo, options?: { bypassGuards?: boolean; streamingBaseUrl?: string; variantId?: string }) => { if (!selectedProvider) return; console.log("handlePlayGame entry", { @@ -2621,7 +2625,7 @@ export function App(): JSX.Element { return; } - const selectedVariantId = variantByGameId[game.id] ?? defaultVariantId(game); + const selectedVariantId = options?.variantId ?? variantByGameId[game.id] ?? defaultVariantId(game); const selectedVariant = getSelectedVariant(game, selectedVariantId); const epicOwnershipError = getEpicOwnershipLaunchError(selectedVariant); if (epicOwnershipError) { @@ -2905,7 +2909,7 @@ export function App(): JSX.Element { ]); // Gate handler: shows queue server modal for FREE-tier users before launching - const handleInitiatePlay = useCallback(async (game: GameInfo) => { + const continueLaunchWithVariant = useCallback(async (game: GameInfo, variantId?: string) => { const effectiveTier = normalizeMembershipTier( subscriptionInfo?.membershipTier ?? authSession?.user.membershipTier, ); @@ -2913,12 +2917,12 @@ export function App(): JSX.Element { const isAllianceServer = isAllianceStreamingBaseUrl(effectiveStreamingBaseUrl); if (isAllianceServer) { setQueueModalData(null); - void handlePlayGame(game); + void handlePlayGame(game, { variantId }); return; } if (settings.hideServerSelector) { setQueueModalData(null); - void handlePlayGame(game); + void handlePlayGame(game, { variantId }); return; } if (isFreeUser && streamStatus === "idle" && !launchInFlightRef.current) { @@ -2937,14 +2941,14 @@ export function App(): JSX.Element { }, ); setQueueModalData(null); - void handlePlayGame(game); + void handlePlayGame(game, { variantId }); return; } const queueData = queueResult.value; if (!queueData || Object.keys(queueData).length === 0) { setQueueModalData(null); - void handlePlayGame(game); + void handlePlayGame(game, { variantId }); return; } @@ -2953,36 +2957,96 @@ export function App(): JSX.Element { "[QueueServerSelect] No eligible non-nuked PrintedWaste zones available, skipping queue checks.", ); setQueueModalData(null); - void handlePlayGame(game); + void handlePlayGame(game, { variantId }); return; } setQueueModalData(queueData); setQueueModalGame(game); + setQueueModalVariantId(variantId ?? null); } catch (error) { console.warn("[QueueServerSelect] PrintedWaste queue checks failed, launching without modal.", error); setQueueModalData(null); - void handlePlayGame(game); + void handlePlayGame(game, { variantId }); } return; } - void handlePlayGame(game); - }, [subscriptionInfo, authSession, streamStatus, handlePlayGame, effectiveStreamingBaseUrl]); + void handlePlayGame(game, { variantId }); + }, [subscriptionInfo, authSession, streamStatus, handlePlayGame, effectiveStreamingBaseUrl, settings.hideServerSelector]); + + const handleInitiatePlay = useCallback(async (game: GameInfo) => { + const storeOptions = getLaunchStoreOptions(game, variantByGameId[game.id]); + if (storeOptions.length > 1) { + setLaunchStorePickerGame(game); + return; + } + + await continueLaunchWithVariant(game, storeOptions[0]?.variantId); + }, [continueLaunchWithVariant, variantByGameId]); const handleQueueModalConfirm = useCallback((zoneUrl: string | null) => { const game = queueModalGame; + const variantId = queueModalVariantId ?? undefined; setQueueModalGame(null); + setQueueModalVariantId(null); setQueueModalData(null); if (game) { - void handlePlayGame(game, { streamingBaseUrl: zoneUrl ?? undefined }); + void handlePlayGame(game, { streamingBaseUrl: zoneUrl ?? undefined, variantId }); } - }, [queueModalGame, handlePlayGame]); + }, [queueModalGame, queueModalVariantId, handlePlayGame]); const handleQueueModalCancel = useCallback(() => { setQueueModalGame(null); + setQueueModalVariantId(null); setQueueModalData(null); }, []); + const launchStoreOptions = useMemo(() => ( + launchStorePickerGame + ? getLaunchStoreOptions(launchStorePickerGame, variantByGameId[launchStorePickerGame.id]).map((option) => ({ + ...option, + displayName: getStoreDisplayName(option.store), + IconComponent: getStoreIconComponent(option.store), + })) + : [] + ), [launchStorePickerGame, variantByGameId]); + + const handleLaunchStoreCancel = useCallback(() => { + setLaunchStorePickerGame(null); + }, []); + + const handleLaunchStoreSelect = useCallback((variantId: string) => { + const game = launchStorePickerGame; + if (!game) { + return; + } + + setLaunchStorePickerGame(null); + handleSelectGameVariant(game.id, variantId); + void continueLaunchWithVariant(game, variantId); + }, [continueLaunchWithVariant, handleSelectGameVariant, launchStorePickerGame]); + + useEffect(() => { + if (!launchStorePickerGame) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setLaunchStorePickerGame(null); + } + }; + + window.addEventListener("keydown", handleKeyDown); + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + return () => { + window.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = previousOverflow; + }; + }, [launchStorePickerGame]); + useEffect(() => { if (!logoutConfirmOpen && !removeAccountConfirmOpen) return; diff --git a/opennow-stable/src/renderer/src/components/QueueAdPreview.tsx b/opennow-stable/src/renderer/src/components/QueueAdPreview.tsx index 7ecab6612..c6c7331aa 100644 --- a/opennow-stable/src/renderer/src/components/QueueAdPreview.tsx +++ b/opennow-stable/src/renderer/src/components/QueueAdPreview.tsx @@ -1,5 +1,5 @@ -import { AlertTriangle, Loader2, PauseCircle, PlayCircle, RefreshCcw, XCircle } from "lucide-react"; -import { forwardRef, useEffect, useImperativeHandle, useRef, useState, type JSX } from "react"; +import { AlertTriangle, Loader2, PauseCircle, PlayCircle, RefreshCcw, Volume2, VolumeX, XCircle } from "lucide-react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type JSX } from "react"; type QueueAdPlaybackState = "loading" | "playing" | "paused" | "stalled" | "blocked" | "timeout" | "error"; export type QueueAdPlaybackEvent = "loadstart" | "playing" | "paused" | "ended" | "timeupdate" | "error"; @@ -101,6 +101,18 @@ export const QueueAdPreview = forwardRef("loading"); + const [controlsVisible, setControlsVisible] = useState(false); + const [muted, setMuted] = useState(false); + const mutedRef = useRef(false); + + const revealControls = useCallback((): void => { + setControlsVisible(true); + }, []); + + const setMutedState = (nextMuted: boolean): void => { + mutedRef.current = nextMuted; + setMuted(nextMuted); + }; const setPlayback = (next: QueueAdPlaybackState): void => { playbackStateRef.current = next; @@ -115,27 +127,61 @@ export const QueueAdPreview = forwardRef { + const video = videoRef.current; + if (!video) { + return; + } + revealControls(); + if (video.paused || video.ended) { + void attemptPlayback(); + return; } + video.pause(); + }; + + const toggleMuted = (): void => { + const video = videoRef.current; + if (!video) { + return; + } + const nextMuted = !video.muted; + video.muted = nextMuted; + setMutedState(nextMuted); + revealControls(); }; useImperativeHandle(ref, () => ({ @@ -166,6 +212,27 @@ export const QueueAdPreview = forwardRef { + const video = videoRef.current; + if (video) { + video.muted = muted; + } + }, [muted, mediaUrl]); + + useEffect(() => { + if (!controlsVisible || playbackState !== "playing") { + return; + } + + const timeout = window.setTimeout(() => { + setControlsVisible(false); + }, 2400); + + return () => { + window.clearTimeout(timeout); + }; + }, [controlsVisible, playbackState]); + useEffect(() => { const video = videoRef.current; if (!video) { @@ -188,6 +255,7 @@ export const QueueAdPreview = forwardRef { + setMutedState(video.muted); setPlayback("playing"); onPlaybackEventRef.current?.("playing"); }; @@ -201,6 +269,7 @@ export const QueueAdPreview = forwardRef { if (!video.ended && playbackStateRef.current === "playing") { setPlayback("paused"); + revealControls(); onPlaybackEventRef.current?.("paused"); } }; @@ -222,17 +291,20 @@ export const QueueAdPreview = forwardRef { if (!video.paused && !video.ended) { setPlayback("stalled"); + revealControls(); } }; const handleStalled = (): void => { if (!video.paused && !video.ended) { setPlayback("stalled"); + revealControls(); } }; const handleError = (): void => { setPlayback("error"); + revealControls(); onPlaybackEventRef.current?.("error"); restoreOriginalVolume(); }; @@ -259,15 +331,18 @@ export const QueueAdPreview = forwardRef -
+
+
)} +
+ + +
{presentation.retryLabel && (
@@ -302,4 +403,4 @@ export const QueueAdPreview = forwardRef ); -}); \ No newline at end of file +}); diff --git a/opennow-stable/src/renderer/src/components/StatsOverlay.tsx b/opennow-stable/src/renderer/src/components/StatsOverlay.tsx index 52c87de6a..ca9c95b6c 100644 --- a/opennow-stable/src/renderer/src/components/StatsOverlay.tsx +++ b/opennow-stable/src/renderer/src/components/StatsOverlay.tsx @@ -23,6 +23,7 @@ export function StatsOverlay({ const rttColor = getRttColor(stats.rttMs); const showPacketLoss = stats.packetLossPercent > 0; const hasData = stats.resolution !== "" || stats.bitrateKbps > 0; + const currentFps = stats.decodeFps > 0 ? `${stats.decodeFps} FPS` : "-- FPS"; if (!hasData) { return ( @@ -40,7 +41,7 @@ export function StatsOverlay({ {/* Resolution & FPS */}
- {stats.resolution} @ {stats.decodeFps} FPS + {stats.resolution || "--"} @ {currentFps}
{/* Bitrate */} diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index b8eaf1365..ee8b672e0 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -1149,8 +1149,8 @@ export class GfnWebRtcClient { this.diagnostics.colorCodec = describeColorQuality(settings.colorQuality); this.diagnostics.isHdr = this.isHdr; this.diagnostics.targetBitrateKbps = this.negotiatedMaxBitrateKbps; - this.diagnostics.decodeFps = settings.fps; - this.diagnostics.renderFps = settings.fps; + this.diagnostics.decodeFps = 0; + this.diagnostics.renderFps = 0; } private closeDataChannels(): void { diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 635149dd8..5786fd5db 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -6381,89 +6381,75 @@ button.game-card-store-chip.owned.active:hover { font-weight: 700; } -.queue-ad-preview-status { - display: flex; +.queue-ad-preview-overlay-action, +.queue-ad-preview-control { + display: inline-flex; align-items: center; - justify-content: space-between; - gap: 12px; - padding: 10px 12px; - border-radius: 12px; - background: rgba(0, 0, 0, 0.28); -} - -.queue-ad-preview-status-main { - display: flex; - align-items: flex-start; - gap: 10px; - min-width: 0; -} - -.queue-ad-preview-copy { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; + justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(0, 0, 0, 0.52); + color: #fff; + font: inherit; + cursor: pointer; + backdrop-filter: blur(12px); + transition: background var(--t-normal), border-color var(--t-normal), transform var(--t-normal), opacity var(--t-normal); } -.queue-ad-preview-label { - color: var(--ink); - font-size: 0.78rem; +.queue-ad-preview-overlay-action { + gap: 6px; + padding: 8px 11px; + border-radius: 999px; + font-size: 0.76rem; font-weight: 700; } -.queue-ad-preview-message { - color: var(--ink-soft); - font-size: 0.74rem; - line-height: 1.4; -} - -.queue-ad-preview-icon { - flex: 0 0 auto; - color: var(--accent); - margin-top: 1px; -} - -.queue-ad-preview-icon--spinning { - animation: spin 1s linear infinite; -} - -.queue-ad-preview--blocked .queue-ad-preview-icon, -.queue-ad-preview--stalled .queue-ad-preview-icon, -.queue-ad-preview--timeout .queue-ad-preview-icon { - color: #ffd166; +.queue-ad-preview-controls { + position: absolute; + right: 10px; + bottom: 10px; + display: flex; + gap: 8px; + opacity: 0; + pointer-events: none; + transform: translateY(4px); + transition: opacity var(--t-normal), transform var(--t-normal); } -.queue-ad-preview--error .queue-ad-preview-icon { - color: #f87171; +.queue-ad-preview:hover .queue-ad-preview-controls, +.queue-ad-preview:focus-within .queue-ad-preview-controls, +.queue-ad-preview--controls-visible .queue-ad-preview-controls, +.queue-ad-preview--paused .queue-ad-preview-controls, +.queue-ad-preview--blocked .queue-ad-preview-controls, +.queue-ad-preview--stalled .queue-ad-preview-controls, +.queue-ad-preview--timeout .queue-ad-preview-controls, +.queue-ad-preview--error .queue-ad-preview-controls { + opacity: 1; + pointer-events: auto; + transform: translateY(0); } -.queue-ad-preview-retry { - display: inline-flex; - align-items: center; - gap: 6px; - flex: 0 0 auto; - padding: 7px 10px; - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 999px; - background: rgba(255, 255, 255, 0.06); - color: var(--ink); - font: inherit; - font-size: 0.74rem; - font-weight: 600; - cursor: pointer; - transition: background var(--t-normal), border-color var(--t-normal), transform var(--t-normal); +.queue-ad-preview-control { + width: 36px; + height: 36px; + border-radius: 50%; } -.queue-ad-preview-retry:hover { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.2); +.queue-ad-preview-overlay-action:hover, +.queue-ad-preview-control:hover { + background: rgba(255, 255, 255, 0.16); + border-color: rgba(255, 255, 255, 0.3); transform: translateY(-1px); } -.queue-ad-preview-retry:active { +.queue-ad-preview-overlay-action:active, +.queue-ad-preview-control:active { transform: translateY(0); } +.queue-ad-preview-icon--spinning { + animation: spin 1s linear infinite; +} + .sload-message { margin: 0; font-size: 0.95rem; @@ -10278,22 +10264,6 @@ button.game-card-store-chip.owned.active:hover { object-fit: cover; } -.csl-ad-media .queue-ad-preview-status { - background: rgba(0, 0, 0, 0.32); -} - -.csl-ad-media .queue-ad-preview-label { - font-size: 0.82rem; -} - -.csl-ad-media .queue-ad-preview-message { - font-size: 0.78rem; -} - -.csl-ad-media .queue-ad-preview-retry { - background: rgba(255, 255, 255, 0.08); -} - .csl-error-panel { display: flex; flex-direction: column; @@ -10409,4 +10379,3 @@ button.game-card-store-chip.owned.active:hover { max-height: min(72vh, 760px); vertical-align: middle; } -