diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/ExodusScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/ExodusScreen.kt index 2ace901da..cba170457 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/ExodusScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/ExodusScreen.kt @@ -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 */ diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/ManualDownloadScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/ManualDownloadScreen.kt index 314ee138f..ba5ac66fc 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/ManualDownloadScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/ManualDownloadScreen.kt @@ -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 = 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") + ) ) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/sheets/VersionPickerSheet.kt b/app/src/main/java/com/aurora/store/compose/ui/sheets/VersionPickerSheet.kt new file mode 100644 index 000000000..716ed7dd2 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/sheets/VersionPickerSheet.kt @@ -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, + 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 + ) + } + } +} diff --git a/app/src/main/java/com/aurora/store/data/model/Exodus.kt b/app/src/main/java/com/aurora/store/data/model/Exodus.kt index 69a10f175..5e9456e85 100644 --- a/app/src/main/java/com/aurora/store/data/model/Exodus.kt +++ b/app/src/main/java/com/aurora/store/data/model/Exodus.kt @@ -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(), diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt index 2fac7963b..f49a8aecb 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt @@ -122,6 +122,9 @@ class AppDetailsViewModel @Inject constructor( private val _exodusReports = MutableStateFlow>(emptyList()) val exodusReports = _exodusReports.asStateFlow() + private val _versionLookupInProgress = MutableStateFlow(false) + val versionLookupInProgress = _versionLookupInProgress.asStateFlow() + private val _plexusScores = MutableStateFlow(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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 48d3fbf7e..359dff829 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -510,6 +510,10 @@ Enter version code you wish to download Version code can only contain digits + Look up versions + Select a version + No known versions found + Some versions may be unavailable to download or incompatible with your device. FAQs