diff --git a/app/build.gradle b/app/build.gradle index 7133161b2..aaf0a718a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -167,6 +167,7 @@ dependencies { implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.vectordrawable:vectordrawable:1.1.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2' implementation 'androidx.palette:palette:1.0.0' implementation 'androidx.work:work-runtime:2.8.1' implementation 'com.google.guava:guava:31.0-android' // somehow needed for work-runtime to function diff --git a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java index dbb755aa0..0b07367f2 100644 --- a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java +++ b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java @@ -376,7 +376,7 @@ public final class AppUpdateStatusManager { @WorkerThread private List getUpdatableApps() { List releaseChannels = Preferences.get().getBackendReleaseChannels(); - return updateChecker.getUpdatableApps(releaseChannels); + return updateChecker.getUpdatableApps(releaseChannels, true); } private void addUpdatableApps(@Nullable List canUpdate) { diff --git a/app/src/main/java/org/fdroid/fdroid/Preferences.java b/app/src/main/java/org/fdroid/fdroid/Preferences.java index d46615515..85260fdc5 100644 --- a/app/src/main/java/org/fdroid/fdroid/Preferences.java +++ b/app/src/main/java/org/fdroid/fdroid/Preferences.java @@ -38,6 +38,7 @@ import androidx.preference.PreferenceManager; import com.google.common.collect.Lists; import org.fdroid.fdroid.data.Apk; +import org.fdroid.fdroid.data.DBHelper; import org.fdroid.fdroid.installer.PrivilegedInstaller; import org.fdroid.fdroid.net.ConnectivityMonitorService; @@ -46,6 +47,7 @@ import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Random; @@ -138,6 +140,7 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh private static final String PREF_HIDE_ON_LONG_PRESS_SEARCH = "hideOnLongPressSearch"; private static final String PREF_HIDE_ALL_NOTIFICATIONS = "hideAllNotifications"; private static final String PREF_SEND_VERSION_AND_UUID_TO_SERVERS = "sendVersionAndUUIDToServers"; + private static final String PREF_DEFAULT_REPO_ADDRESSES = "defaultRepoAddresses"; public static final int OVER_NETWORK_NEVER = 0; private static final int OVER_NETWORK_ON_DEMAND = 1; @@ -751,6 +754,21 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh return gateways; } + private void setPrefDefaultRepoAddresses(Set addresses) { + preferences.edit().putStringSet(PREF_DEFAULT_REPO_ADDRESSES, addresses).apply(); + } + + public Set getDefaultRepoAddresses(Context context) { + Set def = Collections.singleton("empty"); + Set addresses = preferences.getStringSet(PREF_DEFAULT_REPO_ADDRESSES, def); + if (addresses == def) { + Utils.debugLog(TAG, "Parsing XML to get default repo addresses..."); + addresses = new HashSet<>(DBHelper.getDefaultRepoAddresses(context)); + setPrefDefaultRepoAddresses(addresses); + } + return addresses; + } + public void registerAppsRequiringAntiFeaturesChangeListener(ChangeListener listener) { showAppsRequiringAntiFeaturesListeners.add(listener); } diff --git a/app/src/main/java/org/fdroid/fdroid/UpdateService.java b/app/src/main/java/org/fdroid/fdroid/UpdateService.java index 5ee6a8d6e..d7331603b 100644 --- a/app/src/main/java/org/fdroid/fdroid/UpdateService.java +++ b/app/src/main/java/org/fdroid/fdroid/UpdateService.java @@ -532,7 +532,7 @@ public class UpdateService extends JobIntentService { public static Disposable autoDownloadUpdates(Context context) { DbUpdateChecker updateChecker = new DbUpdateChecker(DBHelper.getDb(context), context.getPackageManager()); List releaseChannels = Preferences.get().getBackendReleaseChannels(); - return Single.fromCallable(() -> updateChecker.getUpdatableApps(releaseChannels)) + return Single.fromCallable(() -> updateChecker.getUpdatableApps(releaseChannels, true)) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doOnError(throwable -> Log.e(TAG, "Error auto-downloading updates: ", throwable)) 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/data/ContentProviderMigrator.java b/app/src/main/java/org/fdroid/fdroid/data/ContentProviderMigrator.java index dada626b5..29c3ceaf3 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/ContentProviderMigrator.java +++ b/app/src/main/java/org/fdroid/fdroid/data/ContentProviderMigrator.java @@ -42,7 +42,6 @@ final class ContentProviderMigrator { private void migrateOldRepos(FDroidDatabase db, SQLiteDatabase oldDb) { RepositoryDao repoDao = db.getRepositoryDao(); List repos = repoDao.getRepositories(); - int weight = repos.isEmpty() ? 0 : repos.get(repos.size() - 1).getWeight(); String[] projection = new String[] { @@ -81,7 +80,7 @@ final class ContentProviderMigrator { // add new repo if not existing if (repo == null) { // new repo to be added to new DB InitialRepository newRepo = new InitialRepository(name, address, "", certificate, - 0, enabled, ++weight); + 0, enabled); long repoId = repoDao.insert(newRepo); repo = ObjectsCompat.requireNonNull(repoDao.getRepository(repoId)); } else { // old repo that may need an update for the new DB @@ -123,7 +122,7 @@ final class ContentProviderMigrator { // ignored version code is max code to ignore all updates, or a specific one to ignore long v = ignoreAllUpdates ? Long.MAX_VALUE : ignoreVersionCode; // this is a new DB, so we can just start to insert new AppPrefs - appPrefsDao.update(new AppPrefs(packageName, v, null)); + appPrefsDao.update(new AppPrefs(packageName, v, null, null)); } } } diff --git a/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java b/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java index 8f0c80a39..f0bc60c93 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java +++ b/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java @@ -71,7 +71,6 @@ public class DBHelper { @VisibleForTesting static void prePopulateDb(Context context, FDroidDatabase db) { List initialRepos = DBHelper.loadInitialRepos(context); - int weight = 1; boolean hasEnabledRepo = false; for (int i = 0; i < initialRepos.size(); i += REPO_XML_ITEM_COUNT) { boolean enabled = initialRepos.get(i + 4).equals("1"); @@ -91,8 +90,7 @@ public class DBHelper { initialRepos.get(i + 2), // description initialRepos.get(i + 6), // certificate Integer.parseInt(initialRepos.get(i + 3)), // version - enabled, // enabled - weight++ // weight + enabled // enabled ); } catch (IllegalArgumentException e) { Log.e(TAG, "Invalid repo: " + addresses.get(0), e); @@ -252,4 +250,26 @@ public class DBHelper { + repoItems.size() + " % " + (REPO_XML_ITEM_COUNT - 1) + " != 0"); return new LinkedList<>(); } + + public static List getDefaultRepoAddresses(Context context) { + List defaultRepos = Arrays.asList(context.getResources().getStringArray(R.array.default_repos)); + if (defaultRepos.size() % REPO_XML_ITEM_COUNT != 0) { + throw new IllegalArgumentException("default_repos.xml has wrong item count: " + + defaultRepos.size() + " % REPO_XML_ARG_COUNT(" + REPO_XML_ITEM_COUNT + + ") != 0, FYI the priority item was removed in v1.16"); + } + List addresses = new ArrayList<>(); + for (int i = 0; i < defaultRepos.size(); i += REPO_XML_ITEM_COUNT) { + boolean enabled = defaultRepos.get(i + 4).equals("1"); + if (!enabled) continue; + // split addresses into a list + for (String address : defaultRepos.get(i + 1).split("\\s+")) { + if (!address.isEmpty()) { + addresses.add(address); + break; // only first one is canonical + } + } + } + return addresses; + } } 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..96482c48a 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsActivity.java @@ -43,6 +43,7 @@ import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.content.res.AppCompatResources; import androidx.core.util.ObjectsCompat; +import androidx.lifecycle.ViewModelProvider; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -53,7 +54,6 @@ import com.google.android.material.appbar.MaterialToolbar; import org.fdroid.database.AppPrefs; import org.fdroid.database.AppVersion; -import org.fdroid.database.FDroidDatabase; import org.fdroid.database.Repository; import org.fdroid.fdroid.AppUpdateStatusManager; import org.fdroid.fdroid.CompatibilityChecker; @@ -64,19 +64,19 @@ import org.fdroid.fdroid.R; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.Apk; import org.fdroid.fdroid.data.App; -import org.fdroid.fdroid.data.DBHelper; import org.fdroid.fdroid.installer.ErrorDialogActivity; import org.fdroid.fdroid.installer.InstallManagerService; import org.fdroid.fdroid.installer.Installer; import org.fdroid.fdroid.installer.InstallerFactory; import org.fdroid.fdroid.installer.InstallerService; import org.fdroid.fdroid.nearby.PublicSourceDirProvider; +import org.fdroid.fdroid.views.appdetails.AppData; +import org.fdroid.fdroid.views.appdetails.AppDetailsViewModel; import org.fdroid.fdroid.views.apps.FeatureImage; import java.util.ArrayList; import java.util.Iterator; import java.util.List; -import java.util.Objects; public class AppDetailsActivity extends AppCompatActivity implements AppDetailsRecyclerViewAdapter.AppDetailsRecyclerViewAdapterCallbacks { @@ -89,7 +89,7 @@ public class AppDetailsActivity extends AppCompatActivity private static final int REQUEST_UNINSTALL_DIALOG = 4; private FDroidApp fdroidApp; - private FDroidDatabase db; + private AppDetailsViewModel model; private volatile App app; @Nullable private volatile List versions; @@ -131,6 +131,7 @@ public class AppDetailsActivity extends AppCompatActivity AppCompatResources.getDrawable(toolbar.getContext(), R.drawable.ic_more_with_background) ); + model = new ViewModelProvider(this).get(AppDetailsViewModel.class); localBroadcastManager = LocalBroadcastManager.getInstance(this); recyclerView = findViewById(R.id.rvDetails); @@ -143,6 +144,7 @@ public class AppDetailsActivity extends AppCompatActivity finish(); return; } + model.loadApp(packageName); recyclerView.setLayoutManager(lm); recyclerView.setAdapter(adapter); @@ -152,10 +154,9 @@ public class AppDetailsActivity extends AppCompatActivity return true; }); checker = new CompatibilityChecker(this); - db = DBHelper.getDb(getApplicationContext()); - db.getAppDao().getApp(packageName).observe(this, this::onAppChanged); - db.getVersionDao().getAppVersions(packageName).observe(this, this::onVersionsChanged); - db.getAppPrefsDao().getAppPrefs(packageName).observe(this, this::onAppPrefsChanged); + model.getApp().observe(this, this::onAppChanged); + model.getAppData().observe(this, this::onAppDataChanged); + model.getVersions().observe(this, this::onVersionsChanged); } private String getPackageNameFromIntent(Intent intent) { @@ -318,22 +319,13 @@ public class AppDetailsActivity extends AppCompatActivity } return true; } else if (item.getItemId() == R.id.action_ignore_all) { - final AppPrefs prefs = Objects.requireNonNull(appPrefs); - Utils.runOffUiThread(() -> db.getAppPrefsDao().update(prefs.toggleIgnoreAllUpdates())); - AppUpdateStatusManager.getInstance(this).checkForUpdates(); + model.ignoreAllUpdates(); return true; } else if (item.getItemId() == R.id.action_ignore_this) { - final AppPrefs prefs = Objects.requireNonNull(appPrefs); - Utils.runOffUiThread(() -> - db.getAppPrefsDao().update(prefs.toggleIgnoreVersionCodeUpdate(app.autoInstallVersionCode))); - AppUpdateStatusManager.getInstance(this).checkForUpdates(); + model.ignoreVersionCodeUpdate(app.autoInstallVersionCode); return true; } else if (item.getItemId() == R.id.action_release_channel_beta) { - final AppPrefs prefs = Objects.requireNonNull(appPrefs); - Utils.runOffUiThread(() -> { - db.getAppPrefsDao().update(prefs.toggleReleaseChannel(Apk.RELEASE_CHANNEL_BETA)); - return true; // we don't really care about the result here - }, result -> AppUpdateStatusManager.getInstance(this).checkForUpdates()); + model.toggleBetaReleaseChannel(); return true; } else if (item.getItemId() == android.R.id.home) { onBackPressed(); @@ -708,8 +700,11 @@ public class AppDetailsActivity extends AppCompatActivity if (app != null && appPrefs != null) updateAppInfo(app, apks, appPrefs); } - private void onAppPrefsChanged(AppPrefs appPrefs) { - this.appPrefs = appPrefs; + private void onAppDataChanged(AppData appData) { + this.appPrefs = appData.getAppPrefs(); + if (appData.getRepos().size() > 0) { + adapter.setRepos(appData.getRepos(), appData.getPreferredRepoId()); + } if (app != null) updateAppInfo(app, versions, appPrefs); } @@ -719,7 +714,7 @@ public class AppDetailsActivity extends AppCompatActivity // If versions are not available, we use an empty list temporarily. List apkList = apks == null ? new ArrayList<>() : apks; app.update(this, apkList, appPrefs); - adapter.updateItems(app, apkList, appPrefs); + adapter.updateItems(app, apks, appPrefs); // pass apks no apkList as null means loading refreshStatus(); supportInvalidateOptionsMenu(); } @@ -788,6 +783,16 @@ public class AppDetailsActivity extends AppCompatActivity } } + @Override + public void onRepoChanged(long repoId) { + model.selectRepo(repoId); + } + + @Override + public void onPreferredRepoChanged(long repoId) { + model.setPreferredRepo(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..4d0a2da8a 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; @@ -75,6 +77,7 @@ import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.Set; @SuppressWarnings("LineLength") public class AppDetailsRecyclerViewAdapter @@ -98,6 +101,10 @@ public class AppDetailsRecyclerViewAdapter void installCancel(); void launchApk(); + + void onRepoChanged(long repoId); + + void onPreferredRepoChanged(long repoId); } private static final int VIEWTYPE_HEADER = 0; @@ -107,7 +114,8 @@ public class AppDetailsRecyclerViewAdapter private static final int VIEWTYPE_PERMISSIONS = 4; private static final int VIEWTYPE_VERSIONS = 5; private static final int VIEWTYPE_NO_VERSIONS = 6; - private static final int VIEWTYPE_VERSION = 7; + private static final int VIEWTYPE_VERSIONS_LOADING = 7; + private static final int VIEWTYPE_VERSION = 8; private final Context context; @Nullable @@ -115,8 +123,12 @@ 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 versionsLoading = true; private boolean showVersions; private HeaderViewHolder headerView; @@ -134,39 +146,44 @@ public class AppDetailsRecyclerViewAdapter addItem(VIEWTYPE_HEADER); } - public void updateItems(@NonNull App app, @NonNull List apks, @NonNull AppPrefs appPrefs) { + public void updateItems(@NonNull App app, @Nullable List apks, @NonNull AppPrefs appPrefs) { this.app = app; + versionsLoading = apks == null; items.clear(); versions.clear(); // Get versions compatibleVersionsDifferentSigner.clear(); - addInstalledApkIfExists(apks); + if (apks != null) addInstalledApkIfExists(apks); boolean showIncompatibleVersions = Preferences.get().showIncompatibleVersions(); - for (final Apk apk : apks) { - boolean allowByCompatibility = apk.compatible || showIncompatibleVersions; - String installedSigner = app.installedSigner; - boolean allowBySigner = installedSigner == null - || showIncompatibleVersions || TextUtils.equals(installedSigner, apk.signer); - if (allowByCompatibility) { - compatibleVersionsDifferentSigner.add(apk); - if (allowBySigner) { - versions.add(apk); - if (!versionsExpandTracker.containsKey(apk.getApkPath())) { - versionsExpandTracker.put(apk.getApkPath(), false); + if (apks != null) { + for (final Apk apk : apks) { + boolean allowByCompatibility = apk.compatible || showIncompatibleVersions; + String installedSigner = app.installedSigner; + boolean allowBySigner = installedSigner == null + || showIncompatibleVersions || TextUtils.equals(installedSigner, apk.signer); + if (allowByCompatibility) { + compatibleVersionsDifferentSigner.add(apk); + if (allowBySigner) { + versions.add(apk); + if (!versionsExpandTracker.containsKey(apk.getApkPath())) { + versionsExpandTracker.put(apk.getApkPath(), false); + } } } } } - suggestedApk = app.findSuggestedApk(apks, appPrefs); + if (apks != null) suggestedApk = app.findSuggestedApk(apks, appPrefs); addItem(VIEWTYPE_HEADER); if (app.getAllScreenshots().size() > 0) addItem(VIEWTYPE_SCREENSHOTS); addItem(VIEWTYPE_DONATE); addItem(VIEWTYPE_LINKS); addItem(VIEWTYPE_PERMISSIONS); - if (versions.isEmpty()) { + if (versionsLoading) { + addItem(VIEWTYPE_VERSIONS_LOADING); + } else if (versions.isEmpty()) { addItem(VIEWTYPE_NO_VERSIONS); } else { addItem(VIEWTYPE_VERSIONS); @@ -177,6 +194,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); @@ -316,6 +340,9 @@ public class AppDetailsRecyclerViewAdapter case VIEWTYPE_NO_VERSIONS: View noVersionsView = inflater.inflate(R.layout.app_details2_links, parent, false); return new NoVersionsViewHolder(noVersionsView); + case VIEWTYPE_VERSIONS_LOADING: + View loadingView = inflater.inflate(R.layout.app_details2_loading, parent, false); + return new VersionsLoadingViewHolder(loadingView); case VIEWTYPE_VERSION: View version = inflater.inflate(R.layout.app_details2_version_item, parent, false); return new VersionViewHolder(version); @@ -340,6 +367,7 @@ public class AppDetailsRecyclerViewAdapter case VIEWTYPE_PERMISSIONS: case VIEWTYPE_VERSIONS: case VIEWTYPE_NO_VERSIONS: + case VIEWTYPE_VERSIONS_LOADING: ((AppDetailsViewHolder) holder).bindModel(); break; @@ -378,8 +406,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 +431,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); @@ -443,7 +471,7 @@ public class AppDetailsRecyclerViewAdapter progressLayout.setVisibility(View.GONE); buttonPrimaryView.setVisibility(versions.isEmpty() ? View.GONE : View.VISIBLE); buttonSecondaryView.setVisibility(app != null && app.isUninstallable(context) ? - View.VISIBLE : View.INVISIBLE); + View.VISIBLE : View.GONE); } void setIndeterminateProgress(int resIdString) { @@ -500,18 +528,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 +550,22 @@ public class AppDetailsRecyclerViewAdapter } else { lastUpdateView.setVisibility(View.GONE); } + if (app != null && preferredRepoId != null) { + Set defaultAddresses = Preferences.get().getDefaultRepoAddresses(context); + Repository repo = FDroidApp.getRepoManager(context).getRepository(app.repoId); + // show repo banner, if + // * app is in more than one repo, or + // * app is from a non-default repo + if (repos.size() > 1 || (repo != null && !defaultAddresses.contains(repo.getAddress()))) { + RepoChooserKt.setContentRepoChooser(repoChooserView, repos, app.repoId, preferredRepoId, + r -> callbacks.onRepoChanged(r.getRepoId()), callbacks::onPreferredRepoChanged); + repoChooserView.setVisibility(View.VISIBLE); + } else { + repoChooserView.setVisibility(View.GONE); + } + } else { + repoChooserView.setVisibility(View.GONE); + } if (SessionInstallManager.canBeUsed(context) && suggestedApk != null && !SessionInstallManager.isTargetSdkSupported(suggestedApk.targetSdkVersion)) { @@ -601,7 +633,7 @@ public class AppDetailsRecyclerViewAdapter buttonPrimaryView.setText(R.string.menu_install); buttonPrimaryView.setVisibility(versions.isEmpty() ? View.GONE : View.VISIBLE); buttonSecondaryView.setText(R.string.menu_uninstall); - buttonSecondaryView.setVisibility(app.isUninstallable(context) ? View.VISIBLE : View.INVISIBLE); + buttonSecondaryView.setVisibility(app.isUninstallable(context) ? View.VISIBLE : View.GONE); buttonSecondaryView.setOnClickListener(v -> callbacks.uninstallApk()); if (callbacks.isAppDownloading()) { buttonPrimaryView.setText(R.string.downloading); @@ -663,6 +695,25 @@ public class AppDetailsRecyclerViewAdapter progressLayout.setVisibility(View.GONE); } progressCancel.setOnClickListener(v -> callbacks.installCancel()); + if (versionsLoading) { + progressLayout.setVisibility(View.VISIBLE); + progressLabel.setVisibility(View.GONE); + progressCancel.setVisibility(View.GONE); + progressPercent.setVisibility(View.GONE); + progressBar.setIndeterminate(true); + progressBar.setVisibility(View.VISIBLE); + } else { + progressLabel.setVisibility(View.VISIBLE); + progressCancel.setVisibility(View.VISIBLE); + progressPercent.setVisibility(View.VISIBLE); + } + // Hide primary buttons when current repo is not the preferred one. + // This requires the user to prefer the repo first, if they want to install/update from it. + if (preferredRepoId != null && preferredRepoId != app.repoId) { + // we don't need to worry about making it visible, because changing current repo refreshes this view + buttonPrimaryView.setVisibility(View.GONE); + buttonSecondaryView.setVisibility(View.GONE); + } } private void updateAntiFeaturesWarning() { @@ -935,6 +986,16 @@ public class AppDetailsRecyclerViewAdapter } } + private class VersionsLoadingViewHolder extends AppDetailsViewHolder { + VersionsLoadingViewHolder(View itemView) { + super(itemView); + } + + @Override + public void bindModel() { + } + } + private class PermissionsViewHolder extends ExpandableLinearLayoutViewHolder { PermissionsViewHolder(View view) { @@ -1050,7 +1111,6 @@ public class AppDetailsRecyclerViewAdapter final TextView added; final ImageView expandArrow; final View expandedLayout; - final TextView repository; final TextView size; final TextView api; final Button buttonInstallUpgrade; @@ -1071,7 +1131,6 @@ public class AppDetailsRecyclerViewAdapter added = view.findViewById(R.id.added); expandArrow = view.findViewById(R.id.expand_arrow); expandedLayout = view.findViewById(R.id.expanded_layout); - repository = view.findViewById(R.id.repository); size = view.findViewById(R.id.size); api = view.findViewById(R.id.api); buttonInstallUpgrade = view.findViewById(R.id.button_install_upgrade); @@ -1124,19 +1183,9 @@ public class AppDetailsRecyclerViewAdapter added.setVisibility(View.INVISIBLE); } - // Repository name, APK size and required Android version - Repository repo = FDroidApp.getRepoManager(context).getRepository(apk.repoId); - if (repo != null) { - repository.setVisibility(View.VISIBLE); - String name = repo.getName(App.getLocales()); - repository.setText(String.format(context.getString(R.string.app_repository), name)); - } else { - repository.setVisibility(View.INVISIBLE); - } size.setText(context.getString(R.string.app_size, Utils.getFriendlySize(apk.size))); api.setText(getApiText(apk)); - // Figuring out whether to show Install or Update button buttonInstallUpgrade.setVisibility(View.GONE); buttonInstallUpgrade.setText(context.getString(R.string.menu_install)); @@ -1283,7 +1332,6 @@ public class AppDetailsRecyclerViewAdapter // This is required to make these labels // auto-scrollable when they are too long version.setSelected(expand); - repository.setSelected(expand); size.setSelected(expand); api.setSelected(expand); } diff --git a/app/src/main/java/org/fdroid/fdroid/views/appdetails/AppDetailsViewModel.kt b/app/src/main/java/org/fdroid/fdroid/views/appdetails/AppDetailsViewModel.kt new file mode 100644 index 000000000..3529b6125 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/appdetails/AppDetailsViewModel.kt @@ -0,0 +1,159 @@ +package org.fdroid.fdroid.views.appdetails + +import android.app.Application +import androidx.annotation.UiThread +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.fdroid.database.App +import org.fdroid.database.AppPrefs +import org.fdroid.database.AppVersion +import org.fdroid.database.Repository +import org.fdroid.fdroid.AppUpdateStatusManager +import org.fdroid.fdroid.FDroidApp +import org.fdroid.fdroid.data.Apk.RELEASE_CHANNEL_BETA +import org.fdroid.fdroid.data.DBHelper + +data class AppData( + val appPrefs: AppPrefs, + val preferredRepoId: Long, + /** + * A list of [Repository]s the app is in. If this is empty, the list doesn't matter, + * because the user only has one repo. + */ + val repos: List, +) + +class AppDetailsViewModel(app: Application) : AndroidViewModel(app) { + + private val _app = MutableLiveData() + val app: LiveData = _app + private val _versions = MutableLiveData>() + val versions: LiveData> = _versions + private val _appData = MutableLiveData() + val appData: LiveData = _appData + + private val db = DBHelper.getDb(app.applicationContext) + private val repoManager = FDroidApp.getRepoManager(app.applicationContext) + private var packageName: String? = null + private var appLiveData: LiveData? = null + private var versionsLiveData: LiveData>? = null + private var appPrefsLiveData: LiveData? = null + private var preferredRepoId: Long? = null + private var repos: List? = null + + @UiThread + fun loadApp(packageName: String) { + if (this.packageName == packageName) return // already set and loaded + if (this.packageName != null && this.packageName != packageName) error { + "Called loadApp() with different packageName." + } + this.packageName = packageName + + // load app and observe changes + // this is a bit hacky, but uses the existing DB API made for old Java code + appLiveData?.removeObserver(onAppChanged) + appLiveData = db.getAppDao().getApp(packageName).also { liveData -> + liveData.observeForever(onAppChanged) + } + // load repos for app, if user have more than one (+ one archive) repo + if (repoManager.getRepositories().size > 2) viewModelScope.launch { + loadRepos(packageName) + } + // load appPrefs + appPrefsLiveData = db.getAppPrefsDao().getAppPrefs(packageName).also { liveData -> + liveData.observeForever(onAppPrefsChanged) + } + } + + override fun onCleared() { + appLiveData?.removeObserver(onAppChanged) + appPrefsLiveData?.removeObserver(onAppPrefsChanged) + versionsLiveData?.removeObserver(onVersionsChanged) + } + + @UiThread + fun selectRepo(repoId: Long) { + appLiveData?.removeObserver(onAppChanged) + viewModelScope.launch(Dispatchers.IO) { + // this will lose observation of changes in the DB, but uses existing API + _app.postValue(db.getAppDao().getApp(repoId, packageName ?: error(""))) + } + tryToPublishAppData() + resetVersionsLiveData(repoId) + } + + @UiThread + fun setPreferredRepo(repoId: Long) { + repoManager.setPreferredRepoId(packageName ?: error(""), repoId) + } + + private val onAppChanged: Observer = Observer { app -> + // set repoIds on first load + if (_app.value == null && app != null) { + preferredRepoId = app.repoId // DB loads preferred repo first + resetVersionsLiveData(app.repoId) + tryToPublishAppData() + } + _app.value = app + } + + private val onAppPrefsChanged: Observer = Observer { appPrefs -> + if (appPrefs.preferredRepoId != null) preferredRepoId = appPrefs.preferredRepoId + tryToPublishAppData() + } + + private val onVersionsChanged: Observer> = Observer { versions -> + _versions.value = versions + } + + private suspend fun loadRepos(packageName: String) = withContext(Dispatchers.IO) { + repos = db.getAppDao().getRepositoryIdsForApp(packageName).mapNotNull { repoId -> + repoManager.getRepository(repoId) + } + tryToPublishAppData() + } + + private fun tryToPublishAppData() { + val data = AppData( + appPrefs = appPrefsLiveData?.value ?: return, + preferredRepoId = preferredRepoId ?: return, + repos = repos ?: emptyList(), + ) + _appData.postValue(data) + } + + private fun resetVersionsLiveData(repoId: Long) { + versionsLiveData?.removeObserver(onVersionsChanged) + val packageName = this.packageName ?: error("packageName not initialized") + versionsLiveData = db.getVersionDao().getAppVersions(repoId, packageName).also { liveData -> + liveData.observeForever(onVersionsChanged) + } + } + + /* AppPrefs methods */ + + fun ignoreAllUpdates() = viewModelScope.launch(Dispatchers.IO) { + val appPrefs = appPrefsLiveData?.value ?: return@launch + db.getAppPrefsDao().update(appPrefs.toggleIgnoreAllUpdates()) + AppUpdateStatusManager.getInstance(getApplication()).checkForUpdates() + } + + fun ignoreVersionCodeUpdate(versionCode: Long) = viewModelScope.launch(Dispatchers.IO) { + val appPrefs = appPrefsLiveData?.value ?: return@launch + db.getAppPrefsDao().update(appPrefs.toggleIgnoreVersionCodeUpdate(versionCode)) + AppUpdateStatusManager.getInstance(getApplication()).checkForUpdates() + } + + fun toggleBetaReleaseChannel() = viewModelScope.launch(Dispatchers.IO) { + val appPrefs = appPrefsLiveData?.value ?: return@launch + db.getAppPrefsDao().update(appPrefs.toggleReleaseChannel(RELEASE_CHANNEL_BETA)) + AppUpdateStatusManager.getInstance(getApplication()).checkForUpdates() + } + +} 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..4663f20ed --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/appdetails/RepoChooser.kt @@ -0,0 +1,222 @@ +package org.fdroid.fdroid.views.appdetails + +import android.content.res.Configuration +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.padding +import androidx.compose.foundation.layout.size +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.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.res.colorResource +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.Companion.Bold +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.LocaleListCompat +import androidx.core.util.Consumer +import org.fdroid.database.Repository +import org.fdroid.fdroid.R +import org.fdroid.fdroid.compose.ComposeUtils.FDroidContent +import org.fdroid.fdroid.compose.ComposeUtils.FDroidOutlineButton +import org.fdroid.fdroid.views.repos.RepoIcon +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()) return + var expanded by remember { mutableStateOf(false) } + val currentRepo = repos.find { it.repoId == currentRepoId } + ?: error("Current repoId not in list") + val isPreferred = currentRepo.repoId == preferredRepoId + Column( + modifier = modifier.fillMaxWidth(), + ) { + Box { + val borderColor = if (isPreferred) { + colorResource(id = R.color.fdroid_blue) + } else { + LocalContentColor.current.copy(alpha = LocalContentAlpha.current) + } + OutlinedTextField( + value = TextFieldValue( + annotatedString = getRepoString( + repo = currentRepo, + isPreferred = repos.size > 1 && isPreferred, + ), + ), + textStyle = MaterialTheme.typography.body2, + onValueChange = {}, + label = { + if (repos.size == 1) { + Text(stringResource(R.string.app_details_repository)) + } else { + Text(stringResource(R.string.app_details_repositories)) + } + }, + leadingIcon = { + RepoIcon(repo = currentRepo, modifier = Modifier.size(24.dp)) + }, + trailingIcon = { + if (repos.size > 1) Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = stringResource(R.string.app_details_repository_expand), + tint = if (isPreferred) { + colorResource(id = R.color.fdroid_blue) + } else { + LocalContentColor.current.copy(alpha = LocalContentAlpha.current) + }, + ) + }, + singleLine = false, + enabled = false, + colors = TextFieldDefaults.outlinedTextFieldColors( + // hack to enable clickable + disabledTextColor = LocalContentColor.current.copy(LocalContentAlpha.current), + disabledBorderColor = borderColor, + disabledLabelColor = borderColor, + disabledLeadingIconColor = MaterialTheme.colors.onSurface, + ), + modifier = Modifier + .fillMaxWidth() + .let { + if (repos.size > 1) it.clickable(onClick = { expanded = true }) else it + }, + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + repos.iterator().forEach { repo -> + DropdownMenuItem(onClick = { + onRepoChanged(repo) + expanded = false + }) { + RepoItem(repo, repo.repoId == preferredRepoId) + } + } + } + } + if (!isPreferred) { + FDroidOutlineButton( + text = stringResource(R.string.app_details_repository_button_prefer), + onClick = { onPreferredRepoChanged(currentRepo.repoId) }, + modifier = Modifier.align(End).padding(top = 8.dp), + ) + } + } +} + +@Composable +private fun RepoItem(repo: Repository, isPreferred: Boolean, modifier: Modifier = Modifier) { + Row( + horizontalArrangement = spacedBy(8.dp), + verticalAlignment = CenterVertically, + modifier = modifier, + ) { + RepoIcon(repo, Modifier.size(24.dp)) + Text( + text = getRepoString(repo, isPreferred), + style = MaterialTheme.typography.body2, + ) + } +} + +@Composable +private fun getRepoString(repo: Repository, isPreferred: Boolean) = buildAnnotatedString { + append(repo.getName(LocaleListCompat.getDefault()) ?: "Unknown Repository") + if (isPreferred) { + append(" ") + pushStyle(SpanStyle(fontWeight = Bold)) + append(" ") + append(stringResource(R.string.app_details_repository_preferred)) + } +} + +@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/java/org/fdroid/fdroid/views/apps/AppListActivity.java b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java index 0087b59a9..1fbf2b92f 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java @@ -278,6 +278,10 @@ public class AppListActivity extends AppCompatActivity implements CategoryTextWa return 0; }); } + // Hide install button, if showing apps from a specific repo, because then we show repo versions + // and do not respect the preferred repo. + // The user may not be aware of this, so we force going through app details. + appAdapter.setHideInstallButton(repoId > 0); appAdapter.setItems(items); if (items.size() > 0) { emptyState.setVisibility(View.GONE); diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListAdapter.java index 7314bc9e9..516e4d6b3 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListAdapter.java @@ -19,6 +19,7 @@ class AppListAdapter extends RecyclerView.Adapter private final List items = new ArrayList<>(); private Runnable hasHiddenAppsCallback; private final AppCompatActivity activity; + private boolean hideInstallButton = false; AppListAdapter(AppCompatActivity activity) { this.activity = activity; @@ -30,6 +31,10 @@ class AppListAdapter extends RecyclerView.Adapter notifyDataSetChanged(); } + void setHideInstallButton(boolean hide) { + hideInstallButton = hide; + } + void setHasHiddenAppsCallback(Runnable callback) { hasHiddenAppsCallback = callback; } @@ -46,6 +51,7 @@ class AppListAdapter extends RecyclerView.Adapter AppListItem appItem = items.get(position); final App app = new App(appItem); holder.bindModel(app, null, null); + if (hideInstallButton) holder.hideInstallButton(); if (app.isDisabledByAntiFeatures(activity)) { holder.itemView.setVisibility(View.GONE); diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java index 280ac87fe..b8f244f6d 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListItemController.java @@ -28,6 +28,8 @@ import androidx.core.util.Pair; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.recyclerview.widget.RecyclerView; +import com.google.android.material.progressindicator.LinearProgressIndicator; + import org.fdroid.database.AppVersion; import org.fdroid.database.DbUpdateChecker; import org.fdroid.database.FDroidDatabase; @@ -48,8 +50,6 @@ import org.fdroid.fdroid.installer.InstallerFactory; import org.fdroid.fdroid.views.AppDetailsActivity; import org.fdroid.fdroid.views.updates.UpdatesAdapter; -import com.google.android.material.progressindicator.LinearProgressIndicator; - import java.io.File; import java.util.Iterator; import java.util.List; @@ -216,6 +216,10 @@ public abstract class AppListItemController extends RecyclerView.ViewHolder { broadcastManager.registerReceiver(onStatusChanged, intentFilter); } + void hideInstallButton() { + if (installButton != null) installButton.setVisibility(View.GONE); + } + /** * To be overridden if required */ @@ -549,13 +553,15 @@ public abstract class AppListItemController extends RecyclerView.ViewHolder { if (disposable != null) disposable.dispose(); disposable = Utils.runOffUiThread(() -> { AppVersion version = updateChecker.getSuggestedVersion(app.packageName, - app.preferredSigner, releaseChannels); - if (version == null) return null; + app.preferredSigner, releaseChannels, true); + if (version == null) return new Apk(); Repository repo = FDroidApp.getRepoManager(activity).getRepository(version.getRepoId()); - if (repo == null) return null; + if (repo == null) return new Apk(); return new Apk(version, repo); }, receivedApk -> { - if (receivedApk != null) { + if (receivedApk.packageName == null) { + Toast.makeText(activity, R.string.app_list_no_suggested_version, Toast.LENGTH_SHORT).show(); + } else { String canonicalUrl = receivedApk.getCanonicalUrl(); Uri canonicalUri = Uri.parse(canonicalUrl); broadcastManager.registerReceiver(receiver, diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/AddRepoErrorScreen.kt b/app/src/main/java/org/fdroid/fdroid/views/repos/AddRepoErrorScreen.kt index d83101ac1..c3d1180a2 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/AddRepoErrorScreen.kt +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/AddRepoErrorScreen.kt @@ -31,6 +31,7 @@ import org.fdroid.repo.AddRepoError import org.fdroid.repo.AddRepoError.ErrorType.INVALID_FINGERPRINT import org.fdroid.repo.AddRepoError.ErrorType.INVALID_INDEX import org.fdroid.repo.AddRepoError.ErrorType.IO_ERROR +import org.fdroid.repo.AddRepoError.ErrorType.IS_ARCHIVE_REPO import org.fdroid.repo.AddRepoError.ErrorType.UNKNOWN_SOURCES_DISALLOWED import java.io.IOException @@ -62,6 +63,7 @@ fun AddRepoErrorScreen(paddingValues: PaddingValues, state: AddRepoError) { INVALID_INDEX -> stringResource(R.string.repo_invalid) IO_ERROR -> stringResource(R.string.repo_io_error) + IS_ARCHIVE_REPO -> stringResource(R.string.repo_error_adding_archive) } Text( text = title, @@ -110,3 +112,11 @@ fun AddRepoErrorUnknownSourcesPreview() { AddRepoErrorScreen(PaddingValues(0.dp), AddRepoError(UNKNOWN_SOURCES_DISALLOWED)) } } + +@Preview +@Composable +fun AddRepoErrorArchivePreview() { + ComposeUtils.FDroidContent { + AddRepoErrorScreen(PaddingValues(0.dp), AddRepoError(IS_ARCHIVE_REPO)) + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java b/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java index 1693a2d7b..6349c0bb0 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/ManageReposActivity.java @@ -24,14 +24,23 @@ import android.content.Intent; import android.os.Build; import android.os.Bundle; import android.os.UserManager; -import android.widget.Toast; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.NavUtils; import androidx.core.app.TaskStackBuilder; +import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.appbar.MaterialToolbar; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.snackbar.Snackbar; import org.fdroid.database.Repository; import org.fdroid.fdroid.AppUpdateStatusManager; @@ -42,14 +51,65 @@ import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.App; import org.fdroid.index.RepoManager; +import java.util.ArrayList; + import io.reactivex.rxjava3.disposables.CompositeDisposable; public class ManageReposActivity extends AppCompatActivity implements RepoAdapter.RepoItemListener { - public static final String EXTRA_FINISH_AFTER_ADDING_REPO = "finishAfterAddingRepo"; private RepoManager repoManager; private final CompositeDisposable compositeDisposable = new CompositeDisposable(); + private final RepoAdapter repoAdapter = new RepoAdapter(this); + private boolean isItemReorderingEnabled = false; + private final ItemTouchHelper.Callback itemTouchCallback = + new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { + + private int lastFromPos = -1; + private int lastToPos = -1; + + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, + @NonNull RecyclerView.ViewHolder target) { + final int fromPos = viewHolder.getBindingAdapterPosition(); + final int toPos = target.getBindingAdapterPosition(); + repoAdapter.notifyItemMoved(fromPos, toPos); + if (lastFromPos == -1) lastFromPos = fromPos; + lastToPos = toPos; + return true; + } + + @Override + public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState) { + super.onSelectedChanged(viewHolder, actionState); + if (actionState == ItemTouchHelper.ACTION_STATE_IDLE) { + if (lastFromPos != lastToPos) { + Repository repoToMove = repoAdapter.getItem(lastFromPos); + Repository repoDropped = repoAdapter.getItem(lastToPos); + if (repoToMove != null && repoDropped != null) { + // don't allow more re-orderings until this one was completed + isItemReorderingEnabled = false; + repoManager.reorderRepositories(repoToMove, repoDropped); + } else { + Log.w("ManageReposActivity", + "Could not find one of the repos: " + lastFromPos + " to " + lastToPos); + } + } + lastFromPos = -1; + lastToPos = -1; + } + } + + @Override + public boolean isLongPressDragEnabled() { + return isItemReorderingEnabled; + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { + // noop + } + }; @Override protected void onCreate(Bundle savedInstanceState) { @@ -81,9 +141,13 @@ public class ManageReposActivity extends AppCompatActivity implements RepoAdapte }); final RecyclerView repoList = findViewById(R.id.list); - RepoAdapter repoAdapter = new RepoAdapter(this); + final ItemTouchHelper touchHelper = new ItemTouchHelper(itemTouchCallback); + touchHelper.attachToRecyclerView(repoList); repoList.setAdapter(repoAdapter); - FDroidApp.getRepoManager(this).getLiveRepositories().observe(this, repoAdapter::updateItems); + FDroidApp.getRepoManager(this).getLiveRepositories().observe(this, items -> { + repoAdapter.updateItems(new ArrayList<>(items)); // copy list, so we don't modify original in adapter + isItemReorderingEnabled = true; + }); } @Override @@ -119,21 +183,52 @@ public class ManageReposActivity extends AppCompatActivity implements RepoAdapte * update the repos if you toggled on on. */ @Override - public void onSetEnabled(Repository repo, boolean isEnabled) { - if (repo.getEnabled() != isEnabled) { - Utils.runOffUiThread(() -> repoManager.setRepositoryEnabled(repo.getRepoId(), isEnabled)); - - if (isEnabled) { - UpdateService.updateRepoNow(this, repo.getAddress()); - } else { - AppUpdateStatusManager.getInstance(this).removeAllByRepo(repo.getRepoId()); - // RepoProvider.Helper.purgeApps(this, repo); - String notification = getString(R.string.repo_disabled_notification, repo.getName(App.getLocales())); - Toast.makeText(this, notification, Toast.LENGTH_LONG).show(); - } + public void onToggleEnabled(Repository repo) { + if (repo.getEnabled()) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setMessage(R.string.repo_disable_warning); + builder.setPositiveButton(R.string.repo_disable_warning_button, (dialog, id) -> { + disableRepo(repo); + dialog.dismiss(); + }); + builder.setNegativeButton(R.string.cancel, (dialog, id) -> { + repoAdapter.updateRepoItem(repo); + dialog.cancel(); + }); + builder.show(); + } else { + Utils.runOffUiThread(() -> repoManager.setRepositoryEnabled(repo.getRepoId(), true)); + UpdateService.updateRepoNow(this, repo.getAddress()); } } + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.repo_list, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.action_info) { + new MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.repo_list_info_title)) + .setMessage(getString(R.string.repo_list_info_text)) + .setPositiveButton(getString(R.string.ok), (dialog, which) -> dialog.dismiss()) + .show(); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void disableRepo(Repository repo) { + Utils.runOffUiThread(() -> repoManager.setRepositoryEnabled(repo.getRepoId(), false)); + AppUpdateStatusManager.getInstance(this).removeAllByRepo(repo.getRepoId()); + String notification = getString(R.string.repo_disabled_notification, repo.getName(App.getLocales())); + Snackbar.make(findViewById(R.id.list), notification, Snackbar.LENGTH_LONG).setTextMaxLines(3).show(); + } + private static final int SHOW_REPO_DETAILS = 1; private void editRepo(Repository repo) { diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoAdapter.java index 46db4117d..c95dd2ab4 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoAdapter.java @@ -9,6 +9,7 @@ import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.core.os.LocaleListCompat; import androidx.recyclerview.widget.RecyclerView; @@ -22,13 +23,14 @@ import org.fdroid.index.v2.FileV2; import java.util.ArrayList; import java.util.List; +import java.util.ListIterator; public class RepoAdapter extends RecyclerView.Adapter { public interface RepoItemListener { void onClicked(Repository repo); - void onSetEnabled(Repository repo, boolean isEnabled); + void onToggleEnabled(Repository repo); } private final List items = new ArrayList<>(); @@ -38,10 +40,20 @@ public class RepoAdapter extends RecyclerView.Adapter items) { this.items.clear(); + // filter out archive repos + ListIterator iterator = items.listIterator(); + while (iterator.hasNext()) { + if (iterator.next().isArchiveRepo()) iterator.remove(); + } this.items.addAll(items); notifyDataSetChanged(); } @@ -64,6 +76,15 @@ public class RepoAdapter extends RecyclerView.Adapter { - if (repoItemListener != null) { - repoItemListener.onSetEnabled(repo, isChecked); - } + switchView.setOnClickListener(buttonView -> { + if (repoItemListener != null) repoItemListener.onToggleEnabled(repo); }); FileV2 iconFile = repo.getIcon(LocaleListCompat.getDefault()); if (iconFile == null) { diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsActivity.java b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsActivity.java index 85670a9c2..79aecdda6 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsActivity.java @@ -29,8 +29,10 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.SwitchCompat; import androidx.core.app.NavUtils; import androidx.core.content.ContextCompat; +import androidx.lifecycle.ViewModelProvider; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -105,7 +107,10 @@ public class RepoDetailsActivity extends AppCompatActivity { private MirrorAdapter adapterToNotify; + private RepoDetailsViewModel model; + // FIXME access to this could be moved into ViewModel private RepositoryDao repositoryDao; + // FIXME access to this could be moved into ViewModel private AppDao appDao; @Nullable private Disposable disposable; @@ -128,6 +133,7 @@ public class RepoDetailsActivity extends AppCompatActivity { fdroidApp.setSecureWindow(this); fdroidApp.applyPureBlackBackgroundInDarkTheme(this); + model = new ViewModelProvider(this).get(RepoDetailsViewModel.class); repositoryDao = DBHelper.getDb(this).getRepositoryDao(); appDao = DBHelper.getDb(this).getAppDao(); @@ -142,6 +148,7 @@ public class RepoDetailsActivity extends AppCompatActivity { repoView = findViewById(R.id.repo_view); repoId = getIntent().getLongExtra(ARG_REPO_ID, 0); + model.initRepo(repoId); repo = FDroidApp.getRepoManager(this).getRepository(repoId); TextView inputUrl = findViewById(R.id.input_repo_url); @@ -179,6 +186,18 @@ public class RepoDetailsActivity extends AppCompatActivity { qrCode.setImageBitmap(bitmap); } }); + + SwitchCompat switchCompat = findViewById(R.id.archiveRepo); + model.getLiveData().observe(this, s -> { + Boolean enabled = s.getArchiveEnabled(); + if (enabled == null) { + switchCompat.setEnabled(false); + } else { + switchCompat.setEnabled(true); + switchCompat.setChecked(enabled); + } + }); + switchCompat.setOnClickListener(v -> model.setArchiveRepoEnabled(repo, switchCompat.isChecked())); } @Override diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsViewModel.kt b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsViewModel.kt new file mode 100644 index 000000000..42381e393 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoDetailsViewModel.kt @@ -0,0 +1,73 @@ +package org.fdroid.fdroid.views.repos + +import android.app.Application +import android.util.Log +import android.widget.Toast +import android.widget.Toast.LENGTH_SHORT +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import info.guardianproject.netcipher.NetCipher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.fdroid.database.Repository +import org.fdroid.fdroid.FDroidApp +import org.fdroid.fdroid.R +import org.fdroid.fdroid.UpdateService + +data class RepoDetailsState( + val repo: Repository?, + val archiveEnabled: Boolean? = null, +) + +class RepoDetailsViewModel(app: Application) : AndroidViewModel(app) { + + private val repoManager = FDroidApp.getRepoManager(app) + private val _state = MutableStateFlow(null) + val state = _state.asStateFlow() + val liveData = _state.asLiveData() + + fun initRepo(repoId: Long) { + val repo = repoManager.getRepository(repoId) + if (repo == null) { + _state.value = RepoDetailsState(null) + } else { + _state.value = RepoDetailsState( + repo = repo, + archiveEnabled = repo.isArchiveEnabled(), + ) + } + } + + fun setArchiveRepoEnabled(repo: Repository, enabled: Boolean) { + // archiveEnabled = null means we don't know current state, it's in progress + _state.value = _state.value?.copy(archiveEnabled = null) + viewModelScope.launch(Dispatchers.IO) { + try { + repoManager.setArchiveRepoEnabled(repo, enabled, NetCipher.getProxy()) + _state.value = _state.value?.copy(archiveEnabled = enabled) + if (enabled) withContext(Dispatchers.Main) { + val address = repo.address.replace(Regex("repo/?$"), "archive") + UpdateService.updateRepoNow(getApplication(), address) + } + } catch (e: Exception) { + Log.e(this.javaClass.simpleName, "Error toggling archive repo: ", e) + _state.value = _state.value?.copy(archiveEnabled = repo.isArchiveEnabled()) + withContext(Dispatchers.Main) { + Toast.makeText(getApplication(), R.string.repo_archive_failed, LENGTH_SHORT) + .show() + } + } + } + } + + private fun Repository.isArchiveEnabled(): Boolean { + return repoManager.getRepositories().find { r -> + r.isArchiveRepo && r.certificate == certificate + }?.enabled ?: false + } + +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoIconComposable.kt b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoIconComposable.kt new file mode 100644 index 000000000..7da226fc9 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoIconComposable.kt @@ -0,0 +1,35 @@ +package org.fdroid.fdroid.views.repos + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.core.content.res.ResourcesCompat.getDrawable +import androidx.core.os.LocaleListCompat +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.getDownloadRequest + +@Composable +@OptIn(ExperimentalGlideComposeApi::class) +fun RepoIcon(repo: Repository, modifier: Modifier = Modifier) { + if (LocalInspectionMode.current) Image( + painter = rememberDrawablePainter( + getDrawable(LocalContext.current.resources, R.drawable.ic_launcher, null) + ), + contentDescription = null, + modifier = modifier, + ) else GlideImage( + model = getDownloadRequest(repo, repo.getIcon(LocaleListCompat.getDefault())), + contentDescription = null, + modifier = modifier, + ) { requestBuilder -> + requestBuilder + .fallback(R.drawable.ic_repo_app_default) + .error(R.drawable.ic_repo_app_default) + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoPreviewScreen.kt b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoPreviewScreen.kt index dad1ee287..635c7030c 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/repos/RepoPreviewScreen.kt +++ b/app/src/main/java/org/fdroid/fdroid/views/repos/RepoPreviewScreen.kt @@ -89,7 +89,6 @@ fun RepoPreviewScreen(paddingValues: PaddingValues, state: Fetching, onAddRepo: } @Composable -@OptIn(ExperimentalGlideComposeApi::class) fun RepoPreviewHeader( state: Fetching, onAddRepo: () -> Unit, @@ -101,24 +100,11 @@ fun RepoPreviewHeader( modifier = Modifier.fillMaxWidth(), ) { val repo = state.repo ?: error("repo was null") - val res = LocalContext.current.resources Row( horizontalArrangement = spacedBy(8.dp), verticalAlignment = CenterVertically, ) { - if (isPreview) Image( - painter = rememberDrawablePainter( - getDrawable(res, R.drawable.ic_launcher, null) - ), - contentDescription = null, - modifier = Modifier.size(48.dp), - ) else GlideImage( - model = getDownloadRequest(repo, repo.getIcon(localeList)), - contentDescription = null, - modifier = Modifier.size(48.dp), - ) { - it.fallback(R.drawable.ic_repo_app_default).error(R.drawable.ic_repo_app_default) - } + RepoIcon(repo, Modifier.size(48.dp)) Column(horizontalAlignment = Alignment.Start) { Text( text = repo.getName(localeList) ?: "Unknown Repository", @@ -132,7 +118,7 @@ fun RepoPreviewHeader( modifier = Modifier.alpha(ContentAlpha.medium), ) Text( - text = Utils.formatLastUpdated(res, repo.timestamp), + text = Utils.formatLastUpdated(LocalContext.current.resources, repo.timestamp), style = MaterialTheme.typography.body2, ) } diff --git a/app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesAdapter.java index 2e8cb55b9..495536b81 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/updates/UpdatesAdapter.java @@ -112,7 +112,7 @@ public class UpdatesAdapter extends RecyclerView.Adapter releaseChannels = Preferences.get().getBackendReleaseChannels(); if (disposable != null) disposable.dispose(); - disposable = Utils.runOffUiThread(() -> updateChecker.getUpdatableApps(releaseChannels, true), + disposable = Utils.runOffUiThread(() -> updateChecker.getUpdatableApps(releaseChannels, true, true), this::onCanUpdateLoadFinished); } diff --git a/app/src/main/res/drawable/ic_info.xml b/app/src/main/res/drawable/ic_info.xml new file mode 100644 index 000000000..fed2fb116 --- /dev/null +++ b/app/src/main/res/drawable/ic_info.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_repo_details.xml b/app/src/main/res/layout/activity_repo_details.xml index 9f7800ed7..148d87403 100644 --- a/app/src/main/res/layout/activity_repo_details.xml +++ b/app/src/main/res/layout/activity_repo_details.xml @@ -161,6 +161,17 @@ android:layout_height="wrap_content" android:text="@string/repo_edit_credentials" /> + + + + - - - + 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"> + + + + diff --git a/app/src/main/res/layout/app_details2_version_item.xml b/app/src/main/res/layout/app_details2_version_item.xml index 0ab584c26..e58a0a65a 100644 --- a/app/src/main/res/layout/app_details2_version_item.xml +++ b/app/src/main/res/layout/app_details2_version_item.xml @@ -118,16 +118,6 @@ android:layout_marginRight="8dp" android:layout_marginEnd="8dp"> - - - + android:layout_marginHorizontal="8dp" + android:layout_marginVertical="4dp" + android:descendantFocusability="blocksDescendants"> - - + android:orientation="horizontal" + android:padding="8dp"> - + - + android:layout_weight="1" + android:gravity="center_vertical" + android:orientation="vertical" + tools:ignore="RtlSymmetry"> - + - + + + + + + + + + android:layout_height="match_parent" /> - - - + diff --git a/app/src/main/res/menu/repo_list.xml b/app/src/main/res/menu/repo_list.xml new file mode 100644 index 000000000..4c2b7b62e --- /dev/null +++ b/app/src/main/res/menu/repo_list.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/default_repos.xml b/app/src/main/res/values/default_repos.xml index f2dab7308..4b2bcaee1 100644 --- a/app/src/main/res/values/default_repos.xml +++ b/app/src/main/res/values/default_repos.xml @@ -1,8 +1,47 @@ + + + F-Droid + + + https://f-droid.org/repo + http://fdroidorg6cooksyluodepej4erfctzk7rrjpjbbr6wx24jh3lqyfwyd.onion/fdroid/repo + http://dotsrccccbidkzg7oc7oj4ugxrlfbt64qebyunxbrgqhxiwj3nl6vcad.onion/fdroid/repo + http://ftpfaudev4triw2vxiwzf4334e3mynz7osqgtozhbc77fixncqzbyoyd.onion/fdroid/repo + http://lysator7eknrfl47rlyxvgeamrv7ucefgrrlhk7rouv3sna25asetwid.onion/pub/fdroid/repo + http://mirror.ossplanetnyou5xifr6liw5vhzwc2g2fmmlohza25wwgnnaw65ytfsad.onion/fdroid/repo + https://fdroid.tetaneutral.net/fdroid/repo + https://ftp.agdsn.de/fdroid/repo + https://ftp.fau.de/fdroid/repo + https://ftp.gwdg.de/pub/android/fdroid/repo + https://ftp.lysator.liu.se/pub/fdroid/repo + https://mirror.cyberbits.eu/fdroid/repo + https://mirror.fcix.net/fdroid/repo + https://mirror.kumi.systems/fdroid/repo + https://mirror.level66.network/fdroid/repo + https://mirror.ossplanet.net/fdroid/repo + https://mirrors.dotsrc.org/fdroid/repo + https://opencolo.mm.fcix.net/fdroid/repo + https://plug-mirror.rcac.purdue.edu/fdroid/repo + + + The official F-Droid Free Software repository. Everything in this repository is always built from the source code. + + + 13 + + 1 + + ignore + + + 3082035e30820246a00302010202044c49cd00300d06092a864886f70d01010505003071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b73301e170d3130303732333137313032345a170d3337313230383137313032345a3071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b7330820122300d06092a864886f70d01010105000382010f003082010a028201010096d075e47c014e7822c89fd67f795d23203e2a8843f53ba4e6b1bf5f2fd0e225938267cfcae7fbf4fe596346afbaf4070fdb91f66fbcdf2348a3d92430502824f80517b156fab00809bdc8e631bfa9afd42d9045ab5fd6d28d9e140afc1300917b19b7c6c4df4a494cf1f7cb4a63c80d734265d735af9e4f09455f427aa65a53563f87b336ca2c19d244fcbba617ba0b19e56ed34afe0b253ab91e2fdb1271f1b9e3c3232027ed8862a112f0706e234cf236914b939bcf959821ecb2a6c18057e070de3428046d94b175e1d89bd795e535499a091f5bc65a79d539a8d43891ec504058acb28c08393b5718b57600a211e803f4a634e5c57f25b9b8c4422c6fd90203010001300d06092a864886f70d0101050500038201010008e4ef699e9807677ff56753da73efb2390d5ae2c17e4db691d5df7a7b60fc071ae509c5414be7d5da74df2811e83d3668c4a0b1abc84b9fa7d96b4cdf30bba68517ad2a93e233b042972ac0553a4801c9ebe07bf57ebe9a3b3d6d663965260e50f3b8f46db0531761e60340a2bddc3426098397fda54044a17e5244549f9869b460ca5e6e216b6f6a2db0580b480ca2afe6ec6b46eedacfa4aa45038809ece0c5978653d6c85f678e7f5a2156d1bedd8117751e64a4b0dcd140f3040b021821a8d93aed8d01ba36db6c82372211fed714d9a32607038cdfd565bd529ffc637212aaa2c224ef22b603eccefb5bf1e085c191d4b24fe742b17ab3f55d4e6f05ef + + F-Droid Archive @@ -42,44 +81,6 @@ 3082035e30820246a00302010202044c49cd00300d06092a864886f70d01010505003071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b73301e170d3130303732333137313032345a170d3337313230383137313032345a3071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b7330820122300d06092a864886f70d01010105000382010f003082010a028201010096d075e47c014e7822c89fd67f795d23203e2a8843f53ba4e6b1bf5f2fd0e225938267cfcae7fbf4fe596346afbaf4070fdb91f66fbcdf2348a3d92430502824f80517b156fab00809bdc8e631bfa9afd42d9045ab5fd6d28d9e140afc1300917b19b7c6c4df4a494cf1f7cb4a63c80d734265d735af9e4f09455f427aa65a53563f87b336ca2c19d244fcbba617ba0b19e56ed34afe0b253ab91e2fdb1271f1b9e3c3232027ed8862a112f0706e234cf236914b939bcf959821ecb2a6c18057e070de3428046d94b175e1d89bd795e535499a091f5bc65a79d539a8d43891ec504058acb28c08393b5718b57600a211e803f4a634e5c57f25b9b8c4422c6fd90203010001300d06092a864886f70d0101050500038201010008e4ef699e9807677ff56753da73efb2390d5ae2c17e4db691d5df7a7b60fc071ae509c5414be7d5da74df2811e83d3668c4a0b1abc84b9fa7d96b4cdf30bba68517ad2a93e233b042972ac0553a4801c9ebe07bf57ebe9a3b3d6d663965260e50f3b8f46db0531761e60340a2bddc3426098397fda54044a17e5244549f9869b460ca5e6e216b6f6a2db0580b480ca2afe6ec6b46eedacfa4aa45038809ece0c5978653d6c85f678e7f5a2156d1bedd8117751e64a4b0dcd140f3040b021821a8d93aed8d01ba36db6c82372211fed714d9a32607038cdfd565bd529ffc637212aaa2c224ef22b603eccefb5bf1e085c191d4b24fe742b17ab3f55d4e6f05ef - - F-Droid - - - https://f-droid.org/repo - http://fdroidorg6cooksyluodepej4erfctzk7rrjpjbbr6wx24jh3lqyfwyd.onion/fdroid/repo - http://dotsrccccbidkzg7oc7oj4ugxrlfbt64qebyunxbrgqhxiwj3nl6vcad.onion/fdroid/repo - http://ftpfaudev4triw2vxiwzf4334e3mynz7osqgtozhbc77fixncqzbyoyd.onion/fdroid/repo - http://lysator7eknrfl47rlyxvgeamrv7ucefgrrlhk7rouv3sna25asetwid.onion/pub/fdroid/repo - http://mirror.ossplanetnyou5xifr6liw5vhzwc2g2fmmlohza25wwgnnaw65ytfsad.onion/fdroid/repo - https://fdroid.tetaneutral.net/fdroid/repo - https://ftp.agdsn.de/fdroid/repo - https://ftp.fau.de/fdroid/repo - https://ftp.gwdg.de/pub/android/fdroid/repo - https://ftp.lysator.liu.se/pub/fdroid/repo - https://mirror.cyberbits.eu/fdroid/repo - https://mirror.fcix.net/fdroid/repo - https://mirror.kumi.systems/fdroid/repo - https://mirror.level66.network/fdroid/repo - https://mirror.ossplanet.net/fdroid/repo - https://mirrors.dotsrc.org/fdroid/repo - https://opencolo.mm.fcix.net/fdroid/repo - https://plug-mirror.rcac.purdue.edu/fdroid/repo - - - The official F-Droid Free Software repository. Everything in this repository is always built from the source code. - - - 13 - - 1 - - ignore - - - 3082035e30820246a00302010202044c49cd00300d06092a864886f70d01010505003071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b73301e170d3130303732333137313032345a170d3337313230383137313032345a3071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b7330820122300d06092a864886f70d01010105000382010f003082010a028201010096d075e47c014e7822c89fd67f795d23203e2a8843f53ba4e6b1bf5f2fd0e225938267cfcae7fbf4fe596346afbaf4070fdb91f66fbcdf2348a3d92430502824f80517b156fab00809bdc8e631bfa9afd42d9045ab5fd6d28d9e140afc1300917b19b7c6c4df4a494cf1f7cb4a63c80d734265d735af9e4f09455f427aa65a53563f87b336ca2c19d244fcbba617ba0b19e56ed34afe0b253ab91e2fdb1271f1b9e3c3232027ed8862a112f0706e234cf236914b939bcf959821ecb2a6c18057e070de3428046d94b175e1d89bd795e535499a091f5bc65a79d539a8d43891ec504058acb28c08393b5718b57600a211e803f4a634e5c57f25b9b8c4422c6fd90203010001300d06092a864886f70d0101050500038201010008e4ef699e9807677ff56753da73efb2390d5ae2c17e4db691d5df7a7b60fc071ae509c5414be7d5da74df2811e83d3668c4a0b1abc84b9fa7d96b4cdf30bba68517ad2a93e233b042972ac0553a4801c9ebe07bf57ebe9a3b3d6d663965260e50f3b8f46db0531761e60340a2bddc3426098397fda54044a17e5244549f9869b460ca5e6e216b6f6a2db0580b480ca2afe6ec6b46eedacfa4aa45038809ece0c5978653d6c85f678e7f5a2156d1bedd8117751e64a4b0dcd140f3040b021821a8d93aed8d01ba36db6c82372211fed714d9a32607038cdfd565bd529ffc637212aaa2c224ef22b603eccefb5bf1e085c191d4b24fe742b17ab3f55d4e6f05ef - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9f0ca13a0..85fb2e42a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -91,6 +91,21 @@ 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! @@ -126,11 +141,11 @@ This often occurs with apps installed via Google Play or other sources, if they Update File installed to %s F-Droid needs the storage permission to install this to storage. Please allow it on the next screen to proceed with installation. - Repository: %1$s Size: %1$s Could not launch app. Some results were hidden based on your antifeature settings. + No version recommended for installation. Downloading %1$s %1$s installed Downloaded, ready to install @@ -206,6 +221,7 @@ This often occurs with apps installed via Google Play or other sources, if they This is a copy of %1$s, add it as a mirror? Invalid repository.\n\nContact the maintainer and let them know about the issue. Error connecting to the repository. + Archive repositories can not be added directly. Tap the repository in the list and enable the archive there. Could not find repo address in shared text. Bad fingerprint This is not a valid URL. @@ -214,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 @@ -434,10 +449,14 @@ This often occurs with apps installed via Google Play or other sources, if they Recommended only for OLED screens. Unsigned Unverified + Repository List + A repository is a source of apps. This list shows all currently added repositories. Disabled repositories are not used.\n\nIf an app is in more than one repository, the repository higher in the list is automatically preferred. You can reorder repositories by long pressing and dragging them. Repository Address Number of apps Show apps + Repository Archive + Show archived apps and outdated versions of apps Fingerprint of the signing key (SHA-256) Description Last update @@ -453,6 +472,7 @@ This often occurs with apps installed via Google Play or other sources, if they You need to enable it to view the apps it provides. Unknown + Archive repo currently not available Delete Repository? Deleting a repository means apps from it will no longer be available.\n\nNote: All @@ -461,6 +481,8 @@ This often occurs with apps installed via Google Play or other sources, if they Disabled "%1$s".\n\nYou will need to re-enable this repository to install apps from it. + Disabling this repository will remove it as \"preferred\" from any apps you may have manually preferred it. + Disable Saved package repository %1$s. Looking for package repository at\n%1$s diff --git a/app/src/test/java/org/fdroid/fdroid/data/SuggestedVersionTest.java b/app/src/test/java/org/fdroid/fdroid/data/SuggestedVersionTest.java index aab71e7d0..9a9c6c090 100644 --- a/app/src/test/java/org/fdroid/fdroid/data/SuggestedVersionTest.java +++ b/app/src/test/java/org/fdroid/fdroid/data/SuggestedVersionTest.java @@ -135,7 +135,7 @@ public class SuggestedVersionTest { assertEquals("Installed signature on Apk", app.installedSigner, suggestedApk.signer); } assertTrue(app.canAndWantToUpdate(suggestedApk)); - AppPrefs appPrefs = new AppPrefs(app.packageName, 0, Collections.singletonList(releaseChannel)); + AppPrefs appPrefs = new AppPrefs(app.packageName, 0, null, Collections.singletonList(releaseChannel)); assertEquals(hasUpdates, app.hasUpdates(apks, appPrefs)); } } 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 de91a0c36..8c657074b 100644 --- a/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java +++ b/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java @@ -32,7 +32,7 @@ import java.util.List; @RunWith(RobolectricTestRunner.class) public class AppDetailsAdapterTest { - private final AppPrefs appPrefs = new AppPrefs("com.example.app", 0, null); + private final AppPrefs appPrefs = new AppPrefs("com.example.app", 0, null, null); private Context context; @Before @@ -147,6 +147,16 @@ public class AppDetailsAdapterTest { public void launchApk() { } + + @Override + public void onRepoChanged(long repoId) { + + } + + @Override + public void onPreferredRepoChanged(long repoId) { + + } }; } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 869d28066..439ac43ca 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -1514,6 +1514,11 @@ + + + + + diff --git a/libs/database/build.gradle b/libs/database/build.gradle index 75a8afe2a..e6ee2490a 100644 --- a/libs/database/build.gradle +++ b/libs/database/build.gradle @@ -12,6 +12,7 @@ android { defaultConfig { minSdkVersion 21 + targetSdk 33 // relevant for instrumentation tests (targetSdk 21 fails on Android 14) consumerProguardFiles "consumer-rules.pro" javaCompileOptions { @@ -32,9 +33,13 @@ android { sourceSets { androidTest { java.srcDirs += "src/dbTest/java" + // Adds exported schema location as test app assets. + assets.srcDirs += files("$projectDir/schemas".toString()) } test { java.srcDirs += "src/dbTest/java" + // Adds exported schema location as test app assets. + assets.srcDirs += files("$projectDir/schemas".toString()) } } compileOptions { @@ -84,6 +89,7 @@ dependencies { testImplementation 'androidx.test:core:1.5.0' testImplementation 'androidx.test.ext:junit:1.1.5' testImplementation 'androidx.arch.core:core-testing:2.2.0' + testImplementation "androidx.room:room-testing:2.5.2" testImplementation 'org.robolectric:robolectric:4.10.3' testImplementation 'commons-io:commons-io:2.6' testImplementation 'ch.qos.logback:logback-classic:1.4.5' @@ -97,6 +103,7 @@ dependencies { androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.arch.core:core-testing:2.2.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation "androidx.room:room-testing:2.5.2" androidTestImplementation 'commons-io:commons-io:2.6' } diff --git a/libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/2.json b/libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/2.json new file mode 100644 index 000000000..b902276c7 --- /dev/null +++ b/libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/2.json @@ -0,0 +1,1098 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "0f46ee261c488b0d38dae6aa541b57cc", + "entities": [ + { + "tableName": "CoreRepository", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT, `address` TEXT NOT NULL, `webBaseUrl` TEXT, `timestamp` INTEGER NOT NULL, `version` INTEGER, `formatVersion` TEXT, `maxAge` INTEGER, `description` TEXT NOT NULL, `certificate` TEXT)", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "webBaseUrl", + "columnName": "webBaseUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "formatVersion", + "columnName": "formatVersion", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxAge", + "columnName": "maxAge", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate", + "columnName": "certificate", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "repoId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Mirror", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `url` TEXT NOT NULL, `location` TEXT, PRIMARY KEY(`repoId`, `url`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "url" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "AntiFeature", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `id` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, PRIMARY KEY(`repoId`, `id`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "Category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `id` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, PRIMARY KEY(`repoId`, `id`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "ReleaseChannel", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `id` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, PRIMARY KEY(`repoId`, `id`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "RepositoryPreferences", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `lastUpdated` INTEGER, `lastETag` TEXT, `userMirrors` TEXT, `disabledMirrors` TEXT, `username` TEXT, `password` TEXT, PRIMARY KEY(`repoId`))", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastETag", + "columnName": "lastETag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userMirrors", + "columnName": "userMirrors", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "disabledMirrors", + "columnName": "disabledMirrors", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AppMetadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `added` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `name` TEXT, `summary` TEXT, `description` TEXT, `localizedName` TEXT, `localizedSummary` TEXT, `webSite` TEXT, `changelog` TEXT, `license` TEXT, `sourceCode` TEXT, `issueTracker` TEXT, `translation` TEXT, `preferredSigner` TEXT, `video` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorWebSite` TEXT, `authorPhone` TEXT, `donate` TEXT, `liberapayID` TEXT, `liberapay` TEXT, `openCollective` TEXT, `bitcoin` TEXT, `litecoin` TEXT, `flattrID` TEXT, `categories` TEXT, `isCompatible` INTEGER NOT NULL, PRIMARY KEY(`repoId`, `packageName`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "added", + "columnName": "added", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "summary", + "columnName": "summary", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localizedName", + "columnName": "localizedName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localizedSummary", + "columnName": "localizedSummary", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "webSite", + "columnName": "webSite", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "changelog", + "columnName": "changelog", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceCode", + "columnName": "sourceCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "issueTracker", + "columnName": "issueTracker", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "translation", + "columnName": "translation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "preferredSigner", + "columnName": "preferredSigner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "video", + "columnName": "video", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorWebSite", + "columnName": "authorWebSite", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorPhone", + "columnName": "authorPhone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "donate", + "columnName": "donate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liberapayID", + "columnName": "liberapayID", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liberapay", + "columnName": "liberapay", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "openCollective", + "columnName": "openCollective", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitcoin", + "columnName": "bitcoin", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "litecoin", + "columnName": "litecoin", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "flattrID", + "columnName": "flattrID", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isCompatible", + "columnName": "isCompatible", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "packageName" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "AppMetadata", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_BEFORE_UPDATE BEFORE UPDATE ON `AppMetadata` BEGIN DELETE FROM `AppMetadataFts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_BEFORE_DELETE BEFORE DELETE ON `AppMetadata` BEGIN DELETE FROM `AppMetadataFts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_AFTER_UPDATE AFTER UPDATE ON `AppMetadata` BEGIN INSERT INTO `AppMetadataFts`(`docid`, `repoId`, `packageName`, `localizedName`, `localizedSummary`) VALUES (NEW.`rowid`, NEW.`repoId`, NEW.`packageName`, NEW.`localizedName`, NEW.`localizedSummary`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_AFTER_INSERT AFTER INSERT ON `AppMetadata` BEGIN INSERT INTO `AppMetadataFts`(`docid`, `repoId`, `packageName`, `localizedName`, `localizedSummary`) VALUES (NEW.`rowid`, NEW.`repoId`, NEW.`packageName`, NEW.`localizedName`, NEW.`localizedSummary`); END" + ], + "tableName": "AppMetadataFts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `localizedName` TEXT, `localizedSummary` TEXT, content=`AppMetadata`)", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "localizedName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "summary", + "columnName": "localizedSummary", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LocalizedFile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `type` TEXT NOT NULL, `locale` TEXT NOT NULL, `name` TEXT NOT NULL, `sha256` TEXT, `size` INTEGER, `ipfsCidV1` TEXT, PRIMARY KEY(`repoId`, `packageName`, `type`, `locale`), FOREIGN KEY(`repoId`, `packageName`) REFERENCES `AppMetadata`(`repoId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sha256", + "columnName": "sha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ipfsCidV1", + "columnName": "ipfsCidV1", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "packageName", + "type", + "locale" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AppMetadata", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId", + "packageName" + ], + "referencedColumns": [ + "repoId", + "packageName" + ] + } + ] + }, + { + "tableName": "LocalizedFileList", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `type` TEXT NOT NULL, `locale` TEXT NOT NULL, `name` TEXT NOT NULL, `sha256` TEXT, `size` INTEGER, `ipfsCidV1` TEXT, PRIMARY KEY(`repoId`, `packageName`, `type`, `locale`, `name`), FOREIGN KEY(`repoId`, `packageName`) REFERENCES `AppMetadata`(`repoId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sha256", + "columnName": "sha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "ipfsCidV1", + "columnName": "ipfsCidV1", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "packageName", + "type", + "locale", + "name" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AppMetadata", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId", + "packageName" + ], + "referencedColumns": [ + "repoId", + "packageName" + ] + } + ] + }, + { + "tableName": "Version", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `versionId` TEXT NOT NULL, `added` INTEGER NOT NULL, `releaseChannels` TEXT, `antiFeatures` TEXT, `whatsNew` TEXT, `isCompatible` INTEGER NOT NULL, `file_name` TEXT NOT NULL, `file_sha256` TEXT NOT NULL, `file_size` INTEGER, `file_ipfsCidV1` TEXT, `src_name` TEXT, `src_sha256` TEXT, `src_size` INTEGER, `src_ipfsCidV1` TEXT, `manifest_versionName` TEXT NOT NULL, `manifest_versionCode` INTEGER NOT NULL, `manifest_maxSdkVersion` INTEGER, `manifest_nativecode` TEXT, `manifest_features` TEXT, `manifest_usesSdk_minSdkVersion` INTEGER, `manifest_usesSdk_targetSdkVersion` INTEGER, `manifest_signer_sha256` TEXT, `manifest_signer_hasMultipleSigners` INTEGER, PRIMARY KEY(`repoId`, `packageName`, `versionId`), FOREIGN KEY(`repoId`, `packageName`) REFERENCES `AppMetadata`(`repoId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionId", + "columnName": "versionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "added", + "columnName": "added", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseChannels", + "columnName": "releaseChannels", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "antiFeatures", + "columnName": "antiFeatures", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "whatsNew", + "columnName": "whatsNew", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isCompatible", + "columnName": "isCompatible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "file.name", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "file.sha256", + "columnName": "file_sha256", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "file.size", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "file.ipfsCidV1", + "columnName": "file_ipfsCidV1", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "src.name", + "columnName": "src_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "src.sha256", + "columnName": "src_sha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "src.size", + "columnName": "src_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "src.ipfsCidV1", + "columnName": "src_ipfsCidV1", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "manifest.versionName", + "columnName": "manifest_versionName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manifest.versionCode", + "columnName": "manifest_versionCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manifest.maxSdkVersion", + "columnName": "manifest_maxSdkVersion", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "manifest.nativecode", + "columnName": "manifest_nativecode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "manifest.features", + "columnName": "manifest_features", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "manifest.usesSdk.minSdkVersion", + "columnName": "manifest_usesSdk_minSdkVersion", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "manifest.usesSdk.targetSdkVersion", + "columnName": "manifest_usesSdk_targetSdkVersion", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "manifest.signer.sha256", + "columnName": "manifest_signer_sha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "manifest.signer.hasMultipleSigners", + "columnName": "manifest_signer_hasMultipleSigners", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "packageName", + "versionId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AppMetadata", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId", + "packageName" + ], + "referencedColumns": [ + "repoId", + "packageName" + ] + } + ] + }, + { + "tableName": "VersionedString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `versionId` TEXT NOT NULL, `type` TEXT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER, PRIMARY KEY(`repoId`, `packageName`, `versionId`, `type`, `name`), FOREIGN KEY(`repoId`, `packageName`, `versionId`) REFERENCES `Version`(`repoId`, `packageName`, `versionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionId", + "columnName": "versionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repoId", + "packageName", + "versionId", + "type", + "name" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Version", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId", + "packageName", + "versionId" + ], + "referencedColumns": [ + "repoId", + "packageName", + "versionId" + ] + } + ] + }, + { + "tableName": "AppPrefs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `ignoreVersionCodeUpdate` INTEGER NOT NULL, `preferredRepoId` INTEGER, `appPrefReleaseChannels` TEXT, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ignoreVersionCodeUpdate", + "columnName": "ignoreVersionCodeUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "preferredRepoId", + "columnName": "preferredRepoId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "appPrefReleaseChannels", + "columnName": "appPrefReleaseChannels", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "LocalizedIcon", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM LocalizedFile WHERE type='icon'" + }, + { + "viewName": "HighestVersion", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT repoId, packageName, antiFeatures FROM Version\n GROUP BY repoId, packageName HAVING MAX(manifest_versionCode)" + }, + { + "viewName": "PreferredRepo", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT packageName, repoId AS preferredRepoId FROM AppMetadata\n JOIN RepositoryPreferences AS pref USING (repoId)\n LEFT JOIN AppPrefs USING (packageName)\n WHERE repoId = COALESCE(preferredRepoId, repoId)\n GROUP BY packageName HAVING MAX(pref.weight)" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0f46ee261c488b0d38dae6aa541b57cc')" + ] + } +} \ No newline at end of file diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt index d825b6a83..0c1ebe25a 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt @@ -2,6 +2,7 @@ package org.fdroid.database import androidx.core.os.LocaleListCompat import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fdroid.database.TestUtils.getOrAwaitValue import org.fdroid.database.TestUtils.getOrFail import org.fdroid.database.TestUtils.toMetadataV2 import org.fdroid.test.TestRepoUtils.getRandomRepo @@ -10,6 +11,7 @@ import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2 import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.test.fail @@ -33,9 +35,9 @@ internal class AppDaoTest : AppTest() { @Test fun testGetSameAppFromTwoRepos() { // insert same app into three repos (repoId1 has highest weight) - val repoId2 = repoDao.insertOrReplace(getRandomRepo()) - val repoId3 = repoDao.insertOrReplace(getRandomRepo()) val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId1, packageName, app1, locales) appDao.insert(repoId2, packageName, app2, locales) appDao.insert(repoId3, packageName, app3, locales) @@ -62,11 +64,37 @@ internal class AppDaoTest : AppTest() { assertEquals(0, appDao.countLocalizedFileLists()) } + @Test + fun testAppRepoPref() { + // insert same app into three repos (repoId1 has highest weight) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageName, app1, locales) + appDao.insert(repoId2, packageName, app2, locales) + appDao.insert(repoId3, packageName, app3, locales) + + // app from repo with highest weight is returned, if no prefs are set + assertEquals(app1, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) + + // prefer repo3 for this app + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId3)) + assertEquals(app3, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) + + // prefer repo1 for this app + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId1)) + assertEquals(app1, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) + + // preferring non-existent repo for this app makes query return nothing (avoid this!) + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = 1337L)) + assertNull(appDao.getApp(packageName).getOrAwaitValue()) + } + @Test fun testGetSameAppFromTwoReposOneDisabled() { // insert same app into two repos (repoId2 has highest weight) - val repoId1 = repoDao.insertOrReplace(getRandomRepo()) val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId1, packageName, app1, locales) appDao.insert(repoId2, packageName, app2, locales) @@ -80,6 +108,26 @@ internal class AppDaoTest : AppTest() { assertEquals(app1, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) } + @Test + fun testGetRepositoryIdsForApp() { + // initially, the app is in no repos + assertEquals(emptyList(), appDao.getRepositoryIdsForApp(packageName)) + + // insert same app into one repo + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageName, app1, locales) + assertEquals(listOf(repoId1), appDao.getRepositoryIdsForApp(packageName)) + + // insert the app into one more repo + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId2, packageName, app2, locales) + assertEquals(listOf(repoId1, repoId2), appDao.getRepositoryIdsForApp(packageName)) + + // when repo1 is disabled, it doesn't get returned anymore + repoDao.setRepositoryEnabled(repoId1, false) + assertEquals(listOf(repoId2), appDao.getRepositoryIdsForApp(packageName)) + } + @Test fun testUpdateCompatibility() { // insert two apps with one version each @@ -149,6 +197,13 @@ internal class AppDaoTest : AppTest() { assertEquals(3, appDao.getNumberOfAppsInCategory("A")) assertEquals(2, appDao.getNumberOfAppsInCategory("B")) assertEquals(0, appDao.getNumberOfAppsInCategory("C")) + + // app1 as a variant of app2 in another repo will show one more app in B + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId2, packageName2, app1, locales) + assertEquals(3, appDao.getNumberOfAppsInCategory("A")) + assertEquals(3, appDao.getNumberOfAppsInCategory("B")) + assertEquals(0, appDao.getNumberOfAppsInCategory("C")) } @Test diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt index a989cc401..f06839e93 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt @@ -25,6 +25,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.test.fail +@Suppress("DEPRECATION") @RunWith(AndroidJUnit4::class) internal class AppListItemsTest : AppTest() { @@ -48,7 +49,6 @@ internal class AppListItemsTest : AppTest() { appDao.insert(repoId, packageName2, app2, locales) // one of the apps is installed - @Suppress("DEPRECATION") val packageInfo2 = PackageInfo().apply { packageName = packageName2 versionName = getRandomString() @@ -108,7 +108,6 @@ internal class AppListItemsTest : AppTest() { appDao.insert(repoId, packageName2, app2, locales) // one of the apps is installed - @Suppress("DEPRECATION") val packageInfo2 = PackageInfo().apply { packageName = packageName2 versionName = getRandomString() @@ -177,7 +176,6 @@ internal class AppListItemsTest : AppTest() { appDao.insert(repoId3, packageName3, app3b, locales) // one of the apps is installed - @Suppress("DEPRECATION") val packageInfo2 = PackageInfo().apply { packageName = packageName2 versionName = getRandomString() @@ -306,7 +304,6 @@ internal class AppListItemsTest : AppTest() { appDao.insert(repoId, packageName2, app2, locales) // one of the apps is installed - @Suppress("DEPRECATION") val packageInfo2 = PackageInfo().apply { packageName = packageName2 versionName = getRandomString() @@ -429,9 +426,9 @@ internal class AppListItemsTest : AppTest() { @Test fun testFromRepoWithHighestWeight() { // insert same app into three repos (repoId1 has highest weight) - val repoId2 = repoDao.insertOrReplace(getRandomRepo()) - val repoId3 = repoDao.insertOrReplace(getRandomRepo()) val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId2, packageName, app2, locales) appDao.insert(repoId1, packageName, app1, locales) appDao.insert(repoId3, packageName, app3, locales) @@ -451,6 +448,48 @@ internal class AppListItemsTest : AppTest() { } } + @Test + fun testFromRepoFromAppPrefs() { + // insert same app into three repos (repoId1 has highest weight) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId2, packageName, app2, locales) + appDao.insert(repoId1, packageName, app1, locales) + appDao.insert(repoId3, packageName, app3, locales) + + // app from repo1 with highest weight gets returned + getItems { apps -> + assertEquals(1, apps.size) + assertEquals(packageName, apps[0].packageName) + assertEquals(app1, apps[0]) + } + + // prefer repo3 for this app + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId3)) + getItems { apps -> + assertEquals(1, apps.size) + assertEquals(packageName, apps[0].packageName) + assertEquals(app3, apps[0]) + } + + // prefer repo2 for this app + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId2)) + getItems { apps -> + assertEquals(1, apps.size) + assertEquals(packageName, apps[0].packageName) + assertEquals(app2, apps[0]) + } + + // prefer repo1 for this app + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId1)) + getItems { apps -> + assertEquals(1, apps.size) + assertEquals(packageName, apps[0].packageName) + assertEquals(app1, apps[0]) + } + } + @Test fun testOnlyFromGivenCategories() { // insert three apps @@ -476,6 +515,16 @@ internal class AppListItemsTest : AppTest() { ).forEach { apps -> assertEquals(0, apps.size) } + + // we'll add app1 as a variant of app2, so it should be in category B as well + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId2, packageName2, app1, locales) + listOf( + appDao.getAppListItemsByName("B").getOrFail(), + appDao.getAppListItemsByLastUpdated("B").getOrFail(), + ).forEach { apps -> + assertEquals(3, apps.size) // all apps are in B now + } } @Test @@ -487,7 +536,6 @@ internal class AppListItemsTest : AppTest() { appDao.insert(repoId, packageName3, app3, locales) // define packageInfo for each test - @Suppress("DEPRECATION") val packageInfo1 = PackageInfo().apply { packageName = packageName1 versionName = getRandomString() @@ -529,7 +577,6 @@ internal class AppListItemsTest : AppTest() { appDao.insert(repoId, packageName, app1, locales) val packageInfoCreator = { name: String -> - @Suppress("DEPRECATION") PackageInfo().apply { packageName = name versionName = name diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt index ad7aa0c72..b99234465 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt @@ -71,7 +71,14 @@ internal class AppOverviewItemsTest : AppTest() { val repoId2 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId2, packageName, app2, locales) - // now icon is returned from app in second repo + // app is still returned as before + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1.icon.getBestLocale(locales), apps[0].getIcon(locales)) + } + + // after preferring second repo, icon is returned from app in second repo + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId2)) appDao.getAppOverviewItems().getOrFail().let { apps -> assertEquals(1, apps.size) assertEquals(app2.icon.getBestLocale(locales), apps[0].getIcon(locales)) @@ -152,13 +159,77 @@ internal class AppOverviewItemsTest : AppTest() { val repoId2 = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId2, packageName, app2, locales) - // now second app from second repo is returned + // app is still returned as before, new repo doesn't override old one + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1, apps[0]) + } + + // now second app from second repo is returned after preferring it explicitly + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId2)) appDao.getAppOverviewItems().getOrFail().let { apps -> assertEquals(1, apps.size) assertEquals(app2, apps[0]) } } + @Test + fun testGetByRepoPref() { + // insert same app into three repos (repoId1 has highest weight) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageName, app1, locales) + appDao.insert(repoId2, packageName, app2, locales) + appDao.insert(repoId3, packageName, app3, locales) + + // app is returned correctly from repo1 + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1, apps[0]) + } + appDao.getAppOverviewItems("A").getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1, apps[0]) + } + + // prefer repo3 for this app + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId3)) + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app3, apps[0]) + } + appDao.getAppOverviewItems("B").getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app3, apps[0]) + } + + // prefer repo2 for this app + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId2)) + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app2, apps[0]) + } + appDao.getAppOverviewItems("A").getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app2, apps[0]) + } + appDao.getAppOverviewItems("B").getOrFail().let { apps -> + assertEquals(0, apps.size) // app2 is not in category B + } + + // prefer repo1 for this app + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId1)) + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1, apps[0]) + } + appDao.getAppOverviewItems("A").getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1, apps[0]) + } + } + @Test fun testSortOrder() { // insert two apps with one version each @@ -198,8 +269,10 @@ internal class AppOverviewItemsTest : AppTest() { assertEquals(3, appDao.getAppOverviewItems().getOrFail().size) // app3b is the same as app3, but has an icon, so is not last anymore + // after we prefer that repo for this app val app3b = app3.copy(icon = icons2) appDao.insert(repoId2, packageName3, app3b) + appPrefsDao.update(AppPrefs(packageName3, preferredRepoId = repoId2)) // note that we don't insert a version here appDao.getAppOverviewItems().getOrFail().let { apps -> assertEquals(3, apps.size) @@ -250,9 +323,10 @@ internal class AppOverviewItemsTest : AppTest() { // note that we don't insert a version here assertEquals(3, appDao.getAppOverviewItems("A").getOrFail().size) - // app3b is the same as app3, but has an icon, so is not last anymore + // app3b is the same as app3, but has an icon and is preferred, so is not last anymore val app3b = app3.copy(icon = icons2) appDao.insert(repoId2, packageName3, app3b) + appPrefsDao.update(AppPrefs(packageName3, preferredRepoId = repoId2)) // note that we don't insert a version here appDao.getAppOverviewItems("A").getOrFail().let { apps -> assertEquals(3, apps.size) diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppPrefsDaoTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppPrefsDaoTest.kt new file mode 100644 index 000000000..da767aa8b --- /dev/null +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppPrefsDaoTest.kt @@ -0,0 +1,114 @@ +package org.fdroid.database + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fdroid.database.TestUtils.getOrFail +import org.fdroid.database.TestUtils.toMetadataV2 +import org.fdroid.test.TestRepoUtils.getRandomRepo +import org.fdroid.test.TestUtils.sort +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +internal class AppPrefsDaoTest : AppTest() { + + @Test + fun testDisablingPreferredRepo() { + // insert same app into three repos (repoId3 has highest weight) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageName, app1, locales) + appDao.insert(repoId2, packageName, app2, locales) + appDao.insert(repoId2, packageName, app3, locales) + + // app from preferred repo gets returned + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId1)) + assertEquals(app1, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) + + // preferred repo gets disabled + repoDao.setRepositoryEnabled(repoId1, false) + + // now app from repo with highest weight is returned + assertEquals(app3, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) + } + + @Test + fun testRemovingPreferredRepo() { + // insert same app into three repos (repoId3 has highest weight) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageName, app1, locales) + appDao.insert(repoId2, packageName, app2, locales) + appDao.insert(repoId2, packageName, app3, locales) + + // app from preferred repo gets returned + appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId1)) + assertEquals(app1, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) + + // preferred repo gets removed + repoDao.deleteRepository(repoId1) + + // now app from repo with highest weight is returned + assertEquals(app3, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) + } + + @Test + fun testGetPreferredRepos() { + // insert three apps, the third is in repo2 and repo3 + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageName1, app1, locales) + appDao.insert(repoId2, packageName2, app2, locales) + appDao.insert(repoId2, packageName3, app3, locales) + appDao.insert(repoId3, packageName3, app3, locales) + + // app1 and app2 are only in one repo, so that one is preferred + appPrefsDao.getPreferredRepos(listOf(packageName1, packageName2)).also { preferredRepos -> + assertEquals(2, preferredRepos.size) + assertEquals(repoId1, preferredRepos[packageName1]) + assertEquals(repoId2, preferredRepos[packageName2]) + } + + // preference only based on global repo priority/weight (3>2>1) + appPrefsDao.getPreferredRepos(listOf(packageName3, packageName2)).also { preferredRepos -> + assertEquals(2, preferredRepos.size) + assertEquals(repoId2, preferredRepos[packageName2]) + assertEquals(repoId3, preferredRepos[packageName3]) + } + + // now app3 prefers repo2 explicitly + appPrefsDao.update(AppPrefs(packageName3, preferredRepoId = repoId2)) + appPrefsDao.getPreferredRepos(listOf(packageName3)).also { preferredRepos -> + assertEquals(1, preferredRepos.size) + assertEquals(repoId2, preferredRepos[packageName3]) + } + + // app3 moves back to preferring repo3 and query for non-existent package name as well + appPrefsDao.update(AppPrefs(packageName3, preferredRepoId = repoId3)) + appPrefsDao.getPreferredRepos(listOf(packageName, packageName3)).also { preferredRepos -> + assertEquals(1, preferredRepos.size) + assertEquals(repoId3, preferredRepos[packageName3]) + } + } + + @Test + fun getGetPreferredReposHandlesMaxVariableNumber() { + // sqlite has a maximum number of 999 variables that can be used in a query + val packagesOk = MutableList(998) { "" } + listOf(packageName) + val packagesNotOk1 = MutableList(1000) { "" } + listOf(packageName) + val packagesNotOk2 = MutableList(5000) { "" } + listOf(packageName) + + // insert same app in three repos + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName, app1, locales) + appDao.insert(repoId, packageName, app2, locales) + appDao.insert(repoId, packageName, app3, locales) + + // preferred repos are returned as expected for all lists, no matter their size + assertEquals(1, appPrefsDao.getPreferredRepos(packagesOk).size) + assertEquals(1, appPrefsDao.getPreferredRepos(packagesNotOk1).size) + assertEquals(1, appPrefsDao.getPreferredRepos(packagesNotOk2).size) + } + +} diff --git a/libs/database/src/dbTest/java/org/fdroid/database/AppTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppTest.kt index bbaf51cda..056e8fc45 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/AppTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/AppTest.kt @@ -1,17 +1,12 @@ package org.fdroid.database -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import org.fdroid.test.TestAppUtils.getRandomMetadataV2 import org.fdroid.test.TestRepoUtils.getRandomFileV2 import org.fdroid.test.TestUtils.getRandomString import org.fdroid.test.TestUtils.sort -import org.junit.Rule internal abstract class AppTest : DbTest() { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - protected val packageName = getRandomString() protected val packageName1 = getRandomString() protected val packageName2 = getRandomString() diff --git a/libs/database/src/dbTest/java/org/fdroid/database/DbTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/DbTest.kt index 63baa388c..4f96668e3 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/DbTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/DbTest.kt @@ -3,6 +3,7 @@ package org.fdroid.database import android.content.Context import android.content.res.AssetManager import android.os.Build +import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.core.os.LocaleListCompat import androidx.room.Room import androidx.test.core.app.ApplicationProvider.getApplicationContext @@ -10,6 +11,7 @@ import io.mockk.every import io.mockk.mockkObject import kotlinx.coroutines.Dispatchers import org.fdroid.database.TestUtils.assertRepoEquals +import org.fdroid.database.TestUtils.getOrFail import org.fdroid.database.TestUtils.toMetadataV2 import org.fdroid.database.TestUtils.toPackageVersionV2 import org.fdroid.index.v1.IndexV1StreamProcessor @@ -21,6 +23,7 @@ import org.fdroid.test.VerifierConstants.CERTIFICATE import org.junit.After import org.junit.Assume.assumeTrue import org.junit.Before +import org.junit.Rule import java.io.IOException import java.util.Locale import kotlin.test.assertEquals @@ -28,6 +31,9 @@ import kotlin.test.fail internal abstract class DbTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + internal lateinit var repoDao: RepositoryDaoInt internal lateinit var appDao: AppDaoInt internal lateinit var appPrefsDao: AppPrefsDaoInt @@ -111,7 +117,7 @@ internal abstract class DbTest { packageV2.metadata, appDao.getApp(repoId, packageName)?.toMetadataV2()?.sort() ) - val versions = versionDao.getAppVersions(repoId, packageName).map { + val versions = versionDao.getAppVersions(repoId, packageName).getOrFail().map { it.toPackageVersionV2() }.associateBy { it.file.sha256 } assertEquals(packageV2.versions.size, versions.size, "number of versions") diff --git a/libs/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt index 864bb493b..b846c3b90 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt @@ -2,10 +2,12 @@ package org.fdroid.database import android.content.pm.PackageInfo import android.content.pm.PackageManager +import android.content.pm.PackageManager.NameNotFoundException import androidx.test.ext.junit.runners.AndroidJUnit4 import io.mockk.every import io.mockk.mockk import org.fdroid.index.RELEASE_CHANNEL_BETA +import org.fdroid.index.v2.PackageVersionV2 import org.fdroid.index.v2.SignerV2 import org.fdroid.test.TestDataMidV2 import org.fdroid.test.TestDataMinV2 @@ -15,17 +17,21 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue +@Suppress("DEPRECATION") @RunWith(AndroidJUnit4::class) internal class DbUpdateCheckerTest : AppTest() { private lateinit var updateChecker: DbUpdateChecker private val packageManager: PackageManager = mockk() + private val compatChecker: (PackageVersionV2) -> Boolean = { true } private val packageInfo = PackageInfo().apply { packageName = TestDataMinV2.packageName - @Suppress("DEPRECATION") versionCode = 0 } @@ -120,12 +126,90 @@ internal class DbUpdateCheckerTest : AppTest() { // if signer of second version is preferred second version is suggested as update assertEquals( version2, - updateChecker.getSuggestedVersion(packageName, + updateChecker.getSuggestedVersion( + packageName = packageName, preferredSigner = signer2.sha256[0] )?.version, ) } + @Test + fun testSuggestedVersionOnlyFromPreferredRepo() { + // insert the same app into two repos + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageName, app1, locales) + appDao.insert(repoId2, packageName, app2, locales) + + // every app has a compatible version + val packageVersion1 = mapOf( + "1" to getRandomPackageVersionV2(2, null).copy(releaseChannels = emptyList()) + ) + val packageVersion2 = mapOf( + "2" to getRandomPackageVersionV2(1, null).copy(releaseChannels = emptyList()) + ) + versionDao.insert(repoId1, packageName, packageVersion1, compatChecker) + versionDao.insert(repoId2, packageName, packageVersion2, compatChecker) + + // nothing is installed + every { + packageManager.getPackageInfo(packageName, any()) + } throws NameNotFoundException() + + // without preferring repos, version with highest version code gets returned + updateChecker.getSuggestedVersion(packageName).also { appVersion -> + assertNotNull(appVersion) + assertEquals(repoId1, appVersion.repoId) + assertEquals(2, appVersion.manifest.versionCode) + } + + // now we want versions only from preferred repo and get the one with highest weight + updateChecker.getSuggestedVersion(packageName, onlyFromPreferredRepo = true) + .also { appVersion -> + assertNotNull(appVersion) + assertEquals(repoId2, appVersion.repoId) + assertEquals(1, appVersion.manifest.versionCode) + } + + // now we allow all repos, but explicitly prefer repo 1, getting same result as above + appPrefsDao.update(AppPrefs(packageInfo.packageName, preferredRepoId = repoId1)) + updateChecker.getSuggestedVersion(packageName).also { appVersion -> + assertNotNull(appVersion) + assertEquals(repoId1, appVersion.repoId) + assertEquals(2, appVersion.manifest.versionCode) + } + + // now we prefer repo 2 and only want versions from preferred repo + appPrefsDao.update(AppPrefs(packageInfo.packageName, preferredRepoId = repoId2)) + updateChecker.getSuggestedVersion(packageName, onlyFromPreferredRepo = true) + .also { appVersion -> + assertNotNull(appVersion) + assertEquals(repoId2, appVersion.repoId) + assertEquals(1, appVersion.manifest.versionCode) + } + + // now we have version 1 already installed + every { + packageManager.getPackageInfo(packageName, any()) + } returns PackageInfo().apply { + packageName = this@DbUpdateCheckerTest.packageName + versionCode = 1 + } + + // preferred repos don't have suggested versions + updateChecker.getSuggestedVersion(packageName, onlyFromPreferredRepo = true) + .also { appVersion -> + assertNull(appVersion) + } + + // but other repos still have + updateChecker.getSuggestedVersion(packageName).also { appVersion -> + assertNotNull(appVersion) + assertEquals(repoId1, appVersion.repoId) + assertEquals(2, appVersion.manifest.versionCode) + } + } + @Test fun testGetUpdatableApps() { streamIndexV2IntoDb("index-min-v2.json") @@ -138,4 +222,67 @@ internal class DbUpdateCheckerTest : AppTest() { assertEquals(TestDataMinV2.version.file.sha256, appVersions[0].update.version.versionId) } + @Test + fun testGetUpdatableAppsOnlyFromPreferredRepo() { + // insert the same app into three repos + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageInfo.packageName, app1, locales) + appDao.insert(repoId2, packageInfo.packageName, app2, locales) + appDao.insert(repoId3, packageInfo.packageName, app3, locales) + + // every app has a compatible update (versionCode greater than 0) + val packageVersion1 = mapOf( + "1" to getRandomPackageVersionV2(13, null).copy(releaseChannels = emptyList()) + ) + val packageVersion2 = mapOf( + "2" to getRandomPackageVersionV2(12, null).copy(releaseChannels = emptyList()) + ) + val packageVersion3 = mapOf( + "3" to getRandomPackageVersionV2(10, null).copy(releaseChannels = emptyList()) + ) + versionDao.insert(repoId1, packageInfo.packageName, packageVersion1, compatChecker) + versionDao.insert(repoId2, packageInfo.packageName, packageVersion2, compatChecker) + versionDao.insert(repoId3, packageInfo.packageName, packageVersion3, compatChecker) + + // app is installed with version code 0 + assertEquals(0, packageInfo.versionCode) + every { packageManager.getInstalledPackages(any()) } returns listOf(packageInfo) + + // without preferring repos, version with highest version code gets returned + updateChecker.getUpdatableApps().also { appVersions -> + assertEquals(1, appVersions.size) + assertEquals(repoId1, appVersions[0].repoId) + assertEquals(13, appVersions[0].update.manifest.versionCode) + assertFalse(appVersions[0].isFromPreferredRepo) // preferred repo is 3 per weight + } + + // now we want versions only from preferred repo and get the one with highest weight + updateChecker.getUpdatableApps(onlyFromPreferredRepo = true).also { appVersions -> + assertEquals(1, appVersions.size) + assertEquals(repoId3, appVersions[0].repoId) + assertEquals(10, appVersions[0].update.manifest.versionCode) + assertTrue(appVersions[0].isFromPreferredRepo) // preferred repo is 3 due to weight + } + + // now we allow all repos, but explicitly prefer repo 1, isFromPreferredRepo becomes true + appPrefsDao.update(AppPrefs(packageInfo.packageName, preferredRepoId = repoId1)) + updateChecker.getUpdatableApps().also { appVersions -> + assertEquals(1, appVersions.size) + assertEquals(repoId1, appVersions[0].repoId) + assertEquals(13, appVersions[0].update.manifest.versionCode) + assertTrue(appVersions[0].isFromPreferredRepo) // preferred repo is 1 now + } + + // now we prefer repo 2 and only want versions from preferred repo + appPrefsDao.update(AppPrefs(packageInfo.packageName, preferredRepoId = repoId2)) + updateChecker.getUpdatableApps(onlyFromPreferredRepo = true).also { appVersions -> + assertEquals(1, appVersions.size) + assertEquals(repoId2, appVersions[0].repoId) + assertEquals(12, appVersions[0].update.manifest.versionCode) + assertTrue(appVersions[0].isFromPreferredRepo) // preferred repo is 2 now + } + } + } diff --git a/libs/database/src/dbTest/java/org/fdroid/database/MultiRepoMigrationTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/MultiRepoMigrationTest.kt new file mode 100644 index 000000000..c5b89696e --- /dev/null +++ b/libs/database/src/dbTest/java/org/fdroid/database/MultiRepoMigrationTest.kt @@ -0,0 +1,284 @@ +package org.fdroid.database + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase.CONFLICT_FAIL +import androidx.room.Room.databaseBuilder +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.fdroid.database.Converters.localizedTextV2toString +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.fail + +private const val TEST_DB = "migration-test" + +@RunWith(AndroidJUnit4::class) +internal class MultiRepoMigrationTest { + + @get:Rule + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + FDroidDatabaseInt::class.java, + listOf(MultiRepoMigration()), + FrameworkSQLiteOpenHelperFactory(), + ) + + private val fdroidArchiveRepo = InitialRepository( + name = "F-Droid Archive", + address = "https://f-droid.org/archive", + description = "The archive repository of the F-Droid client. " + + "This contains older versions of\n" + + "applications from the main repository.", + certificate = "3082035e30820246a00302010202044c49cd00300d06092a864886f70d010105", + version = 13L, + enabled = false, + weight = 0, // gets set later + ) + private val fdroidRepo = InitialRepository( + name = "F-Droid", + address = "https://f-droid.org/repo", + description = "The official F-Droid Free Software repository. " + + "Everything in this repository is always built from the source code.", + certificate = "3082035e30820246a00302010202044c49cd00300d06092a864886f70d010105", + version = 13L, + enabled = true, + weight = 0, // gets set later + ) + private val guardianArchiveRepo = InitialRepository( + name = "Guardian Project Archive", + address = "https://guardianproject.info/fdroid/archive", + description = "The official repository of The Guardian Project apps" + + " for use with F-Droid client. This\n" + + " contains older versions of applications from the main repository.\n", + certificate = "308205d8308203c0020900a397b4da7ecda034300d06092a864886f70d010105", + version = 13L, + enabled = false, + weight = 0, // gets set later + ) + private val guardianRepo = InitialRepository( + name = "Guardian Project", + address = "https://guardianproject.info/fdroid/repo", + description = "The official app repository of The Guardian Project. " + + "Applications in this repository\n" + + " are official binaries build by the original application developers " + + "and signed by the\n" + + " same key as the APKs that are released in the Google Play store.", + certificate = "308205d8308203c0020900a397b4da7ecda034300d06092a864886f70d010105", + version = 13L, + enabled = false, + weight = 0, // gets set later + ) + + @Test + fun migrateDefaultRepos() { + val reposToMigrate = listOf( + fdroidArchiveRepo.copy(weight = 1), + fdroidRepo.copy(weight = 2), + ) + runRepoMigration(reposToMigrate) { db -> + db.getRepositoryDao().getRepositories().sortedByDescending { it.weight }.also { repos -> + assertEquals(reposToMigrate.size, repos.size) + assertEquals(reposToMigrate.size, repos.map { it.weight }.toSet().size) + assertEquals(fdroidRepo.address, repos[0].address) + assertEquals(1_000_000_000, repos[0].weight) + assertEquals(fdroidArchiveRepo.address, repos[1].address) + assertEquals(999_999_999, repos[1].weight) + } + } + } + + @Test + fun migrateOldDefaultRepos() { + val reposToMigrate = listOf( + fdroidArchiveRepo.copy(weight = 1), + fdroidRepo.copy(weight = 2), + guardianArchiveRepo.copy(weight = 3), + guardianRepo.copy(weight = 4), + ) + runRepoMigration(reposToMigrate) { db -> + db.getRepositoryDao().getRepositories().sortedByDescending { it.weight }.also { repos -> + assertEquals(reposToMigrate.size, repos.size) + assertEquals(reposToMigrate.size, repos.map { it.weight }.toSet().size) + assertEquals(fdroidRepo.address, repos[0].address) + assertEquals(1_000_000_000, repos[0].weight) + assertEquals(fdroidArchiveRepo.address, repos[1].address) + assertEquals(999_999_999, repos[1].weight) + assertEquals(guardianRepo.address, repos[2].address) + assertEquals(999_999_998, repos[2].weight) + assertEquals(guardianArchiveRepo.address, repos[3].address) + assertEquals(999_999_997, repos[3].weight) + } + } + } + + @Test + fun migrateOldDefaultReposPlusRandomOnes() { + val reposToMigrate = listOf( + fdroidArchiveRepo.copy(weight = 1), + fdroidRepo.copy(weight = 2), + guardianArchiveRepo.copy(weight = 3), + guardianRepo.copy(weight = 4), + InitialRepository( + name = "Foo bar", + address = "https://example.org/fdroid/repo", + description = "foo bar repo", + certificate = "1234567890", + version = 0L, + enabled = true, + weight = 5, + ), + InitialRepository( + name = "Bla Blub", + address = "https://example.com/fdroid/repo", + description = "bla blub repo", + certificate = "0987654321", + version = 0L, + enabled = true, + weight = 6, + ), + ) + runRepoMigration(reposToMigrate) { db -> + db.getRepositoryDao().getRepositories().sortedByDescending { it.weight }.also { repos -> + assertEquals(reposToMigrate.size, repos.size) + assertEquals(reposToMigrate.size, repos.map { it.weight }.toSet().size) + assertEquals(fdroidRepo.address, repos[0].address) + assertEquals(1_000_000_000, repos[0].weight) + assertEquals(fdroidArchiveRepo.address, repos[1].address) + assertEquals(999_999_999, repos[1].weight) + assertEquals(guardianRepo.address, repos[2].address) + assertEquals(999_999_998, repos[2].weight) + assertEquals(guardianArchiveRepo.address, repos[3].address) + assertEquals(999_999_997, repos[3].weight) + assertEquals("https://example.org/fdroid/repo", repos[4].address) + assertEquals(999_999_996, repos[4].weight) + assertEquals("https://example.com/fdroid/repo", repos[5].address) + assertEquals(999_999_994, repos[5].weight) // space for archive above + } + } + } + + @Test + fun migrateArchiveWithoutMainRepo() { + val reposToMigrate = listOf( + InitialRepository( + name = "Foo bar", + address = "https://example.org/fdroid/repo", + description = "foo bar repo", + certificate = "1234567890", + version = 0L, + enabled = true, + weight = 2, + ), + fdroidArchiveRepo.copy(weight = 5), + guardianRepo.copy(weight = 6), + ) + runRepoMigration(reposToMigrate) { db -> + db.getRepositoryDao().getRepositories().sortedByDescending { it.weight }.also { repos -> + assertEquals(reposToMigrate.size, repos.size) + assertEquals(reposToMigrate.size, repos.map { it.weight }.toSet().size) + assertEquals("https://example.org/fdroid/repo", repos[0].address) + assertEquals(1_000_000_000, repos[0].weight) + assertEquals(guardianRepo.address, repos[1].address) + assertEquals(999_999_998, repos[1].weight) // space for archive above + assertEquals(fdroidArchiveRepo.address, repos[2].address) + assertEquals(999_999_996, repos[2].weight) // space for archive above + } + } + } + + @Test + fun testPreferredRepoChanges() { + var repoId: Long + val packageName = "org.example" + helper.createDatabase(TEST_DB, 1).use { db -> + // Database has schema version 1. Insert some data using SQL queries. + // We can't use DAO classes because they expect the latest schema. + val repo = fdroidRepo + repoId = db.insert(CoreRepository.TABLE, CONFLICT_FAIL, ContentValues().apply { + put("name", localizedTextV2toString(mapOf("en-US" to repo.name))) + put("description", localizedTextV2toString(mapOf("en-US" to repo.description))) + put("address", repo.address) + put("timestamp", -1) + put("certificate", repo.certificate) + }) + db.insert(RepositoryPreferences.TABLE, CONFLICT_FAIL, ContentValues().apply { + put("repoId", repoId) + put("enabled", repo.enabled) + put("weight", repo.weight) + }) + // insert an app with empty app prefs + db.insert(AppMetadata.TABLE, CONFLICT_FAIL, ContentValues().apply { + put("repoId", repoId) + put("packageName", packageName) + put("added", 23L) + put("lastUpdated", 42L) + put("isCompatible", true) + }) + db.insert(AppPrefs.TABLE, CONFLICT_FAIL, ContentValues().apply { + put("packageName", packageName) + put("ignoreVersionCodeUpdate", 0) + }) + } + + // Re-open the database with version 2, auto-migrations are applied automatically + helper.runMigrationsAndValidate(TEST_DB, 2, true).close() + + // now get the Room DB, so we can use our DAOs for verifying the migration + databaseBuilder(getApplicationContext(), FDroidDatabaseInt::class.java, TEST_DB) + .allowMainThreadQueries() + .build() + .use { db -> + // migrated apps have no preferred repo set + assertNotNull(db.getAppDao().getApp(repoId, packageName)) + val appPrefs = db.getAppPrefsDao().getAppPrefsOrNull(packageName) ?: fail() + assertEquals(packageName, appPrefs.packageName) + assertNull(appPrefs.preferredRepoId) + + // preferred repo inferred from repo priorities + val preferredRepos = db.getAppPrefsDao().getPreferredRepos(listOf(packageName)) + assertEquals(1, preferredRepos.size) + assertEquals(repoId, preferredRepos[packageName]) + } + } + + private fun runRepoMigration( + repos: List, + check: (FDroidDatabaseInt) -> Unit, + ) { + helper.createDatabase(TEST_DB, 1).use { db -> + // Database has schema version 1. Insert some data using SQL queries. + // We can't use DAO classes because they expect the latest schema. + repos.forEach { repo -> + val repoId = db.insert(CoreRepository.TABLE, CONFLICT_FAIL, ContentValues().apply { + put("name", localizedTextV2toString(mapOf("en-US" to repo.name))) + put("description", localizedTextV2toString(mapOf("en-US" to repo.description))) + put("address", repo.address) + put("timestamp", -1) + put("certificate", repo.certificate) + }) + db.insert(RepositoryPreferences.TABLE, CONFLICT_FAIL, ContentValues().apply { + put("repoId", repoId) + put("enabled", repo.enabled) + put("weight", repo.weight) + }) + } + } + + // Re-open the database with version 2, auto-migrations are applied automatically + helper.runMigrationsAndValidate(TEST_DB, 2, true).close() + + // now get the Room DB, so we can use our DAOs for verifying the migration + databaseBuilder(getApplicationContext(), FDroidDatabaseInt::class.java, TEST_DB) + .allowMainThreadQueries() + .build().use { db -> + check(db) + } + } +} diff --git a/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt index f041fd2a7..2084cc8a3 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt @@ -1,6 +1,5 @@ package org.fdroid.database -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fdroid.database.TestUtils.assertRepoEquals import org.fdroid.database.TestUtils.getOrFail @@ -9,11 +8,11 @@ import org.fdroid.test.TestRepoUtils.getRandomRepo import org.fdroid.test.TestUtils.getRandomString import org.fdroid.test.TestUtils.orNull import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2 -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import kotlin.random.Random import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -22,9 +21,6 @@ import kotlin.test.fail @RunWith(AndroidJUnit4::class) internal class RepositoryDaoTest : DbTest() { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - @Test fun testInsertInitialRepository() { val repo = InitialRepository( @@ -35,7 +31,6 @@ internal class RepositoryDaoTest : DbTest() { certificate = "abcdef", // not random, because format gets checked version = Random.nextLong(), enabled = Random.nextBoolean(), - weight = Random.nextInt(), ) val repoId = repoDao.insert(repo) @@ -46,7 +41,7 @@ internal class RepositoryDaoTest : DbTest() { assertEquals(repo.certificate, actualRepo.certificate) assertEquals(repo.version, actualRepo.version) assertEquals(repo.enabled, actualRepo.enabled) - assertEquals(repo.weight, actualRepo.weight) + assertEquals(Int.MAX_VALUE - 2, actualRepo.weight) // ignoring provided weight assertEquals(-1, actualRepo.timestamp) assertEquals(3, actualRepo.mirrors.size) assertEquals(emptyList(), actualRepo.userMirrors) @@ -115,7 +110,7 @@ internal class RepositoryDaoTest : DbTest() { val repositoryPreferences2 = repoDao.getRepositoryPreferences(repoId2) assertEquals(repoId2, repositoryPreferences2?.repoId) // second repo has one weight point more than first repo - assertEquals(repositoryPreferences1?.weight?.plus(1), repositoryPreferences2?.weight) + assertEquals(repositoryPreferences1?.weight?.minus(2), repositoryPreferences2?.weight) // remove first repo and check that the database only returns one repoDao.deleteRepository(repoId1) @@ -255,7 +250,7 @@ internal class RepositoryDaoTest : DbTest() { // data is there as expected assertEquals(1, repoDao.getRepositories().size) assertEquals(1, appDao.getAppMetadata().size) - assertEquals(1, versionDao.getAppVersions(repoId, packageName).size) + assertEquals(1, versionDao.getAppVersions(repoId, packageName).getOrFail().size) assertTrue(versionDao.getVersionedStrings(repoId, packageName).isNotEmpty()) // clearing the repo removes apps and versions @@ -264,7 +259,7 @@ internal class RepositoryDaoTest : DbTest() { assertEquals(0, appDao.countApps()) assertEquals(0, appDao.countLocalizedFiles()) assertEquals(0, appDao.countLocalizedFileLists()) - assertEquals(0, versionDao.getAppVersions(repoId, packageName).size) + assertEquals(0, versionDao.getAppVersions(repoId, packageName).getOrFail().size) assertEquals(0, versionDao.getVersionedStrings(repoId, packageName).size) // preferences are not touched by clearing assertEquals(repositoryPreferences, repoDao.getRepositoryPreferences(repoId)) @@ -282,4 +277,118 @@ internal class RepositoryDaoTest : DbTest() { assertEquals(1, repoDao.getRepositories().size) assertEquals(cert, repoDao.getRepositories()[0].certificate) } + + @Test + fun testGetMinRepositoryWeight() { + assertEquals(Int.MAX_VALUE, repoDao.getMinRepositoryWeight()) + + repoDao.insertOrReplace(getRandomRepo()) + assertEquals(Int.MAX_VALUE - 2, repoDao.getMinRepositoryWeight()) + + repoDao.insertOrReplace(getRandomRepo()) + assertEquals(Int.MAX_VALUE - 4, repoDao.getMinRepositoryWeight()) + } + + @Test + fun testReorderRepositories() { + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId4 = repoDao.insertOrReplace(getRandomRepo()) + val repoId5 = repoDao.insertOrReplace(getRandomRepo()) + + // repos are listed in the order they entered the DB [1, 2, 3, 4, 5] + assertEquals( + listOf(repoId1, repoId2, repoId3, repoId4, repoId5), + repoDao.getRepositories().map { it.repoId }, + ) + + // 2 gets moved to 5 [1, 3, 4, 5, 2] + repoDao.reorderRepositories( + repoToReorder = repoDao.getRepository(repoId2) ?: fail(), + repoTarget = repoDao.getRepository(repoId5) ?: fail(), + ) + assertEquals( + listOf(repoId1, repoId3, repoId4, repoId5, repoId2), + repoDao.getRepositories().map { it.repoId }, + ) + + // 5 gets moved to 1 [5, 1, 3, 4, 2] + repoDao.reorderRepositories( + repoToReorder = repoDao.getRepository(repoId5) ?: fail(), + repoTarget = repoDao.getRepository(repoId1) ?: fail(), + ) + assertEquals( + listOf(repoId5, repoId1, repoId3, repoId4, repoId2), + repoDao.getRepositories().map { it.repoId }, + ) + + // 3 gets moved to 5 [3, 5, 1, 4, 2] + repoDao.reorderRepositories( + repoToReorder = repoDao.getRepository(repoId3) ?: fail(), + repoTarget = repoDao.getRepository(repoId5) ?: fail(), + ) + assertEquals( + listOf(repoId3, repoId5, repoId1, repoId4, repoId2), + repoDao.getRepositories().map { it.repoId }, + ) + + // 3 gets moved to itself, list shouldn't change [3, 5, 1, 4, 2] + repoDao.reorderRepositories( + repoToReorder = repoDao.getRepository(repoId3) ?: fail(), + repoTarget = repoDao.getRepository(repoId3) ?: fail(), + ) + assertEquals( + listOf(repoId3, repoId5, repoId1, repoId4, repoId2), + repoDao.getRepositories().map { it.repoId }, + ) + + // we'll add an archive repo for repo1 to the list [3, 5, (1, 1a), 4, 2] + repoDao.updateRepository(repoId1, "1234abcd") + val repo1 = repoDao.getRepository(repoId1) ?: fail() + val repo1a = InitialRepository( + name = getRandomString(), + address = "https://example.org/archive", + description = getRandomString(), + certificate = repo1.certificate ?: fail(), + version = 42L, + enabled = false, + ) + val repoId1a = repoDao.insert(repo1a) + repoDao.setWeight(repoId1a, repo1.weight - 1) + + // now we move repo 1 to position of repo 2 [3, 5, 4, 2, (1, 1a)] + repoDao.reorderRepositories( + repoToReorder = repoDao.getRepository(repoId1) ?: fail(), + repoTarget = repoDao.getRepository(repoId2) ?: fail(), + ) + assertEquals( + listOf(repoId3, repoId5, repoId4, repoId2, repoId1, repoId1a), + repoDao.getRepositories().map { it.repoId }, + ) + + // now move repo 1 and its archive to top position [(1, 1a), 3, 5, 4, 2] + repoDao.reorderRepositories( + repoToReorder = repoDao.getRepository(repoId1) ?: fail(), + repoTarget = repoDao.getRepository(repoId3) ?: fail(), + ) + assertEquals( + listOf(repoId1, repoId1a, repoId3, repoId5, repoId4, repoId2), + repoDao.getRepositories().map { it.repoId }, + ) + + // archive repos can't be reordered directly + assertFailsWith { + repoDao.reorderRepositories( + repoToReorder = repoDao.getRepository(repoId1a) ?: fail(), + repoTarget = repoDao.getRepository(repoId3) ?: fail(), + ) + } + assertFailsWith { + repoDao.reorderRepositories( + repoToReorder = repoDao.getRepository(repoId3) ?: fail(), + repoTarget = repoDao.getRepository(repoId1a) ?: fail(), + ) + } + } } diff --git a/libs/database/src/dbTest/java/org/fdroid/database/VersionTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/VersionTest.kt index 8599948a1..bbf2b3174 100644 --- a/libs/database/src/dbTest/java/org/fdroid/database/VersionTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/database/VersionTest.kt @@ -1,6 +1,5 @@ package org.fdroid.database -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fdroid.database.TestUtils.getOrFail import org.fdroid.index.v2.PackageVersionV2 @@ -8,7 +7,6 @@ import org.fdroid.test.TestAppUtils.getRandomMetadataV2 import org.fdroid.test.TestRepoUtils.getRandomRepo import org.fdroid.test.TestUtils.getRandomString import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2 -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import kotlin.random.Random @@ -18,9 +16,6 @@ import kotlin.test.fail @RunWith(AndroidJUnit4::class) internal class VersionTest : DbTest() { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - private val packageName = getRandomString() private val packageVersion1 = getRandomPackageVersionV2() private val packageVersion2 = getRandomPackageVersionV2() @@ -35,6 +30,22 @@ internal class VersionTest : DbTest() { versionId2 to packageVersion2, ) + private fun getAppVersion1(repoId: Long): AppVersion { + val version = getVersion1(repoId) + return AppVersion( + version = version, + versionedStrings = packageVersion1.manifest.getVersionedStrings(version), + ) + } + + private fun getAppVersion2(repoId: Long): AppVersion { + val version = getVersion2(repoId) + return AppVersion( + version = version, + versionedStrings = packageVersion2.manifest.getVersionedStrings(version), + ) + } + private fun getVersion1(repoId: Long) = packageVersion1.toVersion(repoId, packageName, versionId1, isCompatible1) @@ -55,25 +66,22 @@ internal class VersionTest : DbTest() { appDao.insert(repoId, packageName, getRandomMetadataV2()) versionDao.insert(repoId, packageName, versionId1, packageVersion1, isCompatible1) - val appVersions = versionDao.getAppVersions(repoId, packageName) + val appVersions = versionDao.getAppVersions(repoId, packageName).getOrFail() assertEquals(1, appVersions.size) - val appVersion = appVersions[0] - assertEquals(versionId1, appVersion.version.versionId) - assertEquals(getVersion1(repoId), appVersion.version) - val manifest = packageVersion1.manifest - assertEquals(manifest.usesPermission.toSet(), appVersion.usesPermission.toSet()) - assertEquals(manifest.usesPermissionSdk23.toSet(), appVersion.usesPermissionSdk23.toSet()) - assertEquals( - manifest.features.map { it.name }.toSet(), - appVersion.version.manifest.features?.toSet() - ) + assertEquals(getAppVersion1(repoId), appVersions[0]) + val manifest = packageVersion1.manifest val versionedStrings = versionDao.getVersionedStrings(repoId, packageName) val expectedSize = manifest.usesPermission.size + manifest.usesPermissionSdk23.size assertEquals(expectedSize, versionedStrings.size) + // getting version by repo produces same result + val versionsByRepo = versionDao.getAppVersions(repoId, packageName).getOrFail() + assertEquals(1, versionsByRepo.size) + assertEquals(getAppVersion1(repoId), versionsByRepo[0]) + versionDao.deleteAppVersion(repoId, packageName, versionId1) - assertEquals(0, versionDao.getAppVersions(repoId, packageName).size) + assertEquals(0, versionDao.getAppVersions(repoId, packageName).getOrFail().size) assertEquals(0, versionDao.getVersionedStrings(repoId, packageName).size) } @@ -86,39 +94,29 @@ internal class VersionTest : DbTest() { versionDao.insert(repoId, packageName, versionId2, packageVersion2, isCompatible2) // get app versions from DB and assign them correctly - val appVersions = versionDao.getAppVersions(packageName).getOrFail() - assertEquals(2, appVersions.size) - val appVersion = if (versionId1 == appVersions[0].version.versionId) { - appVersions[0] - } else appVersions[1] - val appVersion2 = if (versionId2 == appVersions[0].version.versionId) { - appVersions[0] - } else appVersions[1] + listOf( + versionDao.getAppVersions(packageName).getOrFail(), + versionDao.getAppVersions(repoId, packageName).getOrFail(), + ).forEach { appVersions -> + assertEquals(2, appVersions.size) + val appVersion = if (versionId1 == appVersions[0].version.versionId) { + appVersions[0] + } else appVersions[1] + val appVersion2 = if (versionId2 == appVersions[0].version.versionId) { + appVersions[0] + } else appVersions[1] - // check first version matches - assertEquals(getVersion1(repoId), appVersion.version) - val manifest = packageVersion1.manifest - assertEquals(manifest.usesPermission.toSet(), appVersion.usesPermission.toSet()) - assertEquals(manifest.usesPermissionSdk23.toSet(), appVersion.usesPermissionSdk23.toSet()) - assertEquals( - manifest.features.map { it.name }.toSet(), - appVersion.version.manifest.features?.toSet() - ) + // check first version matches + assertEquals(getAppVersion1(repoId), appVersion) - // check second version matches - assertEquals(getVersion2(repoId), appVersion2.version) - val manifest2 = packageVersion2.manifest - assertEquals(manifest2.usesPermission.toSet(), appVersion2.usesPermission.toSet()) - assertEquals(manifest2.usesPermissionSdk23.toSet(), - appVersion2.usesPermissionSdk23.toSet()) - assertEquals( - manifest.features.map { it.name }.toSet(), - appVersion.version.manifest.features?.toSet() - ) + // check second version matches + assertEquals(getAppVersion2(repoId), appVersion2) + } // delete app and check that all associated data also gets deleted appDao.deleteAppMetadata(repoId, packageName) - assertEquals(0, versionDao.getAppVersions(repoId, packageName).size) + assertEquals(0, versionDao.getAppVersions(packageName).getOrFail().size) + assertEquals(0, versionDao.getAppVersions(repoId, packageName).getOrFail().size) assertEquals(0, versionDao.getVersionedStrings(repoId, packageName).size) } @@ -138,6 +136,10 @@ internal class VersionTest : DbTest() { assertEquals(3, versionDao.getAppVersions(packageName).getOrFail().size) assertEquals(3, versionDao.getVersions(listOf(packageName)).size) + // query by repo only returns the versions from each repo + assertEquals(2, versionDao.getAppVersions(repoId, packageName).getOrFail().size) + assertEquals(1, versionDao.getAppVersions(repoId2, packageName).getOrFail().size) + // disable second repo repoDao.setRepositoryEnabled(repoId2, false) @@ -155,8 +157,10 @@ internal class VersionTest : DbTest() { versionDao.insert(repoId, packageName, versionId3, packageVersion3, true) val versions1 = versionDao.getAppVersions(packageName).getOrFail() val versions2 = versionDao.getVersions(listOf(packageName)) + val versions3 = versionDao.getAppVersions(repoId, packageName).getOrFail() assertEquals(3, versions1.size) assertEquals(3, versions2.size) + assertEquals(3, versions3.size) // check that they are sorted as expected listOf( @@ -166,6 +170,7 @@ internal class VersionTest : DbTest() { ).sortedDescending().forEachIndexed { i, versionCode -> assertEquals(versionCode, versions1[i].version.manifest.versionCode) assertEquals(versionCode, versions2[i].versionCode) + assertEquals(versionCode, versions3[i].version.manifest.versionCode) } } diff --git a/libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt b/libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt index cd8079bfb..167c416c0 100644 --- a/libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt @@ -2,7 +2,6 @@ package org.fdroid.index.v1 import android.Manifest import android.net.Uri -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 import io.mockk.Runs import io.mockk.every @@ -39,9 +38,6 @@ internal class IndexV1UpdaterTest : DbTest() { @get:Rule var tmpFolder: TemporaryFolder = TemporaryFolder() - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - private val tempFileProvider: TempFileProvider = mockk() private val downloaderFactory: DownloaderFactory = mockk() private val downloader: Downloader = mockk() diff --git a/libs/database/src/main/java/org/fdroid/database/App.kt b/libs/database/src/main/java/org/fdroid/database/App.kt index d75edbc2a..58ec6f82a 100644 --- a/libs/database/src/main/java/org/fdroid/database/App.kt +++ b/libs/database/src/main/java/org/fdroid/database/App.kt @@ -334,6 +334,7 @@ public data class UpdatableApp internal constructor( public override val packageName: String, public val installedVersionCode: Long, public val update: AppVersion, + public val isFromPreferredRepo: Boolean, /** * If true, this is not necessarily an update (contrary to the class name), * but an app with the `KnownVuln` anti-feature. diff --git a/libs/database/src/main/java/org/fdroid/database/AppDao.kt b/libs/database/src/main/java/org/fdroid/database/AppDao.kt index 5f5e17c37..a5ada080a 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppDao.kt @@ -70,6 +70,12 @@ public interface AppDao { */ public fun getApp(repoId: Long, packageName: String): App? + /** + * Returns a list of all enabled repositories identified by their [Repository.repoId] + * that contain the app identified by the given [packageName]. + */ + public fun getRepositoryIdsForApp(packageName: String): List + /** * Returns a limited number of apps with limited data. * Apps without name, icon or summary are at the end (or excluded if limit is too small). @@ -305,7 +311,9 @@ internal interface AppDaoInt : AppDao { @Transaction @Query("""SELECT ${AppMetadata.TABLE}.* FROM ${AppMetadata.TABLE} JOIN RepositoryPreferences AS pref USING (repoId) - WHERE packageName = :packageName AND pref.enabled = 1 + LEFT JOIN AppPrefs USING (packageName) + WHERE packageName = :packageName AND pref.enabled = 1 AND + COALESCE(preferredRepoId, repoId) = repoId ORDER BY pref.weight DESC LIMIT 1""") override fun getApp(packageName: String): LiveData @@ -314,6 +322,11 @@ internal interface AppDaoInt : AppDao { WHERE repoId = :repoId AND packageName = :packageName""") override fun getApp(repoId: Long, packageName: String): App? + @Query("""SELECT repoId FROM ${AppMetadata.TABLE} + JOIN RepositoryPreferences AS pref USING (repoId) + WHERE pref.enabled = 1 AND packageName = :packageName""") + override fun getRepositoryIdsForApp(packageName: String): List + /** * Used for diffing. */ @@ -341,7 +354,8 @@ internal interface AppDaoInt : AppDao { JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) LEFT JOIN ${LocalizedIcon.TABLE} AS icon USING (repoId, packageName) - WHERE pref.enabled = 1 + LEFT JOIN ${AppPrefs.TABLE} USING (packageName) + WHERE pref.enabled = 1 AND COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight) ORDER BY localizedName IS NULL ASC, icon.packageName IS NULL ASC, localizedSummary IS NULL ASC, app.lastUpdated DESC @@ -355,7 +369,9 @@ internal interface AppDaoInt : AppDao { JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) LEFT JOIN ${LocalizedIcon.TABLE} AS icon USING (repoId, packageName) - WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' + LEFT JOIN AppPrefs USING (packageName) + WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND + COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight) ORDER BY localizedName IS NULL ASC, icon.packageName IS NULL ASC, localizedSummary IS NULL ASC, app.lastUpdated DESC @@ -440,8 +456,10 @@ internal interface AppDaoInt : AppDao { FROM ${AppMetadata.TABLE} AS app JOIN ${AppMetadataFts.TABLE} USING (repoId, packageName) LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) + LEFT JOIN AppPrefs USING (packageName) JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) - WHERE pref.enabled = 1 AND ${AppMetadataFts.TABLE} MATCH :searchQuery + WHERE pref.enabled = 1 AND ${AppMetadataFts.TABLE} MATCH :searchQuery AND + COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight)""") fun getAppListItems(searchQuery: String): LiveData> @@ -455,9 +473,11 @@ internal interface AppDaoInt : AppDao { FROM ${AppMetadata.TABLE} AS app JOIN ${AppMetadataFts.TABLE} USING (repoId, packageName) LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) + LEFT JOIN AppPrefs USING (packageName) JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND - ${AppMetadataFts.TABLE} MATCH :searchQuery + ${AppMetadataFts.TABLE} MATCH :searchQuery AND + COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight)""") fun getAppListItems(category: String, searchQuery: String): LiveData> @@ -485,8 +505,9 @@ internal interface AppDaoInt : AppDao { version.antiFeatures, app.isCompatible, app.preferredSigner FROM ${AppMetadata.TABLE} AS app LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) + LEFT JOIN AppPrefs USING (packageName) JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) - WHERE pref.enabled = 1 + WHERE pref.enabled = 1 AND COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight) ORDER BY localizedName COLLATE NOCASE ASC""") fun getAppListItemsByName(): LiveData> @@ -498,7 +519,8 @@ internal interface AppDaoInt : AppDao { FROM ${AppMetadata.TABLE} AS app JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) - WHERE pref.enabled = 1 + LEFT JOIN AppPrefs USING (packageName) + WHERE pref.enabled = 1 AND COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight) ORDER BY app.lastUpdated DESC""") fun getAppListItemsByLastUpdated(): LiveData> @@ -510,7 +532,9 @@ internal interface AppDaoInt : AppDao { FROM ${AppMetadata.TABLE} AS app JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) - WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' + LEFT JOIN AppPrefs USING (packageName) + WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND + COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight) ORDER BY app.lastUpdated DESC""") fun getAppListItemsByLastUpdated(category: String): LiveData> @@ -522,7 +546,9 @@ internal interface AppDaoInt : AppDao { FROM ${AppMetadata.TABLE} AS app JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) - WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' + LEFT JOIN AppPrefs USING (packageName) + WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND + COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight) ORDER BY localizedName COLLATE NOCASE ASC""") fun getAppListItemsByName(category: String): LiveData> @@ -556,7 +582,9 @@ internal interface AppDaoInt : AppDao { app.isCompatible, app.preferredSigner FROM ${AppMetadata.TABLE} AS app JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) - WHERE pref.enabled = 1 AND packageName IN (:packageNames) + LEFT JOIN AppPrefs USING (packageName) + WHERE pref.enabled = 1 AND packageName IN (:packageNames) AND + COALESCE(preferredRepoId, repoId) = repoId GROUP BY packageName HAVING MAX(pref.weight) ORDER BY localizedName COLLATE NOCASE ASC""") fun getAppListItems(packageNames: List): LiveData> diff --git a/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt b/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt index 3b08bdc09..1cf34bb3f 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt @@ -1,5 +1,6 @@ package org.fdroid.database +import androidx.room.DatabaseView import androidx.room.Entity import androidx.room.PrimaryKey import org.fdroid.PackagePreference @@ -13,6 +14,7 @@ public data class AppPrefs( @PrimaryKey val packageName: String, override val ignoreVersionCodeUpdate: Long = 0, + val preferredRepoId: Long? = null, // This is named like this, because it hit a Room bug when joining with Version table // which had exactly the same field. internal val appPrefReleaseChannels: List? = null, @@ -53,3 +55,13 @@ public data class AppPrefs( }, ) } + +@DatabaseView("""SELECT packageName, repoId AS preferredRepoId FROM ${AppMetadata.TABLE} + JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) + LEFT JOIN ${AppPrefs.TABLE} USING (packageName) + WHERE repoId = COALESCE(preferredRepoId, repoId) + GROUP BY packageName HAVING MAX(pref.weight)""") +internal class PreferredRepo( + val packageName: String, + val preferredRepoId: Long, +) diff --git a/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt b/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt index d14c09913..d6eb2e357 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import androidx.room.Dao import androidx.room.Insert +import androidx.room.MapInfo import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query @@ -28,6 +29,23 @@ internal interface AppPrefsDaoInt : AppPrefsDao { @Query("SELECT * FROM ${AppPrefs.TABLE} WHERE packageName = :packageName") fun getAppPrefsOrNull(packageName: String): AppPrefs? + fun getPreferredRepos(packageNames: List): Map { + return if (packageNames.size <= 999) getPreferredReposInternal(packageNames) + else HashMap(packageNames.size).also { map -> + packageNames.chunked(999).forEach { map.putAll(getPreferredReposInternal(it)) } + } + } + + /** + * Use [getPreferredRepos] instead as this handles more than 1000 package names. + */ + @MapInfo(keyColumn = "packageName", valueColumn = "preferredRepoId") + @Query( + """SELECT packageName, preferredRepoId FROM PreferredRepo + WHERE packageName IN (:packageNames)""" + ) + fun getPreferredReposInternal(packageNames: List): Map + @Insert(onConflict = REPLACE) override fun update(appPrefs: AppPrefs) } diff --git a/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt b/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt index 470979a78..b763e92be 100644 --- a/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt +++ b/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt @@ -4,7 +4,7 @@ import android.annotation.SuppressLint import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.PackageManager.GET_SIGNATURES -import androidx.core.content.pm.PackageInfoCompat +import androidx.core.content.pm.PackageInfoCompat.getLongVersionCode import org.fdroid.CompatibilityChecker import org.fdroid.CompatibilityCheckerImpl import org.fdroid.PackagePreference @@ -25,10 +25,15 @@ public class DbUpdateChecker @JvmOverloads constructor( * Returns a list of apps that can be updated. * @param releaseChannels optional list of release channels to consider on top of stable. * If this is null or empty, only versions without channel (stable) will be considered. + * @param onlyFromPreferredRepo if true updates coming from repositories that are not preferred, + * either via [AppPrefs.preferredRepoId] or [Repository.weight] will not be returned. + * If false, updates from all enabled repositories will be considered + * and the one with the highest version code returned. */ @JvmOverloads public fun getUpdatableApps( releaseChannels: List? = null, + onlyFromPreferredRepo: Boolean = false, includeKnownVulnerabilities: Boolean = false, ): List { val updatableApps = ArrayList() @@ -36,8 +41,14 @@ public class DbUpdateChecker @JvmOverloads constructor( @Suppress("DEPRECATION") // we'll use this as long as it works, new one was broken val installedPackages = packageManager.getInstalledPackages(GET_SIGNATURES) val packageNames = installedPackages.map { it.packageName } + val preferredRepos = appPrefsDao.getPreferredRepos(packageNames) + val versionsByPackage = HashMap>(packageNames.size) versionDao.getVersions(packageNames).forEach { version -> + val preferredRepoId = preferredRepos[version.packageName] + ?: error { "No preferred repo for ${version.packageName}" } + // disregard version, if we only want from preferred repo and this version is not + if (onlyFromPreferredRepo && preferredRepoId != version.repoId) return@forEach val list = versionsByPackage.getOrPut(version.packageName) { ArrayList() } list.add(version) } @@ -53,8 +64,13 @@ public class DbUpdateChecker @JvmOverloads constructor( includeKnownVulnerabilities = includeKnownVulnerabilities, ) if (version != null) { - val versionCode = PackageInfoCompat.getLongVersionCode(packageInfo) - val app = getUpdatableApp(version, versionCode) + val preferredRepoId = preferredRepos[packageName] + ?: error { "No preferred repo for $packageName" } + val app = getUpdatableApp( + version = version, + installedVersionCode = getLongVersionCode(packageInfo), + isFromPreferredRepo = preferredRepoId == version.repoId, + ) if (app != null) updatableApps.add(app) } } @@ -66,14 +82,28 @@ public class DbUpdateChecker @JvmOverloads constructor( * or null if there is none. * @param releaseChannels optional list of release channels to consider on top of stable. * If this is null or empty, only versions without channel (stable) will be considered. + * @param onlyFromPreferredRepo if true a version from a repository that is not preferred, + * either via [AppPrefs.preferredRepoId] or [Repository.weight] will not be returned. + * If false, versions from all enabled repositories will be considered. */ @SuppressLint("PackageManagerGetSignatures") public fun getSuggestedVersion( packageName: String, preferredSigner: String? = null, releaseChannels: List? = null, + onlyFromPreferredRepo: Boolean = false, ): AppVersion? { - val versions = versionDao.getVersions(listOf(packageName)) + val preferredRepoId = if (onlyFromPreferredRepo) { + appPrefsDao.getPreferredRepos(listOf(packageName))[packageName] + ?: error { "No preferred repo for $packageName" } + } else 0L + val versions = if (onlyFromPreferredRepo) { + versionDao.getVersions(listOf(packageName)).filter { version -> + version.repoId == preferredRepoId + } + } else { + versionDao.getVersions(listOf(packageName)) + } if (versions.isEmpty()) return null val packageInfo = try { @Suppress("DEPRECATION") @@ -125,7 +155,11 @@ public class DbUpdateChecker @JvmOverloads constructor( } } - private fun getUpdatableApp(version: Version, installedVersionCode: Long): UpdatableApp? { + private fun getUpdatableApp( + version: Version, + installedVersionCode: Long, + isFromPreferredRepo: Boolean, + ): UpdatableApp? { val versionedStrings = versionDao.getVersionedStrings( repoId = version.repoId, packageName = version.packageName, @@ -138,6 +172,7 @@ public class DbUpdateChecker @JvmOverloads constructor( packageName = version.packageName, installedVersionCode = installedVersionCode, update = version.toAppVersion(versionedStrings), + isFromPreferredRepo = isFromPreferredRepo, hasKnownVulnerability = version.hasKnownVulnerability, name = appOverviewItem.name, summary = appOverviewItem.summary, diff --git a/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index be228fb03..5f586a30c 100644 --- a/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -3,10 +3,12 @@ package org.fdroid.database import android.content.res.Resources import androidx.core.os.ConfigurationCompat.getLocales import androidx.core.os.LocaleListCompat +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import org.fdroid.LocaleChooser.getBestLocale +import java.io.Closeable import java.util.Locale import java.util.concurrent.Callable @@ -14,7 +16,7 @@ import java.util.concurrent.Callable // When bumping this version, please make sure to add one (or more) migration(s) below! // Consider also providing tests for that migration. // Don't forget to commit the new schema to the git repo as well. - version = 1, + version = 2, entities = [ // repo CoreRepository::class, @@ -37,14 +39,16 @@ import java.util.concurrent.Callable views = [ LocalizedIcon::class, HighestVersion::class, + PreferredRepo::class, ], exportSchema = true, autoMigrations = [ + AutoMigration(1, 2, MultiRepoMigration::class), // add future migrations here (if they are easy enough to be done automatically) ], ) @TypeConverters(Converters::class) -internal abstract class FDroidDatabaseInt internal constructor() : RoomDatabase(), FDroidDatabase { +internal abstract class FDroidDatabaseInt : RoomDatabase(), FDroidDatabase, Closeable { abstract override fun getRepositoryDao(): RepositoryDaoInt abstract override fun getAppDao(): AppDaoInt abstract override fun getVersionDao(): VersionDaoInt diff --git a/libs/database/src/main/java/org/fdroid/database/Migrations.kt b/libs/database/src/main/java/org/fdroid/database/Migrations.kt new file mode 100644 index 000000000..3e326eb9d --- /dev/null +++ b/libs/database/src/main/java/org/fdroid/database/Migrations.kt @@ -0,0 +1,106 @@ +package org.fdroid.database + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase.CONFLICT_FAIL +import androidx.room.migration.AutoMigrationSpec +import androidx.sqlite.db.SupportSQLiteDatabase +import mu.KotlinLogging + +private const val REPO_WEIGHT = 1_000_000_000 + +internal class MultiRepoMigration : AutoMigrationSpec { + + private val log = KotlinLogging.logger {} + + override fun onPostMigrate(db: SupportSQLiteDatabase) { + super.onPostMigrate(db) + // do migration in one transaction that can be rolled back if there's issues + db.beginTransaction() + try { + migrateWeights(db) + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + private fun migrateWeights(db: SupportSQLiteDatabase) { + // get repositories + val repos = ArrayList() + val archiveMap = HashMap() + db.query( + """ + SELECT repoId, address, certificate, weight FROM ${CoreRepository.TABLE} + JOIN ${RepositoryPreferences.TABLE} USING (repoId) + ORDER BY weight ASC""" + ).use { cursor -> + while (cursor.moveToNext()) { + val repo = getRepo(cursor) + log.error { repo.toString() } + if (repo.isArchive()) { + if (archiveMap.containsKey(repo.certificate)) { + log.error { "More than two repos with certificate of ${repo.address}" } + // still migrating this as a normal repo then + repos.add(repo) + } else { + // remember archive repo, so we get position it below main repo + archiveMap[repo.certificate] = repo + } + } else { + repos.add(repo) + } + } + } + + // now go through all repos and adapt their weight, + // so that repos with a low weight get a high weight with space for archive repos + var nextWeight = REPO_WEIGHT + repos.forEach { repo -> + val archiveRepo = archiveMap[repo.certificate] + if (archiveRepo == null) { + db.updateRepoWeight(repo, nextWeight) + } else { + db.updateRepoWeight(repo, nextWeight) + db.updateRepoWeight(archiveRepo, nextWeight - 1) + archiveMap.remove(repo.certificate) + } + nextWeight -= 2 + } + // going through archive repos without main repo as well and put them at the end + // so they don't get stuck with minimum weights + archiveMap.forEach { (_, repo) -> + db.updateRepoWeight(repo, nextWeight) + nextWeight -= 1 + } + } + + private fun SupportSQLiteDatabase.updateRepoWeight(repo: Repo, newWeight: Int) { + val rowsAffected = update( + table = RepositoryPreferences.TABLE, + conflictAlgorithm = CONFLICT_FAIL, + values = ContentValues(1).apply { + put("weight", newWeight) + }, + whereClause = "repoId = ?", + whereArgs = arrayOf(repo.repoId), + ) + if (rowsAffected > 1) error("repo ${repo.address} had more than one preference") + } + + private fun getRepo(c: Cursor) = Repo( + repoId = c.getLong(0), + address = c.getString(1), + certificate = c.getString(2), + weight = c.getInt(3), + ) + + private data class Repo( + val repoId: Long, + val address: String, + val certificate: String, + val weight: Int, + ) { + fun isArchive(): Boolean = address.trimEnd('/').endsWith("/archive") + } +} diff --git a/libs/database/src/main/java/org/fdroid/database/Repository.kt b/libs/database/src/main/java/org/fdroid/database/Repository.kt index 668bf9905..c5135bfa7 100644 --- a/libs/database/src/main/java/org/fdroid/database/Repository.kt +++ b/libs/database/src/main/java/org/fdroid/database/Repository.kt @@ -137,6 +137,13 @@ public data class Repository internal constructor( public val formatVersion: IndexFormatVersion? get() = repository.formatVersion public val certificate: String? get() = repository.certificate + /** + * True if this repository is an archive repo. + * It is suggested to not show archive repos in the list of repos in the UI. + */ + public val isArchiveRepo: Boolean + get() = repository.address.trimEnd('/').endsWith("/archive") + public fun getName(localeList: LocaleListCompat): String? = repository.name.getBestLocale(localeList) @@ -393,7 +400,8 @@ public data class InitialRepository @JvmOverloads constructor( val certificate: String, val version: Long, val enabled: Boolean, - val weight: Int, + @Deprecated("This is automatically assigned now and can be safely removed.") + val weight: Int = 0, // still used for testing, could be made internal or tests migrate away ) { init { validateCertificate(certificate) diff --git a/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt index 022b50510..6c8347410 100644 --- a/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -127,9 +127,10 @@ internal interface RepositoryDaoInt : RepositoryDao { certificate = initialRepo.certificate, ) val repoId = insertOrReplace(repo) + val currentMinWeight = getMinRepositoryWeight() val repositoryPreferences = RepositoryPreferences( repoId = repoId, - weight = initialRepo.weight, + weight = currentMinWeight - 2, lastUpdated = null, enabled = initialRepo.enabled, ) @@ -151,10 +152,10 @@ internal interface RepositoryDaoInt : RepositoryDao { certificate = newRepository.certificate, ) val repoId = insertOrReplace(repo) - val currentMaxWeight = getMaxRepositoryWeight() + val currentMinWeight = getMinRepositoryWeight() val repositoryPreferences = RepositoryPreferences( repoId = repoId, - weight = currentMaxWeight + 1, + weight = currentMinWeight - 2, lastUpdated = null, username = newRepository.username, password = newRepository.password, @@ -181,10 +182,10 @@ internal interface RepositoryDaoInt : RepositoryDao { certificate = null, ) val repoId = insertOrReplace(repo) - val currentMaxWeight = getMaxRepositoryWeight() + val currentMinWeight = getMinRepositoryWeight() val repositoryPreferences = RepositoryPreferences( repoId = repoId, - weight = currentMaxWeight + 1, + weight = currentMinWeight - 2, lastUpdated = null, username = username, password = password, @@ -197,32 +198,45 @@ internal interface RepositoryDaoInt : RepositoryDao { @VisibleForTesting fun insertOrReplace(repository: RepoV2, version: Long = 0): Long { val repoId = insertOrReplace(repository.toCoreRepository(version = version)) - val currentMaxWeight = getMaxRepositoryWeight() - val repositoryPreferences = RepositoryPreferences(repoId, currentMaxWeight + 1) + val currentMinWeight = getMinRepositoryWeight() + val repositoryPreferences = RepositoryPreferences(repoId, currentMinWeight - 2) insert(repositoryPreferences) insertRepoTables(repoId, repository) return repoId } - @Query("SELECT MAX(weight) FROM ${RepositoryPreferences.TABLE}") - fun getMaxRepositoryWeight(): Int + @Query("SELECT COALESCE(MIN(weight), ${Int.MAX_VALUE}) FROM ${RepositoryPreferences.TABLE}") + fun getMinRepositoryWeight(): Int @Transaction @Query("SELECT * FROM ${CoreRepository.TABLE} WHERE repoId = :repoId") override fun getRepository(repoId: Long): Repository? - // the query uses strange ordering as a hacky workaround to not return default archive repos + /** + * Returns a non-archive repository with the given [certificate], if it exists in the DB. + */ @Transaction - @Query("""SELECT * FROM ${CoreRepository.TABLE} WHERE certificate = :certificate - COLLATE NOCASE ORDER BY repoId DESC LIMIT 1""") + @Query("""SELECT * FROM ${CoreRepository.TABLE} + WHERE certificate = :certificate AND address NOT LIKE "%/archive" COLLATE NOCASE + LIMIT 1""") fun getRepository(certificate: String): Repository? @Transaction - @Query("SELECT * FROM ${CoreRepository.TABLE}") + @RewriteQueriesToDropUnusedColumns + @Query( + """SELECT * FROM ${CoreRepository.TABLE} + JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) + ORDER BY pref.weight DESC""" + ) override fun getRepositories(): List @Transaction - @Query("SELECT * FROM ${CoreRepository.TABLE}") + @RewriteQueriesToDropUnusedColumns + @Query( + """SELECT * FROM ${CoreRepository.TABLE} + JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) + ORDER BY pref.weight DESC""" + ) override fun getLiveRepositories(): LiveData> @Query("SELECT * FROM ${RepositoryPreferences.TABLE} WHERE repoId = :repoId") @@ -329,8 +343,19 @@ internal interface RepositoryDaoInt : RepositoryDao { ) } + @Transaction + override fun setRepositoryEnabled(repoId: Long, enabled: Boolean) { + // When disabling a repository, we need to remove it as preferred repo for all apps, + // otherwise our queries that ignore disabled repos will not return anything anymore. + if (!enabled) resetPreferredRepoInAppPrefs(repoId) + setRepositoryEnabledInternal(repoId, enabled) + } + @Query("UPDATE ${RepositoryPreferences.TABLE} SET enabled = :enabled WHERE repoId = :repoId") - override fun setRepositoryEnabled(repoId: Long, enabled: Boolean) + fun setRepositoryEnabledInternal(repoId: Long, enabled: Boolean) + + @Query("UPDATE ${AppPrefs.TABLE} SET preferredRepoId = NULL WHERE preferredRepoId = :repoId") + fun resetPreferredRepoInAppPrefs(repoId: Long) @Query("""UPDATE ${RepositoryPreferences.TABLE} SET userMirrors = :mirrors WHERE repoId = :repoId""") @@ -344,12 +369,68 @@ internal interface RepositoryDaoInt : RepositoryDao { WHERE repoId = :repoId""") override fun updateDisabledMirrors(repoId: Long, disabledMirrors: List) + /** + * Changes repository weights/priorities that determine list order and preferred repositories. + * The lower a repository is in the list, the lower is its priority. + * If an app is in more than one repo, by default, the repo higher in the list wins. + * + * @param repoToReorder this repository will change its position in the list. + * @param repoTarget the repository in which place the [repoToReorder] shall be moved. + * If our list is [ A B C D ] and we call reorderRepositories(B, D), + * then the new list will be [ A C D B ]. + * + * @throws IllegalArgumentException if one of the repos is an archive repo. + * Those are expected to be tied to their main repo one down the list + * and are moved automatically when their main repo moves. + */ + @Transaction + fun reorderRepositories(repoToReorder: Repository, repoTarget: Repository) { + require(!repoToReorder.isArchiveRepo && !repoTarget.isArchiveRepo) { + "Re-ordering of archive repos is not supported" + } + if (repoToReorder.weight > repoTarget.weight) { + // repoToReorder is higher, + // so move repos below repoToReorder (and its archive below) two weights up + shiftRepoWeights(repoTarget.weight, repoToReorder.weight - 2, 2) + } else if (repoToReorder.weight < repoTarget.weight) { + // repoToReorder is lower, so move repos above repoToReorder two weights down + shiftRepoWeights(repoToReorder.weight + 1, repoTarget.weight, -2) + } else { + return // both repos have same weight, not re-ordering anything + } + // move repoToReorder in place of repoTarget + setWeight(repoToReorder.repoId, repoTarget.weight) + // also adjust weight of archive repo, if it exists + val archiveRepoId = repoToReorder.certificate?.let { getArchiveRepoId(it) } + if (archiveRepoId != null) { + setWeight(archiveRepoId, repoTarget.weight - 1) + } + } + + @Query("""UPDATE ${RepositoryPreferences.TABLE} SET weight = :weight WHERE repoId = :repoId""") + fun setWeight(repoId: Long, weight: Int) + + @Query( + """UPDATE ${RepositoryPreferences.TABLE} SET weight = weight + :offset + WHERE weight >= :weightFrom AND weight <= :weightTo""" + ) + fun shiftRepoWeights(weightFrom: Int, weightTo: Int, offset: Int) + + @Query( + """SELECT repoId FROM ${CoreRepository.TABLE} + WHERE certificate = :cert AND address LIKE '%/archive' COLLATE NOCASE""" + ) + fun getArchiveRepoId(cert: String): Long? + @Transaction override fun deleteRepository(repoId: Long) { deleteCoreRepository(repoId) // we don't use cascading delete for preferences, // so we can replace index data on full updates deleteRepositoryPreferences(repoId) + // When deleting a repository, we need to remove it as preferred repo for all apps, + // otherwise our queries will not return anything anymore. + resetPreferredRepoInAppPrefs(repoId) } @Query("DELETE FROM ${CoreRepository.TABLE} WHERE repoId = :repoId") diff --git a/libs/database/src/main/java/org/fdroid/database/VersionDao.kt b/libs/database/src/main/java/org/fdroid/database/VersionDao.kt index 1bf357e96..f5309a3e3 100644 --- a/libs/database/src/main/java/org/fdroid/database/VersionDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/VersionDao.kt @@ -35,6 +35,12 @@ public interface VersionDao { * Returns a list of versions for the given [packageName] sorting by highest version code first. */ public fun getAppVersions(packageName: String): LiveData> + + /** + * Returns a list of versions from the repo identified by the given [repoId] + * for the given [packageName] sorting by highest version code first. + */ + public fun getAppVersions(repoId: Long, packageName: String): LiveData> } /** @@ -161,13 +167,12 @@ internal interface VersionDaoInt : VersionDao { ORDER BY manifest_versionCode DESC, pref.weight DESC""") override fun getAppVersions(packageName: String): LiveData> - /** - * Only use for testing, not sorted, does take disabled repos into account. - */ @Transaction + @RewriteQueriesToDropUnusedColumns @Query("""SELECT * FROM ${Version.TABLE} - WHERE repoId = :repoId AND packageName = :packageName""") - fun getAppVersions(repoId: Long, packageName: String): List + WHERE repoId = :repoId AND packageName = :packageName + ORDER BY manifest_versionCode DESC""") + override fun getAppVersions(repoId: Long, packageName: String): LiveData> @Query("""SELECT * FROM ${Version.TABLE} WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""") diff --git a/libs/database/src/main/java/org/fdroid/index/RepoManager.kt b/libs/database/src/main/java/org/fdroid/index/RepoManager.kt index 5cd554525..4e0281fbf 100644 --- a/libs/database/src/main/java/org/fdroid/index/RepoManager.kt +++ b/libs/database/src/main/java/org/fdroid/index/RepoManager.kt @@ -15,8 +15,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.fdroid.database.AppPrefs +import org.fdroid.database.AppPrefsDaoInt import org.fdroid.database.FDroidDatabase import org.fdroid.database.Repository +import org.fdroid.database.RepositoryDaoInt import org.fdroid.download.DownloaderFactory import org.fdroid.download.HttpManager import org.fdroid.repo.AddRepoState @@ -24,20 +27,21 @@ import org.fdroid.repo.RepoAdder import org.fdroid.repo.RepoUriGetter import java.io.File import java.net.Proxy +import java.util.concurrent.CancellationException import java.util.concurrent.CountDownLatch import kotlin.coroutines.CoroutineContext @OptIn(DelicateCoroutinesApi::class) public class RepoManager @JvmOverloads constructor( context: Context, - db: FDroidDatabase, + private val db: FDroidDatabase, downloaderFactory: DownloaderFactory, httpManager: HttpManager, - private val repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder, + repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder, private val coroutineContext: CoroutineContext = Dispatchers.IO, ) { - - private val repositoryDao = db.getRepositoryDao() + private val repositoryDao = db.getRepositoryDao() as RepositoryDaoInt + private val appPrefsDao = db.getAppPrefsDao() as AppPrefsDaoInt private val tempFileProvider = TempFileProvider { File.createTempFile("dl-", "", context.cacheDir) } @@ -160,6 +164,69 @@ public class RepoManager @JvmOverloads constructor( repoAdder.abortAddingRepo() } + @AnyThread + public fun setPreferredRepoId(packageName: String, repoId: Long) { + GlobalScope.launch(coroutineContext) { + db.runInTransaction { + val appPrefs = appPrefsDao.getAppPrefsOrNull(packageName) ?: AppPrefs(packageName) + appPrefsDao.update(appPrefs.copy(preferredRepoId = repoId)) + } + } + } + + /** + * Changes repository priorities that determine the order + * they are returned from [getRepositories] and the preferred repositories. + * The lower a repository is in the list, the lower is its priority. + * If an app is in more than one repository, by default, + * the repo higher in the list will provide metadata and updates. + * Only setting [AppPrefs.preferredRepoId] overrides this. + * + * @param repoToReorder this repository will change its position in the list. + * @param repoTarget the repository in which place the [repoToReorder] shall be moved. + * If our list is [ A B C D ] and we call reorderRepositories(B, D), + * then the new list will be [ A C D B ]. + * @throws IllegalArgumentException if one of the repos is an archive repo. + * Those are expected to be tied to their main repo one down the list + * and are moved automatically when their main repo moves. + */ + @AnyThread + public fun reorderRepositories(repoToReorder: Repository, repoTarget: Repository) { + GlobalScope.launch(coroutineContext) { + repositoryDao.reorderRepositories(repoToReorder, repoTarget) + } + } + + /** + * Enables or disabled the archive repo for the given [repository]. + * + * Note that this can throw all kinds of exceptions, + * especially when the given [repository] does not have a (working) archive repository. + * You should catch those and update your UI accordingly. + */ + @WorkerThread + public suspend fun setArchiveRepoEnabled( + repository: Repository, + enabled: Boolean, + proxy: Proxy? = null, + ) { + val cert = repository.certificate ?: error { "$repository has no cert" } + val archiveRepoId = repositoryDao.getArchiveRepoId(cert) + if (enabled) { + if (archiveRepoId == null) { + try { + repoAdder.addArchiveRepo(repository, proxy) + } catch (e: CancellationException) { + if (e.message != "expected") throw e + } + } else { + repositoryDao.setRepositoryEnabled(archiveRepoId, true) + } + } else if (archiveRepoId != null) { + repositoryDao.setRepositoryEnabled(archiveRepoId, false) + } + } + /** * Returns true if the given [uri] belongs to a swap repo. */ diff --git a/libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt b/libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt index 09934ef5a..e1e468acc 100644 --- a/libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt +++ b/libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt @@ -6,6 +6,7 @@ import android.os.Build.VERSION.SDK_INT import android.os.UserManager import android.os.UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES import android.os.UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY +import androidx.annotation.AnyThread import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import androidx.core.content.ContextCompat.getSystemService @@ -13,8 +14,10 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.SerializationException import mu.KotlinLogging import org.fdroid.database.AppOverviewItem @@ -34,6 +37,7 @@ import org.fdroid.index.TempFileProvider import org.fdroid.repo.AddRepoError.ErrorType.INVALID_FINGERPRINT import org.fdroid.repo.AddRepoError.ErrorType.INVALID_INDEX import org.fdroid.repo.AddRepoError.ErrorType.IO_ERROR +import org.fdroid.repo.AddRepoError.ErrorType.IS_ARCHIVE_REPO import org.fdroid.repo.AddRepoError.ErrorType.UNKNOWN_SOURCES_DISALLOWED import java.io.IOException import java.net.Proxy @@ -79,6 +83,7 @@ public data class AddRepoError( public enum class ErrorType { UNKNOWN_SOURCES_DISALLOWED, INVALID_FINGERPRINT, + IS_ARCHIVE_REPO, INVALID_INDEX, IO_ERROR, } @@ -138,6 +143,10 @@ internal class RepoAdder( addRepoState.value = AddRepoError(INVALID_INDEX, e) return } + if (nUri.uri.lastPathSegment == "archive") { + addRepoState.value = AddRepoError(IS_ARCHIVE_REPO) + return + } // some plumping to receive the repo preview var receivedRepo: Repository? = null @@ -166,21 +175,7 @@ internal class RepoAdder( // try fetching repo with v2 format first and fallback to v1 try { - try { - val repo = - getTempRepo(nUri.uri, IndexFormatVersion.TWO, nUri.username, nUri.password) - val repoFetcher = RepoV2Fetcher( - tempFileProvider, downloaderFactory, httpManager, repoUriBuilder, proxy - ) - repoFetcher.fetchRepo(nUri.uri, repo, receiver, nUri.fingerprint) - } catch (e: NotFoundException) { - log.warn(e) { "Did not find v2 repo, trying v1 now." } - // try to fetch v1 repo - val repo = - getTempRepo(nUri.uri, IndexFormatVersion.ONE, nUri.username, nUri.password) - val repoFetcher = RepoV1Fetcher(tempFileProvider, downloaderFactory, repoUriBuilder) - repoFetcher.fetchRepo(nUri.uri, repo, receiver, nUri.fingerprint) - } + fetchRepo(nUri.uri, nUri.fingerprint, proxy, nUri.username, nUri.password, receiver) } catch (e: SigningException) { log.error(e) { "Error verifying repo with given fingerprint." } addRepoState.value = AddRepoError(INVALID_FINGERPRINT, e) @@ -207,6 +202,31 @@ internal class RepoAdder( } } + private suspend fun fetchRepo( + uri: Uri, + fingerprint: String?, + proxy: Proxy?, + username: String?, + password: String?, + receiver: RepoPreviewReceiver, + ) { + try { + val repo = + getTempRepo(uri, IndexFormatVersion.TWO, username, password) + val repoFetcher = RepoV2Fetcher( + tempFileProvider, downloaderFactory, httpManager, repoUriBuilder, proxy + ) + repoFetcher.fetchRepo(uri, repo, receiver, fingerprint) + } catch (e: NotFoundException) { + log.warn(e) { "Did not find v2 repo, trying v1 now." } + // try to fetch v1 repo + val repo = + getTempRepo(uri, IndexFormatVersion.ONE, username, password) + val repoFetcher = RepoV1Fetcher(tempFileProvider, downloaderFactory, repoUriBuilder) + repoFetcher.fetchRepo(uri, repo, receiver, fingerprint) + } + } + private fun getFetchResult(url: String, repo: Repository): FetchResult { val cert = repo.certificate ?: error("Certificate was null") val existingRepo = repositoryDao.getRepository(cert) @@ -293,6 +313,41 @@ internal class RepoAdder( fetchJob?.cancel() } + @AnyThread + internal suspend fun addArchiveRepo(repo: Repository, proxy: Proxy? = null) = + withContext(coroutineContext) { + if (repo.isArchiveRepo) error { "Repo ${repo.address} is already an archive repo." } + + val address = repo.address.replace(Regex("repo/?$"), "archive") + @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") + val receiver = object : RepoPreviewReceiver { + override fun onRepoReceived(archiveRepo: Repository) { + // reset the timestamp of the actual repo, + // so a following repo update will pick this up + val newRepo = NewRepository( + name = archiveRepo.repository.name, + icon = archiveRepo.repository.icon ?: emptyMap(), + address = archiveRepo.address, + formatVersion = archiveRepo.formatVersion, + certificate = archiveRepo.certificate ?: error("Repo had no certificate"), + username = archiveRepo.username, + password = archiveRepo.password, + ) + db.runInTransaction { + val repoId = repositoryDao.insert(newRepo) + repositoryDao.setWeight(repoId, repo.weight - 1) + } + cancel("expected") // no need to continue downloading the entire repo + } + + override fun onAppReceived(app: AppOverviewItem) { + // no-op + } + } + val uri = Uri.parse(address) + fetchRepo(uri, repo.fingerprint, proxy, repo.username, repo.password, receiver) + } + private fun hasDisallowInstallUnknownSources(context: Context): Boolean { val userManager = getSystemService(context, UserManager::class.java) ?: error("No UserManager available.") diff --git a/libs/database/src/main/java/org/fdroid/repo/RepoUriGetter.kt b/libs/database/src/main/java/org/fdroid/repo/RepoUriGetter.kt index 511a9d81a..ed8b1854a 100644 --- a/libs/database/src/main/java/org/fdroid/repo/RepoUriGetter.kt +++ b/libs/database/src/main/java/org/fdroid/repo/RepoUriGetter.kt @@ -49,10 +49,12 @@ internal object RepoUriGetter { // do some path auto-adding, if it is missing if (pathSegments.size >= 2 && pathSegments[pathSegments.lastIndex - 1] == "fdroid" && - pathSegments.last() == "repo" + (pathSegments.last() == "repo" || pathSegments.last() == "archive") ) { // path already is /fdroid/repo, use as is - } else if (pathSegments.lastOrNull() == "repo") { + } else if (pathSegments.lastOrNull() == "repo" || + pathSegments.lastOrNull() == "archive" + ) { // path already ends in /repo, use as is } else if (pathSegments.size >= 1 && pathSegments.last() == "fdroid") { // path is /fdroid with missing /repo, so add that diff --git a/libs/database/src/test/java/org/fdroid/repo/RepoAdderTest.kt b/libs/database/src/test/java/org/fdroid/repo/RepoAdderTest.kt index 714f7b5a2..eb58d8864 100644 --- a/libs/database/src/test/java/org/fdroid/repo/RepoAdderTest.kt +++ b/libs/database/src/test/java/org/fdroid/repo/RepoAdderTest.kt @@ -100,7 +100,7 @@ internal class RepoAdderTest { val userManager = mockk() val repoAdder = RepoAdder(context, db, tempFileProvider, downloaderFactory, httpManager) - every { context.getSystemService("user") } returns userManager + every { context.getSystemService(UserManager::class.java) } returns userManager every { userManager.hasUserRestriction(DISALLOW_INSTALL_UNKNOWN_SOURCES) } returns true diff --git a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestVersionUtils.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestVersionUtils.kt index 2416c72f2..2803b58f6 100644 --- a/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestVersionUtils.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestVersionUtils.kt @@ -19,19 +19,22 @@ object TestVersionUtils { fun getRandomPackageVersionV2( versionCode: Long = Random.nextLong(1, Long.MAX_VALUE), + signer: SignerV2? = SignerV2(getRandomList(Random.nextInt(1, 3)) { + getRandomString(64) + }).orNull(), ) = PackageVersionV2( added = Random.nextLong(), file = getRandomFileV2(false).let { FileV1(it.name, it.sha256!!, it.size) }, src = getRandomFileV2().orNull(), - manifest = getRandomManifestV2(versionCode), + manifest = getRandomManifestV2(versionCode, signer), releaseChannels = getRandomList { getRandomString() }, antiFeatures = getRandomMap { getRandomString() to getRandomLocalizedTextV2() }, whatsNew = getRandomLocalizedTextV2(), ) - fun getRandomManifestV2(versionCode: Long) = ManifestV2( + private fun getRandomManifestV2(versionCode: Long, signer: SignerV2?) = ManifestV2( versionName = getRandomString(), versionCode = versionCode, usesSdk = UsesSdkV2( @@ -39,9 +42,7 @@ object TestVersionUtils { targetSdkVersion = Random.nextInt(), ), maxSdkVersion = Random.nextInt().orNull(), - signer = SignerV2(getRandomList(Random.nextInt(1, 3)) { - getRandomString(64) - }).orNull(), + signer = signer, usesPermission = getRandomList { PermissionV2(getRandomString(), Random.nextInt().orNull()) },