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
87 changes: 87 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,8 @@ class StoresFragment : Fragment() {
refresh()
}
},
onUbisoftInstall = { installUbisoftConnect() },
onUbisoftUninstall = { uninstallUbisoftConnect() },
onSharedFolderChanged = {
PrefManager.useSingleDownloadFolder = it
refresh()
Expand Down Expand Up @@ -171,6 +180,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 +198,86 @@ 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)
}
}

// Fully uninstall Ubisoft Connect (delete the shared store) after the user confirms.
private fun uninstallUbisoftConnect() {
val ctx = context ?: return
if (storeState.ubisoftBusy) return
val appCtx = ctx.applicationContext
storeState = storeState.copy(ubisoftBusy = true, ubisoftStatus = "")
CoroutineScope(Dispatchers.IO).launch {
UbisoftConnect.uninstall(appCtx)
withContext(Dispatchers.Main) {
storeState = storeState.copy(ubisoftBusy = false, ubisoftStatus = "")
refresh()
}
}
}

private fun loadServerOptions(): List<Pair<Int, String>> =
try {
val json =
Expand Down
192 changes: 192 additions & 0 deletions app/src/main/feature/settings/stores/StoresScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.winlator.cmod.R
import com.winlator.cmod.shared.theme.WinNativeDanger
import com.winlator.cmod.shared.theme.WinNativeOutline
import com.winlator.cmod.shared.theme.WinNativeTextPrimary
import com.winlator.cmod.shared.theme.WinNativeTextSecondary
import com.winlator.cmod.shared.ui.dialog.WinNativeDialogButton
import com.winlator.cmod.shared.ui.dialog.WinNativeDialogShell
import com.winlator.cmod.shared.ui.focus.rememberSettingsContentNav
import com.winlator.cmod.shared.ui.nav.LocalPaneNav
import com.winlator.cmod.shared.ui.nav.paneNavItem
Expand Down Expand Up @@ -112,6 +118,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 +133,8 @@ fun StoresScreen(
onEpicSignOut: () -> Unit,
onGogSignIn: () -> Unit,
onGogSignOut: () -> Unit,
onUbisoftInstall: () -> Unit,
onUbisoftUninstall: () -> Unit,
onSharedFolderChanged: (Boolean) -> Unit,
onDownloadSpeedChanged: (Int) -> Unit,
onDownloadServerChanged: (Int) -> Unit,
Expand Down Expand Up @@ -189,6 +200,20 @@ 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,
onUninstall = onUbisoftUninstall,
)

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

SettingsToggleCard(
Expand Down Expand Up @@ -483,6 +508,173 @@ 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,
onUninstall: () -> Unit,
) {
var showUninstallDialog by remember { mutableStateOf(false) }
if (showUninstallDialog) {
UninstallConfirmDialog(
onConfirm = onUninstall,
onDismiss = { showUninstallDialog = false },
)
}
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) {
ActionButton(
label = stringResource(R.string.stores_accounts_ubisoft_uninstall),
textColor = DangerRed,
onClick = { showUninstallDialog = true },
)
} else {
ActionButton(
label = stringResource(R.string.stores_accounts_ubisoft_download),
textColor = accentColor,
onClick = onDownload,
)
}
}
}
}

// Ubisoft Connect uninstall confirmation — uses the app's standard dialog shell + buttons so it
// matches every other confirm dialog in the app (WinNativeComposeDialogs.showConfirm style).
@Composable
private fun UninstallConfirmDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
WinNativeDialogShell(onDismiss = onDismiss) {
Text(
text = stringResource(R.string.stores_accounts_ubisoft_uninstall_confirm),
color = WinNativeTextSecondary,
fontSize = 14.sp,
lineHeight = 20.sp,
)
Spacer(Modifier.height(16.dp))
Box(
modifier =
Modifier
.fillMaxWidth()
.height(1.dp)
.background(WinNativeOutline),
)
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
) {
WinNativeDialogButton(
label = stringResource(R.string.common_ui_cancel),
textColor = WinNativeTextPrimary,
onClick = onDismiss,
)
WinNativeDialogButton(
label = stringResource(R.string.stores_accounts_ubisoft_uninstall),
textColor = WinNativeDanger,
backgroundColor = WinNativeDanger.copy(alpha = 0.12f),
borderColor = WinNativeDanger.copy(alpha = 0.3f),
onClick = {
onConfirm()
onDismiss()
},
)
}
}
}

@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
Loading
Loading