From f6970e4245e7bee5ddbf22e33554ebb10fb82e8d Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 6 Nov 2023 17:20:49 -0300 Subject: [PATCH] [app] allow changing preferred repo in app details --- .../org/fdroid/fdroid/compose/ComposeUtils.kt | 10 +- .../fdroid/views/AppDetailsActivity.java | 26 ++ .../views/AppDetailsRecyclerViewAdapter.java | 45 +-- .../fdroid/views/appdetails/RepoChooser.kt | 275 ++++++++++++++++++ .../main/res/layout/app_details2_header.xml | 37 +-- app/src/main/res/values/strings.xml | 16 +- .../fdroid/views/AppDetailsAdapterTest.java | 10 + 7 files changed, 370 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt diff --git a/app/src/main/java/org/fdroid/fdroid/compose/ComposeUtils.kt b/app/src/main/java/org/fdroid/fdroid/compose/ComposeUtils.kt index c8fc725f6..f287d7b41 100644 --- a/app/src/main/java/org/fdroid/fdroid/compose/ComposeUtils.kt +++ b/app/src/main/java/org/fdroid/fdroid/compose/ComposeUtils.kt @@ -45,8 +45,12 @@ object ComposeUtils { ) val newColors = (colors ?: MaterialTheme.colors).let { c -> if (!LocalInspectionMode.current && !c.isLight && Preferences.get().isPureBlack) { - c.copy(background = Color.Black) - } else c + c.copy(background = Color.Black, surface = Color(0xff1e1e1e)) + } else if (!c.isLight) { + c.copy(surface = Color(0xff1e1e1e)) + } else { + c + } } MaterialTheme( colors = newColors, @@ -111,7 +115,7 @@ object ComposeUtils { ) Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) } - Text(text = text.uppercase(Locale.getDefault())) + Text(text = text.uppercase(Locale.getDefault()), maxLines = 1) } } diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java index 4b0b4ddc9..dc5c20d3c 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java @@ -72,6 +72,7 @@ import org.fdroid.fdroid.installer.InstallerFactory; import org.fdroid.fdroid.installer.InstallerService; import org.fdroid.fdroid.nearby.PublicSourceDirProvider; import org.fdroid.fdroid.views.apps.FeatureImage; +import org.fdroid.index.RepoManager; import java.util.ArrayList; import java.util.Iterator; @@ -710,6 +711,7 @@ public class AppDetailsActivity extends AppCompatActivity private void onAppPrefsChanged(AppPrefs appPrefs) { this.appPrefs = appPrefs; + loadRepos(appPrefs.getPreferredRepoId()); if (app != null) updateAppInfo(app, versions, appPrefs); } @@ -724,6 +726,20 @@ public class AppDetailsActivity extends AppCompatActivity supportInvalidateOptionsMenu(); } + private void loadRepos(@Nullable Long preferredRepoId) { + Utils.runOffUiThread(() -> db.getAppDao().getRepositoryIdsForApp(packageName), repoIds -> { + List repoList = new ArrayList<>(repoIds.size()); + RepoManager repoManager = FDroidApp.getRepoManager(this); + if (repoManager.getRepositories().size() <= 2) return; // don't show if only official repo+archive added + for (long repoId: repoIds) { + Repository repo = repoManager.getRepository(repoId); + if (repo != null) repoList.add(repo); + } + long prefId = preferredRepoId == null ? app.repoId : preferredRepoId; + adapter.setRepos(repoList, prefId); + }); + } + @Nullable @SuppressLint("PackageManagerGetSignatures") private PackageInfo getPackageInfo(String packageName) { @@ -788,6 +804,16 @@ public class AppDetailsActivity extends AppCompatActivity } } + @Override + public void onRepoChanged(long repoId) { + Utils.runOffUiThread(() -> db.getAppDao().getApp(repoId, app.packageName), this::onAppChanged); + } + + @Override + public void onPreferredRepoChanged(long repoId) { + FDroidApp.getRepoManager(this).setPreferredRepoId(app.packageName, repoId); + } + /** * Uninstall the app from the current screen. Since there are many ways * to uninstall an app, including from Google Play, {@code adb uninstall}, diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java index 0fa42bd37..5e9dec686 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java @@ -34,6 +34,8 @@ import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; +import androidx.compose.ui.platform.ComposeView; +import androidx.compose.ui.platform.ViewCompositionStrategy; import androidx.core.content.ContextCompat; import androidx.core.content.FileProvider; import androidx.core.graphics.drawable.DrawableCompat; @@ -49,7 +51,6 @@ import androidx.recyclerview.widget.LinearSmoothScroller; import androidx.recyclerview.widget.RecyclerView; import androidx.transition.TransitionManager; -import com.bumptech.glide.Glide; import com.google.android.material.progressindicator.LinearProgressIndicator; import org.apache.commons.io.FilenameUtils; @@ -66,6 +67,7 @@ import org.fdroid.fdroid.installer.SessionInstallManager; import org.fdroid.fdroid.privileged.views.AppDiff; import org.fdroid.fdroid.privileged.views.AppSecurityPermissions; import org.fdroid.fdroid.views.appdetails.AntiFeaturesListingView; +import org.fdroid.fdroid.views.appdetails.RepoChooserKt; import org.fdroid.fdroid.views.main.MainActivity; import org.fdroid.index.v2.FileV2; @@ -98,6 +100,10 @@ public class AppDetailsRecyclerViewAdapter void installCancel(); void launchApk(); + + void onRepoChanged(long repoId); + + void onPreferredRepoChanged(long repoId); } private static final int VIEWTYPE_HEADER = 0; @@ -115,6 +121,9 @@ public class AppDetailsRecyclerViewAdapter private final AppDetailsRecyclerViewAdapterCallbacks callbacks; private RecyclerView recyclerView; private final List items = new ArrayList<>(); + private final List repos = new ArrayList<>(); + @Nullable + private Long preferredRepoId = null; private final List versions = new ArrayList<>(); private final List compatibleVersionsDifferentSigner = new ArrayList<>(); private boolean showVersions; @@ -177,6 +186,13 @@ public class AppDetailsRecyclerViewAdapter notifyDataSetChanged(); } + void setRepos(List repos, long preferredRepoId) { + this.repos.clear(); + this.repos.addAll(repos); + this.preferredRepoId = preferredRepoId; + notifyItemChanged(0); // header changed + } + private void addInstalledApkIfExists(final List apks) { if (app == null) return; Apk installedApk = app.getInstalledApk(context, apks); @@ -378,8 +394,7 @@ public class AppDetailsRecyclerViewAdapter final TextView titleView; final TextView authorView; final TextView lastUpdateView; - final ImageView repoLogoView; - final TextView repoNameView; + final ComposeView repoChooserView; final TextView warningView; final TextView summaryView; final TextView whatsNewView; @@ -404,8 +419,9 @@ public class AppDetailsRecyclerViewAdapter titleView = view.findViewById(R.id.title); authorView = view.findViewById(R.id.author); lastUpdateView = view.findViewById(R.id.text_last_update); - repoLogoView = view.findViewById(R.id.repo_icon); - repoNameView = view.findViewById(R.id.repo_name); + repoChooserView = view.findViewById(R.id.repoChooserView); + repoChooserView.setViewCompositionStrategy( + ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed.INSTANCE); warningView = view.findViewById(R.id.warning); summaryView = view.findViewById(R.id.summary); whatsNewView = view.findViewById(R.id.latest); @@ -500,18 +516,6 @@ public class AppDetailsRecyclerViewAdapter if (app == null) return; Utils.setIconFromRepoOrPM(app, iconView, iconView.getContext()); titleView.setText(app.name); - Repository repo = FDroidApp.getRepoManager(context).getRepository(app.repoId); - if (repo != null && !repo.getAddress().equals("https://f-droid.org/repo")) { - LocaleListCompat locales = LocaleListCompat.getDefault(); - Utils.loadWithGlide(context, repo.getRepoId(), repo.getIcon(locales), repoLogoView); - repoNameView.setText(repo.getName(locales)); - repoLogoView.setVisibility(View.VISIBLE); - repoNameView.setVisibility(View.VISIBLE); - } else { - Glide.with(context).clear(repoLogoView); - repoLogoView.setVisibility(View.GONE); - repoNameView.setVisibility(View.GONE); - } if (!TextUtils.isEmpty(app.authorName)) { authorView.setText(context.getString(R.string.by_author_format, app.authorName)); authorView.setVisibility(View.VISIBLE); @@ -534,6 +538,13 @@ public class AppDetailsRecyclerViewAdapter } else { lastUpdateView.setVisibility(View.GONE); } + if (app != null && preferredRepoId != null) { + RepoChooserKt.setContentRepoChooser(repoChooserView, repos, app.repoId, preferredRepoId, + repo -> callbacks.onRepoChanged(repo.getRepoId()), callbacks::onPreferredRepoChanged); + repoChooserView.setVisibility(View.VISIBLE); + } else { + repoChooserView.setVisibility(View.GONE); + } if (SessionInstallManager.canBeUsed(context) && suggestedApk != null && !SessionInstallManager.isTargetSdkSupported(suggestedApk.targetSdkVersion)) { diff --git a/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt b/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt new file mode 100644 index 000000000..b95498396 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt @@ -0,0 +1,275 @@ +package org.fdroid.fdroid.views.appdetails + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.ContentAlpha +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Star +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Alignment.Companion.End +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.res.ResourcesCompat +import androidx.core.os.LocaleListCompat +import androidx.core.util.Consumer +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import org.fdroid.database.Repository +import org.fdroid.fdroid.R +import org.fdroid.fdroid.Utils +import org.fdroid.fdroid.compose.ComposeUtils.FDroidContent +import org.fdroid.fdroid.compose.ComposeUtils.FDroidOutlineButton +import org.fdroid.index.IndexFormatVersion.TWO + +/** + * A helper method to show [RepoChooser] from Java code. + */ +fun setContentRepoChooser( + composeView: ComposeView, + repos: List, + currentRepoId: Long, + preferredRepoId: Long, + onRepoChanged: Consumer, + onPreferredRepoChanged: Consumer, +) { + composeView.setContent { + FDroidContent { + RepoChooser( + repos = repos, + currentRepoId = currentRepoId, + preferredRepoId = preferredRepoId, + onRepoChanged = onRepoChanged::accept, + onPreferredRepoChanged = onPreferredRepoChanged::accept, + modifier = Modifier.background(MaterialTheme.colors.surface), + ) + } + } +} + +@Composable +fun RepoChooser( + repos: List, + currentRepoId: Long, + preferredRepoId: Long, + onRepoChanged: (Repository) -> Unit, + onPreferredRepoChanged: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + if (repos.isEmpty()) { + // no-op should not happen + } else if (repos.size == 1) { + RepoItem( + repo = repos[0], + isPreferred = false, // don't show "preferred" if the only repo anyway + modifier = Modifier.fillMaxWidth(), + ) + } else { + RepoDropDown( + repos = repos, + currentRepoId = currentRepoId, + preferredRepoId = preferredRepoId, + onRepoChanged = onRepoChanged, + onPreferredRepoChanged = onPreferredRepoChanged, + modifier = modifier, + ) + } +} + +@Composable +@OptIn(ExperimentalGlideComposeApi::class) +private fun RepoDropDown( + repos: List, + currentRepoId: Long, + preferredRepoId: Long, + onRepoChanged: (Repository) -> Unit, + onPreferredRepoChanged: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + val currentRepo = repos.find { it.repoId == currentRepoId } + ?: error("Current repoId not in list") + val localeList = LocaleListCompat.getDefault() + val res = LocalContext.current.resources + + Column( + modifier = modifier.fillMaxWidth(), + ) { + Box { + OutlinedTextField( + value = TextFieldValue(buildAnnotatedString { + append(currentRepo.getName(localeList) ?: "Unknown Repository") + if (currentRepo.repoId == preferredRepoId) { + append(" ") + pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) + append("★ ") + append(stringResource(R.string.app_details_repository_preferred)) + } + }), + textStyle = MaterialTheme.typography.body2, + onValueChange = {}, + label = { + Text(stringResource(R.string.app_details_repositories)) + }, + leadingIcon = { + if (LocalInspectionMode.current) Image( + painter = rememberDrawablePainter( + ResourcesCompat.getDrawable(res, R.drawable.ic_launcher, null) + ), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) else GlideImage( + model = Utils.getDownloadRequest( + currentRepo, + currentRepo.getIcon(localeList) + ), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) { + it.fallback(R.drawable.ic_repo_app_default) + .error(R.drawable.ic_repo_app_default) + } + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = stringResource(R.string.app_details_repository_expand), + ) + }, + singleLine = true, + enabled = false, + colors = TextFieldDefaults.outlinedTextFieldColors( // hack to enable clickable + disabledTextColor = LocalContentColor.current.copy(LocalContentAlpha.current), + disabledBorderColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled), + disabledLabelColor = MaterialTheme.colors.onSurface.copy(ContentAlpha.medium), + disabledLeadingIconColor = MaterialTheme.colors.onSurface, + ), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = { expanded = true }), + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + repos.iterator().forEach { repo -> + DropdownMenuItem(onClick = { + onRepoChanged(repo) + expanded = false + }) { + RepoItem(repo, repo.repoId == preferredRepoId) + } + } + } + } + if (currentRepo.repoId != preferredRepoId) { + FDroidOutlineButton( + text = stringResource(R.string.app_details_repository_button_prefer), + imageVector = Icons.Default.Star, + onClick = { onPreferredRepoChanged(currentRepo.repoId) }, + modifier = Modifier.align(End), + ) + } + } +} + +@Composable +@OptIn(ExperimentalGlideComposeApi::class) +private fun RepoItem(repo: Repository, isPreferred: Boolean, modifier: Modifier = Modifier) { + Row( + horizontalArrangement = spacedBy(8.dp), + verticalAlignment = CenterVertically, + modifier = modifier, + ) { + val localeList = LocaleListCompat.getDefault() + val res = LocalContext.current.resources + if (LocalInspectionMode.current) Image( + painter = rememberDrawablePainter( + ResourcesCompat.getDrawable(res, R.drawable.ic_launcher, null) + ), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) else GlideImage( + model = Utils.getDownloadRequest(repo, repo.getIcon(localeList)), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) { + it.fallback(R.drawable.ic_repo_app_default).error(R.drawable.ic_repo_app_default) + } + Text( + text = buildAnnotatedString { + append(repo.getName(localeList) ?: "Unknown Repository") + if (isPreferred) { + append(" ") + pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) + append("★ ") + append(stringResource(R.string.app_details_repository_preferred)) + } + }, + style = MaterialTheme.typography.body2, + ) + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +fun RepoChooserSingleRepoPreview() { + val repo1 = Repository(1L, "1", 1L, TWO, null, 1L, 1, 1L) + FDroidContent { + RepoChooser(listOf(repo1), 1L, 1L, {}, {}) + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +fun RepoChooserPreview() { + val repo1 = Repository(1L, "1", 1L, TWO, null, 1L, 1, 1L) + val repo2 = Repository(2L, "2", 2L, TWO, null, 2L, 2, 2L) + val repo3 = Repository(3L, "2", 3L, TWO, null, 3L, 3, 3L) + FDroidContent { + RepoChooser(listOf(repo1, repo2, repo3), 1L, 1L, {}, {}) + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +fun RepoChooserNightPreview() { + val repo1 = Repository(1L, "1", 1L, TWO, null, 1L, 1, 1L) + val repo2 = Repository(2L, "2", 2L, TWO, null, 2L, 2, 2L) + val repo3 = Repository(3L, "2", 3L, TWO, null, 3L, 3, 3L) + FDroidContent { + RepoChooser(listOf(repo1, repo2, repo3), 1L, 2L, {}, {}) + } +} diff --git a/app/src/main/res/layout/app_details2_header.xml b/app/src/main/res/layout/app_details2_header.xml index 4c570c987..27f0a09d9 100644 --- a/app/src/main/res/layout/app_details2_header.xml +++ b/app/src/main/res/layout/app_details2_header.xml @@ -88,32 +88,13 @@ app:barrierDirection="bottom" app:constraint_referenced_ids="icon,text_last_update" /> - - - + android:layout_marginTop="8dp" + app:layout_constraintTop_toBottomOf="@id/barrier" + tools:composableName="org.fdroid.fdroid.views.appdetails.RepoChooserKt.RepoChooserPreview" /> @@ -140,7 +121,7 @@ android:ellipsize="marquee" android:visibility="gone" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@+id/repo_icon" + app:layout_constraintTop_toBottomOf="@+id/repoChooserView" tools:text="Open" tools:visibility="visible" /> @@ -152,7 +133,7 @@ android:layout_marginTop="4dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/repo_icon" + app:layout_constraintTop_toBottomOf="@+id/repoChooserView" tools:visibility="visible"> App Details No such app found. + + Repository + + Repositories + + (preferred) + + Prefer Repository + Expand repository list Buy the developers of %1$s a coffee! %1$s is created by %2$s. Buy them a coffee! @@ -215,7 +230,6 @@ This often occurs with apps installed via Google Play or other sources, if they Your device admin doesn\'t allow installing apps from unknown sources, that includes new repos Unknown sources can\'t be added by this user, that includes new repos - Repository: %s Repositories Add additional sources of apps diff --git a/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java b/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java index b99a725a3..8c657074b 100644 --- a/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java +++ b/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java @@ -147,6 +147,16 @@ public class AppDetailsAdapterTest { public void launchApk() { } + + @Override + public void onRepoChanged(long repoId) { + + } + + @Override + public void onPreferredRepoChanged(long repoId) { + + } }; }