compose: Initial migration of AppDetails* logic to compose [1/*]

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
Aayush Gupta
2025-03-25 15:19:11 +08:00
parent 9ccbd62df0
commit f6f858e9f5
61 changed files with 2528 additions and 509 deletions

View File

@@ -74,6 +74,19 @@ fun Context.share(app: App) {
}
}
fun Context.mailTo(email: String) {
try {
val sendIntent = Intent().apply {
action = Intent.ACTION_SENDTO
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, email)
}
startActivity(Intent.createChooser(sendIntent, getString(R.string.details_dev_email)))
} catch (exception: Exception) {
Log.e(TAG, "Failed to email", exception)
}
}
fun Context.openInfo(packageName: String) {
try {
val intent = Intent(

View File

@@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.extensions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination
/**
* Gets viewModel from the parent composable if exists
*/
@Composable
inline fun <reified T : ViewModel> NavBackStackEntry.parentViewModel(navController: NavController): T {
val navGraphRoute = navController.graph.findStartDestination().route ?: return hiltViewModel()
val parentEntry = remember(this) { navController.getBackStackEntry(navGraphRoute) }
return hiltViewModel<T>(parentEntry)
}

View File

@@ -0,0 +1,65 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.extensions
import androidx.compose.animation.core.FastOutSlowInEasing
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.foundation.background
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.IntSize
/**
* Modifier for composable to play shimmer animation
* @param toShow Whether to play shimmer animation
*/
fun Modifier.shimmer(toShow: Boolean): Modifier {
return composed {
if (toShow) {
val shimmerColors = listOf(
Color.LightGray.copy(alpha = 0.6f),
Color.LightGray.copy(alpha = 0.2f),
Color.LightGray.copy(alpha = 0.6f)
)
var size by remember { mutableStateOf(IntSize.Zero) }
val transition = rememberInfiniteTransition()
val transitionAnimation = transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 1000,
easing = FastOutSlowInEasing
),
repeatMode = RepeatMode.Reverse
)
)
background(
brush = Brush.linearGradient(
colors = shimmerColors,
start = Offset.Zero,
end = Offset(x = transitionAnimation.value, y = transitionAnimation.value)
)
).onGloballyPositioned { size = it.size }
} else {
Modifier
}
}
}

View File

@@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.extensions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.sp
/**
* Text style for composable with very small text
*/
val Typography.bodyVerySmall: TextStyle
@Composable
get() = MaterialTheme.typography.bodySmall.copy(fontSize = 10.sp)

View File

@@ -31,9 +31,9 @@ import com.aurora.store.R
* @see HeaderComposable
*/
@Composable
fun UpdateHeaderComposable(
@StringRes title: Int,
@StringRes actionTitle: Int,
fun ActionHeaderComposable(
title: String,
actionTitle: String,
onAction: () -> Unit = {}
) {
Row(
@@ -47,14 +47,14 @@ fun UpdateHeaderComposable(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(title),
text = title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Button(onClick = onAction) {
Text(
text = stringResource(actionTitle),
text = actionTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -64,9 +64,9 @@ fun UpdateHeaderComposable(
@Preview(showBackground = true)
@Composable
private fun UpdateHeaderComposablePreview() {
UpdateHeaderComposable(
title = R.string.updates_available,
actionTitle = R.string.action_update_all
private fun ActionHeaderComposablePreview() {
ActionHeaderComposable(
title = stringResource(R.string.updates_available),
actionTitle = stringResource(R.string.action_update_all)
)
}

View File

@@ -7,7 +7,6 @@ package com.aurora.store.compose.composables
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -28,8 +27,9 @@ import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.drawable.toDrawable
import com.aurora.extensions.bodyVerySmall
import com.aurora.store.BuildConfig
import com.aurora.store.R
@@ -89,7 +89,7 @@ fun BlackListComposable(
)
Text(
text = stringResource(R.string.version, versionName, versionCode),
style = MaterialTheme.typography.bodySmall.copy(fontSize = 10.sp),
style = MaterialTheme.typography.bodyVerySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -103,7 +103,7 @@ fun BlackListComposable(
@Composable
private fun BlackListComposablePreview() {
BlackListComposable(
icon = ColorDrawable(Color.TRANSPARENT).toBitmap(56, 56),
icon = Color.GRAY.toDrawable().toBitmap(56, 56),
displayName = LocalContext.current.getString(R.string.app_name),
packageName = BuildConfig.APPLICATION_ID,
versionName = BuildConfig.VERSION_NAME,

View File

@@ -14,18 +14,21 @@ import androidx.compose.foundation.layout.requiredSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage
import coil3.compose.LocalAsyncImagePreviewHandler
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.aurora.gplayapi.data.models.Category
import com.aurora.store.R
import com.aurora.store.compose.composables.preview.coilPreviewProvider
/**
* Composable to show a category in a list
@@ -47,7 +50,6 @@ fun CategoryComposable(category: Category, onClick: () -> Unit = {}) {
.crossfade(true)
.build(),
contentDescription = null,
placeholder = painterResource(R.drawable.ic_android),
contentScale = ContentScale.Crop,
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_default))
)
@@ -62,6 +64,9 @@ fun CategoryComposable(category: Category, onClick: () -> Unit = {}) {
@Preview(showBackground = true)
@Composable
@OptIn(ExperimentalCoilApi::class)
private fun CategoryComposablePreview() {
CategoryComposable(category = Category(title = "Art & Design"))
CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) {
CategoryComposable(category = Category(title = "Art & Design"))
}
}

View File

@@ -18,22 +18,26 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
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
import androidx.compose.ui.tooling.preview.PreviewParameter
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage
import coil3.compose.LocalAsyncImagePreviewHandler
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.aurora.gplayapi.data.models.App
import com.aurora.store.BuildConfig
import com.aurora.store.R
import com.aurora.store.compose.composables.preview.AppPreviewProvider
import com.aurora.store.compose.composables.preview.coilPreviewProvider
import com.aurora.store.data.room.download.Download
import com.aurora.store.util.CommonUtil.getDownloadSpeedString
import com.aurora.store.util.CommonUtil.getETAString
@@ -66,7 +70,6 @@ fun DownloadComposable(
.crossfade(true)
.build(),
contentDescription = null,
placeholder = painterResource(R.drawable.ic_android),
contentScale = ContentScale.Crop,
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_medium))
@@ -116,10 +119,9 @@ fun DownloadComposable(
@Preview(showBackground = true)
@Composable
private fun DownloadComposablePreview() {
val app = App(
packageName = BuildConfig.APPLICATION_ID,
displayName = LocalContext.current.getString(R.string.app_name)
)
DownloadComposable(download = Download.fromApp(app))
@OptIn(ExperimentalCoilApi::class)
private fun DownloadComposablePreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) {
DownloadComposable(download = Download.fromApp(app))
}
}

View File

@@ -19,6 +19,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -29,13 +30,17 @@ 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
import androidx.compose.ui.unit.sp
import androidx.compose.ui.tooling.preview.PreviewParameter
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage
import coil3.compose.LocalAsyncImagePreviewHandler
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.aurora.extensions.bodyVerySmall
import com.aurora.gplayapi.data.models.App
import com.aurora.store.BuildConfig
import com.aurora.store.R
import com.aurora.store.compose.composables.preview.AppPreviewProvider
import com.aurora.store.compose.composables.preview.coilPreviewProvider
import com.aurora.store.data.room.favourite.Favourite
/**
@@ -68,7 +73,6 @@ fun FavouriteComposable(
.crossfade(true)
.build(),
contentDescription = null,
placeholder = painterResource(R.drawable.ic_android),
contentScale = ContentScale.Crop,
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_medium))
@@ -95,7 +99,7 @@ fun FavouriteComposable(
favourite.added,
DateUtils.FORMAT_SHOW_DATE
),
style = MaterialTheme.typography.bodySmall.copy(fontSize = 10.sp)
style = MaterialTheme.typography.bodyVerySmall
)
}
}
@@ -110,10 +114,9 @@ fun FavouriteComposable(
@Preview(showBackground = true)
@Composable
private fun FavouriteComposablePreview() {
val app = App(
packageName = BuildConfig.APPLICATION_ID,
displayName = LocalContext.current.getString(R.string.app_name)
)
FavouriteComposable(favourite = Favourite.fromApp(app, Favourite.Mode.MANUAL))
@OptIn(ExperimentalCoilApi::class)
private fun FavouriteComposablePreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) {
FavouriteComposable(favourite = Favourite.fromApp(app, Favourite.Mode.MANUAL))
}
}

View File

@@ -5,18 +5,20 @@
package com.aurora.store.compose.composables
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
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.draw.clip
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -27,16 +29,18 @@ import com.aurora.store.R
/**
* Composable to display sticky header in list
* @param title Title to display
* @param subtitle Optional subtitle to display
* @param onClick Callback when this composable is clicked
* @see TextDividerComposable
* @see UpdateHeaderComposable
* @see ActionHeaderComposable
*/
@Composable
fun HeaderComposable(@StringRes title: Int, onClick: () -> Unit = {}) {
fun HeaderComposable(title: String, subtitle: String? = null, onClick: (() -> Unit)? = null) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small)))
.clickable(onClick = { if (onClick != null) onClick() }, enabled = onClick != null)
.padding(
horizontal = dimensionResource(R.dimen.padding_small),
vertical = dimensionResource(R.dimen.padding_xxsmall)
@@ -44,22 +48,38 @@ fun HeaderComposable(@StringRes title: Int, onClick: () -> Unit = {}) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(title),
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1F)
)
Icon(
painter = painterResource(R.drawable.ic_arrow_right),
contentDescription = null
)
Column(
modifier = Modifier.weight(1F),
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_xsmall))
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (!subtitle.isNullOrBlank()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium
)
}
}
if (onClick != null) {
Icon(
painter = painterResource(R.drawable.ic_arrow_right),
contentDescription = null,
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun HeaderComposablePreview() {
HeaderComposable(title = R.string.app_name)
HeaderComposable(
title = stringResource(R.string.details_privacy),
subtitle = stringResource(R.string.exodus_powered),
onClick = {}
)
}

View File

@@ -0,0 +1,85 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.composables
import androidx.annotation.DrawableRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
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.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import com.aurora.gplayapi.data.models.App
import com.aurora.store.R
import com.aurora.store.compose.composables.preview.AppPreviewProvider
/**
* Composable to show some information
* @param title Title of the information
* @param description Information to show
* @param icon Optional icon representing the information
* @param onClick Callback when this composable is clicked
*/
@Composable
fun InfoComposable(
title: AnnotatedString,
description: AnnotatedString? = null,
@DrawableRes icon: Int? = null,
onClick: (() -> Unit)? = null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = { if (onClick != null) onClick() }, enabled = onClick != null)
.padding(
horizontal = dimensionResource(R.dimen.padding_small),
vertical = dimensionResource(R.dimen.padding_xxsmall)
),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_normal)),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) Icon(painter = painterResource(icon), contentDescription = null)
Column(modifier = Modifier.weight(1F)) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (!description.isNullOrBlank()) {
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.secondary
)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun InfoComposablePreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
InfoComposable(
title = AnnotatedString(text = stringResource(R.string.details_dev_website)),
description = AnnotatedString.fromHtml(htmlString = app.developerWebsite),
icon = R.drawable.ic_network
)
}

View File

@@ -7,7 +7,6 @@ package com.aurora.store.compose.composables
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
@@ -26,8 +25,9 @@ import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.drawable.toDrawable
import com.aurora.extensions.bodyVerySmall
import com.aurora.store.BuildConfig
import com.aurora.store.R
@@ -84,7 +84,7 @@ fun InstalledAppComposable(
)
Text(
text = stringResource(R.string.version, versionName, versionCode),
style = MaterialTheme.typography.bodySmall.copy(fontSize = 10.sp),
style = MaterialTheme.typography.bodyVerySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -97,7 +97,7 @@ fun InstalledAppComposable(
@Composable
private fun InstalledAppComposablePreview() {
InstalledAppComposable(
icon = ColorDrawable(Color.TRANSPARENT).toBitmap(56, 56),
icon = Color.GRAY.toDrawable().toBitmap(56, 56),
displayName = LocalContext.current.getString(R.string.app_name),
packageName = BuildConfig.APPLICATION_ID,
versionName = BuildConfig.VERSION_NAME,

View File

@@ -23,7 +23,7 @@ import com.aurora.store.R
* Composable to display a sticky header in a list
* @param title Title to display
* @see HeaderComposable
* @see UpdateHeaderComposable
* @see ActionHeaderComposable
*/
@Composable
fun TextDividerComposable(@StringRes title: Int) {

View File

@@ -5,7 +5,6 @@
package com.aurora.store.compose.composables
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -28,14 +27,12 @@ import com.aurora.store.R
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun TopAppBarComposable(
@StringRes title: Int? = null,
title: String? = null,
onNavigateUp: () -> Unit,
actions: @Composable (RowScope.() -> Unit) = {}
) {
TopAppBar(
title = {
if (title != null) Text(text = stringResource(title))
},
title = { if (title != null) Text(text = title) },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(
@@ -52,7 +49,7 @@ fun TopAppBarComposable(
@Composable
private fun TopAppBarComposablePreview() {
TopAppBarComposable(
title = R.string.title_about,
title = stringResource(R.string.title_about),
onNavigateUp = {}
)
}

View File

@@ -5,6 +5,7 @@
package com.aurora.store.compose.composables.app
import android.text.format.Formatter
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@@ -14,22 +15,25 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import androidx.compose.ui.tooling.preview.PreviewParameter
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage
import coil3.compose.LocalAsyncImagePreviewHandler
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.aurora.extensions.bodyVerySmall
import com.aurora.gplayapi.data.models.App
import com.aurora.store.BuildConfig
import com.aurora.store.R
import com.aurora.store.util.CommonUtil.addSiPrefix
import com.aurora.store.compose.composables.preview.AppPreviewProvider
import com.aurora.store.compose.composables.preview.coilPreviewProvider
/**
* Composable for displaying minimal app details in a horizontal-scrollable list
@@ -39,6 +43,8 @@ import com.aurora.store.util.CommonUtil.addSiPrefix
*/
@Composable
fun AppComposable(app: App, onClick: () -> Unit = {}) {
val context = LocalContext.current
Column(
modifier = Modifier
.width(dimensionResource(R.dimen.icon_size_cluster))
@@ -51,7 +57,6 @@ fun AppComposable(app: App, onClick: () -> Unit = {}) {
.crossfade(true)
.build(),
contentDescription = null,
placeholder = painterResource(R.drawable.ic_android),
contentScale = ContentScale.Crop,
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_cluster))
@@ -64,20 +69,21 @@ fun AppComposable(app: App, onClick: () -> Unit = {}) {
overflow = TextOverflow.Ellipsis
)
Text(
text = if (app.size > 0) addSiPrefix(app.size) else app.downloadString,
style = MaterialTheme.typography.bodySmall.copy(fontSize = 10.sp)
text = if (app.size > 0) {
Formatter.formatShortFileSize(context, app.size)
} else {
app.downloadString
},
style = MaterialTheme.typography.bodyVerySmall
)
}
}
@Preview(showBackground = true)
@Composable
private fun AppComposablePreview() {
AppComposable(
app = App(
packageName = BuildConfig.APPLICATION_ID,
displayName = LocalContext.current.getString(R.string.app_name),
size = 7431013
)
)
@OptIn(ExperimentalCoilApi::class)
private fun AppComposablePreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) {
AppComposable(app = app)
}
}

View File

@@ -15,25 +15,28 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
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
import androidx.compose.ui.unit.sp
import androidx.compose.ui.tooling.preview.PreviewParameter
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage
import coil3.compose.LocalAsyncImagePreviewHandler
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.aurora.Constants.PACKAGE_NAME_GMS
import com.aurora.extensions.bodyVerySmall
import com.aurora.gplayapi.data.models.App
import com.aurora.store.BuildConfig
import com.aurora.store.R
import com.aurora.store.compose.composables.preview.AppPreviewProvider
import com.aurora.store.compose.composables.preview.coilPreviewProvider
import com.aurora.store.util.CommonUtil
import com.aurora.store.util.PackageUtil
/**
* Composable for displaying minimal app details in a vertical-scrollable list
@@ -58,7 +61,6 @@ fun AppListComposable(app: App, onClick: () -> Unit = {}) {
.crossfade(true)
.build(),
contentDescription = null,
placeholder = painterResource(R.drawable.ic_android),
contentScale = ContentScale.Crop,
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_medium))
@@ -76,7 +78,7 @@ fun AppListComposable(app: App, onClick: () -> Unit = {}) {
Text(text = app.developerName, style = MaterialTheme.typography.bodySmall)
Text(
text = buildExtras(app).joinToString(separator = ""),
style = MaterialTheme.typography.bodySmall.copy(fontSize = 10.sp)
style = MaterialTheme.typography.bodyVerySmall
)
}
}
@@ -84,17 +86,11 @@ fun AppListComposable(app: App, onClick: () -> Unit = {}) {
@Preview(showBackground = true)
@Composable
fun AppListComposablePreview() {
AppListComposable(
app = App(
packageName = BuildConfig.APPLICATION_ID,
displayName = LocalContext.current.getString(R.string.app_name),
developerName = "Rahul Kumar Patel",
isFree = true,
containsAds = false,
size = 7431013
)
)
@OptIn(ExperimentalCoilApi::class)
fun AppListComposablePreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) {
AppListComposable(app = app)
}
}
@Composable

View File

@@ -19,11 +19,12 @@ import com.aurora.store.R
/**
* Composable to display an indeterminate circular progress indicator
* @param modifier Modifier for the composable
*/
@Composable
fun AppProgressComposable() {
fun AppProgressComposable(modifier: Modifier = Modifier) {
Box(
modifier = Modifier
modifier = modifier
.fillMaxSize()
.padding(dimensionResource(R.dimen.padding_small)),
contentAlignment = Alignment.Center

View File

@@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.composables.app
import androidx.annotation.DrawableRes
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import com.aurora.gplayapi.data.models.App
import com.aurora.store.R
import com.aurora.store.compose.composables.preview.AppPreviewProvider
/**
* Composable to show a tag related to an app
* @param label Label of the tag
* @param icon Icon of the tag
* @param onClick Callback when this composable is clicked
*/
@Composable
fun AppTagComposable(label: String, @DrawableRes icon: Int, onClick: () -> Unit = {}) {
FilterChip(
onClick = onClick,
label = { Text(text = label, style = MaterialTheme.typography.bodySmall) },
leadingIcon = { Icon(painter = painterResource(icon), contentDescription = label) },
selected = true
)
}
@Preview(showBackground = true)
@Composable
private fun AppTagComposablePreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
AppTagComposable(
label = stringResource(R.string.details_free),
icon = R.drawable.ic_paid
)
}

View File

@@ -7,12 +7,12 @@ package com.aurora.store.compose.composables.app
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -26,6 +26,7 @@ import com.aurora.store.R
/**
* Composable to show error message when no apps are available for a request
* @param modifier Modifier for the composable
* @param icon Drawable for error
* @param message Message for error
* @param actionMessage Message to show on action button; defaults to null with button not visible
@@ -34,20 +35,21 @@ import com.aurora.store.R
*/
@Composable
fun NoAppComposable(
modifier: Modifier = Modifier,
@DrawableRes icon: Int,
@StringRes message: Int,
@StringRes actionMessage: Int? = null,
onAction: () -> Unit = {}
) {
Column(
modifier = Modifier.fillMaxSize(),
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(
dimensionResource(R.dimen.margin_small),
Alignment.CenterVertically
),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
Icon(
painter = painterResource(id = icon),
contentDescription = null,
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size))

View File

@@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.composables.details
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.aurora.extensions.bodyVerySmall
import com.aurora.store.R
import com.aurora.store.data.model.ExodusTracker
/**
* Composable to display details about a tracker reported by Exodus Privacy
* @param tracker Tracker to display details about
*/
@Composable
fun ExodusComposable(tracker: ExodusTracker) {
Column(modifier = Modifier.padding(dimensionResource(R.dimen.padding_small))) {
Text(
text = tracker.name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = tracker.signature,
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = tracker.date,
style = MaterialTheme.typography.bodyVerySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Preview(showBackground = true)
@Composable
private fun ExodusComposablePreview() {
ExodusComposable(
tracker = ExodusTracker(
name = "Google Analytics",
signature = "com.google.android.apps.analytics.|com.google.android.gms.analytics.|com.google.analytics.",
date = "2017-09-24"
)
)
}

View File

@@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.composables.details
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
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.res.dimensionResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import com.aurora.store.R
/**
* Composable to show a progress bar with rating for an app
* @param label Label of the rating, for e.g. 5
* @param rating Current rating, for e.g. 0.3
*/
@Composable
fun RatingComposable(label: String, rating: Float) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(R.dimen.padding_small)),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small))
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold
)
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(dimensionResource(R.dimen.radius_small)),
progress = { rating }
)
}
}
@Preview(showBackground = true)
@Composable
private fun RatingComposablePreview() {
RatingComposable(label = "5", rating = 0.5F)
}

View File

@@ -0,0 +1,110 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.composables.details
import android.text.format.DateUtils
import android.widget.RatingBar
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import androidx.compose.ui.viewinterop.AndroidView
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage
import coil3.compose.LocalAsyncImagePreviewHandler
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.aurora.gplayapi.data.models.Review
import com.aurora.store.R
import com.aurora.store.compose.composables.preview.coilPreviewProvider
/**
* Composable for viewing a review about an app
* @param review [Review] about an app
*/
@Composable
fun ReviewComposable(review: Review) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = dimensionResource(R.dimen.padding_medium),
vertical = dimensionResource(R.dimen.padding_small)
)
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(review.userPhotoUrl)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_small))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
)
Column(
modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small)),
) {
Text(
text = review.userName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = DateUtils.formatDateTime(
LocalContext.current,
review.timeStamp,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR
),
style = MaterialTheme.typography.bodySmall
)
AndroidView(
factory = { context ->
RatingBar(context, null, android.R.attr.ratingBarStyleSmall)
},
update = { view ->
view.rating = review.rating.toFloat()
}
)
Text(
text = review.comment,
style = MaterialTheme.typography.bodySmall
)
}
}
}
@Preview(showBackground = true)
@Composable
@OptIn(ExperimentalCoilApi::class)
private fun ReviewComposablePreview() {
CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) {
ReviewComposable(
review = Review(
userName = "Rahul Kumar Patel",
timeStamp = 1745750879,
comment = LoremIpsum(40).values.first(),
rating = 4
)
)
}
}

View File

@@ -0,0 +1,70 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.composables.details
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImagePainter
import coil3.compose.LocalAsyncImagePreviewHandler
import coil3.compose.rememberAsyncImagePainter
import coil3.compose.rememberConstraintsSizeResolver
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.aurora.extensions.shimmer
import com.aurora.gplayapi.data.models.App
import com.aurora.store.compose.composables.preview.AppPreviewProvider
import com.aurora.store.compose.composables.preview.coilPreviewProvider
/**
* Composable to display a screenshot of an app
* @param url URL of the screenshot
* @param modifier Modifier for the composable
*/
@Composable
fun ScreenshotComposable(modifier: Modifier = Modifier, url: String) {
// See https://coil-kt.github.io/coil/compose/#rememberasyncimagepainter
val sizeResolver = rememberConstraintsSizeResolver()
val painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(LocalContext.current)
.data(url)
.size(sizeResolver)
.crossfade(true)
.build()
)
val state by painter.state.collectAsStateWithLifecycle()
val aspectRatioModifier = state.painter?.intrinsicSize?.let { intrinsicSize ->
Modifier.aspectRatio(ratio = intrinsicSize.width / intrinsicSize.height)
}
Image(
painter = painter,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = modifier
.shimmer(state is AsyncImagePainter.State.Loading)
.then(sizeResolver)
.then(aspectRatioModifier ?: Modifier)
)
}
@Preview
@Composable
@OptIn(ExperimentalCoilApi::class)
private fun ScreenshotComposablePreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) {
ScreenshotComposable(url = app.screenshots.firstOrNull()?.url ?: "")
}
}

View File

@@ -0,0 +1,80 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.composables.preview
import android.Manifest
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.Artwork
import com.aurora.gplayapi.data.models.Rating
import com.aurora.store.BuildConfig
/**
* Preview provider for composable working with [App]
*/
class AppPreviewProvider : PreviewParameterProvider<App> {
companion object {
private const val CHANGELOG = """
• New app compatibility ratings powered by Plexus<br>
• Improvements to blacklist manager<br>
• Ability to change auto-update restrictions<br>
• Minor bug fixes and improvements<br>
• Translation updates; additional strings localized
"""
private const val DESCRIPTION = """
<p>Aurora Store is an unofficial, FOSS client to Google Play with an elegant design. Aurora Store allows users to download, update, and search for apps like the Play Store. It works perfectly fine with or without Google Play Services or microG.</p>
<p><strong>Features:</strong></p>
<p>• FOSS: Has GPLv3 licence<br>
• Beautiful design: Built upon latest Material 3 guidelines<br>
• Account login: You can login with either personal or an anonymous account<br>
• Device &amp; Locale spoofing: Change your device and/or locale to access geo locked apps<br>
• Exodus Privacy integration: Instantly see trackers in app<br>
• Plexus integration: Instantly see app compatibility without Google Play Services or with microG<br>
• Updates blacklisting: Ignore updates for specific apps</p>
"""
}
override val values: Sequence<App>
get() = sequenceOf(
App(
packageName = BuildConfig.APPLICATION_ID,
displayName = "Aurora Store",
developerName = "Rahul Kumar Patel",
versionCode = BuildConfig.VERSION_CODE,
versionName = BuildConfig.VERSION_NAME,
shortDescription = "An unofficial FOSS client to Google Play with an elegant design and privacy",
changes = CHANGELOG,
description = DESCRIPTION,
isFree = true,
containsAds = false,
isInstalled = true,
size = 7431013,
updatedOn = "Mar 17, 2025",
labeledRating = "4.3",
installs = 1000000000,
developerEmail = "rahul@auroraoss.com",
developerWebsite = "https://auroraoss.com/",
developerAddress = "330 N Midland Ave, Mumbai, India",
screenshots = MutableList(5) { Artwork(url = "$it") },
rating = Rating(
fiveStar = 201458104,
fourStar = 313829104,
threeStar = 204581672,
twoStar = 183746829,
oneStar = 96384291
),
permissions = mutableListOf(
Manifest.permission.INTERNET,
Manifest.permission.ACCESS_NETWORK_STATE,
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
)
)
)
}

View File

@@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.composables.preview
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import coil3.ColorImage
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImagePreviewHandler
/**
* Preview provider for composable working with coil for loading remote images
*/
@OptIn(ExperimentalCoilApi::class)
val coilPreviewProvider = AsyncImagePreviewHandler {
ColorImage(Color.Gray.toArgb())
}

View File

@@ -10,7 +10,14 @@ import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.aurora.extensions.parentViewModel
import com.aurora.store.compose.ui.commons.BlacklistScreen
import com.aurora.store.compose.ui.details.AppDetailsScreen
import com.aurora.store.compose.ui.details.DetailsExodusScreen
import com.aurora.store.compose.ui.details.DetailsMoreScreen
import com.aurora.store.compose.ui.details.DetailsReviewScreen
import com.aurora.store.compose.ui.details.DetailsScreenshotScreen
/**
* Navigation graph for compose screens
@@ -33,5 +40,52 @@ fun NavGraph(navHostController: NavHostController, startDestination: Screen) {
composable<Screen.Blacklist> {
BlacklistScreen(onNavigateUp = { onNavigateUp() })
}
composable<Screen.AppDetails> { backstackEntry ->
val appDetails = backstackEntry.toRoute<Screen.AppDetails>()
AppDetailsScreen(
packageName = appDetails.packageName,
viewModel = backstackEntry.parentViewModel(navHostController),
onNavigateUp = { onNavigateUp() },
onNavigateToDetailsMore = { navHostController.navigate(Screen.DetailsMore) },
onNavigateToDetailsReview = { navHostController.navigate(Screen.DetailsReview) },
onNavigateToDetailsExodus = { navHostController.navigate(Screen.DetailsExodus) },
onNavigateToDetailsScreenshot = { index ->
navHostController.navigate(
Screen.DetailsScreenshot(index)
)
}
)
}
composable<Screen.DetailsExodus> { backstackEntry ->
DetailsExodusScreen(
onNavigateUp = { onNavigateUp() },
viewModel = backstackEntry.parentViewModel(navHostController)
)
}
composable<Screen.DetailsMore> { backstackEntry ->
DetailsMoreScreen(
onNavigateUp = { onNavigateUp() },
viewModel = backstackEntry.parentViewModel(navHostController)
)
}
composable<Screen.DetailsScreenshot> { backstackEntry ->
val screenshotDetails = backstackEntry.toRoute<Screen.DetailsScreenshot>()
DetailsScreenshotScreen(
index = screenshotDetails.index,
onNavigateUp = { onNavigateUp() },
viewModel = backstackEntry.parentViewModel(navHostController)
)
}
composable<Screen.DetailsReview> { backstackEntry ->
DetailsReviewScreen(
onNavigateUp = { onNavigateUp() },
viewModel = backstackEntry.parentViewModel(navHostController)
)
}
}
}

View File

@@ -19,7 +19,10 @@ import kotlinx.serialization.Serializable
*/
@Parcelize
@Serializable
sealed class Screen(@StringRes val label: Int, @DrawableRes val icon: Int? = null): Parcelable {
sealed class Screen(
@StringRes val label: Int? = null,
@DrawableRes val icon: Int? = null
) : Parcelable {
companion object {
const val PARCEL_KEY = "SCREEN"
@@ -27,4 +30,19 @@ sealed class Screen(@StringRes val label: Int, @DrawableRes val icon: Int? = nul
@Serializable
data object Blacklist : Screen(label = R.string.title_blacklist_manager)
@Serializable
data class AppDetails(val packageName: String) : Screen()
@Serializable
data object DetailsMore : Screen()
@Serializable
data class DetailsScreenshot(val index: Int) : Screen()
@Serializable
data object DetailsExodus : Screen()
@Serializable
data object DetailsReview : Screen()
}

View File

@@ -0,0 +1,741 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.details
import android.content.ActivityNotFoundException
import android.text.format.Formatter
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
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.util.fastForEach
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage
import coil3.compose.LocalAsyncImagePreviewHandler
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.aurora.extensions.bodyVerySmall
import com.aurora.extensions.browse
import com.aurora.extensions.copyToClipBoard
import com.aurora.extensions.mailTo
import com.aurora.extensions.toast
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.Artwork
import com.aurora.gplayapi.data.models.Rating
import com.aurora.gplayapi.data.models.datasafety.EntryType
import com.aurora.store.R
import com.aurora.store.compose.composables.HeaderComposable
import com.aurora.store.compose.composables.InfoComposable
import com.aurora.store.compose.composables.TopAppBarComposable
import com.aurora.store.compose.composables.app.AppProgressComposable
import com.aurora.store.compose.composables.app.AppTagComposable
import com.aurora.store.compose.composables.app.NoAppComposable
import com.aurora.store.compose.composables.details.RatingComposable
import com.aurora.store.compose.composables.details.ScreenshotComposable
import com.aurora.store.compose.composables.preview.AppPreviewProvider
import com.aurora.store.compose.composables.preview.coilPreviewProvider
import com.aurora.store.compose.ui.dialogs.ManualDownloadDialog
import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.model.Report
import com.aurora.store.data.model.Scores
import com.aurora.store.data.room.download.Download
import com.aurora.store.util.CommonUtil
import com.aurora.store.util.PackageUtil
import com.aurora.store.util.PackageUtil.PACKAGE_NAME_GMS
import com.aurora.store.viewmodel.details.AppDetailsViewModel
import java.util.Locale
import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport
@Composable
fun AppDetailsScreen(
packageName: String,
onNavigateUp: () -> Unit,
onNavigateToDetailsMore: () -> Unit,
onNavigateToDetailsScreenshot: (index: Int) -> Unit,
onNavigateToDetailsReview: () -> Unit,
onNavigateToDetailsExodus: () -> Unit,
viewModel: AppDetailsViewModel = hiltViewModel()
) {
val context = LocalContext.current
val app by viewModel.app.collectAsStateWithLifecycle()
val exodusReport by viewModel.exodusReport.collectAsStateWithLifecycle()
val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle()
val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle()
val download by viewModel.download.collectAsStateWithLifecycle()
var shouldShowManualDownloadDialog by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(key1 = Unit) { viewModel.fetchAppDetails(packageName) }
LaunchedEffect(key1 = Unit) {
viewModel.purchaseStatus.collect { success ->
if (success) {
shouldShowManualDownloadDialog = false
context.toast(R.string.toast_manual_available)
} else {
context.toast(R.string.toast_manual_unavailable)
}
}
}
if (shouldShowManualDownloadDialog) {
ManualDownloadDialog(
currentVersionCode = app!!.versionCode.toLong(),
onConfirm = { versionCode ->
viewModel.purchase(app!!.copy(versionCode = versionCode.toInt()))
},
onDismiss = { shouldShowManualDownloadDialog = false }
)
}
with(app) {
when {
this != null -> {
if (this.packageName.isBlank()) {
ScreenContentLoading(onNavigateUp = onNavigateUp)
} else {
ScreenContent(
app = this,
download = download,
plexusScores = plexusScores,
dataSafetyReport = dataSafetyReport,
exodusReport = exodusReport,
hasValidUpdate = viewModel.hasValidUpdate,
showSimilarApps = viewModel.showSimilarApps,
onNavigateUp = onNavigateUp,
onNavigateToDetailsMore = onNavigateToDetailsMore,
onNavigateToDetailsScreenshot = onNavigateToDetailsScreenshot,
onNavigateToDetailsReview = onNavigateToDetailsReview,
onNavigateToDetailsExodus = onNavigateToDetailsExodus,
onDownload = { viewModel.download(this) },
onManualDownload = { shouldShowManualDownloadDialog = true },
onCancelDownload = { viewModel.cancelDownload(this) },
onUninstall = { AppInstaller.uninstall(context, packageName) },
onOpen = {
try {
context.startActivity(
PackageUtil.getLaunchIntent(context, packageName)
)
} catch (exception: ActivityNotFoundException) {
context.toast(context.getString(R.string.unable_to_open))
}
}
)
}
}
// TODO: Deal with different kind of errors
else -> ScreenContentError(onNavigateUp = onNavigateUp)
}
}
}
@Composable
private fun ScreenContentLoading(onNavigateUp: () -> Unit = {}) {
Scaffold(
topBar = { TopAppBarComposable(onNavigateUp = onNavigateUp) }
) { paddingValues ->
AppProgressComposable(modifier = Modifier.padding(paddingValues))
}
}
@Composable
private fun ScreenContentError(onNavigateUp: () -> Unit = {}) {
Scaffold(
topBar = { TopAppBarComposable(onNavigateUp = onNavigateUp) }
) { paddingValues ->
NoAppComposable(
modifier = Modifier.padding(paddingValues),
icon = R.drawable.ic_apps_outage,
message = R.string.toast_app_unavailable
)
}
}
@Composable
private fun ScreenContent(
app: App,
download: Download? = null,
plexusScores: Scores? = null,
dataSafetyReport: DataSafetyReport? = null,
exodusReport: Report? = null,
hasValidUpdate: Boolean = false,
showSimilarApps: Boolean = false,
onNavigateUp: () -> Unit = {},
onNavigateToDetailsMore: () -> Unit = {},
onNavigateToDetailsScreenshot: (index: Int) -> Unit = {},
onNavigateToDetailsReview: () -> Unit = {},
onNavigateToDetailsExodus: () -> Unit = {},
onDownload: () -> Unit = {},
onManualDownload: () -> Unit = {},
onCancelDownload: () -> Unit = {},
onUninstall: () -> Unit = {},
onOpen: () -> Unit = {}
) {
val context = LocalContext.current
Scaffold(
topBar = {
TopAppBarComposable(
onNavigateUp = onNavigateUp
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(dimensionResource(R.dimen.padding_medium)),
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))
) {
// TODO: Deal with download status
AppDetails(app = app, hasValidUpdate = hasValidUpdate)
when {
download?.isRunning == true -> {
AppActions(
primaryActionDisplayName = stringResource(R.string.action_open),
secondaryActionDisplayName = stringResource(R.string.action_cancel),
isPrimaryActionEnabled = false,
onSecondaryAction = onCancelDownload
)
}
app.isInstalled && hasValidUpdate -> {
AppActions(
primaryActionDisplayName = stringResource(R.string.action_update),
secondaryActionDisplayName = stringResource(R.string.action_uninstall),
onPrimaryAction = onDownload,
onSecondaryAction = onUninstall
)
}
app.isInstalled -> {
AppActions(
primaryActionDisplayName = stringResource(R.string.action_open),
secondaryActionDisplayName = stringResource(R.string.action_uninstall),
onPrimaryAction = onOpen,
onSecondaryAction = onUninstall
)
}
else -> {
val primaryActionName = if (PackageUtil.isArchived(context, app.packageName)) {
stringResource(R.string.action_unarchive)
} else {
if (app.isFree) stringResource(R.string.action_install) else app.price
}
AppActions(
primaryActionDisplayName = primaryActionName,
secondaryActionDisplayName = stringResource(R.string.title_manual_download),
onPrimaryAction = onDownload,
onSecondaryAction = onManualDownload
)
}
}
AppTags(app = app)
AppChangelog(changelog = app.changes)
HeaderComposable(
title = stringResource(R.string.details_more_about_app),
subtitle = app.shortDescription,
onClick = onNavigateToDetailsMore
)
AppScreenshots(
list = app.screenshots,
onNavigateToScreenshot = onNavigateToDetailsScreenshot
)
AppReviews(rating = app.rating, onNavigateToDetailsReview = onNavigateToDetailsReview)
AppCompatibility(
needsGms = app.dependencies.dependentPackages.contains(PACKAGE_NAME_GMS),
plexusScores = plexusScores
)
HeaderComposable(
title = stringResource(R.string.details_permission),
subtitle = stringResource(R.string.permissions, app.permissions.size)
)
if (dataSafetyReport != null) AppDataSafety(dataSafetyReport = dataSafetyReport)
AppPrivacy(
report = exodusReport,
onNavigateToDetailsExodus = if (!exodusReport?.trackers.isNullOrEmpty()) {
onNavigateToDetailsExodus
} else {
null
}
)
AppDeveloperDetails(
address = app.developerAddress,
website = app.developerWebsite,
email = app.developerEmail
)
}
}
}
/**
* Composable to display basic app details
*/
@Composable
private fun AppDetails(app: App, hasValidUpdate: Boolean = false) {
val context = LocalContext.current
Row(modifier = Modifier.fillMaxWidth()) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(app.iconArtwork.url)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_large))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
)
Column(modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small))) {
Text(
text = app.displayName,
style = MaterialTheme.typography.titleLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = app.developerName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary
)
Text(
text = if (!hasValidUpdate) {
stringResource(R.string.version, app.versionName, app.versionCode)
} else {
stringResource(
R.string.version_update,
PackageUtil.getInstalledVersionName(context, app.packageName),
PackageUtil.getInstalledVersionCode(context, app.packageName),
app.versionName,
app.versionCode
)
},
style = MaterialTheme.typography.bodyVerySmall
)
}
}
}
/**
* Composable to display primary and secondary actions available for the app
*/
@Composable
private fun AppActions(
primaryActionDisplayName: String,
secondaryActionDisplayName: String,
isPrimaryActionEnabled: Boolean = true,
isSecondaryActionEnabled: Boolean = true,
onPrimaryAction: () -> Unit = {},
onSecondaryAction: () -> Unit = {}
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium))
) {
FilledTonalButton(
modifier = Modifier.weight(1F),
onClick = onSecondaryAction,
enabled = isSecondaryActionEnabled
) {
Text(
text = secondaryActionDisplayName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Button(
modifier = Modifier.weight(1F),
onClick = onPrimaryAction,
enabled = isPrimaryActionEnabled
) {
Text(
text = primaryActionDisplayName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
/**
* Composable to display app changelog
*/
@Composable
private fun AppChangelog(changelog: String) {
HeaderComposable(title = stringResource(R.string.details_changelog))
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small)))
.background(color = MaterialTheme.colorScheme.secondaryContainer)
.padding(dimensionResource(R.dimen.padding_medium))
) {
Text(
text = if (changelog.isBlank()) {
AnnotatedString(text = stringResource(R.string.details_changelog_unavailable))
} else {
AnnotatedString.fromHtml(htmlString = changelog)
},
style = MaterialTheme.typography.bodyMedium
)
}
}
/**
* Composable to display tags related to the app
*/
@Composable
private fun AppTags(app: App) {
val context = LocalContext.current
val installsLabel = CommonUtil.addDiPrefix(app.installs)
val averageRating = if (app.labeledRating == "0.0" || app.labeledRating.isBlank()) {
null
} else {
app.labeledRating
}
val paidLabel = if (app.isFree) {
stringResource(R.string.details_free)
} else {
stringResource(R.string.details_paid)
}
val adsLabel = if (app.containsAds) {
stringResource(R.string.details_contains_ads)
} else {
stringResource(R.string.details_no_ads)
}
val tags = mapOf(
averageRating to R.drawable.ic_star,
installsLabel to R.drawable.ic_download_manager,
Formatter.formatShortFileSize(context, app.size) to R.drawable.ic_apk_install,
app.updatedOn to R.drawable.ic_updates,
paidLabel to R.drawable.ic_paid,
adsLabel to R.drawable.ic_campaign,
).filterKeys { it != null }
LazyRow(
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium))
) {
items(items = tags.keys.toList()) { label ->
AppTagComposable(label = label!!, icon = tags.getValue(label))
}
}
}
/**
* Composable to display details of the app developer
*/
@Composable
private fun AppDeveloperDetails(address: String, website: String, email: String) {
val context = LocalContext.current
HeaderComposable(title = stringResource(R.string.details_dev_details))
Column {
if (website.isNotBlank()) {
InfoComposable(
title = AnnotatedString(text = stringResource(R.string.details_dev_website)),
description = AnnotatedString(text = website),
icon = R.drawable.ic_network,
onClick = { context.browse(website) }
)
}
if (email.isNotBlank()) {
InfoComposable(
title = AnnotatedString(text = stringResource(R.string.details_dev_email)),
description = AnnotatedString(text = email),
icon = R.drawable.ic_mail,
onClick = { context.mailTo(email) }
)
}
if (address.isNotBlank()) {
InfoComposable(
title = AnnotatedString(text = stringResource(R.string.details_dev_address)),
description = AnnotatedString.fromHtml(htmlString = address),
icon = R.drawable.ic_person_location,
onClick = { context.copyToClipBoard(address) }
)
}
}
}
/**
* Composable to display screenshots of the app
*/
@Composable
private fun AppScreenshots(list: List<Artwork>, onNavigateToScreenshot: (index: Int) -> Unit) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small))) {
items(items = list, key = { artwork -> artwork.url }) { artwork ->
ScreenshotComposable(
modifier = Modifier
.height(dimensionResource(R.dimen.screenshot_height))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small)))
.clickable { onNavigateToScreenshot(list.indexOf(artwork)) },
url = "${artwork.url}=rw-w480-v1-e15"
)
}
}
}
/**
* Composable to display reviews of the app
*/
@Composable
private fun AppReviews(rating: Rating, onNavigateToDetailsReview: () -> Unit) {
val stars = listOf(
rating.oneStar, rating.twoStar, rating.threeStar, rating.fourStar, rating.fiveStar
).map { it.toFloat() }.also {
// No ratings available, nothing to show
if (it.sum() == 0F) return
}
HeaderComposable(
title = stringResource(R.string.details_ratings),
subtitle = stringResource(R.string.details_ratings_subtitle),
onClick = onNavigateToDetailsReview
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small))
) {
Column(
modifier = Modifier.padding(dimensionResource(R.dimen.padding_medium)),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = String.format(Locale.getDefault(), "%.1f", rating.average),
maxLines = 1,
style = MaterialTheme.typography.displayMedium,
overflow = TextOverflow.Ellipsis
)
Text(
text = rating.abbreviatedLabel,
maxLines = 1,
style = MaterialTheme.typography.bodySmall,
overflow = TextOverflow.Ellipsis
)
}
Column(
modifier = Modifier.padding(dimensionResource(R.dimen.padding_medium)),
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small))
) {
stars.reversed().fastForEach { star ->
RatingComposable(
label = (stars.indexOf(star) + 1).toString(),
rating = star / stars.sum()
)
}
}
}
}
/**
* Composable to display app compatibility rating from Plexus
*/
@Composable
private fun AppCompatibility(needsGms: Boolean, plexusScores: Scores?) {
HeaderComposable(
title = stringResource(R.string.details_compatibility_title),
subtitle = stringResource(R.string.plexus_powered),
)
if (!needsGms) {
InfoComposable(
icon = R.drawable.ic_menu_about,
title = AnnotatedString(
text = stringResource(R.string.details_compatibility_gms_not_required_title)
),
description = AnnotatedString(
text = stringResource(R.string.details_compatibility_gms_not_required_subtitle)
)
)
// Nothing more to show
return
}
InfoComposable(
icon = R.drawable.ic_menu_about,
title = AnnotatedString(
text = stringResource(R.string.details_compatibility_gms_required_title)
),
description = AnnotatedString(
text = stringResource(R.string.details_compatibility_gms_required_subtitle)
)
)
val scoresStatus = mapOf(
R.string.details_compatibility_no_gms to plexusScores?.aosp?.status,
R.string.details_compatibility_microg to plexusScores?.microG?.status,
)
scoresStatus.forEach { (title, description) ->
InfoComposable(
icon = R.drawable.ic_android,
title = AnnotatedString(text = stringResource(title)),
description = AnnotatedString(
text = stringResource(description ?: R.string.details_compatibility_status_unknown)
)
)
}
}
/**
* Composable to display app's data safety report
*/
@Composable
private fun AppDataSafety(dataSafetyReport: DataSafetyReport) {
HeaderComposable(
title = stringResource(R.string.details_data_safety_title),
subtitle = stringResource(R.string.details_data_safety_subtitle)
)
dataSafetyReport.entries.groupBy { it.type }.forEach { (type, entries) ->
when (type) {
EntryType.DATA_COLLECTED -> {
InfoComposable(
icon = R.drawable.ic_cloud_upload,
title = AnnotatedString(
text = stringResource(R.string.details_data_safety_collect)
),
description = AnnotatedString(
text = entries.first().subEntries.joinToString(", ") { it.name }
.ifBlank {
stringResource(R.string.details_data_safety_collect_none)
}
)
)
}
EntryType.DATA_SHARED -> {
InfoComposable(
icon = R.drawable.ic_share,
title = AnnotatedString(
text = stringResource(R.string.details_data_safety_shared)
),
description = AnnotatedString(
text = entries.first().subEntries.joinToString(", ") { it.name }
.ifBlank {
stringResource(R.string.details_data_safety_share_none)
}
),
)
}
// We don't care about any other sections
else -> {}
}
}
}
/**
* Composable to display app's privacy report from ExodusPrivacy
*/
@Composable
private fun AppPrivacy(report: Report?, onNavigateToDetailsExodus: (() -> Unit)?) {
HeaderComposable(
title = stringResource(R.string.details_privacy),
subtitle = stringResource(R.string.exodus_powered),
onClick = onNavigateToDetailsExodus
)
val reportStatus = when {
report == null -> stringResource(R.string.failed_to_fetch_report)
report.id == -1 -> stringResource(R.string.exodus_progress)
else -> if (report.trackers.isEmpty()) {
stringResource(R.string.exodus_no_tracker)
} else {
stringResource(R.string.exodus_report_trackers, report.trackers.size, report.version)
}
}
InfoComposable(
icon = R.drawable.ic_visibility,
title = AnnotatedString(text = reportStatus),
description = AnnotatedString(text = stringResource(R.string.exodus_tracker_desc))
)
}
@Preview
@Composable
@OptIn(ExperimentalCoilApi::class)
private fun AppDetailsScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) {
ScreenContent(app = app, hasValidUpdate = false)
}
}
@Preview
@Composable
private fun AppDetailsScreenPreviewLoading() {
ScreenContentLoading()
}
@Preview
@Composable
private fun AppDetailsScreenPreviewError() {
ScreenContentError()
}

View File

@@ -0,0 +1,94 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.details
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.Constants.EXODUS_REPORT_URL
import com.aurora.extensions.browse
import com.aurora.gplayapi.data.models.App
import com.aurora.store.R
import com.aurora.store.compose.composables.HeaderComposable
import com.aurora.store.compose.composables.TopAppBarComposable
import com.aurora.store.compose.composables.details.ExodusComposable
import com.aurora.store.compose.composables.preview.AppPreviewProvider
import com.aurora.store.data.model.ExodusTracker
import com.aurora.store.viewmodel.details.AppDetailsViewModel
@Composable
fun DetailsExodusScreen(
onNavigateUp: () -> Unit,
viewModel: AppDetailsViewModel = hiltViewModel()
) {
val app by viewModel.app.collectAsStateWithLifecycle()
val exodusReport by viewModel.exodusReport.collectAsStateWithLifecycle()
ScreenContent(
topAppBarTitle = app!!.displayName,
id = exodusReport!!.id,
version = exodusReport!!.version,
trackers = viewModel.getExodusTrackersFromReport(),
onNavigateUp = onNavigateUp,
)
}
@Composable
private fun ScreenContent(
topAppBarTitle: String? = null,
id: Int = -1,
version: String = String(),
trackers: List<ExodusTracker> = emptyList(),
onNavigateUp: () -> Unit = {},
) {
val context = LocalContext.current
Scaffold(
topBar = {
TopAppBarComposable(title = topAppBarTitle, onNavigateUp = onNavigateUp)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.padding(horizontal = dimensionResource(R.dimen.padding_medium))
) {
stickyHeader {
HeaderComposable(
title = stringResource(R.string.exodus_report_trackers, trackers.size, version),
subtitle = stringResource(R.string.exodus_view_report),
onClick = { context.browse(EXODUS_REPORT_URL + id) }
)
}
items(items = trackers, key = { item -> item.id }) { tracker ->
ExodusComposable(tracker = tracker)
}
}
}
}
@Preview
@Composable
private fun DetailsExodusScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
ScreenContent(
topAppBarTitle = app.displayName,
version = app.versionName
)
}

View File

@@ -0,0 +1,124 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.details
import androidx.compose.foundation.layout.Arrangement
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.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.LocalAsyncImagePreviewHandler
import com.aurora.gplayapi.data.models.App
import com.aurora.store.R
import com.aurora.store.compose.composables.HeaderComposable
import com.aurora.store.compose.composables.InfoComposable
import com.aurora.store.compose.composables.TopAppBarComposable
import com.aurora.store.compose.composables.preview.AppPreviewProvider
import com.aurora.store.compose.composables.preview.coilPreviewProvider
import com.aurora.store.viewmodel.details.AppDetailsViewModel
import java.util.Locale
@Composable
fun DetailsMoreScreen(
onNavigateUp: () -> Unit,
viewModel: AppDetailsViewModel = hiltViewModel()
) {
val app by viewModel.app.collectAsStateWithLifecycle()
ScreenContent(app = app!!, onNavigateUp = onNavigateUp)
}
@Composable
private fun ScreenContent(app: App, onNavigateUp: () -> Unit = {}) {
Scaffold(
topBar = { TopAppBarComposable(title = app.displayName, onNavigateUp = onNavigateUp) }
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))
) {
HeaderComposable(title = stringResource(R.string.details_description))
Text(
modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.padding_medium)),
text = AnnotatedString.fromHtml(
htmlString = app.description
),
style = MaterialTheme.typography.bodyMedium
)
if (app.appInfo.appInfoMap.isNotEmpty()) {
HeaderComposable(title = stringResource(R.string.details_more_info))
InfoComposable(
title = AnnotatedString(
text = stringResource(R.string.details_more_package_name)
),
description = AnnotatedString(text = app.packageName)
)
InfoComposable(
title = AnnotatedString(
text = stringResource(R.string.details_more_content_rating)
),
description = AnnotatedString(text = app.contentRating.title)
)
app.appInfo.appInfoMap.forEach { (title, subtitle) ->
InfoComposable(
title = AnnotatedString(
text = title.replace("_", " ")
.lowercase(Locale.getDefault())
.replaceFirstChar {
if (it.isLowerCase()) {
it.titlecase(Locale.getDefault())
} else {
it.toString()
}
}
),
description = AnnotatedString(text = subtitle)
)
}
InfoComposable(
title = AnnotatedString(
text = stringResource(R.string.details_more_target_api)
),
description = AnnotatedString(text = "API ${app.targetSdk}")
)
}
}
}
}
@Preview
@Composable
@OptIn(ExperimentalCoilApi::class)
private fun DetailsMoreScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) {
ScreenContent(app = app)
}
}

View File

@@ -0,0 +1,156 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.details
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import com.aurora.gplayapi.data.models.Review
import com.aurora.store.R
import com.aurora.store.compose.composables.TopAppBarComposable
import com.aurora.store.compose.composables.details.ReviewComposable
import com.aurora.store.viewmodel.details.AppDetailsViewModel
import kotlinx.coroutines.flow.flowOf
@Composable
fun DetailsReviewScreen(
onNavigateUp: () -> Unit,
viewModel: AppDetailsViewModel = hiltViewModel()
) {
val app by viewModel.app.collectAsStateWithLifecycle()
val reviews = viewModel.reviews.collectAsLazyPagingItems()
ScreenContent(
topAppBarTitle = app!!.displayName,
reviews = reviews,
onNavigateUp = onNavigateUp,
onFilter = { filter -> viewModel.fetchReviews(filter) }
)
}
@Composable
private fun ScreenContent(
topAppBarTitle: String? = null,
onNavigateUp: () -> Unit = {},
reviews: LazyPagingItems<Review>,
onFilter: (filter: Review.Filter) -> Unit = {}
) {
Scaffold(
topBar = {
TopAppBarComposable(title = topAppBarTitle, onNavigateUp = onNavigateUp)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.padding(horizontal = dimensionResource(R.dimen.padding_medium))
) {
FilterHeader { filter -> onFilter(filter) }
// TODO: Implement loading and error screen
when (reviews.loadState.refresh) {
is LoadState.Loading -> {}
is LoadState.Error -> {}
else -> {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(
count = reviews.itemCount,
key = reviews.itemKey { it.commentId }
) { index ->
ReviewComposable(review = reviews[index]!!)
}
}
}
}
}
}
}
/**
* Composable to hold sticky header for filtering through the reviews
*/
@Composable
private fun FilterHeader(onClick: (filter: Review.Filter) -> Unit) {
var activeFilter by rememberSaveable { mutableStateOf(Review.Filter.ALL) }
val filters = mapOf(
Review.Filter.ALL to R.string.filter_review_all,
Review.Filter.NEWEST to R.string.filter_latest,
Review.Filter.CRITICAL to R.string.filter_review_critical,
Review.Filter.POSITIVE to R.string.filter_review_positive,
Review.Filter.FIVE to R.string.filter_review_five,
Review.Filter.FOUR to R.string.filter_review_four,
Review.Filter.THREE to R.string.filter_review_three,
Review.Filter.TWO to R.string.filter_review_two,
Review.Filter.ONE to R.string.filter_review_one
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_normal))
) {
items(items = filters.keys.toList(), key = { item -> item }) { filter ->
val selected = activeFilter == filter
FilterChip(
onClick = {
activeFilter = filter
onClick(filter)
},
label = { Text(text = stringResource(filters.getValue(filter))) },
selected = selected,
leadingIcon = {
if (selected) {
Icon(
imageVector = Icons.Filled.Done,
contentDescription = stringResource(filters.getValue(filter))
)
}
},
)
}
}
}
@Preview
@Composable
private fun DetailsReviewScreenPreview() {
val reviews = flowOf(PagingData.empty<Review>()).collectAsLazyPagingItems()
ScreenContent(
reviews = reviews
)
}

View File

@@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.details
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aurora.gplayapi.data.models.Artwork
import com.aurora.store.R
import com.aurora.store.compose.composables.TopAppBarComposable
import com.aurora.store.compose.composables.details.ScreenshotComposable
import com.aurora.store.viewmodel.details.AppDetailsViewModel
@Composable
fun DetailsScreenshotScreen(
index: Int,
onNavigateUp: () -> Unit,
viewModel: AppDetailsViewModel = hiltViewModel()
) {
val app by viewModel.app.collectAsStateWithLifecycle()
ScreenContent(
topAppBarTitle = app!!.displayName,
onNavigateUp = onNavigateUp,
screenshots = app!!.screenshots,
index = index
)
}
@Composable
private fun ScreenContent(
topAppBarTitle: String? = null,
onNavigateUp: () -> Unit = {},
screenshots: List<Artwork> = emptyList(),
index: Int = 0
) {
val displayMetrics = LocalContext.current.resources.displayMetrics
val pagerState = rememberPagerState(initialPage = index) { screenshots.size }
Scaffold(
topBar = {
TopAppBarComposable(title = topAppBarTitle, onNavigateUp = onNavigateUp)
}
) { paddingValues ->
HorizontalPager(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
state = pagerState
) { page ->
val artwork = screenshots[page]
ScreenshotComposable(
modifier = Modifier.fillMaxSize(),
url = "${artwork.url}=rw-w${displayMetrics.widthPixels}-v1-e15"
)
}
}
}
@Preview
@Composable
private fun DetailsScreenshotScreenPreview() {
ScreenContent(
topAppBarTitle = stringResource(R.string.app_name)
)
}

View File

@@ -0,0 +1,102 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.dialogs
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.core.text.isDigitsOnly
import com.aurora.gplayapi.data.models.App
import com.aurora.store.R
import com.aurora.store.compose.composables.preview.AppPreviewProvider
import kotlinx.coroutines.android.awaitFrame
/**
* Dialog for entering the versionCode manually for an App to download
* @param currentVersionCode Current versionCode of the app
* @param onConfirm Callback when the versionCode has been entered
* @param onDismiss Callback when the dialog has been dismissed
*/
@Composable
fun ManualDownloadDialog(
currentVersionCode: Long,
onConfirm: (versionCode: Long) -> Unit = {},
onDismiss: () -> Unit = {}
) {
val focusRequester = remember { FocusRequester() }
var versionCode by remember {
val initText = currentVersionCode.toString()
mutableStateOf(TextFieldValue(text = initText, selection = TextRange(initText.length)))
}
LaunchedEffect(focusRequester) {
awaitFrame()
focusRequester.requestFocus()
}
AlertDialog(
title = { Text(text = stringResource(R.string.title_manual_download)) },
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(text = stringResource(R.string.manual_download_hint))
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
value = versionCode,
onValueChange = { if (it.text.isDigitsOnly()) versionCode = it },
shape = RoundedCornerShape(10.dp),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number
)
)
}
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton (onClick = { onConfirm(versionCode.text.toLong()) }) {
Text(text = stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = stringResource(android.R.string.cancel))
}
}
)
}
@Preview
@Composable
private fun ManualDownloadDialogPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
ManualDownloadDialog(currentVersionCode = app.versionCode.toLong())
}

View File

@@ -33,7 +33,7 @@ data class ExodusReport(
@Serializable
@Parcelize
data class Report(
val id: Int = 0,
val id: Int = -1,
val downloads: String = String(),
val version: String = String(),
val creationDate: String = String(),

View File

@@ -22,23 +22,25 @@ data class Data(
@Serializable
data class Scores(
@SerialName("micro_g")
val microG: Rating,
val microG: Rating = Rating(),
@SerialName("native")
val aosp: Rating
val aosp: Rating = Rating()
)
@Serializable
data class Rating(
val denominator: Float,
val numerator: Float,
val rating_type: String,
val total_count: Long
val denominator: Float = -1F,
val numerator: Float = -1F,
val rating_type: String = String(),
val total_count: Long = -1
) {
private val fraction get() = numerator / denominator
private val fraction
get() = if (numerator == -1F && denominator == -1F) -1F else numerator / denominator
@get:StringRes
val status: Int
get() = when {
fraction == -1F -> R.string.plexus_progress
fraction == 0F -> R.string.details_compatibility_status_unknown
fraction >= 0.90 -> R.string.details_compatibility_status_compatible
fraction >= 0.50 -> R.string.details_compatibility_status_limited

View File

@@ -0,0 +1,69 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.data.paging
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.PagingState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* A generic paging source that is supposed to be able to load any type of data
*
* Consider calling [createPager] method to create an instance of [Pager] instead of interacting
* with the class directly.
* @param block Data to load into the pager
*/
class GenericPagingSource<T : Any>(
private val block: suspend (Int) -> List<T>
) : PagingSource<Int, T>() {
companion object {
private const val DEFAULT_PAGE_SIZE = 20
/**
* Method to dynamically create and manage pager objects
* @param pageSize Size of the page
* @param enablePlaceholders Whether placeholders should be shown
* @param data Data to load into the pager
*/
fun <T : Any> createPager(
pageSize: Int = DEFAULT_PAGE_SIZE,
enablePlaceholders: Boolean = true,
data: suspend (Int) -> List<T>
): Pager<Int, T> = Pager(
config = PagingConfig(enablePlaceholders = enablePlaceholders, pageSize = pageSize),
pagingSourceFactory = { GenericPagingSource(data) }
)
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, T> {
val page = params.key ?: 1
return try {
withContext(Dispatchers.IO) {
val data = block(page)
val totalPages = if (data.isNotEmpty()) page + 1 else page
LoadResult.Page(
data = data,
prevKey = if (page == 1) null else page - 1,
nextKey = if (page == totalPages) null else totalPages,
itemsAfter = if (page == totalPages) 0 else DEFAULT_PAGE_SIZE
)
}
} catch (exception: Exception) {
LoadResult.Error(exception)
}
}
override fun getRefreshKey(state: PagingState<Int, T>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
}

View File

@@ -62,9 +62,8 @@ object CommonUtil {
return tempValue.toString() + siPrefixes[order]
}
fun addDiPrefix(value: Long): String {
if (value <= 1L)
return "NA"
fun addDiPrefix(value: Long): String? {
if (value <= 1L) return null
var tempValue = value
var order = 0
while (tempValue >= 1000.0) {

View File

@@ -28,10 +28,12 @@ import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.viewbinding.ViewBinding
import com.airbnb.epoxy.EpoxyRecyclerView
import com.aurora.extensions.navigate
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.Category
import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.store.MobileNavigationDirections
import com.aurora.store.compose.navigation.Screen
import com.aurora.store.data.model.MinimalApp
import com.aurora.store.data.providers.PermissionProvider
import com.aurora.store.view.ui.details.AppDetailsFragmentDirections
@@ -81,8 +83,8 @@ abstract class BaseFragment<ViewBindingType : ViewBinding> : Fragment() {
}
fun openDetailsFragment(packageName: String, app: App? = null) {
findNavController().navigate(
MobileNavigationDirections.actionGlobalAppDetailsFragment(packageName, app)
requireContext().navigate(
Screen.AppDetails(packageName)
)
}

View File

@@ -30,7 +30,6 @@ import android.provider.Settings
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.RelativeLayout
import android.widget.Toast
import androidx.compose.ui.util.fastAny
import androidx.core.content.ContextCompat
import androidx.core.text.HtmlCompat
@@ -52,7 +51,6 @@ import com.aurora.extensions.browse
import com.aurora.extensions.hide
import com.aurora.extensions.px
import com.aurora.extensions.requiresObbDir
import com.aurora.extensions.runOnUiThread
import com.aurora.extensions.share
import com.aurora.extensions.show
import com.aurora.extensions.toast
@@ -85,7 +83,6 @@ import com.aurora.store.util.ShortcutManagerUtil
import com.aurora.store.view.custom.RatingView
import com.aurora.store.view.epoxy.controller.DetailsCarouselController
import com.aurora.store.view.epoxy.controller.GenericCarouselController
import com.aurora.store.view.epoxy.views.details.ReviewViewModel_
import com.aurora.store.view.epoxy.views.details.ScreenshotView
import com.aurora.store.view.epoxy.views.details.ScreenshotViewModel_
import com.aurora.store.view.ui.commons.BaseFragment
@@ -186,45 +183,6 @@ class AppDetailsFragment : BaseFragment<FragmentDetailsBinding>() {
// Toolbar
updateToolbar(app)
// App Details
viewModel.fetchAppDetails(app.packageName)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.app.collect {
if (it.packageName.isNotBlank()) {
app = it
// App User Review
// We can not fetch it outside of this block, as we need the testing program status
if (!viewModel.authProvider.isAnonymous && app.isInstalled) {
viewModel.fetchUserAppReview(app)
}
updateToolbar(app)
updateAppHeader(app) // Re-inflate the app details, as web data may vary.
updateExtraDetails(app)
updateCompatibilityInfo()
if (app.versionCode == 0L) {
warnAppUnavailable(app)
}
// Fetch App Reviews
viewModel.fetchAppReviews(app.packageName)
// Fetch Data Safety Report
viewModel.fetchAppDataSafetyReport(app.packageName)
// Fetch Exodus Privacy Report
viewModel.fetchAppReport(app.packageName)
} else {
toast(getString(R.string.status_unavailable))
// TODO: Redirect to App Unavailable Fragment
}
}
}
viewModel.download.filterNotNull().onEach {
when (it.downloadStatus) {
DownloadStatus.QUEUED,
@@ -260,35 +218,6 @@ class AppDetailsFragment : BaseFragment<FragmentDetailsBinding>() {
}
}.launchIn(viewLifecycleOwner.lifecycleScope)
// Reviews
viewLifecycleOwner.lifecycleScope.launch {
viewModel.reviews.collect {
binding.layoutDetailsReview.epoxyRecycler.withModels {
it.take(4).forEach { add(ReviewViewModel_().id(it.timeStamp).review(it)) }
}
}
}
// User Rating
viewLifecycleOwner.lifecycleScope.launch {
viewModel.userReview.collect {
if (it.commentId.isNotEmpty()) {
runOnUiThread { updateUserReview(it) }
} else {
Toast.makeText(
requireContext(),
getString(R.string.toast_rated_failed),
Toast.LENGTH_SHORT
).show()
}
}
}
// Data Safety Report
viewLifecycleOwner.lifecycleScope.launch {
viewModel.dataSafetyReport.collect { updateDataSafetyViews(it) }
}
// Exodus Privacy Report
viewLifecycleOwner.lifecycleScope.launch {
viewModel.exodusReport.collect { report ->
@@ -331,27 +260,6 @@ class AppDetailsFragment : BaseFragment<FragmentDetailsBinding>() {
}
}
// Plexus Report
viewModel.plexusReport.onEach { plexusReport ->
if (plexusReport?.report?.scores == null) {
binding.layoutDetailsCompatibility.txtStatusMicroG.subTitle =
getString(R.string.details_compatibility_status_unknown)
binding.layoutDetailsCompatibility.txtStatusAosp.subTitle =
getString(R.string.details_compatibility_status_unknown)
} else {
binding.layoutDetailsCompatibility.headerCompatibility.addClickListener {
requireContext().browse("${Constants.PLEXUS_SEARCH_URL}${app.packageName}")
}
binding.layoutDetailsCompatibility.txtStatusMicroG.subTitle =
getString(plexusReport.report.scores.microG.status)
binding.layoutDetailsCompatibility.txtStatusAosp.subTitle =
getString(plexusReport.report.scores.microG.status)
}
}.launchIn(viewLifecycleOwner.lifecycleScope)
// Beta program
viewLifecycleOwner.lifecycleScope.launch {
viewModel.testingProgramStatus.collect {
@@ -774,7 +682,7 @@ class AppDetailsFragment : BaseFragment<FragmentDetailsBinding>() {
binding.layoutDetailDescription.apply {
val installs = CommonUtil.addDiPrefix(app.installs)
if (installs != "NA") {
if (installs != null) {
txtInstalls.text = CommonUtil.addDiPrefix(app.installs)
} else {
txtInstalls.hide()

View File

@@ -1,28 +1,43 @@
/*
* SPDX-FileCopyrightText: 2023-2025 The Calyx Institute
* SPDX-FileCopyrightText: 2023-2024 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.viewmodel.details
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.aurora.Constants
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.details.TestingProgramStatus
import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.gplayapi.helpers.PurchaseHelper
import com.aurora.gplayapi.helpers.ReviewsHelper
import com.aurora.gplayapi.helpers.web.WebDataSafetyHelper
import com.aurora.gplayapi.network.IHttpClient
import com.aurora.store.BuildConfig
import com.aurora.store.R
import com.aurora.store.data.helper.DownloadHelper
import com.aurora.store.data.model.ExodusReport
import com.aurora.store.data.model.ExodusTracker
import com.aurora.store.data.model.PlexusReport
import com.aurora.store.data.model.Report
import com.aurora.store.data.model.Scores
import com.aurora.store.data.paging.GenericPagingSource.Companion.createPager
import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.data.room.favourite.Favourite
import com.aurora.store.data.room.favourite.FavouriteDao
import com.aurora.store.util.CertUtil
import com.aurora.store.util.PackageUtil
import com.aurora.store.util.PackageUtil.PACKAGE_NAME_GMS
import com.aurora.store.util.Preferences
import com.aurora.store.util.Preferences.PREFERENCE_SIMILAR
import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_EXTENDED
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
@@ -32,6 +47,9 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
@@ -43,112 +61,124 @@ import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport
class AppDetailsViewModel @Inject constructor(
val authProvider: AuthProvider,
@ApplicationContext private val context: Context,
private val purchaseHelper: PurchaseHelper,
private val appDetailsHelper: AppDetailsHelper,
private val reviewsHelper: ReviewsHelper,
private val webDataSafetyHelper: WebDataSafetyHelper,
private val downloadHelper: DownloadHelper,
private val favouriteDao: FavouriteDao,
private val httpClient: IHttpClient,
private val json: Json
private val json: Json,
private val exodusTrackers: JSONObject
) : ViewModel() {
private val TAG = AppDetailsViewModel::class.java.simpleName
private val appStash: MutableMap<String, App> = mutableMapOf()
private val _app = MutableSharedFlow<App>()
val app = _app.asSharedFlow()
private val _app = MutableStateFlow<App?>(App(""))
val app = _app.asStateFlow()
private val reviewsStash = mutableMapOf<String, List<Review>>()
private val _reviews = MutableSharedFlow<List<Review>>()
val reviews = _reviews.asSharedFlow()
private var reviewsNextPageUrl: String? = null
private val _reviews = MutableStateFlow<PagingData<Review>>(PagingData.empty())
val reviews = _reviews.asStateFlow()
private val userReviewStash = mutableMapOf<String, Review?>()
private val _userReview = MutableSharedFlow<Review>()
val userReview = _userReview.asSharedFlow()
private val _userReview = MutableStateFlow<Review?>(null)
val userReview = _userReview.asStateFlow()
private val dataSafetyReportStash = mutableMapOf<String, DataSafetyReport>()
private val _dataSafetyReport = MutableSharedFlow<DataSafetyReport>()
val dataSafetyReport = _dataSafetyReport.asSharedFlow()
private val _dataSafetyReport = MutableStateFlow<DataSafetyReport?>(null)
val dataSafetyReport = _dataSafetyReport.asStateFlow()
private val exodusReportStash = mutableMapOf<String, Report?>()
private val _exodusReport = MutableSharedFlow<Report?>()
val exodusReport = _exodusReport.asSharedFlow()
private val _exodusReport = MutableStateFlow<Report?>(Report())
val exodusReport = _exodusReport.asStateFlow()
private val plexusReportStash = mutableMapOf<String, PlexusReport?>()
private val _plexusReport = MutableSharedFlow<PlexusReport?>()
val plexusReport = _plexusReport.asSharedFlow()
private val _plexusScores = MutableStateFlow<Scores?>(Scores())
val plexusScores = _plexusScores.asStateFlow()
private val testProgramStatusStash = mutableMapOf<String, TestingProgramStatus?>()
private val _testingProgramStatus = MutableSharedFlow<TestingProgramStatus?>()
val testingProgramStatus = _testingProgramStatus.asSharedFlow()
private val _testingProgramStatus = MutableStateFlow<TestingProgramStatus?>(null)
val testingProgramStatus = _testingProgramStatus.asStateFlow()
private val _favourite = MutableStateFlow<Boolean>(false)
private val _favourite = MutableStateFlow(false)
val favourite = _favourite.asStateFlow()
private val _purchaseStatus = MutableSharedFlow<Boolean>()
val purchaseStatus = _purchaseStatus.asSharedFlow()
val download = combine(app, downloadHelper.downloadsList) { a, list ->
if (a.packageName.isBlank()) return@combine null
list.find { d -> d.packageName == a.packageName }
if (a?.packageName.isNullOrBlank()) return@combine null
list.find { d -> d.packageName == a?.packageName }
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val isExtendedUpdateEnabled: Boolean
get() = Preferences.getBoolean(context, PREFERENCE_UPDATES_EXTENDED)
private val isUpdatable: Boolean
get() = PackageUtil.isUpdatable(
context,
app.value!!.packageName,
app.value!!.versionCode.toLong()
)
private val hasValidCerts: Boolean
get() = app.value!!.certificateSetList.any {
it.certificateSet in CertUtil.getEncodedCertificateHashes(
context,
app.value!!.packageName
)
}
val hasValidUpdate: Boolean
get() = (isUpdatable && hasValidCerts) || (isUpdatable && isExtendedUpdateEnabled)
val showSimilarApps: Boolean
get() = Preferences.getBoolean(context, PREFERENCE_SIMILAR)
fun fetchAppDetails(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
checkFavourite(packageName)
_favourite.value = favouriteDao.isFavourite(packageName)
val app: App = appStash.getOrPut(packageName) {
appDetailsHelper.getAppByPackageName(packageName).copy(
isInstalled = PackageUtil.isInstalled(context, packageName)
)
}
_app.emit(app)
_dataSafetyReport.value = webDataSafetyHelper.fetch(packageName)
_app.value = appDetailsHelper.getAppByPackageName(packageName).copy(
isInstalled = PackageUtil.isInstalled(context, packageName)
)
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch app details", exception)
_app.emit(App(""))
_app.value = null
}
}.invokeOnCompletion { throwable ->
// Only proceed if there was no error while fetching the app details
if (throwable != null) return@invokeOnCompletion
fetchReviews()
fetchExodusPrivacyReport(packageName)
if (app.value!!.dependencies.dependentPackages.contains(PACKAGE_NAME_GMS)) {
fetchPlexusReport(packageName)
}
}
}
fun fetchAppReviews(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
val reviews = reviewsStash.getOrPut(packageName) {
reviewsHelper.getReviewSummary(packageName)
}
fun fetchReviews(filter: Review.Filter = Review.Filter.ALL) {
reviewsNextPageUrl = null
_reviews.emit(reviews)
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch app reviews", exception)
_reviews.emit(emptyList())
}
}
createPager {
val reviewsCluster = reviewsNextPageUrl?.let { nextPageUrl ->
reviewsHelper.next(nextPageUrl)
} ?: reviewsHelper.getReviews(app.value!!.packageName, filter)
reviewsNextPageUrl = reviewsCluster.nextPageUrl
reviewsCluster.reviewList
}.flow.distinctUntilChanged()
.cachedIn(viewModelScope)
.onEach { _reviews.value = it }
.launchIn(viewModelScope)
}
fun fetchAppDataSafetyReport(packageName: String) {
private fun fetchExodusPrivacyReport(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
val report = dataSafetyReportStash.getOrPut(packageName) {
webDataSafetyHelper.fetch(packageName)
}
_dataSafetyReport.emit(report)
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch data safety report", exception)
}
}
}
fun fetchAppReport(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
val exodusReport = exodusReportStash.getOrPut(packageName) {
getLatestExodusReport(packageName)
}
_exodusReport.emit(exodusReport)
_exodusReport.value = getLatestExodusReport(packageName)
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch privacy report", exception)
exodusReportStash[packageName] = null
_exodusReport.emit(null)
_exodusReport.value = null
}
}
}
@@ -156,17 +186,10 @@ class AppDetailsViewModel @Inject constructor(
fun fetchPlexusReport(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
val plexusReport = plexusReportStash.getOrPut(packageName) {
val url = "${Constants.PLEXUS_API_URL}/${packageName}/?scores=true"
val playResponse = httpClient.get(url, emptyMap())
json.decodeFromString<PlexusReport>(String(playResponse.responseBytes))
}
_plexusReport.emit(plexusReport)
_plexusScores.value = getPlexusReport(packageName)?.report?.scores
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch compatibility report", exception)
plexusReportStash[packageName] = null
_plexusReport.emit(null)
_plexusScores.value = null
}
}
}
@@ -174,15 +197,9 @@ class AppDetailsViewModel @Inject constructor(
fun fetchTestingProgramStatus(packageName: String, subscribe: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
try {
val testingProgramStatus = testProgramStatusStash.getOrPut(packageName) {
appDetailsHelper.testingProgram(packageName, subscribe)
}
_testingProgramStatus.emit(testingProgramStatus)
_testingProgramStatus.value = appDetailsHelper.testingProgram(packageName, subscribe)
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch testing program status", exception)
testProgramStatusStash[packageName] = null
_testingProgramStatus.emit(null)
}
}
}
@@ -190,19 +207,8 @@ class AppDetailsViewModel @Inject constructor(
fun fetchUserAppReview(app: App) {
viewModelScope.launch(Dispatchers.IO) {
try {
val stashedUserReview = userReviewStash[app.packageName]
if (stashedUserReview != null) {
_userReview.emit(stashedUserReview)
return@launch
}
val isTesting = app.testingProgram?.isSubscribed ?: false
val userReview = reviewsHelper.getUserReview(app.packageName, isTesting)
if (userReview != null) {
userReviewStash[app.packageName] = userReview
_userReview.emit(userReview)
}
_userReview.value = reviewsHelper.getUserReview(app.packageName, isTesting)
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch user review", exception)
}
@@ -212,19 +218,13 @@ class AppDetailsViewModel @Inject constructor(
fun postAppReview(packageName: String, review: Review, isBeta: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
try {
val userReview = reviewsHelper.addOrEditReview(
_userReview.value = reviewsHelper.addOrEditReview(
packageName,
review.title,
review.comment,
review.rating,
isBeta
)
if (userReview != null) {
context.toast(R.string.toast_rated_success)
userReviewStash[packageName] = userReview
_userReview.emit(userReview)
}
} catch (exception: Exception) {
Log.e(TAG, "Failed to post review", exception)
}
@@ -235,6 +235,19 @@ class AppDetailsViewModel @Inject constructor(
viewModelScope.launch { downloadHelper.enqueueApp(app) }
}
fun purchase(app: App) {
viewModelScope.launch(Dispatchers.IO) {
try {
val files = purchaseHelper.purchase(app.packageName, app.versionCode, app.offerType)
_purchaseStatus.emit(files.isNotEmpty())
if (files.isNotEmpty()) download(app.copy(fileList = files.toMutableList()))
} catch (exception: Exception) {
_purchaseStatus.emit(false)
Log.e(TAG, "Failed to purchase the app ", exception)
}
}
}
fun cancelDownload(app: App) {
viewModelScope.launch { downloadHelper.cancelDownload(app.packageName) }
}
@@ -259,25 +272,43 @@ class AppDetailsViewModel @Inject constructor(
}
}
private fun checkFavourite(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
_favourite.value = favouriteDao.isFavourite(packageName)
}
fun getExodusTrackersFromReport(): List<ExodusTracker> {
val trackerObjects = exodusReport.value!!.trackers.map {
exodusTrackers.getJSONObject(it.toString())
}.toList()
return trackerObjects.map {
ExodusTracker(
id = it.getInt("id"),
name = it.getString("name"),
url = it.getString("website"),
signature = it.getString("code_signature"),
date = it.getString("creation_date"),
description = it.getString("description"),
networkSignature = it.getString("network_signature"),
documentation = listOf(it.getString("documentation")),
categories = listOf(it.getString("categories"))
)
}.toList()
}
private fun getLatestExodusReport(packageName: String): Report? {
val headers: MutableMap<String, String> = mutableMapOf()
headers["Content-Type"] = Constants.JSON_MIME_TYPE
headers["Accept"] = Constants.JSON_MIME_TYPE
headers["Authorization"] = "Token ${BuildConfig.EXODUS_API_KEY}"
val url = "${Constants.EXODUS_SEARCH_URL}$packageName"
val headers = mutableMapOf(
"Content-Type" to Constants.JSON_MIME_TYPE,
"Accept" to Constants.JSON_MIME_TYPE,
"Authorization" to "Token ${BuildConfig.EXODUS_API_KEY}"
)
val url = Constants.EXODUS_SEARCH_URL + packageName
val playResponse = httpClient.get(url, headers)
val report = parseExodusResponse(String(playResponse.responseBytes), packageName)
return parseExodusResponse(String(playResponse.responseBytes), packageName)
.firstOrNull()
}
return report
private fun getPlexusReport(packageName: String): PlexusReport? {
val url = "${Constants.PLEXUS_API_URL}/${packageName}/?scores=true"
val playResponse = httpClient.get(url, emptyMap())
return json.decodeFromString<PlexusReport>(String(playResponse.responseBytes))
}
private fun parseExodusResponse(response: String, packageName: String): List<Report> {