Merge branch 'fix/improve-microg-bundle-installation-ui' into 'dev'

microg: Improve bundle installation state & UX

See merge request AuroraOSS/AuroraStore!553
This commit is contained in:
Rahul Patel
2026-05-06 01:17:05 +05:30
10 changed files with 628 additions and 235 deletions

View File

@@ -54,7 +54,7 @@ fun LinkListItem(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(dimensionResource(R.dimen.padding_medium)), .padding(dimensionResource(R.dimen.padding_small)),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))
) { ) {

View File

@@ -5,15 +5,18 @@
package com.aurora.store.compose.composable package com.aurora.store.compose.composable
import android.text.format.Formatter
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -23,8 +26,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -32,20 +37,27 @@ import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEach
import com.aurora.extensions.browse import com.aurora.extensions.browse
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.compose.composable.app.AnimatedAppIcon
import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.data.model.ExternalItem
import com.aurora.store.data.model.InstallStatus
import com.aurora.store.data.model.Link import com.aurora.store.data.model.Link
import com.aurora.store.util.CommonUtil.getETAString
import com.aurora.store.viewmodel.onboarding.MicroGUIState import com.aurora.store.viewmodel.onboarding.MicroGUIState
/** /**
* Composable to display suggestion to install microG * Composable to display suggestion to install microG
* @param modifier Modifier for the composable * @param modifier Modifier for the composable
* @param onInstall Callback when user requests installing microG bundle * @param onInstall Callback when user requests installing microG bundle
* @param onRetry Callback when user retries after a failed download
* @param onTOSChecked Callback when user toggles the TOS checkbox
*/ */
@Composable @Composable
fun MicroG( fun MicroG(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
uiState: MicroGUIState, uiState: MicroGUIState,
onInstall: () -> Unit = {}, onInstall: () -> Unit = {},
onRetry: () -> Unit = {},
onTOSChecked: (Boolean) -> Unit = {} onTOSChecked: (Boolean) -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -103,55 +115,176 @@ fun MicroG(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = dimensionResource(R.dimen.padding_xlarge)), .padding(vertical = dimensionResource(R.dimen.padding_large)),
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small)) verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small))
) { ) {
Row( uiState.items.fastForEach { item ->
verticalAlignment = Alignment.CenterVertically, MicroGItemRow(item = item)
horizontalArrangement = Arrangement.spacedBy( }
dimensionResource(R.dimen.padding_small)
) if (!uiState.isInProgress && !uiState.isInstalled && !uiState.hasFailed) {
) { Row(
Checkbox( verticalAlignment = Alignment.CenterVertically,
checked = isChecked, horizontalArrangement = Arrangement.spacedBy(
onCheckedChange = { dimensionResource(R.dimen.padding_small)
isChecked = it )
onTOSChecked(it) ) {
}, Checkbox(
enabled = !uiState.isInstalled && !uiState.isDownloading checked = isChecked,
) onCheckedChange = {
isChecked = it
onTOSChecked(it)
}
)
Text(
text = stringResource(R.string.onboarding_gms_agreement),
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
if (!uiState.isInProgress && !uiState.isInstalled && !uiState.isOnline) {
Text( Text(
text = stringResource(R.string.onboarding_gms_agreement), text = stringResource(R.string.check_connectivity),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodySmall,
maxLines = 2, color = MaterialTheme.colorScheme.error
overflow = TextOverflow.Ellipsis
) )
} }
Button(
onClick = onInstall, if (!uiState.isInProgress && !uiState.isInstalled) {
enabled = isChecked && !uiState.isDownloading && !uiState.isInstalled, val isRetry = uiState.hasFailed
modifier = Modifier.fillMaxWidth() Button(
) { onClick = if (isRetry) onRetry else onInstall,
Text( enabled = uiState.isOnline && (isRetry || isChecked),
text = if (uiState.isDownloading) { colors = if (isRetry) {
stringResource(R.string.action_installing) ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
} else { } else {
stringResource(R.string.action_install_microG) ButtonDefaults.buttonColors()
} },
)
}
if (uiState.isDownloading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) ) {
Text(
text = if (isRetry) {
stringResource(R.string.action_retry)
} else {
stringResource(R.string.action_install_microG)
}
)
}
} }
} }
} }
} }
@Composable
private fun MicroGItemRow(item: ExternalItem) {
val context = LocalContext.current
val inProgress = item.status == InstallStatus.DOWNLOADING ||
item.status == InstallStatus.INSTALLING
val statusText = stringResource(item.status.localized)
val statusColor = when (item.status) {
InstallStatus.FAILED -> MaterialTheme.colorScheme.error
InstallStatus.INSTALLED -> MaterialTheme.colorScheme.primary
else -> Color.Unspecified
}
val detailText = when (item.status) {
InstallStatus.DOWNLOADING -> {
val downloaded = Formatter.formatShortFileSize(
context,
(item.size * item.progress / 100L).coerceAtLeast(0L)
)
val total = Formatter.formatShortFileSize(context, item.size)
val speed = Formatter.formatShortFileSize(context, item.speed)
val eta = getETAString(context, item.timeRemaining)
"$downloaded / $total$speed/s • $eta"
}
else -> Formatter.formatShortFileSize(context, item.size)
}
val trailingIcon = when (item.status) {
InstallStatus.INSTALLED -> R.drawable.ic_check
InstallStatus.FAILED -> R.drawable.ic_menu_about
else -> null
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(R.dimen.padding_small),
vertical = dimensionResource(R.dimen.padding_xsmall)
),
verticalAlignment = Alignment.CenterVertically
) {
AnimatedAppIcon(
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)),
iconUrl = item.iconURL,
inProgress = inProgress,
progress = item.progress.toFloat()
)
Column(
modifier = Modifier
.weight(1F)
.padding(horizontal = dimensionResource(R.dimen.margin_small))
) {
Text(
text = item.displayName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = statusColor
)
Text(
text = detailText,
style = MaterialTheme.typography.bodySmall
)
}
if (trailingIcon != null) {
Icon(
painter = painterResource(trailingIcon),
contentDescription = statusText,
tint = statusColor,
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_default))
)
}
}
}
private fun previewItems(
companionState: InstallStatus = InstallStatus.PENDING,
companionProgress: Int = 0
): List<ExternalItem> = listOf(
ExternalItem(
packageName = "",
displayName = "microG Services",
iconURL = "",
size = 98_765_432L,
status = companionState,
progress = companionProgress
),
ExternalItem(
packageName = "",
displayName = "microG Companion",
iconURL = "",
size = 1_234_567L,
status = companionState,
progress = companionProgress
)
)
@PreviewWrapper(ThemePreviewProvider::class) @PreviewWrapper(ThemePreviewProvider::class)
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun MicroGPreview() { private fun MicroGReadyPreview() {
MicroG(uiState = MicroGUIState()) MicroG(uiState = MicroGUIState(items = previewItems()))
} }

View File

@@ -0,0 +1,116 @@
/*
* SPDX-FileCopyrightText: 2025 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.composable
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.EaseInOut
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.Center
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.aurora.store.R
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
@Composable
fun ScrollHint(
listState: LazyListState,
modifier: Modifier = Modifier,
bottomPadding: Dp = 72.dp, // keep above bottom bar
autoHideOnScroll: Boolean = true,
enableBounce: Boolean = true,
onClickScrollOffset: Float = 300f
) {
val coroutineScope = rememberCoroutineScope()
val canScrollForward by remember {
derivedStateOf { listState.canScrollForward }
}
var userScrolled by remember { mutableStateOf(false) }
LaunchedEffect(listState.isScrollInProgress) {
if (listState.isScrollInProgress) {
userScrolled = true
}
}
val visible = canScrollForward && (!autoHideOnScroll || !userScrolled)
val offsetY by if (enableBounce && visible) {
val transition = rememberInfiniteTransition(label = "scrollHintBounce")
transition.animateFloat(
initialValue = 0f,
targetValue = 10f,
animationSpec = infiniteRepeatable(
animation = tween(800, easing = EaseInOut),
repeatMode = RepeatMode.Reverse
),
label = "offsetY"
)
} else {
remember { mutableFloatStateOf(0f) }
}
AnimatedVisibility(
visible = visible,
modifier = modifier
.fillMaxWidth()
.padding(bottom = bottomPadding),
enter = fadeIn() + slideInVertically { it / 2 },
exit = fadeOut()
) {
Box(
contentAlignment = Center,
modifier = Modifier.fillMaxWidth()
) {
Icon(
painter = painterResource(id = R.drawable.ic_arrow_down),
contentDescription = "Scroll down",
modifier = Modifier
.size(32.dp)
.offset {
IntOffset(0, offsetY.roundToInt())
}
.alpha(0.7f)
.clickable {
coroutineScope.launch {
listState.animateScrollBy(onClickScrollOffset)
}
}
)
}
}
}

View File

@@ -6,21 +6,24 @@
package com.aurora.store.compose.ui.details package com.aurora.store.compose.ui.details
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.WindowAdaptiveInfo import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
@@ -29,6 +32,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.extensions.adaptiveNavigationIcon import com.aurora.extensions.adaptiveNavigationIcon
@@ -37,6 +41,7 @@ import com.aurora.extensions.toast
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.compose.composable.MicroG import com.aurora.store.compose.composable.MicroG
import com.aurora.store.compose.composable.ScrollHint
import com.aurora.store.compose.composable.TopAppBar import com.aurora.store.compose.composable.TopAppBar
import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.AppPreviewProvider
import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider
@@ -66,6 +71,7 @@ fun MicroGScreen(
uiState = viewModel.uiState, uiState = viewModel.uiState,
onNavigateUp = onNavigateUp, onNavigateUp = onNavigateUp,
onInstall = { viewModel.downloadMicroG() }, onInstall = { viewModel.downloadMicroG() },
onRetry = { viewModel.retryDownload() },
onIgnore = onIgnore onIgnore = onIgnore
) )
} }
@@ -76,10 +82,12 @@ private fun ScreenContent(
uiState: MicroGUIState = MicroGUIState(), uiState: MicroGUIState = MicroGUIState(),
onNavigateUp: () -> Unit = {}, onNavigateUp: () -> Unit = {},
onInstall: () -> Unit = {}, onInstall: () -> Unit = {},
onRetry: () -> Unit = {},
onIgnore: (Boolean) -> Unit = {}, onIgnore: (Boolean) -> Unit = {},
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo() windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo()
) { ) {
val context = LocalContext.current val context = LocalContext.current
val listState = rememberLazyListState()
Scaffold( Scaffold(
topBar = { topBar = {
@@ -88,66 +96,83 @@ private fun ScreenContent(
navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon, navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon,
onNavigateUp = onNavigateUp onNavigateUp = onNavigateUp
) )
},
bottomBar = {
Surface(shadowElevation = 4.dp) {
Row(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = dimensionResource(R.dimen.padding_medium))
.padding(vertical = dimensionResource(R.dimen.padding_small)),
horizontalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.padding_medium)
)
) {
FilledTonalButton(
modifier = Modifier.weight(1F),
onClick = onNavigateUp
) {
Text(
text = stringResource(R.string.action_cancel),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Button(
modifier = Modifier.weight(1F),
onClick = { onIgnore(uiState.isInstalled) },
enabled = !uiState.isInProgress
) {
Text(
text = if (uiState.isInstalled) {
stringResource(R.string.action_install)
} else {
stringResource(R.string.action_ignore)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
} }
) { paddingValues -> ) { paddingValues ->
Column( Box(modifier = Modifier.fillMaxSize()) {
modifier = Modifier LazyColumn(
.padding(paddingValues) modifier = Modifier
.fillMaxSize() .padding(paddingValues)
.padding(dimensionResource(R.dimen.padding_medium)) .fillMaxSize()
.verticalScroll(rememberScrollState()), .padding(horizontal = dimensionResource(R.dimen.padding_medium)),
verticalArrangement = Arrangement.SpaceBetween state = listState
) {
MicroG(
modifier = Modifier.weight(1F),
onInstall = {
when {
PermissionProvider.isGranted(
context,
PermissionType.INSTALL_UNKNOWN_APPS
) -> {
onInstall()
}
else -> context.toast(R.string.permissions_denied)
}
},
uiState = uiState
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.padding_medium)
)
) { ) {
FilledTonalButton( item {
modifier = Modifier.weight(1F), MicroG(
onClick = onNavigateUp modifier = Modifier.fillMaxWidth(),
) { onInstall = {
Text( when {
text = stringResource(R.string.action_cancel), PermissionProvider.isGranted(
maxLines = 1, context,
overflow = TextOverflow.Ellipsis PermissionType.INSTALL_UNKNOWN_APPS
) ) -> {
} onInstall()
}
Button( else -> context.toast(R.string.permissions_denied)
modifier = Modifier.weight(1F), }
onClick = { onIgnore(uiState.isInstalled) },
enabled = !uiState.isDownloading
) {
Text(
text = if (uiState.isInstalled) {
stringResource(R.string.action_install)
} else {
stringResource(R.string.action_ignore)
}, },
maxLines = 1, onRetry = onRetry,
overflow = TextOverflow.Ellipsis uiState = uiState
) )
} }
} }
ScrollHint(
listState = listState,
bottomPadding = 5.dp,
modifier = Modifier.align(Alignment.BottomCenter)
)
} }
} }
} }

View File

@@ -5,15 +5,16 @@
package com.aurora.store.compose.ui.onboarding package com.aurora.store.compose.ui.onboarding
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
@@ -21,10 +22,12 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.aurora.extensions.toast import com.aurora.extensions.toast
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.compose.composable.MicroG import com.aurora.store.compose.composable.MicroG
import com.aurora.store.compose.composable.ScrollHint
import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.data.model.PermissionType import com.aurora.store.data.model.PermissionType
import com.aurora.store.data.providers.PermissionProvider import com.aurora.store.data.providers.PermissionProvider
@@ -39,6 +42,7 @@ fun MicroGPage(
ScreenContent( ScreenContent(
uiState = viewModel.uiState, uiState = viewModel.uiState,
onInstall = { viewModel.downloadMicroG() }, onInstall = { viewModel.downloadMicroG() },
onRetry = { viewModel.retryDownload() },
onMicrogTOSChecked = onMicrogTOSChecked onMicrogTOSChecked = onMicrogTOSChecked
) )
} }
@@ -47,46 +51,61 @@ fun MicroGPage(
private fun ScreenContent( private fun ScreenContent(
uiState: MicroGUIState, uiState: MicroGUIState,
onInstall: () -> Unit = {}, onInstall: () -> Unit = {},
onRetry: () -> Unit = {},
onMicrogTOSChecked: (Boolean) -> Unit = {} onMicrogTOSChecked: (Boolean) -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val listState = rememberLazyListState()
Column( Box(modifier = Modifier.fillMaxSize()) {
modifier = Modifier LazyColumn(
.fillMaxSize() modifier = Modifier
.padding(horizontal = dimensionResource(R.dimen.padding_medium)) .fillMaxSize()
.verticalScroll(rememberScrollState()), .padding(horizontal = dimensionResource(R.dimen.padding_medium)),
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) state = listState
) {
Column(
modifier = Modifier.padding(dimensionResource(R.dimen.padding_medium))
) { ) {
Text( item {
text = stringResource(R.string.onboarding_title_gsf), Column(
style = MaterialTheme.typography.headlineLargeEmphasized, modifier = Modifier.padding(dimensionResource(R.dimen.padding_medium))
maxLines = 1, ) {
overflow = TextOverflow.Ellipsis Text(
) text = stringResource(R.string.onboarding_title_gsf),
Text( style = MaterialTheme.typography.headlineLargeEmphasized,
text = stringResource(R.string.onboarding_title_gsf_desc), maxLines = 1,
style = MaterialTheme.typography.titleMedium, overflow = TextOverflow.Ellipsis
maxLines = 2, )
overflow = TextOverflow.Ellipsis Text(
) text = stringResource(R.string.onboarding_title_gsf_desc),
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
MicroG(
onInstall = {
when {
PermissionProvider.isGranted(
context,
PermissionType.INSTALL_UNKNOWN_APPS
) -> {
onInstall()
}
else -> context.toast(R.string.permissions_denied)
}
},
onRetry = onRetry,
uiState = uiState,
onTOSChecked = onMicrogTOSChecked
)
}
} }
MicroG( ScrollHint(
onInstall = { listState = listState,
when { bottomPadding = 5.dp,
PermissionProvider.isGranted(context, PermissionType.INSTALL_UNKNOWN_APPS) -> { modifier = Modifier.align(Alignment.BottomCenter)
onInstall()
}
else -> context.toast(R.string.permissions_denied)
}
},
uiState = uiState,
onTOSChecked = onMicrogTOSChecked
) )
} }
} }

View File

@@ -11,12 +11,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -74,83 +76,6 @@ private fun ScreenContent(
fun isFinalPage(): Boolean = pagerState.currentPage == (pagerState.pageCount - 1) fun isFinalPage(): Boolean = pagerState.currentPage == (pagerState.pageCount - 1)
@Composable
fun SetupActions(uiState: OnboardingUiState) {
val horizontalButtonPadding = when {
windowAdaptiveInfo.isWindowCompact -> dimensionResource(R.dimen.padding_medium)
else -> dimensionResource(R.dimen.padding_xlarge)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
vertical = dimensionResource(R.dimen.padding_medium),
horizontal = horizontalButtonPadding
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.padding_medium),
Alignment.End
)
) {
val buttonWidthModifier = when {
windowAdaptiveInfo.isWindowCompact -> Modifier.weight(1F)
else -> Modifier.widthIn(min = dimensionResource(R.dimen.width_button))
}
TextButton(
modifier = buttonWidthModifier,
onClick = {
when (pagerState.currentPage) {
0 -> onFinishOnboarding()
else -> {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
}
}
}
) {
Text(
text = when (pagerState.currentPage) {
0 -> stringResource(R.string.action_skip)
else -> stringResource(R.string.action_back)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Button(
modifier = buttonWidthModifier,
enabled = !isFinalPage() ||
(!uiState.isMicroBundleChecked || uiState.isMicroGBundleInstalled),
onClick = {
when {
isFinalPage() -> onFinishOnboarding()
else -> {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
}
}
) {
Text(
text = when {
isFinalPage() -> stringResource(R.string.action_finish)
else -> stringResource(R.string.action_next)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -159,6 +84,83 @@ private fun ScreenContent(
PageIndicator(totalPages = pages.size, currentPage = pagerState.currentPage) PageIndicator(totalPages = pages.size, currentPage = pagerState.currentPage)
} }
) )
},
bottomBar = {
Surface(shadowElevation = 4.dp) {
Row(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(
vertical = dimensionResource(R.dimen.padding_medium),
horizontal = if (windowAdaptiveInfo.isWindowCompact) {
dimensionResource(R.dimen.padding_medium)
} else {
dimensionResource(R.dimen.padding_xlarge)
}
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.padding_medium),
Alignment.End
)
) {
val buttonWidthModifier = when {
windowAdaptiveInfo.isWindowCompact -> Modifier.weight(1F)
else -> Modifier.widthIn(min = dimensionResource(R.dimen.width_button))
}
TextButton(
modifier = buttonWidthModifier,
onClick = {
when (pagerState.currentPage) {
0 -> onFinishOnboarding()
else -> {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
}
}
}
) {
Text(
text = when (pagerState.currentPage) {
0 -> stringResource(R.string.action_skip)
else -> stringResource(R.string.action_back)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Button(
modifier = buttonWidthModifier,
enabled = !isFinalPage() ||
(!uiState.isMicroBundleChecked || uiState.isMicroGBundleInstalled),
onClick = {
when {
isFinalPage() -> onFinishOnboarding()
else -> {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
}
}
) {
Text(
text = when {
isFinalPage() -> stringResource(R.string.action_finish)
else -> stringResource(R.string.action_next)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
} }
) { paddingValues -> ) { paddingValues ->
Column( Column(
@@ -193,8 +195,6 @@ private fun ScreenContent(
} }
} }
} }
SetupActions(uiState)
} }
} }
} }

View File

@@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2025 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data.model
data class ExternalItem(
val packageName: String,
val displayName: String,
val iconURL: String,
val size: Long,
val status: InstallStatus,
val progress: Int = 0,
val speed: Long = 0L,
val timeRemaining: Long = -1L
)

View File

@@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2025 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data.model
import androidx.annotation.StringRes
import com.aurora.store.R
enum class InstallStatus(@StringRes val localized: Int) {
PENDING(R.string.action_pending),
DOWNLOADING(R.string.status_downloading),
INSTALLING(R.string.action_installing),
INSTALLED(R.string.title_installed),
FAILED(R.string.status_failed)
}

View File

@@ -17,26 +17,44 @@ import com.aurora.gplayapi.data.models.PlayFile
import com.aurora.store.AuroraApp import com.aurora.store.AuroraApp
import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.helper.DownloadHelper import com.aurora.store.data.helper.DownloadHelper
import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.data.model.ExternalItem
import com.aurora.store.data.model.InstallStatus
import com.aurora.store.data.model.NetworkStatus
import com.aurora.store.data.providers.NetworkProvider
import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.suite.ExternalApk import com.aurora.store.data.room.suite.ExternalApk
import com.aurora.store.util.PackageUtil import com.aurora.store.util.PackageUtil
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class MicroGUIState( data class MicroGUIState(
var isDownloading: Boolean = false, val items: List<ExternalItem> = emptyList(),
var isInstalled: Boolean = false val isOnline: Boolean = true
) ) {
val isInstalled: Boolean
get() = items.isNotEmpty() && items.all { it.status == InstallStatus.INSTALLED }
val isInProgress: Boolean
get() = items.any {
it.status == InstallStatus.DOWNLOADING || it.status == InstallStatus.INSTALLING
}
val hasFailed: Boolean
get() = !isInstalled && items.any { it.status == InstallStatus.FAILED }
}
@HiltViewModel @HiltViewModel
class MicroGViewModel @Inject constructor( class MicroGViewModel @Inject constructor(
@ApplicationContext @ApplicationContext
private val context: Context, private val context: Context,
private val downloadHelper: DownloadHelper private val downloadHelper: DownloadHelper,
networkProvider: NetworkProvider
) : ViewModel() { ) : ViewModel() {
companion object { companion object {
@@ -55,15 +73,6 @@ class MicroGViewModel @Inject constructor(
private const val ICON_FILE_PATH = "src/main/res/mipmap-xxxhdpi/ic_app.png" private const val ICON_FILE_PATH = "src/main/res/mipmap-xxxhdpi/ic_app.png"
} }
init {
AuroraApp.events.installerEvent.onEach {
when (it) {
is InstallerEvent.Installed -> confirmBundleInstall()
else -> {}
}
}.launchIn(AuroraApp.scope)
}
var uiState: MicroGUIState by mutableStateOf(MicroGUIState()) var uiState: MicroGUIState by mutableStateOf(MicroGUIState())
private set private set
@@ -101,27 +110,83 @@ class MicroGViewModel @Inject constructor(
) )
) )
private val bundle = listOf(microGServiceApk, microGCompanionApk)
init {
AuroraApp.events.installerEvent.onEach {
if (it is InstallerEvent.Installed) refresh()
}.launchIn(AuroraApp.scope)
combine(
downloadHelper.downloadsList,
networkProvider.status.onStart { emit(NetworkStatus.AVAILABLE) }
) { downloads, network ->
val byPackage = downloads.associateBy { it.packageName }
MicroGUIState(
items = bundle.map { apk -> buildItem(apk, byPackage[apk.packageName]) },
isOnline = network == NetworkStatus.AVAILABLE
)
}.onEach { uiState = it }.launchIn(viewModelScope)
}
fun downloadMicroG() { fun downloadMicroG() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
if (microGCompanionApk.isInstalled(context)) { bundle.forEach { enqueueIfNeeded(it) }
AuroraApp.events.send(InstallerEvent.Installed(PACKAGE_NAME_PLAY_STORE)) }
} else { }
downloadHelper.enqueueStandalone(microGCompanionApk)
uiState = uiState.copy(isDownloading = true)
}
if (microGServiceApk.isInstalled(context)) { fun retryDownload() {
AuroraApp.events.send(InstallerEvent.Installed(PACKAGE_NAME_GMS)) viewModelScope.launch(Dispatchers.IO) {
} else { bundle.forEach { apk ->
downloadHelper.enqueueStandalone(microGServiceApk) downloadHelper.getDownload(apk.packageName)?.takeIf { it.isFinished }?.let {
uiState = uiState.copy(isDownloading = true) downloadHelper.clearDownload(apk.packageName, apk.versionCode)
}
enqueueIfNeeded(apk)
} }
} }
} }
private fun confirmBundleInstall() { private suspend fun enqueueIfNeeded(apk: ExternalApk) {
if (PackageUtil.isMicroGBundleInstalled(context)) { if (apk.isInstalled(context)) {
uiState = uiState.copy(isInstalled = true, isDownloading = false) AuroraApp.events.send(InstallerEvent.Installed(apk.packageName))
} else {
downloadHelper.enqueueStandalone(apk)
} }
} }
private fun refresh() {
uiState = uiState.copy(
items = uiState.items.map { item ->
if (item.status != InstallStatus.INSTALLED &&
PackageUtil.isInstalled(context, item.packageName)
) {
item.copy(status = InstallStatus.INSTALLED)
} else {
item
}
}
)
}
private fun buildItem(apk: ExternalApk, download: Download?): ExternalItem {
val state = when {
apk.isInstalled(context) -> InstallStatus.INSTALLED
download == null -> InstallStatus.PENDING
download.status == DownloadStatus.FAILED -> InstallStatus.FAILED
download.status == DownloadStatus.CANCELLED -> InstallStatus.PENDING
download.status == DownloadStatus.COMPLETED -> InstallStatus.INSTALLING
else -> InstallStatus.DOWNLOADING
}
return ExternalItem(
packageName = apk.packageName,
displayName = apk.displayName,
iconURL = apk.iconURL,
size = apk.fileList.sumOf { it.size },
status = state,
progress = download?.progress ?: 0,
speed = download?.speed ?: 0L,
timeRemaining = download?.timeRemaining ?: -1L
)
}
} }

View File

@@ -92,6 +92,7 @@
<string name="action_request_analysis">Request new analysis</string> <string name="action_request_analysis">Request new analysis</string>
<string name="action_restart">"Restart"</string> <string name="action_restart">"Restart"</string>
<string name="action_resume">"Resume"</string> <string name="action_resume">"Resume"</string>
<string name="action_retry">"Retry"</string>
<string name="action_search">"Search"</string> <string name="action_search">"Search"</string>
<string name="action_select_all">"Select all"</string> <string name="action_select_all">"Select all"</string>
<string name="action_share">"Share"</string> <string name="action_share">"Share"</string>