microg: Improve bundle installation state & UX

This commit is contained in:
Rahul Patel
2026-05-06 01:17:05 +05:30
parent 20dabbe9b4
commit 0c1786e77b
10 changed files with 628 additions and 235 deletions

View File

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

View File

@@ -5,15 +5,18 @@
package com.aurora.store.compose.composable
import android.text.format.Formatter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -23,8 +26,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
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 com.aurora.extensions.browse
import com.aurora.store.R
import com.aurora.store.compose.composable.app.AnimatedAppIcon
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.util.CommonUtil.getETAString
import com.aurora.store.viewmodel.onboarding.MicroGUIState
/**
* Composable to display suggestion to install microG
* @param modifier Modifier for the composable
* @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
fun MicroG(
modifier: Modifier = Modifier,
uiState: MicroGUIState,
onInstall: () -> Unit = {},
onRetry: () -> Unit = {},
onTOSChecked: (Boolean) -> Unit = {}
) {
val context = LocalContext.current
@@ -103,55 +115,176 @@ fun MicroG(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = dimensionResource(R.dimen.padding_xlarge)),
.padding(vertical = dimensionResource(R.dimen.padding_large)),
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small))
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.padding_small)
)
) {
Checkbox(
checked = isChecked,
onCheckedChange = {
isChecked = it
onTOSChecked(it)
},
enabled = !uiState.isInstalled && !uiState.isDownloading
)
uiState.items.fastForEach { item ->
MicroGItemRow(item = item)
}
if (!uiState.isInProgress && !uiState.isInstalled && !uiState.hasFailed) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.padding_small)
)
) {
Checkbox(
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 = stringResource(R.string.onboarding_gms_agreement),
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
text = stringResource(R.string.check_connectivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Button(
onClick = onInstall,
enabled = isChecked && !uiState.isDownloading && !uiState.isInstalled,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = if (uiState.isDownloading) {
stringResource(R.string.action_installing)
if (!uiState.isInProgress && !uiState.isInstalled) {
val isRetry = uiState.hasFailed
Button(
onClick = if (isRetry) onRetry else onInstall,
enabled = uiState.isOnline && (isRetry || isChecked),
colors = if (isRetry) {
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
} else {
stringResource(R.string.action_install_microG)
}
)
}
if (uiState.isDownloading) {
LinearProgressIndicator(
ButtonDefaults.buttonColors()
},
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)
@Preview(showBackground = true)
@Composable
private fun MicroGPreview() {
MicroG(uiState = MicroGUIState())
private fun MicroGReadyPreview() {
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
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.extensions.adaptiveNavigationIcon
@@ -37,6 +41,7 @@ import com.aurora.extensions.toast
import com.aurora.gplayapi.data.models.App
import com.aurora.store.R
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.preview.AppPreviewProvider
import com.aurora.store.compose.preview.ThemePreviewProvider
@@ -66,6 +71,7 @@ fun MicroGScreen(
uiState = viewModel.uiState,
onNavigateUp = onNavigateUp,
onInstall = { viewModel.downloadMicroG() },
onRetry = { viewModel.retryDownload() },
onIgnore = onIgnore
)
}
@@ -76,10 +82,12 @@ private fun ScreenContent(
uiState: MicroGUIState = MicroGUIState(),
onNavigateUp: () -> Unit = {},
onInstall: () -> Unit = {},
onRetry: () -> Unit = {},
onIgnore: (Boolean) -> Unit = {},
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo()
) {
val context = LocalContext.current
val listState = rememberLazyListState()
Scaffold(
topBar = {
@@ -88,66 +96,83 @@ private fun ScreenContent(
navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon,
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 ->
Column(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.padding(dimensionResource(R.dimen.padding_medium))
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.SpaceBetween
) {
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)
)
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.padding(horizontal = dimensionResource(R.dimen.padding_medium)),
state = listState
) {
FilledTonalButton(
modifier = Modifier.weight(1F),
onClick = onNavigateUp
) {
Text(
text = stringResource(R.string.action_cancel),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
item {
MicroG(
modifier = Modifier.fillMaxWidth(),
onInstall = {
when {
PermissionProvider.isGranted(
context,
PermissionType.INSTALL_UNKNOWN_APPS
) -> {
onInstall()
}
Button(
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)
else -> context.toast(R.string.permissions_denied)
}
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
onRetry = onRetry,
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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.aurora.extensions.toast
import com.aurora.store.R
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.data.model.PermissionType
import com.aurora.store.data.providers.PermissionProvider
@@ -39,6 +42,7 @@ fun MicroGPage(
ScreenContent(
uiState = viewModel.uiState,
onInstall = { viewModel.downloadMicroG() },
onRetry = { viewModel.retryDownload() },
onMicrogTOSChecked = onMicrogTOSChecked
)
}
@@ -47,46 +51,61 @@ fun MicroGPage(
private fun ScreenContent(
uiState: MicroGUIState,
onInstall: () -> Unit = {},
onRetry: () -> Unit = {},
onMicrogTOSChecked: (Boolean) -> Unit = {}
) {
val context = LocalContext.current
val listState = rememberLazyListState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = dimensionResource(R.dimen.padding_medium))
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))
) {
Column(
modifier = Modifier.padding(dimensionResource(R.dimen.padding_medium))
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = dimensionResource(R.dimen.padding_medium)),
state = listState
) {
Text(
text = stringResource(R.string.onboarding_title_gsf),
style = MaterialTheme.typography.headlineLargeEmphasized,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = stringResource(R.string.onboarding_title_gsf_desc),
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
item {
Column(
modifier = Modifier.padding(dimensionResource(R.dimen.padding_medium))
) {
Text(
text = stringResource(R.string.onboarding_title_gsf),
style = MaterialTheme.typography.headlineLargeEmphasized,
maxLines = 1,
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(
onInstall = {
when {
PermissionProvider.isGranted(context, PermissionType.INSTALL_UNKNOWN_APPS) -> {
onInstall()
}
else -> context.toast(R.string.permissions_denied)
}
},
uiState = uiState,
onTOSChecked = onMicrogTOSChecked
ScrollHint(
listState = listState,
bottomPadding = 5.dp,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}

View File

@@ -11,12 +11,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -74,83 +76,6 @@ private fun ScreenContent(
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(
topBar = {
TopAppBar(
@@ -159,6 +84,83 @@ private fun ScreenContent(
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 ->
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.data.event.InstallerEvent
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.util.PackageUtil
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
data class MicroGUIState(
var isDownloading: Boolean = false,
var isInstalled: Boolean = false
)
val items: List<ExternalItem> = emptyList(),
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
class MicroGViewModel @Inject constructor(
@ApplicationContext
private val context: Context,
private val downloadHelper: DownloadHelper
private val downloadHelper: DownloadHelper,
networkProvider: NetworkProvider
) : ViewModel() {
companion object {
@@ -55,15 +73,6 @@ class MicroGViewModel @Inject constructor(
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())
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() {
viewModelScope.launch(Dispatchers.IO) {
if (microGCompanionApk.isInstalled(context)) {
AuroraApp.events.send(InstallerEvent.Installed(PACKAGE_NAME_PLAY_STORE))
} else {
downloadHelper.enqueueStandalone(microGCompanionApk)
uiState = uiState.copy(isDownloading = true)
}
bundle.forEach { enqueueIfNeeded(it) }
}
}
if (microGServiceApk.isInstalled(context)) {
AuroraApp.events.send(InstallerEvent.Installed(PACKAGE_NAME_GMS))
} else {
downloadHelper.enqueueStandalone(microGServiceApk)
uiState = uiState.copy(isDownloading = true)
fun retryDownload() {
viewModelScope.launch(Dispatchers.IO) {
bundle.forEach { apk ->
downloadHelper.getDownload(apk.packageName)?.takeIf { it.isFinished }?.let {
downloadHelper.clearDownload(apk.packageName, apk.versionCode)
}
enqueueIfNeeded(apk)
}
}
}
private fun confirmBundleInstall() {
if (PackageUtil.isMicroGBundleInstalled(context)) {
uiState = uiState.copy(isInstalled = true, isDownloading = false)
private suspend fun enqueueIfNeeded(apk: ExternalApk) {
if (apk.isInstalled(context)) {
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_restart">"Restart"</string>
<string name="action_resume">"Resume"</string>
<string name="action_retry">"Retry"</string>
<string name="action_search">"Search"</string>
<string name="action_select_all">"Select all"</string>
<string name="action_share">"Share"</string>