diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f26de4c03..e162cf3f9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,15 +5,11 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -@file:OptIn(KspExperimental::class) - import com.android.build.api.dsl.ApplicationExtension -import com.google.devtools.ksp.KspExperimental import java.util.Properties plugins { alias(libs.plugins.android.application) - alias(libs.plugins.jetbrains.kotlin.android) alias(libs.plugins.jetbrains.kotlin.compose) alias(libs.plugins.jetbrains.kotlin.parcelize) alias(libs.plugins.jetbrains.kotlin.serialization) @@ -53,13 +49,19 @@ kotlin { configure { namespace = "com.aurora.store" compileSdk { - version = release(37) + version = release(37) { + minorApiLevel = 0 + } } defaultConfig { applicationId = "com.aurora.store" - minSdk = 23 - targetSdk = 36 + minSdk { + version = release(23) + } + targetSdk { + version = release(37) + } versionCode = 74 versionName = "4.8.2" diff --git a/app/src/main/java/com/aurora/store/ComposeActivity.kt b/app/src/main/java/com/aurora/store/ComposeActivity.kt index ca687974c..5a47d37c9 100644 --- a/app/src/main/java/com/aurora/store/ComposeActivity.kt +++ b/app/src/main/java/com/aurora/store/ComposeActivity.kt @@ -11,6 +11,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.CompositionLocalProvider import androidx.core.content.IntentCompat +import com.aurora.extensions.getPackageName import com.aurora.store.compose.composition.LocalUI import com.aurora.store.compose.composition.UI import com.aurora.store.compose.navigation.NavDisplay @@ -26,12 +27,21 @@ class ComposeActivity : ComponentActivity() { enableEdgeToEdge() super.onCreate(savedInstanceState) - // TODO: Change startDestination logic to mirror MainActivity - val startDestination = IntentCompat.getParcelableExtra( + // Ensure the classloader is set for the Screen parcelable + intent.setExtrasClassLoader(Screen::class.java.classLoader) + + var startDestination = IntentCompat.getParcelableExtra( intent, Screen.PARCEL_KEY, Screen::class.java - ) ?: Screen.Blacklist + ) ?: Screen.Onboarding + + // If the intent contains a package name for app details, + // Override the start destination to be the app details screen for that package. + val packageName = intent.getPackageName() + if (packageName != null) { + startDestination = Screen.AppDetails(packageName) + } val localUI = when { PackageUtil.isTv(this) -> UI.TV diff --git a/app/src/main/java/com/aurora/store/compose/composable/DownloadListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/DownloadListItem.kt index e20424af9..9fe4551a0 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/DownloadListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/DownloadListItem.kt @@ -48,6 +48,7 @@ import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.data.model.DownloadStatus import com.aurora.store.data.room.download.Download import com.aurora.store.util.CommonUtil.getETAString +import com.aurora.store.util.PackageUtil import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -108,7 +109,11 @@ fun DownloadListItem( DropdownMenuItem( text = { Text(text = stringResource(R.string.action_export)) }, onClick = onExport, - enabled = download.canInstall(context) + enabled = download.canInstall(context) || + PackageUtil.isInstalled( + context, + download.packageName + ) ) DropdownMenuItem( text = { Text(text = stringResource(R.string.action_clear)) }, diff --git a/app/src/main/java/com/aurora/store/compose/composable/LinkListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/LinkListItem.kt index e1fddbba0..1b0455ed2 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/LinkListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/LinkListItem.kt @@ -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)) ) { diff --git a/app/src/main/java/com/aurora/store/compose/composable/MicroG.kt b/app/src/main/java/com/aurora/store/compose/composable/MicroG.kt index 25d9e859f..887848043 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/MicroG.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/MicroG.kt @@ -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 = 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())) } diff --git a/app/src/main/java/com/aurora/store/compose/composable/ScrollHint.kt b/app/src/main/java/com/aurora/store/compose/composable/ScrollHint.kt new file mode 100644 index 000000000..3597f508d --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/ScrollHint.kt @@ -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) + } + } + ) + } + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/commons/ForceRestartDialog.kt b/app/src/main/java/com/aurora/store/compose/ui/commons/ForceRestartDialog.kt new file mode 100644 index 000000000..e1f8f00a1 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/commons/ForceRestartDialog.kt @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 Aurora OSS + * SPDX-FileCopyrightText: 2026 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.commons + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.aurora.store.R +import com.aurora.store.compose.preview.ThemePreviewProvider + +/** + * Dialog for prompting users to restart Aurora Store + * @param onConfirm Callback on confirmation + * @param onDismiss Callback on dismissal + */ +@Composable +fun ForceRestartDialog(onConfirm: () -> Unit = {}, onDismiss: () -> Unit = {}) { + AlertDialog( + title = { Text(text = stringResource(R.string.force_restart_title)) }, + text = { Text(text = stringResource(R.string.force_restart_summary)) }, + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = stringResource(R.string.action_restart)) + } + } + ) +} + +@PreviewWrapper(ThemePreviewProvider::class) +@Preview +@Composable +private fun ForceRestartDialogPreview() { + ForceRestartDialog() +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt index 4acc0bb08..568c680f4 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt @@ -6,6 +6,7 @@ package com.aurora.store.compose.ui.details import android.content.ActivityNotFoundException +import android.content.Intent import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -34,8 +35,10 @@ import androidx.compose.material3.adaptive.navigation.rememberSupportingPaneScaf import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +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 import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -57,6 +60,7 @@ import com.aurora.extensions.toast import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.Review import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport +import com.aurora.store.ComposeActivity import com.aurora.store.R import com.aurora.store.compose.composable.ContainedLoadingIndicator import com.aurora.store.compose.composable.Error @@ -66,6 +70,7 @@ import com.aurora.store.compose.composable.app.LargeAppListItem import com.aurora.store.compose.navigation.Screen import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.compose.ui.commons.ForceRestartDialog import com.aurora.store.compose.ui.commons.PermissionRationaleScreen import com.aurora.store.compose.ui.details.composable.Actions import com.aurora.store.compose.ui.details.composable.Changelog @@ -87,11 +92,13 @@ import com.aurora.store.data.model.AppState import com.aurora.store.data.model.PermissionType import com.aurora.store.data.model.Report import com.aurora.store.data.model.Scores +import com.aurora.store.data.providers.PermissionProvider.Companion.isGranted import com.aurora.store.data.providers.PermissionProvider.Companion.isPermittedToInstall import com.aurora.store.util.FlavouredUtil import com.aurora.store.util.PackageUtil import com.aurora.store.util.ShortcutManagerUtil import com.aurora.store.viewmodel.details.AppDetailsViewModel +import com.jakewharton.processphoenix.ProcessPhoenix import kotlin.random.Random import kotlinx.coroutines.launch @@ -162,7 +169,12 @@ fun AppDetailsScreen( onTestingSubscriptionChange = { subscribe -> viewModel.updateTestingProgramStatus(packageName, subscribe) }, - forceSinglePane = forceSinglePane + forceSinglePane = forceSinglePane, + onForceRestart = { + val intent = Intent(context, ComposeActivity::class.java) + .putExtra("packageName", packageName) + ProcessPhoenix.triggerRebirth(context, intent) + } ) } } @@ -226,7 +238,8 @@ private fun ScreenContentApp( onOpen: () -> Unit = {}, onTestingSubscriptionChange: (subscribe: Boolean) -> Unit = {}, windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(), - forceSinglePane: Boolean = false + forceSinglePane: Boolean = false, + onForceRestart: () -> Unit = {} ) { val context = LocalContext.current var scaffoldDirective = calculatePaneScaffoldDirective(windowAdaptiveInfo) @@ -244,6 +257,11 @@ private fun ScreenContentApp( val coroutineScope = rememberCoroutineScope() val shouldShowMenuOnMainPane = scaffoldNavigator .scaffoldValue[SupportingPaneScaffoldRole.Supporting] == PaneAdaptedValue.Hidden + var showRestartDialog by remember { mutableStateOf(false) } + + if (showRestartDialog) { + ForceRestartDialog(onConfirm = onForceRestart) + } fun onNavigateBack() { coroutineScope.launch { @@ -549,7 +567,15 @@ private fun ScreenContentApp( is Screen.PermissionRationale -> PermissionRationaleScreen( onNavigateUp = ::onNavigateBack, requiredPermissions = screen.requiredPermissions, - onPermissionCallback = { onInstall() } + onPermissionCallback = { type -> + val isStoragePermission = type == PermissionType.EXTERNAL_STORAGE || + type == PermissionType.STORAGE_MANAGER + if (isStoragePermission && isGranted(context, type)) { + showRestartDialog = true + } else { + onInstall() + } + } ) else -> {} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/MicroGScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/MicroGScreen.kt index 9299b7f01..a15abfdd9 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/MicroGScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/MicroGScreen.kt @@ -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) + ) } } } diff --git a/app/src/main/java/com/aurora/store/compose/ui/onboarding/MicroGPage.kt b/app/src/main/java/com/aurora/store/compose/ui/onboarding/MicroGPage.kt index 2b0c6619c..671597fb7 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/onboarding/MicroGPage.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/onboarding/MicroGPage.kt @@ -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) ) } } diff --git a/app/src/main/java/com/aurora/store/compose/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/onboarding/OnboardingScreen.kt index a68c009dc..89c10872d 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/onboarding/OnboardingScreen.kt @@ -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) } } } diff --git a/app/src/main/java/com/aurora/store/data/model/ExternalItem.kt b/app/src/main/java/com/aurora/store/data/model/ExternalItem.kt new file mode 100644 index 000000000..07259e950 --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/model/ExternalItem.kt @@ -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 +) diff --git a/app/src/main/java/com/aurora/store/data/model/InstallStatus.kt b/app/src/main/java/com/aurora/store/data/model/InstallStatus.kt new file mode 100644 index 000000000..2b4bddfe6 --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/model/InstallStatus.kt @@ -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) +} diff --git a/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt b/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt index 1e743db42..047bc737d 100644 --- a/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt +++ b/app/src/main/java/com/aurora/store/data/work/ExportWorker.kt @@ -76,7 +76,7 @@ class ExportWorker @AssistedInject constructor( */ fun exportDownloadedApp(context: Context, download: Download, uri: Uri) { val inputData = Data.Builder() - .putBoolean(IS_DOWNLOAD, true) + .putBoolean(IS_DOWNLOAD, download.canInstall(context)) .putString(URI, uri.toString()) .putString(DISPLAY_NAME, download.displayName) .putString(PACKAGE_NAME, download.packageName) diff --git a/app/src/main/java/com/aurora/store/viewmodel/onboarding/MicroGViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/onboarding/MicroGViewModel.kt index 1e063b6a4..c2c697960 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/onboarding/MicroGViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/onboarding/MicroGViewModel.kt @@ -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 = 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 + ) + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a9fbe77bb..417f4208b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -92,6 +92,7 @@ Request new analysis "Restart" "Resume" + "Retry" "Search" "Select all" "Share" diff --git a/build.gradle.kts b/build.gradle.kts index 810cdf068..1c9d267b7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,9 +5,15 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +buildscript { + dependencies { + // https://developer.android.com/build/releases/agp-9-0-0-release-notes#runtime-dependency-on-kotlin-gradle-plugin-upgrade + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${libs.versions.kotlin.get()}") + } +} + plugins { alias(libs.plugins.android.application) apply false - alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.jetbrains.kotlin.compose) apply false alias(libs.plugins.jetbrains.kotlin.parcelize) apply false alias(libs.plugins.jetbrains.kotlin.serialization) apply false diff --git a/gradle.properties b/gradle.properties index 8bad2bce4..35c8bf872 100644 --- a/gradle.properties +++ b/gradle.properties @@ -25,6 +25,11 @@ android.useAndroidX=true kotlin.code.style=official # https://r8.googlesource.com/r8/+/refs/heads/master/compatibility-faq.md#r8-full-mode +# Drop after migration to compose android.enableR8.fullMode=false +android.r8.strictFullModeForKeepRules=false # https://docs.gradle.org/current/userguide/configuration_cache.html org.gradle.configuration-cache=true + +# AGP 9.x disallows using SDK in manifest. We are using it for Shizuku support +android.usesSdkInManifest.disallowed=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 21a6dcb7c..46a2a3caa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,8 +7,8 @@ [versions] activity = "1.13.0" agCoreservice = "13.3.1.300" -androidGradlePlugin = "8.13.2" -adaptive = "1.3.0-alpha09" +androidGradlePlugin = "9.2.0" +adaptive = "1.3.0-alpha10" androidx-hilt = "1.3.0" androidx-junit = "1.3.0" browser = "1.10.0" @@ -20,10 +20,10 @@ epoxy = "5.2.1" espresso = "3.7.0" gplayapi = "3.5.9" hiddenapibypass = "6.1" -hilt = "2.58" +hilt = "2.59.2" junit = "4.13.2" -kotlin = "2.3.20" -ksp = "2.3.6" +kotlin = "2.3.21" +ksp = "2.3.7" ktlint = "14.2.0" leakcanary = "2.14" libsu = "6.0.0" @@ -116,7 +116,6 @@ google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt-android-plugin = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } jetbrains-kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } jetbrains-kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } rikka-tools-refine-plugin = { id = "dev.rikka.tools.refine", version.ref = "rikkaTools" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index d997cfc60..b1b8ef56b 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8e61ef125..4b271a646 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,10 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 739907dfd..b9bb139f7 100755 --- a/gradlew +++ b/gradlew @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/gradlew.bat b/gradlew.bat index e509b2dd8..aa5f10b06 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -23,8 +23,8 @@ @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,7 +65,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line @@ -73,21 +73,10 @@ goto fail @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL%