details: add version lookup to the manual download screen

Add a "Look up versions" button that opens a bottom sheet listing the
app versions known to Exodus (version name, code and release date).
This commit is contained in:
Rahul Patel
2026-07-24 16:09:22 +05:30
parent 84931047fc
commit 44ea58b8cb
6 changed files with 279 additions and 26 deletions

View File

@@ -65,11 +65,9 @@ import com.aurora.store.compose.preview.AppPreviewProvider
import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.data.model.ExodusTracker
import com.aurora.store.data.model.Report
import com.aurora.store.data.model.formatExodusDate
import com.aurora.store.viewmodel.details.AppDetailsViewModel
import com.aurora.store.viewmodel.details.ExodusViewModel
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Locale
@Composable
fun ExodusScreen(
@@ -271,16 +269,6 @@ private fun ExodusReportDialog(
)
}
private fun formatExodusDate(raw: String): String {
val datePart = raw.take(10)
return try {
val parsed = SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(datePart)
if (parsed != null) DateFormat.getDateInstance(DateFormat.MEDIUM).format(parsed) else raw
} catch (_: Exception) {
raw
}
}
/**
* Composable to display errors related to fetching app details
*/

View File

@@ -20,8 +20,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ContainedLoadingIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
@@ -30,15 +32,12 @@ import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
@@ -63,9 +62,10 @@ import com.aurora.store.compose.composable.Info
import com.aurora.store.compose.composable.TopAppBar
import com.aurora.store.compose.preview.AppPreviewProvider
import com.aurora.store.compose.preview.ThemePreviewProvider
import com.aurora.store.compose.ui.sheets.VersionPickerSheet
import com.aurora.store.data.model.AppState
import com.aurora.store.data.model.Report
import com.aurora.store.viewmodel.details.AppDetailsViewModel
import kotlinx.coroutines.android.awaitFrame
import kotlinx.coroutines.launch
@Composable
@@ -77,6 +77,8 @@ fun ManualDownloadScreen(
) {
val app by viewModel.app.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
val reports by viewModel.exodusReports.collectAsStateWithLifecycle()
val lookupInProgress by viewModel.versionLookupInProgress.collectAsStateWithLifecycle()
val topAppBarTitle = when {
windowAdaptiveInfo.isWindowCompact -> app!!.displayName
else -> stringResource(R.string.title_manual_download)
@@ -86,6 +88,9 @@ fun ManualDownloadScreen(
state = state,
topAppBarTitle = topAppBarTitle,
currentVersionCode = app!!.versionCode,
reports = reports,
lookupInProgress = lookupInProgress,
onLookup = viewModel::lookupVersions,
onRequestInstall = { versionCode ->
val requestedApp = app!!.copy(
versionCode = versionCode,
@@ -105,6 +110,9 @@ private fun ScreenContent(
state: AppState = AppState.Unavailable,
topAppBarTitle: String? = null,
currentVersionCode: Long = 0L,
reports: List<Report> = emptyList(),
lookupInProgress: Boolean = false,
onLookup: () -> Unit = {},
onRequestInstall: (versionCode: Long) -> Unit = {},
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2()
) {
@@ -114,15 +122,24 @@ private fun ScreenContent(
val errorMessage = stringResource(R.string.manual_download_version_error)
val focusManager = LocalFocusManager.current
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()
var showVersionPicker by remember { mutableStateOf(false) }
if (showVersionPicker) {
VersionPickerSheet(
reports = reports,
loading = lookupInProgress,
onSelect = { report ->
val code = report.versionCode
versionCode = TextFieldValue(text = code, selection = TextRange(code.length))
showVersionPicker = false
},
onDismiss = { showVersionPicker = false }
)
}
Scaffold(
@@ -153,9 +170,7 @@ private fun ScreenContent(
title = AnnotatedString(text = stringResource(R.string.manual_download_hint))
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
modifier = Modifier.fillMaxWidth(),
enabled = !state.inProgress(),
value = versionCode,
onValueChange = {
@@ -178,6 +193,28 @@ private fun ScreenContent(
}
}
)
Button(
modifier = Modifier.fillMaxWidth(),
enabled = !state.inProgress(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
),
onClick = {
// Dismiss the keyboard so the sheet opens over a settled layout, then
// load versions (a no-op fetch when they are already cached).
focusManager.clearFocus()
showVersionPicker = true
onLookup()
}
) {
Text(
text = stringResource(R.string.manual_download_lookup),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Row(
@@ -199,7 +236,7 @@ private fun ScreenContent(
Button(
modifier = Modifier.weight(1F),
enabled = !state.inProgress(),
enabled = !state.inProgress() && versionCode.text.isNotBlank(),
onClick = {
onRequestInstall(versionCode.text.toLong())
focusManager.clearFocus()
@@ -222,6 +259,11 @@ private fun ScreenContent(
private fun ManualDownloadScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
ScreenContent(
topAppBarTitle = app.displayName,
currentVersionCode = app.versionCode
currentVersionCode = app.versionCode,
reports = listOf(
Report(version = "8.5.1", versionCode = "85100"),
Report(version = "8.4.0", versionCode = "84000"),
Report(version = "8.3.2", versionCode = "83200")
)
)
}

View File

@@ -0,0 +1,179 @@
/*
* SPDX-FileCopyrightText: 2026 Aurora OSS
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.sheets
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ContainedLoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
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
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.aurora.store.R
import com.aurora.store.data.model.Report
import com.aurora.store.data.model.formatExodusDate
/**
* Bottom sheet that lists the versions known to Exodus for an app. Selecting one hands its version
* code back to the manual download screen; the list scrolls internally so long histories stay
* manageable.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VersionPickerSheet(
reports: List<Report>,
loading: Boolean,
onSelect: (Report) -> Unit,
onDismiss: () -> Unit
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.manual_download_select_version),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(
horizontal = dimensionResource(R.dimen.spacing_medium),
vertical = dimensionResource(R.dimen.spacing_xsmall)
)
)
Spacer(Modifier.height(dimensionResource(R.dimen.spacing_xsmall)))
AvailabilityWarning(
modifier = Modifier.padding(
horizontal = dimensionResource(R.dimen.spacing_small)
)
)
Spacer(Modifier.height(dimensionResource(R.dimen.spacing_xsmall)))
when {
loading -> Box(
modifier = Modifier
.fillMaxWidth()
.padding(dimensionResource(R.dimen.spacing_large)),
contentAlignment = Alignment.Center
) {
ContainedLoadingIndicator()
}
reports.isEmpty() -> Text(
text = stringResource(R.string.manual_download_no_versions),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
horizontal = dimensionResource(R.dimen.spacing_medium),
vertical = dimensionResource(R.dimen.spacing_medium)
)
)
else -> LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 480.dp)
.padding(horizontal = dimensionResource(R.dimen.spacing_small))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
) {
itemsIndexed(
items = reports,
key = { _, report -> "report-${report.id}" }
) { index, report ->
if (index > 0) HorizontalDivider()
VersionRow(report = report, onClick = { onSelect(report) })
}
}
}
Spacer(Modifier.navigationBarsPadding())
}
}
}
@Composable
private fun AvailabilityWarning(modifier: Modifier = Modifier) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(dimensionResource(R.dimen.radius_medium)),
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
) {
Row(
modifier = Modifier.padding(dimensionResource(R.dimen.spacing_medium)),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.height(dimensionResource(R.dimen.icon_size_default)),
painter = painterResource(R.drawable.ic_help),
contentDescription = null
)
Text(
text = stringResource(R.string.manual_download_availability_warning),
style = MaterialTheme.typography.bodySmall
)
}
}
}
@Composable
private fun VersionRow(report: Report, onClick: () -> Unit) {
val title = if (report.version.isBlank()) {
report.versionCode
} else {
"${report.version} (${report.versionCode})"
}
val date = report.updatedAt.ifBlank { report.creationDate }
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(
horizontal = dimensionResource(R.dimen.spacing_large),
vertical = dimensionResource(R.dimen.spacing_small)
),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (date.isNotBlank()) {
Text(
text = formatExodusDate(date),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@@ -20,10 +20,27 @@
package com.aurora.store.data.model
import android.os.Parcelable
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Locale
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Formats an Exodus date string (e.g. `2023-01-01T12:34:56Z`) into a localized medium date.
* Falls back to the raw value if it cannot be parsed.
*/
fun formatExodusDate(date: String): String {
val datePart = date.take(10)
return try {
val parsed = SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(datePart)
if (parsed != null) DateFormat.getDateInstance(DateFormat.MEDIUM).format(parsed) else date
} catch (_: Exception) {
date
}
}
@Serializable
data class ExodusReport(
val creator: String = String(),

View File

@@ -122,6 +122,9 @@ class AppDetailsViewModel @Inject constructor(
private val _exodusReports = MutableStateFlow<List<Report>>(emptyList())
val exodusReports = _exodusReports.asStateFlow()
private val _versionLookupInProgress = MutableStateFlow(false)
val versionLookupInProgress = _versionLookupInProgress.asStateFlow()
private val _plexusScores = MutableStateFlow<Scores?>(Scores())
val plexusScores = _plexusScores.asStateFlow()
@@ -492,6 +495,26 @@ class AppDetailsViewModel @Inject constructor(
}
}
/**
* Loads the list of known versions from Exodus for the version picker on the manual download
* screen. Reuses the reports already fetched for the privacy report when available, and only
* hits the network when nothing has been loaded yet.
*/
fun lookupVersions() {
val packageName = app.value?.packageName ?: return
if (_exodusReports.value.isNotEmpty() || _versionLookupInProgress.value) return
viewModelScope.launch {
_versionLookupInProgress.value = true
try {
_exodusReports.value = exodusRepository.fetchReports(packageName)
.sortedByDescending { it.versionCode.toLongOrNull() ?: -1L }
} finally {
_versionLookupInProgress.value = false
}
}
}
suspend fun getNewTrackers(packageName: String, installedVersionCode: Long) =
exodusRepository.getNewTrackers(packageName, installedVersionCode)

View File

@@ -510,6 +510,10 @@
<!-- ManualDownloadSheet -->
<string name="manual_download_hint">Enter version code you wish to download</string>
<string name="manual_download_version_error">Version code can only contain digits</string>
<string name="manual_download_lookup">Look up versions</string>
<string name="manual_download_select_version">Select a version</string>
<string name="manual_download_no_versions">No known versions found</string>
<string name="manual_download_availability_warning">Some versions may be unavailable to download or incompatible with your device.</string>
<!-- WelcomeFragment -->
<string name="faqs_title" translatable="false">FAQs</string>