Skip to content
Open
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
71 changes: 71 additions & 0 deletions app/src/main/feature/settings/stores/StoresFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.winlator.cmod.feature.settings
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
Expand All @@ -26,15 +27,21 @@ import com.winlator.cmod.feature.stores.gog.service.GOGService
import com.winlator.cmod.feature.stores.gog.ui.auth.GOGOAuthActivity
import com.winlator.cmod.feature.stores.steam.SteamLoginActivity
import com.winlator.cmod.feature.stores.steam.enums.Language
import com.winlator.cmod.feature.setup.SetupWizardActivity
import com.winlator.cmod.feature.stores.steam.service.SteamService
import com.winlator.cmod.feature.stores.steam.utils.PrefManager
import com.winlator.cmod.feature.stores.ubisoft.UbisoftConnect
import com.winlator.cmod.runtime.container.Container
import com.winlator.cmod.runtime.container.ContainerManager
import com.winlator.cmod.runtime.content.component.ComponentInstaller
import com.winlator.cmod.shared.android.DirectoryPickerDialog
import com.winlator.cmod.shared.io.AssetPaths
import com.winlator.cmod.shared.io.FileUtils
import com.winlator.cmod.shared.theme.WinNativeTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject

class StoresFragment : Fragment() {
Expand Down Expand Up @@ -134,6 +141,7 @@ class StoresFragment : Fragment() {
refresh()
}
},
onUbisoftInstall = { installUbisoftConnect() },
onSharedFolderChanged = {
PrefManager.useSingleDownloadFolder = it
refresh()
Expand Down Expand Up @@ -171,6 +179,7 @@ class StoresFragment : Fragment() {
// Helpers
private fun refresh() {
val ctx = context ?: return
val prev = storeState
val containerLanguageLabels = Language.displayLabels()
val containerLanguageIndex = Language.indexForContainerLang(PrefManager.containerLanguage)
storeState =
Expand All @@ -188,9 +197,71 @@ class StoresFragment : Fragment() {
gogFolder = resolveUri(PrefManager.gogDownloadFolder, ctx),
containerLanguageLabels = containerLanguageLabels,
containerLanguageIndex = containerLanguageIndex,
isUbisoftInstalled = UbisoftConnect.isInstalled(ctx),
// Preserve the in-flight download/install state across refreshes.
ubisoftBusy = prev.ubisoftBusy,
ubisoftStatus = prev.ubisoftStatus,
)
}

// The installer runs in the user's default container; the install lands in the shared store
// (shared by every container), so which container runs it doesn't matter.
private fun defaultUbisoftContainer(ctx: android.content.Context): Container? =
try {
val cm = ContainerManager(ctx)
cm.getContainerById(SetupWizardActivity.getDefaultX86ContainerId(ctx))
?: cm.getContainers().firstOrNull()
} catch (_: Exception) {
null
}

// Download + silently install Ubisoft Connect (lands in the shared store, shared by all containers).
private fun installUbisoftConnect() {
val ctx = context ?: return
if (storeState.ubisoftBusy) return
val container = defaultUbisoftContainer(ctx)
if (container == null) {
Toast.makeText(ctx, R.string.stores_accounts_ubisoft_no_container, Toast.LENGTH_SHORT).show()
return
}
val appCtx = ctx.applicationContext
storeState = storeState.copy(ubisoftBusy = true, ubisoftStatus = "")
CoroutineScope(Dispatchers.IO).launch {
val listener =
object : ComponentInstaller.Listener {
override fun onStatus(text: String) = postUbisoftStatus(text)

override fun onProgress(fraction: Float) {
if (fraction in 0f..1f) postUbisoftStatus("Downloading ${(fraction * 100).toInt()}%")
}
}
val error =
try {
ComponentInstaller(appCtx, container, "ubisoft-connect", UbisoftConnect.installManifest(), listener).run()
null
} catch (e: Exception) {
e.message ?: "error"
}
withContext(Dispatchers.Main) {
storeState = storeState.copy(ubisoftBusy = false, ubisoftStatus = "")
if (error != null) {
Toast.makeText(
appCtx,
appCtx.getString(R.string.stores_accounts_ubisoft_install_failed, error),
Toast.LENGTH_LONG,
).show()
}
refresh()
}
}
}

private fun postUbisoftStatus(text: String) {
view?.post {
if (storeState.ubisoftBusy) storeState = storeState.copy(ubisoftStatus = text)
}
}

private fun loadServerOptions(): List<Pair<Int, String>> =
try {
val json =
Expand Down
142 changes: 142 additions & 0 deletions app/src/main/feature/settings/stores/StoresScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ data class StoreState(
val gogFolder: String = "",
val containerLanguageLabels: List<String> = emptyList(),
val containerLanguageIndex: Int = 0,
val isUbisoftInstalled: Boolean = false,
val ubisoftBusy: Boolean = false,
val ubisoftStatus: String = "",
)

@Composable
Expand All @@ -124,6 +127,7 @@ fun StoresScreen(
onEpicSignOut: () -> Unit,
onGogSignIn: () -> Unit,
onGogSignOut: () -> Unit,
onUbisoftInstall: () -> Unit,
onSharedFolderChanged: (Boolean) -> Unit,
onDownloadSpeedChanged: (Int) -> Unit,
onDownloadServerChanged: (Int) -> Unit,
Expand Down Expand Up @@ -189,6 +193,19 @@ fun StoresScreen(
onSignOut = onGogSignOut,
)

SectionLabel(
stringResource(R.string.stores_accounts_ubisoft_integration_title),
modifier = Modifier.padding(top = 8.dp),
)
UbisoftStoreCard(
name = stringResource(R.string.stores_accounts_ubisoft_integration_title),
accentColor = Color(0xFF148EFF),
isInstalled = state.isUbisoftInstalled,
isBusy = state.ubisoftBusy,
statusText = state.ubisoftStatus,
onDownload = onUbisoftInstall,
)

SectionLabel(stringResource(R.string.stores_accounts_download_settings), modifier = Modifier.padding(top = 8.dp))

SettingsToggleCard(
Expand Down Expand Up @@ -483,6 +500,131 @@ private fun StoreCard(
}
}

// Ubisoft Connect card: Download when the client isn't installed, Sign In once it is.
@Composable
private fun UbisoftStoreCard(
name: String,
accentColor: Color,
isInstalled: Boolean,
isBusy: Boolean,
statusText: String,
onDownload: () -> Unit,
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(CardDark)
.border(1.dp, CardBorder, RoundedCornerShape(14.dp)),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier =
Modifier
.size(38.dp)
.clip(RoundedCornerShape(11.dp))
.background(IconBoxBg),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.Gamepad,
contentDescription = name,
tint = accentColor,
modifier = Modifier.size(21.dp),
)
}

Spacer(Modifier.width(14.dp))

Column(modifier = Modifier.weight(1f)) {
Text(
text = name,
color = TextPrimary,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
if (!isBusy) {
Box(
modifier =
Modifier
.size(6.dp)
.clip(CircleShape)
.background(
if (isInstalled) StatusGreen else TextSecondary.copy(alpha = 0.4f),
),
)
Spacer(Modifier.width(6.dp))
}
Text(
text =
when {
isBusy -> statusText.ifEmpty { stringResource(R.string.stores_accounts_ubisoft_working) }
isInstalled -> stringResource(R.string.stores_accounts_ubisoft_installed)
else -> stringResource(R.string.stores_accounts_ubisoft_not_installed)
},
color = if (isInstalled && !isBusy) StatusGreen else TextSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}

if (isBusy) {
Box(
modifier =
Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF222232))
.border(1.dp, TextSecondary.copy(alpha = 0.25f), RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 7.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.stores_accounts_ubisoft_working),
color = TextSecondary,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
} else if (isInstalled) {
// Already downloaded + installed: grayed-out, non-interactive Download button.
Box(
modifier =
Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF1B1B27))
.border(1.dp, TextSecondary.copy(alpha = 0.18f), RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 7.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.stores_accounts_ubisoft_download),
color = TextSecondary.copy(alpha = 0.5f),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
} else {
ActionButton(
label = stringResource(R.string.stores_accounts_ubisoft_download),
textColor = accentColor,
onClick = onDownload,
)
}
}
}
}

@Composable
private fun ActionButton(
label: String,
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/feature/stores/steam/utils/PrefManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,13 @@ object PrefManager {
setInt("download_speed", value)
}

// Container the Ubisoft Connect installer / sign-in runs in (0 = fall back to default).
var ubisoftContainerId: Int
get() = getInt("ubisoft_container_id", 0)
set(value) {
setInt("ubisoft_container_id", value)
}

var clientId: Long
get() = getLong("client_id", 0L)
set(value) {
Expand Down
57 changes: 57 additions & 0 deletions app/src/main/feature/stores/ubisoft/UbisoftConnect.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.winlator.cmod.feature.stores.ubisoft

import android.content.Context
import com.winlator.cmod.runtime.display.environment.ImageFs
import java.io.File

/**
* Ubisoft Connect pre-setup helpers.
*
* Ubisoft games launched from Steam (Assassin's Creed, etc.) require the Ubisoft Connect client
* (upc.exe / UbisoftConnect.exe). This object downloads and installs that client, and lets the user
* sign in once. The install and login always land in the single shared store at
* `imagefs/home/.ubisoft-store/` because every container symlinks its Ubisoft directories there
* (see XServerDisplayActivity.shareUbisoftConnectLogin), so one install + one sign-in covers every
* container.
*/
object UbisoftConnect {
const val INSTALLER_URL =
"https://github.com/maxjivi05/proton-wine/releases/download/Ubisoft/UbisoftConnectInstaller.exe"
const val INSTALLER_FILE = "UbisoftConnectInstaller.exe"

// The Windows install location Ubisoft Connect uses; this path is symlinked to the shared store.
private const val LAUNCHER_WINDOWS_DIR = "C:\\Program Files (x86)\\Ubisoft\\Ubisoft Game Launcher"

// Keep in sync with XServerDisplayActivity.UBISOFT_STORE_RELATIVE_PATH + UBISOFT_LAUNCHER_STORE_NAME.
private const val STORE_LAUNCHER_REL = "home/.ubisoft-store/launcher"

/** The single shared directory that every container's launcher dir is symlinked to. */
fun sharedLauncherDir(context: Context): File =
File(ImageFs.find(context).rootDir, STORE_LAUNCHER_REL)

/** True once the Ubisoft Connect client binaries are present in the shared store. */
fun isInstalled(context: Context): Boolean {
val dir = sharedLauncherDir(context)
return File(dir, "UbisoftConnect.exe").isFile || File(dir, "upc.exe").isFile
}

/** Windows path of the executable to run for interactive sign-in. */
fun signInWinPath(context: Context): String {
val dir = sharedLauncherDir(context)
val exe = if (File(dir, "UbisoftConnect.exe").isFile) "UbisoftConnect.exe" else "upc.exe"
return "$LAUNCHER_WINDOWS_DIR\\$exe"
}

/**
* One-step ComponentInstaller manifest: download the installer, then run it silently (`/S`, the
* same switch Bottles uses). It installs into the symlinked launcher dir, i.e. the shared store.
*/
fun installManifest(): String =
"""
Steps:
- action: install_exe
url: "$INSTALLER_URL"
file_name: "$INSTALLER_FILE"
arguments: "/S"
""".trimIndent()
}
7 changes: 7 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,13 @@
<!-- Stores > Accounts -->
<string name="stores_accounts_title">Stores</string>
<string name="stores_accounts_steam_integration_title">Steam</string>
<string name="stores_accounts_ubisoft_integration_title">Ubisoft Connect</string>
<string name="stores_accounts_ubisoft_installed">Installed</string>
<string name="stores_accounts_ubisoft_not_installed">Not installed</string>
<string name="stores_accounts_ubisoft_download">Download</string>
<string name="stores_accounts_ubisoft_working">Working…</string>
<string name="stores_accounts_ubisoft_no_container">No container available — create one first</string>
<string name="stores_accounts_ubisoft_install_failed">Ubisoft Connect install failed: %1$s</string>
<string name="stores_accounts_shared_downloads_folder">Shared Downloads Folder</string>
<string name="stores_accounts_shared_downloads_subtitle">Use a single folder across all stores</string>
<string name="stores_accounts_default_downloads_folder">Default Downloads Folder</string>
Expand Down
Loading