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

@@ -192,7 +192,9 @@ dependencies {
implementation(libs.androidx.swiperefreshlayout)
implementation(libs.androidx.viewpager2)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.navigation.fragment.ktx)

View File

@@ -110,7 +110,8 @@
<!-- Activity to host composable screens -->
<activity
android:name=".ComposeActivity"
android:exported="false" />
android:exported="false"
android:windowSoftInputMode="adjustPan" />
<!-- Notification Action (Download Complete) -->
<activity

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> {

View File

@@ -1,10 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: Material Design Authors / Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M40,720Q49,613 105.5,523Q162,433 256,380L182,252Q176,243 179,233Q182,223 192,218Q200,213 210,216Q220,219 226,228L300,356Q386,320 480,320Q574,320 660,356L734,228Q740,219 750,216Q760,213 768,218Q778,223 781,233Q784,243 778,252L704,380Q798,433 854.5,523Q911,613 920,720L40,720ZM280,610Q301,610 315.5,595.5Q330,581 330,560Q330,539 315.5,524.5Q301,510 280,510Q259,510 244.5,524.5Q230,539 230,560Q230,581 244.5,595.5Q259,610 280,610ZM680,610Q701,610 715.5,595.5Q730,581 730,560Q730,539 715.5,524.5Q701,510 680,510Q659,510 644.5,524.5Q630,539 630,560Q630,581 644.5,595.5Q659,610 680,610Z"/>

View File

@@ -1,10 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: Material Design Authors / Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M160,880Q127,880 103.5,856.5Q80,833 80,800L80,160Q80,127 103.5,103.5Q127,80 160,80L480,80L720,320L720,490L640,490L640,360L440,360L440,160L160,160Q160,160 160,160Q160,160 160,160L160,800Q160,800 160,800Q160,800 160,800L600,800L600,880L160,880ZM160,800L160,490L160,490L160,360L160,160L160,160Q160,160 160,160Q160,160 160,160L160,800Q160,800 160,800Q160,800 160,800ZM200,760Q204,711 230,670Q256,629 298,605L260,537Q260,536 264,522Q269,520 273.5,520Q278,520 280,525L319,595Q339,587 359,582.5Q379,578 400,578Q421,578 441,582.5Q461,587 481,595L520,525Q520,525 535,521Q540,523 541,528Q542,533 540,537L502,605Q544,629 570,670Q596,711 600,760L200,760ZM310,700Q318,700 324,694Q330,688 330,680Q330,672 324,666Q318,660 310,660Q302,660 296,666Q290,672 290,680Q290,688 296,694Q302,700 310,700ZM490,700Q498,700 504,694Q510,688 510,680Q510,672 504,666Q498,660 490,660Q482,660 476,666Q470,672 470,680Q470,688 476,694Q482,700 490,700ZM800,880L640,720L696,663L760,726L760,560L840,560L840,726L904,663L960,720L800,880Z"/>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: Material Design Authors / Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M240,800Q207,800 183.5,776.5Q160,753 160,720Q160,687 183.5,663.5Q207,640 240,640Q273,640 296.5,663.5Q320,687 320,720Q320,753 296.5,776.5Q273,800 240,800ZM240,560Q207,560 183.5,536.5Q160,513 160,480Q160,447 183.5,423.5Q207,400 240,400Q273,400 296.5,423.5Q320,447 320,480Q320,513 296.5,536.5Q273,560 240,560ZM240,320Q207,320 183.5,296.5Q160,273 160,240Q160,207 183.5,183.5Q207,160 240,160Q273,160 296.5,183.5Q320,207 320,240Q320,273 296.5,296.5Q273,320 240,320ZM480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,205 424.5,182.5Q449,160 483,160Q483,160 483,160Q483,160 483,160Q481,170 480.5,179.5Q480,189 480,200Q480,231 486.5,260.5Q493,290 505,316Q499,318 493,319Q487,320 480,320ZM760,400Q677,400 618.5,341.5Q560,283 560,200Q560,117 618.5,58.5Q677,0 760,0Q843,0 901.5,58.5Q960,117 960,200Q960,283 901.5,341.5Q843,400 760,400ZM720,800Q687,800 663.5,776.5Q640,753 640,720Q640,687 663.5,663.5Q687,640 720,640Q753,640 776.5,663.5Q800,687 800,720Q800,753 776.5,776.5Q753,800 720,800ZM720,560Q687,560 663.5,536.5Q640,513 640,480Q640,473 641,467Q642,461 644,455Q670,467 699.5,473.5Q729,480 760,480Q771,480 780.5,479.5Q790,479 800,477Q800,477 800,477Q800,477 800,477Q800,511 777.5,535.5Q755,560 720,560ZM760,320Q768,320 774,314Q780,308 780,300Q780,292 774,286Q768,280 760,280Q752,280 746,286Q740,292 740,300Q740,308 746,314Q752,320 760,320ZM740,240L780,240L780,80L740,80L740,240Z"/>
</vector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: Material Design Authors / Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M720,520L720,440L880,440L880,520L720,520ZM768,800L640,704L688,640L816,736L768,800ZM688,320L640,256L768,160L816,224L688,320ZM200,760L200,600L160,600Q127,600 103.5,576.5Q80,553 80,520L80,440Q80,407 103.5,383.5Q127,360 160,360L320,360L520,240L520,720L320,600L280,600L280,760L200,760ZM440,578L440,382L342,440L160,440Q160,440 160,440Q160,440 160,440L160,520Q160,520 160,520Q160,520 160,520L342,520L440,578ZM560,614L560,346Q587,370 603.5,404.5Q620,439 620,480Q620,521 603.5,555.5Q587,590 560,614ZM300,480L300,480L300,480Q300,480 300,480Q300,480 300,480L300,480Q300,480 300,480Q300,480 300,480L300,480L300,480Z"/>
</vector>

View File

@@ -1,10 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: Material Design Authors / Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M260,800Q169,800 104.5,737Q40,674 40,583Q40,505 87,444Q134,383 210,366Q235,274 310,217Q385,160 480,160Q597,160 678.5,241.5Q760,323 760,440L760,440L760,440Q829,448 874.5,499.5Q920,551 920,620Q920,695 867.5,747.5Q815,800 740,800L520,800Q487,800 463.5,776.5Q440,753 440,720L440,514L376,576L320,520L480,360L640,520L584,576L520,514L520,720Q520,720 520,720Q520,720 520,720L740,720Q782,720 811,691Q840,662 840,620Q840,578 811,549Q782,520 740,520L680,520L680,440Q680,357 621.5,298.5Q563,240 480,240Q397,240 338.5,298.5Q280,357 280,440L260,440Q202,440 161,481Q120,522 120,580Q120,638 161,679Q202,720 260,720L360,720L360,800L260,800ZM480,520L480,520L480,520Q480,520 480,520Q480,520 480,520L480,520L480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Z"/>

View File

@@ -1,29 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Aurora Store
~ Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
~
~ Aurora Store is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 2 of the License, or
~ (at your option) any later version.
~
~ Aurora Store is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with Aurora Store. If not, see <http://www.gnu.org/licenses/>.
~
~ SPDX-FileCopyrightText: Material Design Authors / Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,640L280,440L336,382L440,486L440,160L520,160L520,486L624,382L680,440L480,640ZM240,800Q207,800 183.5,776.5Q160,753 160,720L160,600L240,600L240,720Q240,720 240,720Q240,720 240,720L720,720Q720,720 720,720Q720,720 720,720L720,600L800,600L800,720Q800,753 776.5,776.5Q753,800 720,800L240,800Z" />
<path
android:fillColor="@android:color/white"
android:pathData="M480,640L280,440L336,382L440,486L440,160L520,160L520,486L624,382L680,440L480,640ZM240,800Q207,800 183.5,776.5Q160,753 160,720L160,600L240,600L240,720Q240,720 240,720Q240,720 240,720L720,720Q720,720 720,720Q720,720 720,720L720,600L800,600L800,720Q800,753 776.5,776.5Q753,800 720,800L240,800Z"/>
</vector>

View File

@@ -1,29 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Aurora Store
~ Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
~
~ Aurora Store is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 2 of the License, or
~ (at your option) any later version.
~
~ Aurora Store is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with Aurora Store. If not, see <http://www.gnu.org/licenses/>.
~
~ SPDX-FileCopyrightText: Material Design Authors / Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM480,520L160,320L160,720Q160,720 160,720Q160,720 160,720L800,720Q800,720 800,720Q800,720 800,720L800,320L480,520ZM480,440L800,240L160,240L480,440ZM160,320L160,240L160,240L160,320L160,720Q160,720 160,720Q160,720 160,720L160,720Q160,720 160,720Q160,720 160,720L160,320Z" />
<path
android:fillColor="@android:color/white"
android:pathData="M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM480,520L160,320L160,720Q160,720 160,720Q160,720 160,720L800,720Q800,720 800,720Q800,720 800,720L800,320L480,520ZM480,440L800,240L160,240L480,440ZM160,320L160,240L160,240L160,320L160,720Q160,720 160,720Q160,720 160,720L160,720Q160,720 160,720Q160,720 160,720L160,320Z"/>
</vector>

View File

@@ -1,29 +0,0 @@
<!--
~ Aurora Store
~ Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
~
~ Aurora Store is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 2 of the License, or
~ (at your option) any later version.
~
~ Aurora Store is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with Aurora Store. If not, see <http://www.gnu.org/licenses/>.
~
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,480Q513,480 536.5,456.5Q560,433 560,400Q560,367 536.5,343.5Q513,320 480,320Q447,320 423.5,343.5Q400,367 400,400Q400,433 423.5,456.5Q447,480 480,480ZM480,774Q602,662 661,570.5Q720,479 720,408Q720,299 650.5,229.5Q581,160 480,160Q379,160 309.5,229.5Q240,299 240,408Q240,479 299,570.5Q358,662 480,774ZM480,880Q319,743 239.5,625.5Q160,508 160,408Q160,258 256.5,169Q353,80 480,80Q607,80 703.5,169Q800,258 800,408Q800,508 720.5,625.5Q641,743 480,880ZM480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Z" />
</vector>

View File

@@ -1,29 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Aurora Store
~ Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
~
~ Aurora Store is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 2 of the License, or
~ (at your option) any later version.
~
~ Aurora Store is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with Aurora Store. If not, see <http://www.gnu.org/licenses/>.
~
~ SPDX-FileCopyrightText: Material Design Authors / Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorAccent"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M440,680L520,680L520,440L440,440L440,680ZM480,360Q497,360 508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
<path
android:fillColor="@android:color/white"
android:pathData="M440,680L520,680L520,440L440,440L440,680ZM480,360Q497,360 508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View File

@@ -1,29 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Aurora Store
~ Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
~
~ Aurora Store is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 2 of the License, or
~ (at your option) any later version.
~
~ Aurora Store is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with Aurora Store. If not, see <http://www.gnu.org/licenses/>.
~
~ SPDX-FileCopyrightText: Material Design Authors / Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM440,798L440,720Q407,720 383.5,696.5Q360,673 360,640L360,600L168,408Q165,426 162.5,444Q160,462 160,480Q160,601 239.5,692Q319,783 440,798ZM716,696Q736,674 752,648.5Q768,623 778.5,595.5Q789,568 794.5,539Q800,510 800,480Q800,382 745.5,301Q691,220 600,184L600,200Q600,233 576.5,256.5Q553,280 520,280L440,280L440,360Q440,377 428.5,388.5Q417,400 400,400L320,400L320,480L560,480Q577,480 588.5,491.5Q600,503 600,520L600,640L640,640Q666,640 687,655.5Q708,671 716,696Z" />
<path
android:fillColor="@android:color/white"
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM440,798L440,720Q407,720 383.5,696.5Q360,673 360,640L360,600L168,408Q165,426 162.5,444Q160,462 160,480Q160,601 239.5,692Q319,783 440,798ZM716,696Q757,651 778.5,595.5Q800,540 800,480Q800,382 745.5,301Q691,220 600,184L600,200Q600,233 576.5,256.5Q553,280 520,280L440,280L440,360Q440,377 428.5,388.5Q417,400 400,400L320,400L320,480L560,480Q577,480 588.5,491.5Q600,503 600,520L600,640L640,640Q666,640 687,655.5Q708,671 716,696Z"/>
</vector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: Material Design Authors / Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M444,760L514,760L514,710Q564,701 600,671Q636,641 636,582Q636,540 612,505Q588,470 516,444Q456,424 433,409Q410,394 410,368Q410,342 428.5,327Q447,312 482,312Q514,312 532,327.5Q550,343 558,366L622,340Q611,305 581.5,279Q552,253 516,250L516,200L446,200L446,250Q396,261 368,294Q340,327 340,368Q340,415 367.5,444Q395,473 454,494Q517,517 541.5,535Q566,553 566,582Q566,615 542.5,630.5Q519,646 486,646Q453,646 427.5,625.5Q402,605 390,564L324,590Q338,638 367.5,667.5Q397,697 444,708L444,760ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: Material Design Authors / Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,600Q536,600 581,572.5Q626,545 652,500Q617,471 573,455.5Q529,440 480,440Q431,440 387,455.5Q343,471 308,500Q334,545 379,572.5Q424,600 480,600ZM480,400Q513,400 536.5,376.5Q560,353 560,320Q560,287 536.5,263.5Q513,240 480,240Q447,240 423.5,263.5Q400,287 400,320Q400,353 423.5,376.5Q447,400 480,400ZM480,774Q602,662 661,570.5Q720,479 720,408Q720,299 650.5,229.5Q581,160 480,160Q379,160 309.5,229.5Q240,299 240,408Q240,479 299,570.5Q358,662 480,774ZM480,880Q319,743 239.5,625.5Q160,508 160,408Q160,258 256.5,169Q353,80 480,80Q607,80 703.5,169Q800,258 800,408Q800,508 720.5,625.5Q641,743 480,880ZM480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Q480,400 480,400Z"/>
</vector>

View File

@@ -1,29 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Aurora Store
~ Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
~
~ Aurora Store is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 2 of the License, or
~ (at your option) any later version.
~
~ Aurora Store is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with Aurora Store. If not, see <http://www.gnu.org/licenses/>.
~
~ SPDX-FileCopyrightText: Material Design Authors / Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M720,880Q670,880 635,845Q600,810 600,760Q600,753 601,745.5Q602,738 604,732L322,568Q305,583 284,591.5Q263,600 240,600Q190,600 155,565Q120,530 120,480Q120,430 155,395Q190,360 240,360Q263,360 284,368.5Q305,377 322,392L604,228Q602,222 601,214.5Q600,207 600,200Q600,150 635,115Q670,80 720,80Q770,80 805,115Q840,150 840,200Q840,250 805,285Q770,320 720,320Q697,320 676,311.5Q655,303 638,288L356,452Q358,458 359,465.5Q360,473 360,480Q360,487 359,494.5Q358,502 356,508L638,672Q655,657 676,648.5Q697,640 720,640Q770,640 805,675Q840,710 840,760Q840,810 805,845Q770,880 720,880ZM720,240Q737,240 748.5,228.5Q760,217 760,200Q760,183 748.5,171.5Q737,160 720,160Q703,160 691.5,171.5Q680,183 680,200Q680,217 691.5,228.5Q703,240 720,240ZM240,520Q257,520 268.5,508.5Q280,497 280,480Q280,463 268.5,451.5Q257,440 240,440Q223,440 211.5,451.5Q200,463 200,480Q200,497 211.5,508.5Q223,520 240,520ZM720,800Q737,800 748.5,788.5Q760,777 760,760Q760,743 748.5,731.5Q737,720 720,720Q703,720 691.5,731.5Q680,743 680,760Q680,777 691.5,788.5Q703,800 720,800ZM720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200ZM240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480ZM720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Z" />
<path
android:fillColor="@android:color/white"
android:pathData="M680,880Q630,880 595,845Q560,810 560,760Q560,754 563,732L282,568Q266,583 245,591.5Q224,600 200,600Q150,600 115,565Q80,530 80,480Q80,430 115,395Q150,360 200,360Q224,360 245,368.5Q266,377 282,392L563,228Q561,221 560.5,214.5Q560,208 560,200Q560,150 595,115Q630,80 680,80Q730,80 765,115Q800,150 800,200Q800,250 765,285Q730,320 680,320Q656,320 635,311.5Q614,303 598,288L317,452Q319,459 319.5,465.5Q320,472 320,480Q320,488 319.5,494.5Q319,501 317,508L598,672Q614,657 635,648.5Q656,640 680,640Q730,640 765,675Q800,710 800,760Q800,810 765,845Q730,880 680,880ZM680,800Q697,800 708.5,788.5Q720,777 720,760Q720,743 708.5,731.5Q697,720 680,720Q663,720 651.5,731.5Q640,743 640,760Q640,777 651.5,788.5Q663,800 680,800ZM200,520Q217,520 228.5,508.5Q240,497 240,480Q240,463 228.5,451.5Q217,440 200,440Q183,440 171.5,451.5Q160,463 160,480Q160,497 171.5,508.5Q183,520 200,520ZM680,240Q697,240 708.5,228.5Q720,217 720,200Q720,183 708.5,171.5Q697,160 680,160Q663,160 651.5,171.5Q640,183 640,200Q640,217 651.5,228.5Q663,240 680,240ZM680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760ZM200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480ZM680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Z"/>
</vector>

View File

@@ -1,29 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Aurora Store
~ Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
~
~ Aurora Store is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 2 of the License, or
~ (at your option) any later version.
~
~ Aurora Store is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with Aurora Store. If not, see <http://www.gnu.org/licenses/>.
~
~ SPDX-FileCopyrightText: Material Design Authors / Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M354,713L480,637L606,714L573,570L684,474L538,461L480,325L422,460L276,473L387,570L354,713ZM233,880L298,599L80,410L368,385L480,120L592,385L880,410L662,599L727,880L480,731L233,880ZM480,530L480,530L480,530L480,530L480,530L480,530L480,530L480,530L480,530L480,530L480,530Z" />
<path
android:fillColor="@android:color/white"
android:pathData="M354,673L480,597L606,674L573,530L684,434L538,421L480,285L422,420L276,433L387,530L354,673ZM233,840L298,559L80,370L368,345L480,80L592,345L880,370L662,559L727,840L480,691L233,840ZM480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490Z"/>
</vector>

View File

@@ -1,30 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Aurora Store
~ Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
~
~ Aurora Store is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 2 of the License, or
~ (at your option) any later version.
~
~ Aurora Store is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with Aurora Store. If not, see <http://www.gnu.org/licenses/>.
~
~ SPDX-FileCopyrightText: Material Design Authors / Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,840Q405,840 339.5,811.5Q274,783 225.5,734.5Q177,686 148.5,620.5Q120,555 120,480Q120,405 148.5,339.5Q177,274 225.5,225.5Q274,177 339.5,148.5Q405,120 480,120Q562,120 635.5,155Q709,190 760,254L760,160L840,160L840,400L600,400L600,320L710,320Q669,264 609,232Q549,200 480,200Q363,200 281.5,281.5Q200,363 200,480Q200,597 281.5,678.5Q363,760 480,760Q585,760 663.5,692Q742,624 756,520L838,520Q823,657 720.5,748.5Q618,840 480,840ZM592,648L440,496L440,280L520,280L520,464L648,592L592,648Z" />
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,840Q405,840 339.5,811.5Q274,783 225.5,734.5Q177,686 148.5,620.5Q120,555 120,480Q120,405 148.5,339.5Q177,274 225.5,225.5Q274,177 339.5,148.5Q405,120 480,120Q562,120 635.5,155Q709,190 760,254L760,160L840,160L840,400L600,400L600,320L710,320Q669,264 609,232Q549,200 480,200Q363,200 281.5,281.5Q200,363 200,480Q200,597 281.5,678.5Q363,760 480,760Q585,760 663.5,692Q742,624 756,520L838,520Q823,657 720.5,748.5Q618,840 480,840ZM592,648L440,496L440,280L520,280L520,464L648,592L592,648Z"/>
</vector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: Material Design Authors / Google LLC
~ SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,640Q555,640 607.5,587.5Q660,535 660,460Q660,385 607.5,332.5Q555,280 480,280Q405,280 352.5,332.5Q300,385 300,460Q300,535 352.5,587.5Q405,640 480,640ZM480,568Q435,568 403.5,536.5Q372,505 372,460Q372,415 403.5,383.5Q435,352 480,352Q525,352 556.5,383.5Q588,415 588,460Q588,505 556.5,536.5Q525,568 480,568ZM480,760Q334,760 214,678.5Q94,597 40,460Q94,323 214,241.5Q334,160 480,160Q626,160 746,241.5Q866,323 920,460Q866,597 746,678.5Q626,760 480,760ZM480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460ZM480,680Q593,680 687.5,620.5Q782,561 832,460Q782,359 687.5,299.5Q593,240 480,240Q367,240 272.5,299.5Q178,359 128,460Q178,561 272.5,620.5Q367,680 480,680Z"/>
</vector>

View File

@@ -56,7 +56,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:imgIcon="@drawable/ic_map"
app:imgIcon="@drawable/ic_person_location"
app:txtTitle="@string/details_dev_address"
tools:visibility="visible" />
</LinearLayout>
</LinearLayout>

View File

@@ -128,10 +128,15 @@
<string name="details_no_updates">"No updates available"</string>
<string name="details_paid">"Paid"</string>
<string name="details_permission">"Permission"</string>
<string name="details_data_safety_collect">Data that this app may collect</string>
<string name="details_data_safety_collect_none">The developer says this app doesn\'t collect user data.</string>
<string name="details_data_safety_shared">Data that this app may share</string>
<string name="details_data_safety_share_none">The developer says this app doesn\'t share user data with other companies or organizations.</string>
<string name="details_data_safety_title">Data safety</string>
<string name="details_data_safety_subtitle">Data privacy and security practices declared by developer</string>
<string name="details_privacy">"Privacy"</string>
<string name="details_ratings">"Rating and reviews"</string>
<string name="details_ratings_subtitle">Feedback given by other users</string>
<string name="details_ratings_title_hint">"Review title"</string>
<string name="details_think_this_app">"What do you think about this app?"</string>
<string name="download_cancel_all">"Cancel all"</string>
@@ -153,9 +158,11 @@
<string name="download_resume_all">"Resume all"</string>
<string name="download_speed_estimating">"Estimating"</string>
<string name="exodus_no_tracker">"Contains no known trackers"</string>
<string name="exodus_tracker_desc">This version of the app may still contain more trackers not present yet in the Exodus Privacy\'s database.</string>
<string name="exodus_powered">"Powered by Exodus Privacy"</string>
<string name="exodus_progress">"Checking for trackers…"</string>
<string name="exodus_substring">known trackers(s) found in</string>
<string name="exodus_report_trackers"><xliff:g id="number_of_trackers">%1$d</xliff:g> known trackers(s) found in <xliff:g id="app_version_name">%2$s</xliff:g></string>
<string name="exodus_view_report">"View report"</string>
<string name="filter_review_all">"All"</string>
<string name="filter_latest">"Latest"</string>
@@ -481,6 +488,10 @@
<!-- AppDetailsFragment -->
<string name="permissions_requested"><xliff:g id="number_of_permissions">%1$d</xliff:g> permissions</string>
<string name="details_more_info">More info</string>
<string name="details_more_target_api">Targets</string>
<string name="details_more_package_name">Package name</string>
<string name="details_more_content_rating">Content rating</string>
<string name="action_unarchive">Unarchive</string>
<string name="unable_to_open">Unable to open app</string>
<string name="version_update" translatable="false"><xliff:g id="installed_version_name">%1$s</xliff:g> (<xliff:g id="installed_version_code">%2$d</xliff:g>) ➔ <xliff:g id="update_version_name">%3$s</xliff:g> (<xliff:g id="update_version_code">%4$d</xliff:g>)</string>