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
4 changes: 4 additions & 0 deletions app/src/main/app/PluviaApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.app.Application
import android.os.Bundle
import android.os.Looper
import android.util.Log
import com.winlator.cmod.app.config.DeviceProfileSettings
import com.winlator.cmod.app.db.PluviaDatabase
import com.winlator.cmod.app.update.UpdateService
import com.winlator.cmod.feature.stores.gog.service.GOGAuthManager
Expand Down Expand Up @@ -71,6 +72,9 @@ class PluviaApp : Application() {
// Cached probe for devices whose native stack still needs system libjpeg preloaded.
preloadSystemLibraries()

runCatching { DeviceProfileSettings.seedFromDetection(this) }
.onFailure { Log.w("PluviaApp", "device profile detection failed", it) }

registerRefreshRateLifecycleCallbacks()

PrefManager.install(this)
Expand Down
92 changes: 92 additions & 0 deletions app/src/main/app/config/DeviceProfile.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.winlator.cmod.app.config

import android.os.Build
import android.util.Log
import com.winlator.cmod.R
import java.util.Locale

enum class DeviceProfile(
val prefValue: String,
val assetToken: String,
val titleRes: Int,
val summaryRes: Int,
) {
DEFAULT("default", "", R.string.device_profile_default, R.string.device_profile_default_summary),
ASTRA_2("astra2", "astra2", R.string.device_profile_astra_2, R.string.device_profile_astra_2_summary),
;

companion object {
private const val TAG = "DeviceProfile"

private val MARKETING_NAME_KEYS =
listOf(
"ro.vendor.product.ztename",
"ro.vendor.feature.nubia.exif.module",
"ro.product.marketname",
"ro.vendor.product.marketname",
)

private val ASTRA_2_NAME_TOKENS = listOf("astra 2", "astra2")

private val ASTRA_2_BUILD_TOKENS = listOf("pq85p01", "np06j", "np06p")

@JvmStatic
fun fromPrefValue(value: String?): DeviceProfile =
entries.firstOrNull { it.prefValue.equals(value, ignoreCase = true) } ?: DEFAULT

@JvmStatic
fun detect(): DeviceProfile =
classify(buildHaystack(), marketingName())

internal fun classify(
buildHaystack: String,
marketingName: String,
): DeviceProfile {
val name = marketingName.lowercase(Locale.ROOT)
val build = buildHaystack.lowercase(Locale.ROOT)
if (ASTRA_2_NAME_TOKENS.any { name.contains(it) }) return ASTRA_2
if (ASTRA_2_BUILD_TOKENS.any { build.contains(it) }) return ASTRA_2
if (ASTRA_2_NAME_TOKENS.any { build.contains(it) }) return ASTRA_2
return DEFAULT
}

@JvmStatic
fun deviceLabel(): String {
val marketing = marketingName().trim()
if (marketing.isNotEmpty()) return marketing
val model = Build.MODEL.orEmpty().trim()
val manufacturer = Build.MANUFACTURER.orEmpty().trim()
if (model.isEmpty()) return manufacturer
if (manufacturer.isEmpty()) return model
if (model.lowercase(Locale.ROOT).startsWith(manufacturer.lowercase(Locale.ROOT))) return model
return "$manufacturer $model"
}

private fun buildHaystack(): String =
listOf(
Build.MANUFACTURER,
Build.BRAND,
Build.MODEL,
Build.DEVICE,
Build.PRODUCT,
Build.HARDWARE,
).joinToString(" ") { it.orEmpty() }

private fun marketingName(): String {
for (key in MARKETING_NAME_KEYS) {
val value = readProperty(key)
if (!value.isNullOrBlank()) return value
}
return ""
}

private fun readProperty(key: String): String? =
try {
val clazz = Class.forName("android.os.SystemProperties")
(clazz.getMethod("get", String::class.java).invoke(null, key) as? String)?.ifEmpty { null }
} catch (e: Throwable) {
Log.w(TAG, "SystemProperties unavailable for $key", e)
null
}
}
}
165 changes: 165 additions & 0 deletions app/src/main/app/config/DeviceProfileSettings.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package com.winlator.cmod.app.config

import android.content.Context
import androidx.preference.PreferenceManager
import com.winlator.cmod.R

enum class DeviceProfileFeature(
val titleRes: Int,
val summaryRes: Int,
val integrated: Boolean,
) {
INPUT_CONTROL_LAYOUTS(
R.string.device_profile_feature_input_layouts,
R.string.device_profile_feature_input_layouts_summary,
true,
),
ADAPTIVE_JOYSTICKS(
R.string.device_profile_feature_adaptive_joysticks,
R.string.device_profile_feature_adaptive_joysticks_summary,
true,
),
LIBRARY_ICONS(
R.string.device_profile_feature_library_icons,
R.string.device_profile_feature_library_icons_summary,
true,
),
SESSION_MENU_SIZES(
R.string.device_profile_feature_session_menu,
R.string.device_profile_feature_session_menu_summary,
true,
),
SHORTCUT_RECOMMENDATIONS(
R.string.device_profile_feature_shortcut_defaults,
R.string.device_profile_feature_shortcut_defaults_summary,
false,
),
}

object DeviceProfileSettings {
const val KEY_PROFILE = "device_profile"
const val KEY_DETECTED = "device_profile_detected"
const val STEAM_HEADER_ASPECT = 460f / 215f
const val LIBRARY_TITLE_STRIP_DP = 24f
const val STOCK_LIBRARY_CARD_FACTOR = 1.25f

@Volatile
private var cached: DeviceProfile? = null

private fun prefs(context: Context) = PreferenceManager.getDefaultSharedPreferences(context)

@JvmStatic
fun current(context: Context): DeviceProfile {
cached?.let { return it }
val resolved = DeviceProfile.fromPrefValue(prefs(context).getString(KEY_PROFILE, null))
cached = resolved
return resolved
}

@JvmStatic
fun setCurrent(
context: Context,
profile: DeviceProfile,
) {
prefs(context).edit().putString(KEY_PROFILE, profile.prefValue).apply()
cached = profile
}

@JvmStatic
fun detected(context: Context): DeviceProfile =
DeviceProfile.fromPrefValue(prefs(context).getString(KEY_DETECTED, null))

@JvmStatic
fun isUserOverridden(context: Context): Boolean = current(context) != detected(context)

@JvmStatic
fun seedFromDetection(context: Context) {
val preferences = prefs(context)
val detected = DeviceProfile.detect()
val editor = preferences.edit().putString(KEY_DETECTED, detected.prefValue)
if (!preferences.contains(KEY_PROFILE)) {
editor.putString(KEY_PROFILE, detected.prefValue)
cached = detected
}
editor.apply()
}

@JvmStatic
fun invalidate() {
cached = null
}

@JvmStatic
fun assetProfilesToken(context: Context): String = current(context).assetToken

@JvmStatic
fun adaptiveJoysticksDefault(context: Context): Boolean = current(context) == DeviceProfile.ASTRA_2

@JvmStatic
fun adaptiveJoysticksDefaultExtra(context: Context): String = if (adaptiveJoysticksDefault(context)) "1" else "0"

@JvmStatic
fun preferWideArtwork(context: Context): Boolean = preferWideArtwork(current(context))

@JvmStatic
fun libraryImageAspect(context: Context): Float? = libraryImageAspect(current(context))

@JvmStatic
fun libraryTitleStripDp(): Float = LIBRARY_TITLE_STRIP_DP

@JvmStatic
fun libraryColumns(
context: Context,
widthDp: Int,
portrait: Boolean,
): Int = libraryColumns(current(context), widthDp, portrait)

@JvmStatic
fun sessionActionCardMaxAspect(context: Context): Float = sessionActionCardMaxAspect(current(context))

internal fun preferWideArtwork(profile: DeviceProfile): Boolean = profile == DeviceProfile.ASTRA_2

internal fun libraryImageAspect(profile: DeviceProfile): Float? =
when (profile) {
DeviceProfile.ASTRA_2 -> STEAM_HEADER_ASPECT
DeviceProfile.DEFAULT -> null
}

internal fun libraryColumns(
profile: DeviceProfile,
widthDp: Int,
portrait: Boolean,
): Int =
when (profile) {
DeviceProfile.ASTRA_2 -> if (portrait) 2 else 4
DeviceProfile.DEFAULT -> stockLibraryColumns(widthDp)
}

internal fun stockLibraryColumns(widthDp: Int): Int =
when {
widthDp <= 0 -> 4
widthDp < 480 -> 2
widthDp < 700 -> 3
else -> 4
}

internal fun sessionActionCardMaxAspect(profile: DeviceProfile): Float =
when (profile) {
DeviceProfile.ASTRA_2 -> 1.0f
DeviceProfile.DEFAULT -> Float.MAX_VALUE
}

internal fun actionCardRowHeight(
availableHeight: Float,
cardWidth: Float,
rows: Int,
spacing: Float,
minHeight: Float,
maxAspect: Float,
): Float {
val safeRows = rows.coerceAtLeast(1)
val fill = (availableHeight - spacing * (safeRows - 1)) / safeRows
val capped = if (maxAspect == Float.MAX_VALUE) fill else minOf(fill, cardWidth * maxAspect)
return maxOf(capped, minHeight)
}
}
6 changes: 4 additions & 2 deletions app/src/main/app/shell/UnifiedActivityStores.kt
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ import androidx.navigation.navArgument
import coil.compose.AsyncImage
import coil.imageLoader
import coil.request.ImageRequest
import com.winlator.cmod.app.config.DeviceProfileSettings
import com.winlator.cmod.BuildConfig
import com.winlator.cmod.R
import com.winlator.cmod.app.PluviaApp
Expand Down Expand Up @@ -435,11 +436,12 @@ internal fun UnifiedActivity.GameCapsule(
}
}
} else {
val preferWide = !useLibraryCapsule && !listMode && DeviceProfileSettings.preferWideArtwork(context)
val imageModel =
remember(app.id, gogGame, epicGame, useLibraryCapsule, listMode, artworkCacheRefreshKey) {
remember(app.id, gogGame, epicGame, useLibraryCapsule, listMode, preferWide, artworkCacheRefreshKey) {
StoreArtworkCache.imageModel(
context,
StoreArtworkCache.primaryRef(app, gogGame, epicGame, useLibraryCapsule, listMode),
StoreArtworkCache.primaryRef(app, gogGame, epicGame, useLibraryCapsule, listMode, preferWide),
)
}
AsyncImage(
Expand Down
Loading
Loading