Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ class NovaGameDetailSheetComposeTest {
onPrimaryLaunch = {},
onLaunchOptions = {},
onProfilePreference = {},
onRetryHighFps = {},
onResetProfile = {},
onMangoHudChanged = {},
onSteamLaunchMode = {},
Expand Down
31 changes: 27 additions & 4 deletions app/src/main/java/com/papi/nova/Game.kt
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ private var streamingDisplayId:Int = Display.DEFAULT_DISPLAY
@Volatile private var lastReportedClientPresentationKey:String = ""
@Volatile private var lastPolarisDeviceCapabilities:JSONObject? = null
@Volatile private var lastPolarisAppliedStreamSettings:JSONObject? = null
private var launchProfilePreference:String = "auto"
private var launchOptimizationJson:String? = null
private var clientPresentationReportInFlight:AtomicBoolean = AtomicBoolean(false)
private var cursorVisibilitySyncLock:Any = Any()
private var pendingHostCursorVisible:Boolean = false
Expand Down Expand Up @@ -690,6 +692,8 @@ watchOnlyRequested = this@Game.getIntent().getBooleanExtra(EXTRA_WATCH_ONLY, fal
watchStreamWidth = this@Game.getIntent().getIntExtra(EXTRA_STREAM_WIDTH, 0)
watchStreamHeight = this@Game.getIntent().getIntExtra(EXTRA_STREAM_HEIGHT, 0)
watchStreamFps = this@Game.getIntent().getFloatExtra(EXTRA_STREAM_FPS, 0f)
launchProfilePreference = this@Game.getIntent().getStringExtra(EXTRA_AI_PROFILE_PREFERENCE) ?: ""
launchOptimizationJson = this@Game.getIntent().getStringExtra(EXTRA_LAUNCH_OPTIMIZATION)
serverCmds = this@Game.getIntent().getStringArrayListExtra(EXTRA_SERVER_COMMANDS) ?: ArrayList()
var appSupportsHdr:Boolean = this@Game.getIntent().getBooleanExtra(EXTRA_APP_HDR, false)
var derCertData:ByteArray? = this@Game.getIntent().getByteArrayExtra(EXTRA_SERVER_CERT)
Expand Down Expand Up @@ -1055,6 +1059,11 @@ supportedVideoFormats,
prefConfig!!.videoFormat,
displayModeExplicit
)
try
{
lastPolarisAppliedStreamSettings?.put("profile_preference", launchProfilePreference)
}
catch (ignored:Exception) {}

var config:StreamConfiguration = StreamConfiguration.Builder()
.setResolution(
Expand All @@ -1071,6 +1080,7 @@ displayHeight
.setForceFreshLaunch(forceFreshLaunch)
.setBitrate(configuredStreamBitrateKbps)
.setEnableSops(prefConfig!!.enableSops)
.setProfilePreference(launchProfilePreference)
.enableLocalAudioPlayback(prefConfig!!.playHostAudio)
.setMaxPacketSize(1392)
.setRemoteConfiguration(StreamConfiguration.STREAM_CFG_AUTO) // NvConnection will perform LAN and VPN detection
Expand Down Expand Up @@ -1875,6 +1885,16 @@ getConfiguredStreamFrameRateFps() <= 60f &&
}

private fun loadLaunchOptimization(appName:String?):JSONObject? {
if (!launchOptimizationJson.isNullOrBlank())
{
try
{
return JSONObject(launchOptimizationJson!!)
}
catch (e:Exception) {
LimeLog.warning("Nova: Ignoring invalid preflight optimization payload")
}
}
if (novaApiClient == null)
{
return null
Expand All @@ -1884,10 +1904,11 @@ var result:Array<JSONObject?> = arrayOfNulls<JSONObject?>(1)
var failure:Array<Exception?> = arrayOfNulls<Exception?>(1)
var thread:Thread = Thread({ try
{
var safeAppName:String? = if (appName != null) appName else ""
var preference:String? = getSharedPreferences("nova_prefs", MODE_PRIVATE)
.getString("ai_profile_preference_name_" + safeAppName!!, "auto")
result[0] = novaApiClient!!.getOptimization(DeviceUtils.getModel(), safeAppName, if (preference != null) preference else "auto")
var safeAppName:String = appName ?: ""
var preference:String = launchProfilePreference.takeIf { it.isNotBlank() } ?: getSharedPreferences("nova_prefs", MODE_PRIVATE)
.getString("ai_profile_preference_name_" + safeAppName, "auto") ?: "auto"
launchProfilePreference = preference
result[0] = novaApiClient!!.getOptimization(DeviceUtils.getModel(), safeAppName, preference)
}
catch (e:Exception) {
failure[0] = e
Expand Down Expand Up @@ -5884,6 +5905,8 @@ companion object {
const val EXTRA_STREAM_WIDTH:String = "StreamWidth"
const val EXTRA_STREAM_HEIGHT:String = "StreamHeight"
const val EXTRA_STREAM_FPS:String = "StreamFps"
const val EXTRA_AI_PROFILE_PREFERENCE:String = "AiProfilePreference"
const val EXTRA_LAUNCH_OPTIMIZATION:String = "LaunchOptimization"
const val EXTRA_SERVER_COMMANDS:String = "ServerCommands"
const val EXTRA_DISPLAY_ID:String = "DisplayID"

Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/com/papi/nova/PcView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1098,6 +1098,16 @@ class PcView : AppCompatActivity(), AdapterFragmentCallbacks {
}

initializeViews(prefs)
handleWelcomeAction(intent.getStringExtra(NovaWelcomeActivity.EXTRA_WELCOME_ACTION))
}

private fun handleWelcomeAction(action: String?) {
if (action != NovaWelcomeActivity.ACTION_SCAN_QR) {
return
}

intent.removeExtra(NovaWelcomeActivity.EXTRA_WELCOME_ACTION)
window.decorView.post { launchQrScanner() }
}

private fun startComputerUpdates() {
Expand Down
109 changes: 105 additions & 4 deletions app/src/main/java/com/papi/nova/ShortcutTrampoline.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import android.os.Bundle
import android.os.IBinder
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import com.papi.nova.api.PolarisApiClient
import com.papi.nova.api.PolarisGame
import com.papi.nova.computers.ComputerDatabaseManager
import com.papi.nova.computers.ComputerManagerListener
import com.papi.nova.computers.ComputerManagerService
Expand All @@ -20,11 +22,13 @@ import com.papi.nova.nvstream.http.PairingManager
import com.papi.nova.nvstream.wol.WakeOnLanSender
import com.papi.nova.preferences.PreferenceConfiguration
import com.papi.nova.utils.CacheHelper
import com.papi.nova.utils.DeviceUtils
import com.papi.nova.utils.Dialog
import com.papi.nova.utils.ServerHelper
import com.papi.nova.utils.ShortcutHelper
import com.papi.nova.utils.SpinnerDialog
import com.papi.nova.utils.UiHelper
import java.security.cert.CertificateEncodingException
import org.xmlpull.v1.XmlPullParserException
import java.io.BufferedReader
import java.io.File
Expand All @@ -50,6 +54,12 @@ class ShortcutTrampoline : AppCompatActivity() {

private var managerBinder: ComputerManagerService.ComputerManagerBinder? = null

private data class ShortcutLaunchPlan(
val app: NvApp,
val profilePreference: String = "auto",
val launchOptimizationJson: String? = null,
)

private val serviceConnection: ServiceConnection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, binder: IBinder) {
val localBinder = binder as ComputerManagerService.ComputerManagerBinder
Expand Down Expand Up @@ -109,6 +119,20 @@ class ShortcutTrampoline : AppCompatActivity() {
}

if (details.state != ComputerDetails.State.UNKNOWN) {
val shortcutLaunchPlan = if (
details.state == ComputerDetails.State.ONLINE &&
details.pairState == PairingManager.PairState.PAIRED &&
app != null
) {
preparePolarisShortcutLaunchPlan(
details,
app!!,
prefConfig.useVirtualDisplay,
)
} else {
null
}

runOnUiThread {
if (blockingLoadSpinner != null) {
blockingLoadSpinner?.dismiss()
Expand All @@ -127,18 +151,21 @@ class ShortcutTrampoline : AppCompatActivity() {
) {
val currentApp = app
if (currentApp != null) {
val launchPlan = shortcutLaunchPlan ?: ShortcutLaunchPlan(currentApp)
if (
details.runningGameId == 0 ||
details.runningGameId == currentApp.appId ||
Objects.equals(details.runningGameUUID, currentApp.appUUID)
details.runningGameId == launchPlan.app.appId ||
Objects.equals(details.runningGameUUID, launchPlan.app.appUUID)
) {
intentStack.add(
ServerHelper.createStartIntent(
this@ShortcutTrampoline,
currentApp,
launchPlan.app,
details,
activeBinder,
prefConfig.useVirtualDisplay,
launchPlan.profilePreference,
launchPlan.launchOptimizationJson,
),
)

Expand All @@ -147,10 +174,12 @@ class ShortcutTrampoline : AppCompatActivity() {
} else {
val startIntent = ServerHelper.createStartIntent(
this@ShortcutTrampoline,
currentApp,
launchPlan.app,
details,
activeBinder,
prefConfig.useVirtualDisplay,
launchPlan.profilePreference,
launchPlan.launchOptimizationJson,
)

UiHelper.displayQuitConfirmationDialog(
Expand Down Expand Up @@ -599,6 +628,77 @@ class ShortcutTrampoline : AppCompatActivity() {
)
}

private fun preparePolarisShortcutLaunchPlan(
details: ComputerDetails,
shortcutApp: NvApp,
withVirtualDisplay: Boolean,
): ShortcutLaunchPlan {
val activeAddress = details.activeAddress ?: return ShortcutLaunchPlan(shortcutApp)
val serverCert = try {
details.serverCert?.encoded
} catch (e: CertificateEncodingException) {
LimeLog.warning("Nova: Shortcut launch could not encode server cert for Polaris preflight: ${e.message}")
null
} ?: return ShortcutLaunchPlan(shortcutApp)

return try {
val apiClient = PolarisApiClient(this, activeAddress.address, details.httpsPort, serverCert)
val polarisGame = findPolarisShortcutGame(apiClient, shortcutApp)
?: return ShortcutLaunchPlan(shortcutApp)
val launchApp = NvApp(polarisGame.name, polarisGame.id, polarisGame.appId, polarisGame.hdrSupported)

val mangoHudSynced = apiClient.setMangoHud(polarisGame.id, polarisGame.mangohud)
if (!mangoHudSynced) {
LimeLog.warning("Nova: Shortcut launch MangoHUD state sync failed; continuing launch")
}

syncShortcutLaunchPreflightSettings(apiClient, withVirtualDisplay)
val optimization = apiClient.getOptimization(
DeviceUtils.getModel(),
polarisGame.name,
SHORTCUT_PROFILE_PREFERENCE,
)

ShortcutLaunchPlan(
app = launchApp,
profilePreference = SHORTCUT_PROFILE_PREFERENCE,
launchOptimizationJson = optimization?.toString(),
)
} catch (e: Exception) {
LimeLog.warning("Nova: Shortcut launch Polaris preflight failed: ${e.message}")
ShortcutLaunchPlan(shortcutApp)
}
}

private fun findPolarisShortcutGame(apiClient: PolarisApiClient, shortcutApp: NvApp): PolarisGame? {
val shortcutUuid = shortcutApp.appUUID
val shortcutName = shortcutApp.appName
return apiClient.getGames(limit = 100).firstOrNull { game ->
(!shortcutUuid.isNullOrBlank() && shortcutUuid.equals(game.id, ignoreCase = true)) ||
(shortcutApp.appId > 0 && game.appId == shortcutApp.appId) ||
(!shortcutName.isNullOrBlank() && shortcutName.equals(game.name, ignoreCase = true))
}
}

private fun syncShortcutLaunchPreflightSettings(
apiClient: PolarisApiClient,
withVirtualDisplay: Boolean,
) {
val preferences = PreferenceConfiguration.readPreferences(this)
val syncedSettings = apiClient.updateClientSettings(
streamDisplayMode = if (withVirtualDisplay) "host_virtual_display" else "headless_stream",
displayMode = PreferenceConfiguration.formatStreamingDisplayMode(
preferences.width,
preferences.height,
preferences.fps,
),
targetBitrateKbps = preferences.bitrate.takeIf { it > 0 },
)
if (syncedSettings == null) {
LimeLog.warning("Nova: Shortcut launch preflight client settings sync failed; continuing launch")
}
}

private fun displayAppListError(e: Exception) {
Log.e(TAG, "Error processing app list from cache", e)
Dialog.displayDialog(
Expand Down Expand Up @@ -672,6 +772,7 @@ class ShortcutTrampoline : AppCompatActivity() {

companion object {
private const val MAX_ART_FILE_CHARS = 64 * 1024
private const val SHORTCUT_PROFILE_PREFERENCE = "auto"
private const val TAG = "ShortcutTrampoline"
}
}
47 changes: 37 additions & 10 deletions app/src/main/java/com/papi/nova/api/PolarisApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,27 @@ class PolarisApiClient @JvmOverloads constructor(
.generateCertificate(ByteArrayInputStream(serverCertDer)) as X509Certificate
}

@JvmStatic
fun buildOptimizationPath(
device: String,
game: String,
preference: String = "",
trial: String = ""
): String {
val preferenceParam = preference
.takeIf { it.isNotBlank() }
?.let { "&preference=${java.net.URLEncoder.encode(it, "UTF-8")}" }
?: ""
val trialParam = trial
.takeIf { it.isNotBlank() }
?.let { "&trial=${java.net.URLEncoder.encode(it, "UTF-8")}" }
?: ""
return "/optimize?device=${java.net.URLEncoder.encode(device, "UTF-8")}" +
"&game=${java.net.URLEncoder.encode(game, "UTF-8")}" +
preferenceParam +
trialParam
}

private fun parseStringArray(array: org.json.JSONArray?): List<String> {
if (array == null) return emptyList()
return (0 until array.length()).mapNotNull { index ->
Expand Down Expand Up @@ -1194,20 +1215,26 @@ class PolarisApiClient @JvmOverloads constructor(
* Get AI-recommended streaming settings for a device+game combo.
*/
@JvmOverloads
fun getOptimization(device: String, game: String, preference: String = ""): org.json.JSONObject? {
fun getOptimization(
device: String,
game: String,
preference: String = "",
trial: String = ""
): org.json.JSONObject? {
return try {
val preferenceParam = preference
.takeIf { it.isNotBlank() }
?.let { "&preference=${java.net.URLEncoder.encode(it, "UTF-8")}" }
?: ""
val url = "$baseUrl/optimize?device=${java.net.URLEncoder.encode(device, "UTF-8")}" +
"&game=${java.net.URLEncoder.encode(game, "UTF-8")}" +
preferenceParam
val url = "$baseUrl${buildOptimizationPath(device, game, preference, trial)}"
val request = Request.Builder().url(url).get().build()
LimeLog.info("Nova: Optimization query start for $url")
executeGetWithRetry(request).use { response ->
if (response.code == 200) {
org.json.JSONObject(response.body?.string() ?: "{}")
} else null
LimeLog.info("Nova: Optimization query HTTP 200 for $url")
val body = response.body?.string() ?: "{}"
LimeLog.info("Nova: Optimization query body received (${body.length} bytes)")
org.json.JSONObject(body)
} else {
LimeLog.warning("Nova: Optimization query returned HTTP ${response.code} for $url")
null
}
}
} catch (e: Exception) {
LimeLog.warning("Nova: Optimization query failed: ${errorMessage(e)}")
Expand Down
Loading
Loading