Merge branch 'repo-weights' into 'master'

Overhaul Repository Handling

Closes #702, #1566, #1887, and #2681

See merge request fdroid/fdroidclient!1324
This commit is contained in:
Hans-Christoph Steiner
2024-02-20 13:37:15 +00:00
62 changed files with 3574 additions and 418 deletions

View File

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

View File

@@ -376,7 +376,7 @@ public final class AppUpdateStatusManager {
@WorkerThread
private List<UpdatableApp> getUpdatableApps() {
List<String> releaseChannels = Preferences.get().getBackendReleaseChannels();
return updateChecker.getUpdatableApps(releaseChannels);
return updateChecker.getUpdatableApps(releaseChannels, true);
}
private void addUpdatableApps(@Nullable List<UpdatableApp> canUpdate) {

View File

@@ -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<String> addresses) {
preferences.edit().putStringSet(PREF_DEFAULT_REPO_ADDRESSES, addresses).apply();
}
public Set<String> getDefaultRepoAddresses(Context context) {
Set<String> def = Collections.singleton("empty");
Set<String> 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);
}

View File

@@ -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<String> 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))

View File

@@ -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)
}
}

View File

@@ -42,7 +42,6 @@ final class ContentProviderMigrator {
private void migrateOldRepos(FDroidDatabase db, SQLiteDatabase oldDb) {
RepositoryDao repoDao = db.getRepositoryDao();
List<Repository> 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));
}
}
}

View File

@@ -71,7 +71,6 @@ public class DBHelper {
@VisibleForTesting
static void prePopulateDb(Context context, FDroidDatabase db) {
List<String> 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<String> getDefaultRepoAddresses(Context context) {
List<String> 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<String> 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;
}
}

View File

@@ -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<Apk> 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<Apk> 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},

View File

@@ -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<Object> items = new ArrayList<>();
private final List<Repository> repos = new ArrayList<>();
@Nullable
private Long preferredRepoId = null;
private final List<Apk> versions = new ArrayList<>();
private final List<Apk> 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<Apk> apks, @NonNull AppPrefs appPrefs) {
public void updateItems(@NonNull App app, @Nullable List<Apk> 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<Repository> repos, long preferredRepoId) {
this.repos.clear();
this.repos.addAll(repos);
this.preferredRepoId = preferredRepoId;
notifyItemChanged(0); // header changed
}
private void addInstalledApkIfExists(final List<Apk> 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<String> 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);
}

View File

@@ -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<Repository>,
)
class AppDetailsViewModel(app: Application) : AndroidViewModel(app) {
private val _app = MutableLiveData<App?>()
val app: LiveData<App?> = _app
private val _versions = MutableLiveData<List<AppVersion>>()
val versions: LiveData<List<AppVersion>> = _versions
private val _appData = MutableLiveData<AppData>()
val appData: LiveData<AppData> = _appData
private val db = DBHelper.getDb(app.applicationContext)
private val repoManager = FDroidApp.getRepoManager(app.applicationContext)
private var packageName: String? = null
private var appLiveData: LiveData<App?>? = null
private var versionsLiveData: LiveData<List<AppVersion>>? = null
private var appPrefsLiveData: LiveData<AppPrefs>? = null
private var preferredRepoId: Long? = null
private var repos: List<Repository>? = 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<App?> = 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<AppPrefs> = Observer { appPrefs ->
if (appPrefs.preferredRepoId != null) preferredRepoId = appPrefs.preferredRepoId
tryToPublishAppData()
}
private val onVersionsChanged: Observer<List<AppVersion>> = 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()
}
}

View File

@@ -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<Repository>,
currentRepoId: Long,
preferredRepoId: Long,
onRepoChanged: Consumer<Repository>,
onPreferredRepoChanged: Consumer<Long>,
) {
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<Repository>,
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, {}, {})
}
}

View File

@@ -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);

View File

@@ -19,6 +19,7 @@ class AppListAdapter extends RecyclerView.Adapter<StandardAppListItemController>
private final List<AppListItem> 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<StandardAppListItemController>
notifyDataSetChanged();
}
void setHideInstallButton(boolean hide) {
hideInstallButton = hide;
}
void setHasHiddenAppsCallback(Runnable callback) {
hasHiddenAppsCallback = callback;
}
@@ -46,6 +51,7 @@ class AppListAdapter extends RecyclerView.Adapter<StandardAppListItemController>
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);

View File

@@ -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,

View File

@@ -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))
}
}

View File

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

View File

@@ -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<RepoAdapter.RepoViewHolder> {
public interface RepoItemListener {
void onClicked(Repository repo);
void onSetEnabled(Repository repo, boolean isEnabled);
void onToggleEnabled(Repository repo);
}
private final List<Repository> items = new ArrayList<>();
@@ -38,10 +40,20 @@ public class RepoAdapter extends RecyclerView.Adapter<RepoAdapter.RepoViewHolder
this.repoItemListener = repoItemListener;
}
@Nullable
Repository getItem(int position) {
return items.get(position);
}
@SuppressLint("NotifyDataSetChanged")
// we could do better, but not really worth it at this point
void updateItems(List<Repository> items) {
this.items.clear();
// filter out archive repos
ListIterator<Repository> 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<RepoAdapter.RepoViewHolder
return items.size();
}
void updateRepoItem(Repository repo) {
for (int i = 0; i < items.size(); i++) {
if (items.get(i).getRepoId() == repo.getRepoId()) {
notifyItemChanged(i);
break;
}
}
}
class RepoViewHolder extends RecyclerView.ViewHolder {
private final View rootView;
private final ImageView imageView;
@@ -90,15 +111,13 @@ public class RepoAdapter extends RecyclerView.Adapter<RepoAdapter.RepoViewHolder
// to invoke the listener for the last repo to use it - particularly
// because we are potentially about to change the checked status
// which would in turn invoke this listener....
switchView.setOnCheckedChangeListener(null);
switchView.setOnClickListener(null);
switchView.setChecked(repo.getEnabled());
// Add this listener *after* setting the checked status, so we don't
// invoke the listener while setting up the view...
switchView.setOnCheckedChangeListener((buttonView, isChecked) -> {
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) {

View File

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

View File

@@ -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<RepoDetailsState?>(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
}
}

View File

@@ -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)
}
}

View File

@@ -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,
)
}

View File

@@ -112,7 +112,7 @@ public class UpdatesAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder
private void loadUpdatableApps() {
List<String> 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);
}

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z" />
</vector>

View File

@@ -161,6 +161,17 @@
android:layout_height="wrap_content"
android:text="@string/repo_edit_credentials" />
<TextView
android:id="@+id/label_archive_repo"
style="@style/CaptionText"
android:text="@string/repo_archive_toggle" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/archiveRepo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/repo_archive_toggle_description" />
<!-- Signature (or "unsigned" if none) -->
<TextView
android:id="@+id/label_repo_fingerprint"

View File

@@ -88,32 +88,13 @@
app:barrierDirection="bottom"
app:constraint_referenced_ids="icon,text_last_update" />
<ImageView
android:id="@+id/repo_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="4dp"
android:layout_marginTop="4dp"
android:importantForAccessibility="no"
android:scaleType="fitCenter"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/barrier"
tools:src="@drawable/ic_repo_app_default" />
<TextView
android:id="@+id/repo_name"
android:layout_width="0dp"
<androidx.compose.ui.platform.ComposeView
android:id="@+id/repoChooserView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="marquee"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintBottom_toBottomOf="@+id/repo_icon"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/repo_icon"
app:layout_constraintTop_toTopOf="@+id/repo_icon"
tools:text="A name of a repository with a potentially long name that wraps" />
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@id/barrier"
tools:composableName="org.fdroid.fdroid.views.appdetails.RepoChooserKt.RepoChooserPreview" />
<com.google.android.material.button.MaterialButton
android:id="@+id/secondaryButtonView"
@@ -124,9 +105,9 @@
android:layout_marginEnd="8dp"
android:layout_weight="0"
android:ellipsize="marquee"
android:visibility="invisible"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@+id/primaryButtonView"
app:layout_constraintTop_toBottomOf="@+id/repo_icon"
app:layout_constraintTop_toBottomOf="@+id/repoChooserView"
tools:text="Uninstall"
tools:visibility="visible" />
@@ -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">
<ImageView
@@ -188,10 +169,10 @@
android:id="@+id/progress_bar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignWithParentIfMissing="true"
android:layout_below="@id/progress_label"
android:layout_alignParentStart="true"
android:layout_toStartOf="@id/progress_cancel"
android:layout_toLeftOf="@id/progress_cancel"
android:indeterminate="true"
android:visibility="gone"
app:hideAnimationBehavior="outward"

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="16dp">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true"
app:hideAnimationBehavior="outward"
app:showAnimationBehavior="inward"
app:showDelay="250" />
</FrameLayout>

View File

@@ -118,16 +118,6 @@
android:layout_marginRight="8dp"
android:layout_marginEnd="8dp">
<TextView
android:id="@+id/repository"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:singleLine="true"
android:ellipsize="marquee"
android:textColor="?attr/lightGrayTextColor"
android:textSize="12sp"
tools:text="F-Droid" />
<TextView
android:id="@+id/size"
android:layout_height="wrap_content"

View File

@@ -1,13 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:descendantFocusability="blocksDescendants"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="8dp">
android:layout_marginHorizontal="8dp"
android:layout_marginVertical="4dp"
android:descendantFocusability="blocksDescendants">
<!--
descendantFocusability is here because if you have a child that responds
to touch events (in our case, the switch/toggle button) then the list item
@@ -15,72 +13,82 @@
http://syedasaraahmed.wordpress.com/2012/10/03/android-onitemclicklistener-not-responding-clickable-rowitem-of-custom-listview/
-->
<ImageView
android:id="@+id/repo_icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="8dp"
android:importantForAccessibility="no"
android:scaleType="fitCenter"
tools:src="@tools:sample/avatars" />
<LinearLayout
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="vertical"
tools:ignore="RtlSymmetry">
android:orientation="horizontal"
android:padding="8dp">
<TextView
android:id="@+id/repo_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="marquee"
android:gravity="center_vertical|start"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceListItem"
tools:text="This is the name of the Repo as taken from the index. It can be long." />
<ImageView
android:id="@+id/repo_icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="8dp"
android:importantForAccessibility="no"
android:scaleType="fitCenter"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/repo_address"
android:layout_width="wrap_content"
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center_vertical|start"
android:singleLine="true"
android:textSize="14sp"
tools:text="this.is.a.repo.at.the.official.domain.it.can.be.long.at.f-droid.org" />
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="vertical"
tools:ignore="RtlSymmetry">
<TextView
android:id="@+id/repo_unverified"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center_vertical|start"
android:singleLine="true"
android:text="@string/unverified"
android:textColor="@color/unverified"
android:textSize="14sp"
tools:visibility="visible" />
<TextView
android:id="@+id/repo_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="marquee"
android:gravity="center_vertical|start"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceListItem"
tools:text="This is the name of the Repo as taken from the index. It can be long." />
<TextView
android:id="@+id/repo_unsigned"
<TextView
android:id="@+id/repo_address"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center_vertical|start"
android:singleLine="true"
android:textSize="14sp"
tools:text="this.is.a.repo.at.the.official.domain.it.can.be.long.at.f-droid.org" />
<TextView
android:id="@+id/repo_unverified"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center_vertical|start"
android:singleLine="true"
android:text="@string/unverified"
android:textColor="@color/unverified"
android:textSize="14sp"
tools:visibility="visible" />
<TextView
android:id="@+id/repo_unsigned"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center_vertical|start"
android:singleLine="true"
android:text="@string/unsigned"
android:textColor="@color/unsigned"
android:textSize="14sp"
tools:visibility="gone" />
</LinearLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/repo_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="center_vertical|start"
android:singleLine="true"
android:text="@string/unsigned"
android:textColor="@color/unsigned"
android:textSize="14sp"
tools:visibility="gone" />
android:layout_height="match_parent" />
</LinearLayout>
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/repo_switch"
android:layout_width="wrap_content"
android:layout_height="match_parent" />
</LinearLayout>
</androidx.cardview.widget.CardView>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:fitsSystemWindows="true">
<item
android:id="@+id/action_info"
android:icon="@drawable/ic_info"
android:title="@string/menu_share"
app:showAsAction="ifRoom|withText" />
</menu>

View File

@@ -1,8 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Repositories at the top have a higher priority than those at the bottom -->
<string-array name="default_repos">
<!-- name -->
<item>F-Droid</item>
<!-- address -->
<item>
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
</item>
<!-- description -->
<item>The official F-Droid Free Software repository. Everything in this repository is always built from the source code.
</item>
<!-- version -->
<item>13</item>
<!-- enabled -->
<item>1</item>
<!-- push requests -->
<item>ignore</item>
<!-- pubkey -->
<item>
3082035e30820246a00302010202044c49cd00300d06092a864886f70d01010505003071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b73301e170d3130303732333137313032345a170d3337313230383137313032345a3071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b7330820122300d06092a864886f70d01010105000382010f003082010a028201010096d075e47c014e7822c89fd67f795d23203e2a8843f53ba4e6b1bf5f2fd0e225938267cfcae7fbf4fe596346afbaf4070fdb91f66fbcdf2348a3d92430502824f80517b156fab00809bdc8e631bfa9afd42d9045ab5fd6d28d9e140afc1300917b19b7c6c4df4a494cf1f7cb4a63c80d734265d735af9e4f09455f427aa65a53563f87b336ca2c19d244fcbba617ba0b19e56ed34afe0b253ab91e2fdb1271f1b9e3c3232027ed8862a112f0706e234cf236914b939bcf959821ecb2a6c18057e070de3428046d94b175e1d89bd795e535499a091f5bc65a79d539a8d43891ec504058acb28c08393b5718b57600a211e803f4a634e5c57f25b9b8c4422c6fd90203010001300d06092a864886f70d0101050500038201010008e4ef699e9807677ff56753da73efb2390d5ae2c17e4db691d5df7a7b60fc071ae509c5414be7d5da74df2811e83d3668c4a0b1abc84b9fa7d96b4cdf30bba68517ad2a93e233b042972ac0553a4801c9ebe07bf57ebe9a3b3d6d663965260e50f3b8f46db0531761e60340a2bddc3426098397fda54044a17e5244549f9869b460ca5e6e216b6f6a2db0580b480ca2afe6ec6b46eedacfa4aa45038809ece0c5978653d6c85f678e7f5a2156d1bedd8117751e64a4b0dcd140f3040b021821a8d93aed8d01ba36db6c82372211fed714d9a32607038cdfd565bd529ffc637212aaa2c224ef22b603eccefb5bf1e085c191d4b24fe742b17ab3f55d4e6f05ef
</item>
<!-- name -->
<item>F-Droid Archive</item>
<!-- address -->
@@ -42,44 +81,6 @@
3082035e30820246a00302010202044c49cd00300d06092a864886f70d01010505003071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b73301e170d3130303732333137313032345a170d3337313230383137313032345a3071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b7330820122300d06092a864886f70d01010105000382010f003082010a028201010096d075e47c014e7822c89fd67f795d23203e2a8843f53ba4e6b1bf5f2fd0e225938267cfcae7fbf4fe596346afbaf4070fdb91f66fbcdf2348a3d92430502824f80517b156fab00809bdc8e631bfa9afd42d9045ab5fd6d28d9e140afc1300917b19b7c6c4df4a494cf1f7cb4a63c80d734265d735af9e4f09455f427aa65a53563f87b336ca2c19d244fcbba617ba0b19e56ed34afe0b253ab91e2fdb1271f1b9e3c3232027ed8862a112f0706e234cf236914b939bcf959821ecb2a6c18057e070de3428046d94b175e1d89bd795e535499a091f5bc65a79d539a8d43891ec504058acb28c08393b5718b57600a211e803f4a634e5c57f25b9b8c4422c6fd90203010001300d06092a864886f70d0101050500038201010008e4ef699e9807677ff56753da73efb2390d5ae2c17e4db691d5df7a7b60fc071ae509c5414be7d5da74df2811e83d3668c4a0b1abc84b9fa7d96b4cdf30bba68517ad2a93e233b042972ac0553a4801c9ebe07bf57ebe9a3b3d6d663965260e50f3b8f46db0531761e60340a2bddc3426098397fda54044a17e5244549f9869b460ca5e6e216b6f6a2db0580b480ca2afe6ec6b46eedacfa4aa45038809ece0c5978653d6c85f678e7f5a2156d1bedd8117751e64a4b0dcd140f3040b021821a8d93aed8d01ba36db6c82372211fed714d9a32607038cdfd565bd529ffc637212aaa2c224ef22b603eccefb5bf1e085c191d4b24fe742b17ab3f55d4e6f05ef
</item>
<!-- name -->
<item>F-Droid</item>
<!-- address -->
<item>
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
</item>
<!-- description -->
<item>The official F-Droid Free Software repository. Everything in this repository is always built from the source code.
</item>
<!-- version -->
<item>13</item>
<!-- enabled -->
<item>1</item>
<!-- push requests -->
<item>ignore</item>
<!-- pubkey -->
<item>
3082035e30820246a00302010202044c49cd00300d06092a864886f70d01010505003071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b73301e170d3130303732333137313032345a170d3337313230383137313032345a3071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b7330820122300d06092a864886f70d01010105000382010f003082010a028201010096d075e47c014e7822c89fd67f795d23203e2a8843f53ba4e6b1bf5f2fd0e225938267cfcae7fbf4fe596346afbaf4070fdb91f66fbcdf2348a3d92430502824f80517b156fab00809bdc8e631bfa9afd42d9045ab5fd6d28d9e140afc1300917b19b7c6c4df4a494cf1f7cb4a63c80d734265d735af9e4f09455f427aa65a53563f87b336ca2c19d244fcbba617ba0b19e56ed34afe0b253ab91e2fdb1271f1b9e3c3232027ed8862a112f0706e234cf236914b939bcf959821ecb2a6c18057e070de3428046d94b175e1d89bd795e535499a091f5bc65a79d539a8d43891ec504058acb28c08393b5718b57600a211e803f4a634e5c57f25b9b8c4422c6fd90203010001300d06092a864886f70d0101050500038201010008e4ef699e9807677ff56753da73efb2390d5ae2c17e4db691d5df7a7b60fc071ae509c5414be7d5da74df2811e83d3668c4a0b1abc84b9fa7d96b4cdf30bba68517ad2a93e233b042972ac0553a4801c9ebe07bf57ebe9a3b3d6d663965260e50f3b8f46db0531761e60340a2bddc3426098397fda54044a17e5244549f9869b460ca5e6e216b6f6a2db0580b480ca2afe6ec6b46eedacfa4aa45038809ece0c5978653d6c85f678e7f5a2156d1bedd8117751e64a4b0dcd140f3040b021821a8d93aed8d01ba36db6c82372211fed714d9a32607038cdfd565bd529ffc637212aaa2c224ef22b603eccefb5bf1e085c191d4b24fe742b17ab3f55d4e6f05ef
</item>
</string-array>
</resources>

View File

@@ -91,6 +91,21 @@
<string name="app_details">App Details</string>
<string name="no_such_app">No such app found.</string>
<!-- Shown above the repository an app is in -->
<string name="app_details_repository">Repository</string>
<!-- If an app is in more than one repository, this is shown above the selection dropdown -->
<string name="app_details_repositories">Repositories</string>
<!-- Shown next to the repository name of the repo that is currently preferred by the user.
Information and updates for that app only come from the preferred repo.
Other languages could use other words that work better in their language such as (chosen).
-->
<string name="app_details_repository_preferred">(preferred)</string>
<!-- A button label. When clicked, this changes the preferred repo to the currently shown one.
Information and updates for that app only come from the preferred repo.
Other languages could use other words that work better in their language such as (chosen).
-->
<string name="app_details_repository_button_prefer">Prefer Repository</string>
<string name="app_details_repository_expand">Expand repository list</string>
<string name="app_details_donate_prompt_unknown_author">Buy the developers of %1$s a coffee!</string>
<string name="app_details_donate_prompt">%1$s is created by %2$s. Buy them a coffee!</string>
@@ -126,11 +141,11 @@ This often occurs with apps installed via Google Play or other sources, if they
<string name="app__install_downloaded_update">Update</string>
<string name="app_installed_media">File installed to %s</string>
<string name="app_permission_storage">F-Droid needs the storage permission to install this to storage. Please allow it on the next screen to proceed with installation.</string>
<string name="app_repository">Repository: %1$s</string>
<string name="app_size">Size: %1$s</string>
<string name="app_error_open">Could not launch app.</string>
<string name="app_list_results_hidden_antifeature_settings">Some results were hidden based on your antifeature settings.</string>
<string name="app_list_no_suggested_version">No version recommended for installation.</string>
<string name="app_list__name__downloading_in_progress">Downloading %1$s</string>
<string name="app_list__name__successfully_installed">%1$s installed</string>
<string name="app_list_download_ready">Downloaded, ready to install</string>
@@ -206,6 +221,7 @@ This often occurs with apps installed via Google Play or other sources, if they
<string name="repo_exists_add_mirror">This is a copy of %1$s, add it as a mirror?</string>
<string name="repo_invalid">Invalid repository.\n\nContact the maintainer and let them know about the issue.</string>
<string name="repo_io_error">Error connecting to the repository.</string>
<string name="repo_error_adding_archive">Archive repositories can not be added directly. Tap the repository in the list and enable the archive there.</string>
<string name="repo_share_not_found">Could not find repo address in shared text.</string>
<string name="bad_fingerprint">Bad fingerprint</string>
<string name="invalid_url">This is not a valid URL.</string>
@@ -214,7 +230,6 @@ This often occurs with apps installed via Google Play or other sources, if they
<string name="has_disallow_install_unknown_sources_globally">Your device admin doesn\'t allow installing apps from unknown sources, that includes new repos</string>
<!-- Message presented in a dialog box when the user restriction set by the system restricts adding new repos, e.g. "Unknown Sources". -->
<string name="has_disallow_install_unknown_sources">Unknown sources can\'t be added by this user, that includes new repos</string>
<string name="repo_provider">Repository: %s</string>
<string name="menu_manage">Repositories</string>
<string name="repositories_summary">Add additional sources of apps</string>
@@ -434,10 +449,14 @@ This often occurs with apps installed via Google Play or other sources, if they
<string name="use_pure_black_dark_theme_summary">Recommended only for OLED screens.</string>
<string name="unsigned">Unsigned</string>
<string name="unverified">Unverified</string>
<string name="repo_list_info_title">Repository List</string>
<string name="repo_list_info_text">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.</string>
<string name="repo_details">Repository</string>
<string name="repo_url">Address</string>
<string name="repo_num_apps">Number of apps</string>
<string name="repo_num_apps_button">Show apps</string>
<string name="repo_archive_toggle">Repository Archive</string>
<string name="repo_archive_toggle_description">Show archived apps and outdated versions of apps</string>
<string name="repo_fingerprint">Fingerprint of the signing key (SHA-256)</string>
<string name="repo_description">Description</string>
<string name="repo_last_update">Last update</string>
@@ -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.
</string>
<string name="unknown">Unknown</string>
<string name="repo_archive_failed">Archive repo currently not available</string>
<string name="repo_confirm_delete_title">Delete Repository?</string>
<string name="repo_confirm_delete_body">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
<string name="repo_disabled_notification">Disabled "%1$s".\n\nYou will
need to re-enable this repository to install apps from it.
</string>
<string name="repo_disable_warning">Disabling this repository will remove it as \"preferred\" from any apps you may have manually preferred it.</string>
<string name="repo_disable_warning_button">Disable</string>
<string name="repo_added">Saved package repository %1$s.</string>
<string name="repo_searching_address">Looking for package repository at\n%1$s</string>
<!-- Should be exactly the same as the standard word for "Share" throughout Android and apps -->

View File

@@ -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));
}
}

View File

@@ -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) {
}
};
}

View File

@@ -1514,6 +1514,11 @@
<sha256 value="3520d6f38ebac3f461e587d759a052d98a515f0139cbeb0b68c6791ff7c2de5c" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.room" name="room-testing" version="2.5.2">
<artifact name="room-testing-2.5.2.aar">
<sha256 value="9b4269659237fd52bae9ca0d81b5035536bfb77efdf44b0dd7a1c5a9b1a7f49e" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.savedstate" name="savedstate" version="1.0.0">
<artifact name="savedstate-1.0.0.aar">
<sha256 value="2510a5619c37579c9ce1a04574faaf323cd0ffe2fc4e20fa8f8f01e5bb402e83" origin="Generated by Gradle because artifact wasn't signed"/>

View File

@@ -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'
}

View File

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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()

View File

@@ -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")

View File

@@ -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<Int>())
} 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<Int>())
} 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<Int>()) } 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
}
}
}

View File

@@ -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<InitialRepository>,
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)
}
}
}

View File

@@ -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<IllegalArgumentException> {
repoDao.reorderRepositories(
repoToReorder = repoDao.getRepository(repoId1a) ?: fail(),
repoTarget = repoDao.getRepository(repoId3) ?: fail(),
)
}
assertFailsWith<IllegalArgumentException> {
repoDao.reorderRepositories(
repoToReorder = repoDao.getRepository(repoId3) ?: fail(),
repoTarget = repoDao.getRepository(repoId1a) ?: fail(),
)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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()

View File

@@ -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.

View File

@@ -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<Long>
/**
* 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<App?>
@@ -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<Long>
/**
* 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<List<AppListItem>>
@@ -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<List<AppListItem>>
@@ -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<List<AppListItem>>
@@ -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<List<AppListItem>>
@@ -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<List<AppListItem>>
@@ -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<List<AppListItem>>
@@ -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<String>): LiveData<List<AppListItem>>

View File

@@ -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<String>? = 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,
)

View File

@@ -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<String>): Map<String, Long> {
return if (packageNames.size <= 999) getPreferredReposInternal(packageNames)
else HashMap<String, Long>(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<String>): Map<String, Long>
@Insert(onConflict = REPLACE)
override fun update(appPrefs: AppPrefs)
}

View File

@@ -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<String>? = null,
onlyFromPreferredRepo: Boolean = false,
includeKnownVulnerabilities: Boolean = false,
): List<UpdatableApp> {
val updatableApps = ArrayList<UpdatableApp>()
@@ -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<String, ArrayList<Version>>(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<String>? = 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,

View File

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

View File

@@ -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<Repo>()
val archiveMap = HashMap<String, Repo>()
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")
}
}

View File

@@ -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)

View File

@@ -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<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 getLiveRepositories(): LiveData<List<Repository>>
@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<String>)
/**
* 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")

View File

@@ -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<List<AppVersion>>
/**
* 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<List<AppVersion>>
}
/**
@@ -161,13 +167,12 @@ internal interface VersionDaoInt : VersionDao {
ORDER BY manifest_versionCode DESC, pref.weight DESC""")
override fun getAppVersions(packageName: String): LiveData<List<AppVersion>>
/**
* 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<AppVersion>
WHERE repoId = :repoId AND packageName = :packageName
ORDER BY manifest_versionCode DESC""")
override fun getAppVersions(repoId: Long, packageName: String): LiveData<List<AppVersion>>
@Query("""SELECT * FROM ${Version.TABLE}
WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""")

View File

@@ -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.
*/

View File

@@ -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.")

View File

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

View File

@@ -100,7 +100,7 @@ internal class RepoAdderTest {
val userManager = mockk<UserManager>()
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

View File

@@ -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())
},