[app] Use new IndexV1Updater and make latest and category tab use new DB

This commit is contained in:
Torsten Grote
2022-03-17 15:35:01 -03:00
parent 4b5aaf2d16
commit 90b7570ffb
19 changed files with 460 additions and 412 deletions

View File

@@ -143,6 +143,7 @@ android {
dependencies {
implementation project(":libs:download")
implementation project(":libs:database")
implementation 'androidx.appcompat:appcompat:1.4.2'
implementation 'androidx.preference:preference:1.2.0'
implementation 'androidx.gridlayout:gridlayout:1.0.0'

View File

@@ -1,20 +1,19 @@
package org.fdroid.fdroid.views.main;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.TextView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import org.fdroid.database.Category;
import org.fdroid.database.FDroidDatabase;
import org.fdroid.database.FDroidDatabaseHolder;
import org.fdroid.fdroid.Preferences;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.UpdateService;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.CategoryProvider;
import org.fdroid.fdroid.data.Schema;
import org.fdroid.fdroid.panic.HidingManager;
import org.fdroid.fdroid.views.apps.AppListActivity;
import org.fdroid.fdroid.views.categories.CategoryAdapter;
@@ -25,11 +24,9 @@ import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import androidx.lifecycle.Observer;
import androidx.lifecycle.Transformations;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
@@ -39,11 +36,9 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
* Will start a loader to get the list of categories from the database and populate a recycler
* view with relevant info about each.
*/
class CategoriesViewBinder implements LoaderManager.LoaderCallbacks<Cursor> {
class CategoriesViewBinder implements Observer<List<Category>> {
public static final String TAG = "CategoriesViewBinder";
private static final int LOADER_ID = 429820532;
private final CategoryAdapter categoryAdapter;
private final AppCompatActivity activity;
private final TextView emptyState;
@@ -51,10 +46,12 @@ class CategoriesViewBinder implements LoaderManager.LoaderCallbacks<Cursor> {
CategoriesViewBinder(final AppCompatActivity activity, FrameLayout parent) {
this.activity = activity;
FDroidDatabase db = FDroidDatabaseHolder.getDb(activity);
Transformations.distinctUntilChanged(db.getRepositoryDao().getLiveCategories()).observe(activity, this);
View categoriesView = activity.getLayoutInflater().inflate(R.layout.main_tab_categories, parent, true);
categoryAdapter = new CategoryAdapter(activity, activity.getSupportLoaderManager());
categoryAdapter = new CategoryAdapter(activity, db);
emptyState = (TextView) categoriesView.findViewById(R.id.empty_state);
@@ -92,49 +89,17 @@ class CategoriesViewBinder implements LoaderManager.LoaderCallbacks<Cursor> {
}
}
});
activity.getSupportLoaderManager().restartLoader(LOADER_ID, null, this);
}
@NonNull
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
if (id != LOADER_ID) {
throw new IllegalArgumentException("id != LOADER_ID");
}
return new CursorLoader(
activity,
CategoryProvider.getAllCategories(),
Schema.CategoryTable.Cols.ALL,
null,
null,
null
);
}
/**
* Reads all categories from the cursor and stores them in memory to provide to the {@link CategoryAdapter}.
* <p>
* It does this so it is easier to deal with localized/unlocalized categories without having
* to store the localized version in the database. It is not expected that the list of categories
* will grow so large as to make this a performance concern. If it does in the future, the
* {@link CategoryAdapter} can be reverted to wrap the cursor again, and localized category
* names can be stored in the database (allowing sorting in their localized form).
* Gets all categories from the DB and stores them in memory to provide to the {@link CategoryAdapter}.
*/
@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
if (loader.getId() != LOADER_ID || cursor == null) {
return;
public void onChanged(List<Category> categories) {
List<String> categoryNames = new ArrayList<>(categories.size());
for (Category c : categories) {
categoryNames.add(c.getId());
}
List<String> categoryNames = new ArrayList<>(cursor.getCount());
cursor.moveToFirst();
while (!cursor.isAfterLast()) {
categoryNames.add(cursor.getString(cursor.getColumnIndex(Schema.CategoryTable.Cols.NAME)));
cursor.moveToNext();
}
Collections.sort(categoryNames, new Comparator<String>() {
@Override
public int compare(String categoryOne, String categoryTwo) {
@@ -155,13 +120,4 @@ class CategoriesViewBinder implements LoaderManager.LoaderCallbacks<Cursor> {
}
}
@Override
public void onLoaderReset(Loader<Cursor> loader) {
if (loader.getId() != LOADER_ID) {
return;
}
categoryAdapter.setCategories(Collections.<String>emptyList());
}
}

View File

@@ -33,6 +33,7 @@ import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
@@ -51,12 +52,15 @@ import org.acra.config.CoreConfigurationBuilder;
import org.acra.config.DialogConfigurationBuilder;
import org.acra.config.MailSenderConfigurationBuilder;
import org.apache.commons.net.util.SubnetUtils;
import org.fdroid.database.FDroidDatabase;
import org.fdroid.database.FDroidDatabaseHolder;
import org.fdroid.database.Repository;
import org.fdroid.fdroid.Preferences.ChangeListener;
import org.fdroid.fdroid.Preferences.Theme;
import org.fdroid.fdroid.compat.PRNGFixes;
import org.fdroid.fdroid.data.App;
import org.fdroid.fdroid.data.AppProvider;
import org.fdroid.fdroid.data.InstalledAppProviderService;
import org.fdroid.fdroid.data.DBHelper;
import org.fdroid.fdroid.data.Repo;
import org.fdroid.fdroid.installer.ApkFileProvider;
import org.fdroid.fdroid.installer.InstallHistoryService;
@@ -79,8 +83,13 @@ import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.content.ContextCompat;
import androidx.core.os.ConfigurationCompat;
import androidx.core.os.LocaleListCompat;
import info.guardianproject.netcipher.NetCipher;
import info.guardianproject.netcipher.proxy.OrbotHelper;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class FDroidApp extends Application implements androidx.work.Configuration.Provider {
@@ -100,6 +109,8 @@ public class FDroidApp extends Application implements androidx.work.Configuratio
public static volatile String bssid;
public static volatile Repo repo = new Repo();
public static volatile List<Repository> repos;
public static volatile int networkState = ConnectivityMonitorService.FLAG_NET_UNAVAILABLE;
public static final SubnetUtils.SubnetInfo UNSET_SUBNET_INFO = new SubnetUtils("0.0.0.0/32").getInfo();
@@ -241,7 +252,15 @@ public class FDroidApp extends Application implements androidx.work.Configuratio
currentLocale = newConfig.getLocales().toString();
}
if (!TextUtils.equals(lastLocale, currentLocale)) {
UpdateService.forceUpdateRepo(this);
FDroidDatabase db = DBHelper.getDb(this.getApplicationContext());
Single.fromCallable(() -> {
long now = System.currentTimeMillis();
LocaleListCompat locales =
ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration());
db.afterLocalesChanged(locales);
Log.d(TAG, "Updating DB locales took: " + (System.currentTimeMillis() - now) + "ms");
return true;
}).subscribeOn(Schedulers.io()).subscribe();
}
atStartTime.edit().putString(lastLocaleKey, currentLocale).apply();
}
@@ -316,6 +335,11 @@ public class FDroidApp extends Application implements androidx.work.Configuratio
}
}
// keep a static copy of the repositories around and in-sync
// not how one would normally do this, but it is a common pattern in this codebase
FDroidDatabase db = FDroidDatabaseHolder.getDb(this);
db.getRepositoryDao().getLiveRepositories().observeForever(repositories -> repos = repositories);
PRNGFixes.apply();
applyTheme();
@@ -331,7 +355,8 @@ public class FDroidApp extends Application implements androidx.work.Configuratio
preferences.setForceOldIndex(true);
}
InstalledAppProviderService.compareToPackageManager(this);
// TODO should not be needed anymore
//InstalledAppProviderService.compareToPackageManager(this);
// If the user changes the preference to do with filtering anti-feature apps,
// it is easier to just notify a change in the app provider,
@@ -528,6 +553,15 @@ public class FDroidApp extends Application implements androidx.work.Configuratio
}
}
@Nullable
public static Repository getRepo(long repoId) {
if (repos == null) return null;
for (Repository r : repos) {
if (r.getRepoId() == repoId) return r;
}
return null;
}
public static Context getInstance() {
return instance;
}

View File

@@ -28,7 +28,6 @@ import android.content.ContentValues;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.res.Resources.NotFoundException;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Log;
import android.util.Pair;
@@ -46,7 +45,6 @@ import org.fdroid.fdroid.data.RepoXMLHandler;
import org.fdroid.fdroid.data.Schema.RepoTable;
import org.fdroid.fdroid.installer.InstallManagerService;
import org.fdroid.fdroid.installer.InstallerService;
import org.fdroid.fdroid.net.DownloaderFactory;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;
@@ -134,10 +132,11 @@ public class IndexUpdater {
try {
destFile = File.createTempFile("dl-", "", context.getCacheDir());
destFile.deleteOnExit(); // this probably does nothing, but maybe...
downloader = DownloaderFactory.createWithTryFirstMirror(repo, Uri.parse(indexUrl), destFile);
downloader.setCacheTag(repo.lastetag);
downloader.setListener(downloadListener);
downloader.download();
// TODO we don't use this anymore
// downloader = DownloaderFactory.createWithTryFirstMirror(repo, Uri.parse(indexUrl), destFile);
// downloader.setCacheTag(repo.lastetag);
// downloader.setListener(downloadListener);
// downloader.download();
} catch (IOException e) {
if (destFile != null) {
@@ -147,9 +146,6 @@ public class IndexUpdater {
}
throw new UpdateException(repo, "Error getting F-Droid index file", e);
} catch (InterruptedException e) {
// ignored if canceled, the local database just won't be updated
e.printStackTrace();
} // TODO is it safe to delete destFile in finally block?
return new Pair<>(downloader, destFile);
}
@@ -255,19 +251,19 @@ public class IndexUpdater {
protected final ProgressListener downloadListener = new ProgressListener() {
@Override
public void onProgress(long bytesRead, long totalBytes) {
UpdateService.reportDownloadProgress(context, IndexUpdater.this, bytesRead, totalBytes);
UpdateService.reportDownloadProgress(context, indexUrl, bytesRead, totalBytes);
}
};
protected final ProgressListener processIndexListener = new ProgressListener() {
@Override
public void onProgress(long bytesRead, long totalBytes) {
UpdateService.reportProcessIndexProgress(context, IndexUpdater.this, bytesRead, totalBytes);
UpdateService.reportProcessIndexProgress(context, indexUrl, bytesRead, totalBytes);
}
};
protected void notifyProcessingApps(int appsSaved, int totalApps) {
UpdateService.reportProcessingAppsProgress(context, this, appsSaved, totalApps);
UpdateService.reportProcessingAppsProgress(context, indexUrl, appsSaved, totalApps);
}
protected void notifyCommittingToDb() {

View File

@@ -25,7 +25,6 @@ package org.fdroid.fdroid;
import android.content.ContentValues;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Log;
@@ -41,7 +40,6 @@ import com.fasterxml.jackson.databind.InjectableValues;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.io.FileUtils;
import org.fdroid.download.Downloader;
import org.fdroid.fdroid.data.Apk;
import org.fdroid.fdroid.data.App;
import org.fdroid.fdroid.data.Repo;
@@ -49,7 +47,6 @@ import org.fdroid.fdroid.data.RepoPersister;
import org.fdroid.fdroid.data.RepoProvider;
import org.fdroid.fdroid.data.RepoPushRequest;
import org.fdroid.fdroid.data.Schema;
import org.fdroid.fdroid.net.DownloaderFactory;
import java.io.File;
import java.io.IOException;
@@ -63,7 +60,6 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
/**
* Receives the index data about all available apps and packages via the V1
@@ -105,43 +101,42 @@ public class IndexV1Updater extends IndexUpdater {
@Override
public boolean update() throws IndexUpdater.UpdateException {
File destFile = null;
Downloader downloader;
// Downloader downloader;
try {
destFile = File.createTempFile("dl-", "", context.getCacheDir());
destFile.deleteOnExit(); // this probably does nothing, but maybe...
// TODO we don't use that anymore
// read file name from file
downloader = DownloaderFactory.createWithTryFirstMirror(repo, Uri.parse(indexUrl), destFile);
downloader.setCacheTag(repo.lastetag);
downloader.setListener(downloadListener);
downloader.download();
hasChanged = downloader.hasChanged();
// downloader = DownloaderFactory.createWithTryFirstMirror(repo, Uri.parse(indexUrl), destFile);
// downloader.setCacheTag(repo.lastetag);
// downloader.setListener(downloadListener);
// downloader.download();
// hasChanged = downloader.hasChanged();
if (!hasChanged) {
return true;
}
processDownloadedIndex(destFile, downloader.getCacheTag());
// processDownloadedIndex(destFile, downloader.getCacheTag());
} catch (IOException e) {
if (destFile != null) {
FileUtils.deleteQuietly(destFile);
}
throw new IndexUpdater.UpdateException(repo, "Error getting F-Droid index file", e);
} catch (InterruptedException e) {
// ignored if canceled, the local database just won't be updated
} // TODO is it safe to delete destFile in finally block?
return true;
}
private void processDownloadedIndex(File outputFile, String cacheTag)
throws IOException, IndexUpdater.UpdateException {
JarFile jarFile = new JarFile(outputFile, true);
JarEntry indexEntry = (JarEntry) jarFile.getEntry(DATA_FILE_NAME);
InputStream indexInputStream = new ProgressBufferedInputStream(jarFile.getInputStream(indexEntry),
processIndexListener, (int) indexEntry.getSize());
processIndexV1(indexInputStream, indexEntry, cacheTag);
jarFile.close();
}
//private void processDownloadedIndex(File outputFile, String cacheTag)
// throws IOException, IndexUpdater.UpdateException {
// JarFile jarFile = new JarFile(outputFile, true);
// JarEntry indexEntry = (JarEntry) jarFile.getEntry(DATA_FILE_NAME);
// InputStream indexInputStream = new ProgressBufferedInputStream(jarFile.getInputStream(indexEntry),
// processIndexListener, (int) indexEntry.getSize());
// processIndexV1(indexInputStream, indexEntry, cacheTag);
// jarFile.close();
//}
/**
* Get the standard {@link ObjectMapper} instance used for parsing {@code index-v1.json}.

View File

@@ -37,21 +37,27 @@ import android.text.TextUtils;
import android.util.Log;
import android.widget.Toast;
import org.fdroid.CompatibilityChecker;
import org.fdroid.CompatibilityCheckerImpl;
import org.fdroid.database.Repository;
import org.fdroid.download.Mirror;
import org.fdroid.fdroid.data.Apk;
import org.fdroid.fdroid.data.ApkProvider;
import org.fdroid.fdroid.data.App;
import org.fdroid.fdroid.data.AppProvider;
import org.fdroid.fdroid.data.DBHelper;
import org.fdroid.fdroid.data.InstalledAppProviderService;
import org.fdroid.fdroid.data.Repo;
import org.fdroid.fdroid.data.RepoProvider;
import org.fdroid.fdroid.data.Schema;
import org.fdroid.fdroid.installer.InstallManagerService;
import org.fdroid.fdroid.net.BluetoothDownloader;
import org.fdroid.fdroid.net.ConnectivityMonitorService;
import org.fdroid.fdroid.net.DownloaderFactory;
import org.fdroid.index.v1.IndexUpdateListener;
import org.fdroid.index.v1.IndexUpdateResult;
import org.fdroid.index.v1.IndexUpdaterKt;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import androidx.annotation.NonNull;
@@ -250,28 +256,19 @@ public class UpdateService extends JobIntentService {
}
}
/**
* Return a {@link List} of all {@link Repo}s that have either a local
* canonical URL or a local mirror URL. These are repos that can be
* updated and used without using the Internet.
*/
public static List<Repo> getLocalRepos(Context context) {
return getLocalRepos(RepoProvider.Helper.all(context));
}
/**
* Return the repos in the {@code repos} {@link List} that have either a
* local canonical URL or a local mirror URL. These are repos that can be
* updated and used without using the Internet.
*/
public static List<Repo> getLocalRepos(List<Repo> repos) {
ArrayList<Repo> localRepos = new ArrayList<>();
for (Repo repo : repos) {
if (isLocalRepoAddress(repo.address)) {
public static List<Repository> getLocalRepos(List<Repository> repos) {
ArrayList<Repository> localRepos = new ArrayList<>();
for (Repository repo : repos) {
if (isLocalRepoAddress(repo.getAddress())) {
localRepos.add(repo);
} else {
for (String mirrorAddress : repo.getMirrorList()) {
if (isLocalRepoAddress(mirrorAddress)) {
for (Mirror mirror : repo.getMirrors()) {
if (!mirror.isHttp()) {
localRepos.add(repo);
break;
}
@@ -422,10 +419,10 @@ public class UpdateService extends JobIntentService {
try {
final Preferences fdroidPrefs = Preferences.get();
// Grab some preliminary information, then we can release the
// database while we do all the downloading, etc...
List<Repo> repos = RepoProvider.Helper.all(this);
// always get repos fresh from DB, because
// * when an update is requested early at app start, the repos above might not be available, yet
// * when an update is requested when adding a new repo, it might not be in the FDroidApp list, yet
List<Repository> repos = db.getRepositoryDao().getRepositories();
// See if it's time to actually do anything yet...
int netState = ConnectivityMonitorService.getNetworkState(this);
@@ -433,7 +430,7 @@ public class UpdateService extends JobIntentService {
Utils.debugLog(TAG, "skipping internet check, this is local: " + address);
} else if (netState == ConnectivityMonitorService.FLAG_NET_UNAVAILABLE) {
// keep track of repos that have a local copy in case internet is not available
List<Repo> localRepos = getLocalRepos(repos);
List<Repository> localRepos = getLocalRepos(repos);
if (localRepos.size() > 0) {
repos = localRepos;
} else {
@@ -447,7 +444,7 @@ public class UpdateService extends JobIntentService {
Utils.debugLog(TAG, "manually requested or forced update");
if (forcedUpdate) {
DBHelper.resetTransient(this);
InstalledAppProviderService.compareToPackageManager(this);
// InstalledAppProviderService.compareToPackageManager(this);
}
} else if (!fdroidPrefs.isBackgroundDownloadAllowed() && !fdroidPrefs.isOnDemandDownloadAllowed()) {
Utils.debugLog(TAG, "don't run update");
@@ -464,34 +461,33 @@ public class UpdateService extends JobIntentService {
ArrayList<CharSequence> repoErrors = new ArrayList<>();
boolean changes = false;
boolean singleRepoUpdate = !TextUtils.isEmpty(address);
for (final Repo repo : repos) {
if (!repo.inuse) {
continue;
}
if (singleRepoUpdate && !repo.address.equals(address)) {
for (final Repository repo : repos) {
if (!repo.getEnabled()) continue;
if (!singleRepoUpdate && repo.isSwap()) continue;
if (singleRepoUpdate && !repo.getAddress().equals(address)) {
unchangedRepos++;
continue;
}
if (!singleRepoUpdate && repo.isSwap) {
continue;
}
sendStatus(this, STATUS_INFO, getString(R.string.status_connecting_to_repo, repo.address));
sendStatus(this, STATUS_INFO, getString(R.string.status_connecting_to_repo, repo.getAddress()));
try {
IndexUpdater updater = new IndexV1Updater(this, repo);
if (Preferences.get().isForceOldIndexEnabled() || !updater.update()) {
updater = new IndexUpdater(getBaseContext(), repo);
updater.update();
}
if (updater.hasChanged()) {
final String canonicalUri = IndexUpdaterKt.getCanonicalUri(repo).toString();
final IndexUpdateListener listener = new UpdateServiceListener(this, canonicalUri);
final CompatibilityChecker compatChecker =
new CompatibilityCheckerImpl(getPackageManager(), Preferences.get().forceTouchApps());
// TODO try new v2 index first
final org.fdroid.index.v1.IndexV1Updater updater = new org.fdroid.index.v1.IndexV1Updater(
getApplicationContext(), DownloaderFactory.INSTANCE, compatChecker);
final long repoId = repo.getRepoId();
final String certificate = Objects.requireNonNull(repo.getCertificate());
IndexUpdateResult result = updater.update(repoId, certificate, listener);
if (result == IndexUpdateResult.UNCHANGED) {
unchangedRepos++;
} else if (result == IndexUpdateResult.PROCESSED) {
updatedRepos++;
changes = true;
} else {
unchangedRepos++;
}
} catch (IndexUpdater.UpdateException e) {
} catch (Exception e) {
errorRepos++;
Throwable cause = e.getCause();
if (cause == null) {
@@ -499,7 +495,7 @@ public class UpdateService extends JobIntentService {
} else {
repoErrors.add(e.getLocalizedMessage() + "" + cause.getLocalizedMessage());
}
Log.e(TAG, "Error updating repository " + repo.address);
Log.e(TAG, "Error updating repository " + repo.getAddress());
e.printStackTrace();
}
@@ -560,6 +556,7 @@ public class UpdateService extends JobIntentService {
* to be updated, it is queued last.
*/
public static void autoDownloadUpdates(Context context) {
// TODO adapt to new DB
List<App> canUpdate = AppProvider.Helper.findCanUpdate(context, Schema.AppMetadataTable.Cols.ALL);
String packageName = context.getPackageName();
App updateLastApp = null;
@@ -588,9 +585,9 @@ public class UpdateService extends JobIntentService {
}
}
public static void reportDownloadProgress(Context context, IndexUpdater updater,
public static void reportDownloadProgress(Context context, String indexUrl,
long bytesRead, long totalBytes) {
Utils.debugLog(TAG, "Downloading " + updater.indexUrl + "(" + bytesRead + "/" + totalBytes + ")");
Utils.debugLog(TAG, "Downloading " + indexUrl + "(" + bytesRead + "/" + totalBytes + ")");
String downloadedSizeFriendly = Utils.getFriendlySize(bytesRead);
int percent = -1;
if (totalBytes > 0) {
@@ -599,27 +596,26 @@ public class UpdateService extends JobIntentService {
String message;
if (totalBytes == -1) {
message = context.getString(R.string.status_download_unknown_size,
updater.indexUrl, downloadedSizeFriendly);
indexUrl, downloadedSizeFriendly);
percent = -1;
} else {
String totalSizeFriendly = Utils.getFriendlySize(totalBytes);
message = context.getString(R.string.status_download,
updater.indexUrl, downloadedSizeFriendly, totalSizeFriendly, percent);
indexUrl, downloadedSizeFriendly, totalSizeFriendly, percent);
}
sendStatus(context, STATUS_INFO, message, percent);
}
public static void reportProcessIndexProgress(Context context, IndexUpdater updater,
long bytesRead, long totalBytes) {
Utils.debugLog(TAG, "Processing " + updater.indexUrl + "(" + bytesRead + "/" + totalBytes + ")");
public static void reportProcessIndexProgress(Context context, String indexUrl, long bytesRead, long totalBytes) {
Utils.debugLog(TAG, "Processing " + indexUrl + "(" + bytesRead + "/" + totalBytes + ")");
String downloadedSize = Utils.getFriendlySize(bytesRead);
String totalSize = Utils.getFriendlySize(totalBytes);
int percent = -1;
if (totalBytes > 0) {
percent = Utils.getPercent(bytesRead, totalBytes);
}
String message = context.getString(R.string.status_processing_xml_percent,
updater.indexUrl, downloadedSize, totalSize, percent);
String message = context.getString(R.string.status_processing_xml_percent, indexUrl, downloadedSize,
totalSize, percent);
sendStatus(context, STATUS_INFO, message, percent);
}
@@ -631,12 +627,11 @@ public class UpdateService extends JobIntentService {
* "Saving app details" sent to the user. If you know how many apps you have
* processed, then a message of "Saving app details (x/total)" is displayed.
*/
public static void reportProcessingAppsProgress(Context context, IndexUpdater updater,
int appsSaved, int totalApps) {
Utils.debugLog(TAG, "Committing " + updater.indexUrl + "(" + appsSaved + "/" + totalApps + ")");
public static void reportProcessingAppsProgress(Context context, String indexUrl, int appsSaved, int totalApps) {
Utils.debugLog(TAG, "Committing " + indexUrl + "(" + appsSaved + "/" + totalApps + ")");
if (totalApps > 0) {
String message = context.getString(R.string.status_inserting_x_apps,
appsSaved, totalApps, updater.indexUrl);
appsSaved, totalApps, indexUrl);
sendStatus(context, STATUS_INFO, message, Utils.getPercent(appsSaved, totalApps));
} else {
String message = context.getString(R.string.status_inserting_apps);

View File

@@ -0,0 +1,28 @@
package org.fdroid.fdroid;
import android.content.Context;
import androidx.annotation.NonNull;
import org.fdroid.database.Repository;
import org.fdroid.fdroid.data.App;
import org.fdroid.index.IndexUpdateListener;
class UpdateServiceListener implements IndexUpdateListener {
private final Context context;
UpdateServiceListener(Context context) {
this.context = context;
}
@Override
public void onDownloadProgress(@NonNull Repository repo, long bytesRead, long totalBytes) {
UpdateService.reportDownloadProgress(context, repo.getAddress(), bytesRead, totalBytes);
}
@Override
public void onUpdateProgress(@NonNull Repository repo, int appsProcessed, int totalApps) {
UpdateService.reportProcessingAppsProgress(context, repo.getName(App.getLocales()), appsProcessed, totalApps);
}
}

View File

@@ -55,11 +55,16 @@ import com.google.zxing.BarcodeFormat;
import com.google.zxing.encode.Contents;
import com.google.zxing.encode.QRCodeEncoder;
import org.fdroid.database.AppOverviewItem;
import org.fdroid.database.Repository;
import org.fdroid.download.DownloadRequest;
import org.fdroid.download.Mirror;
import org.fdroid.fdroid.compat.FileCompat;
import org.fdroid.fdroid.data.App;
import org.fdroid.fdroid.data.Repo;
import org.fdroid.fdroid.data.SanitizedFile;
import org.fdroid.fdroid.data.Schema;
import org.fdroid.fdroid.net.TreeUriDownloader;
import org.xml.sax.XMLReader;
import java.io.Closeable;
@@ -70,6 +75,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.Charset;
@@ -101,7 +107,12 @@ import androidx.appcompat.app.AppCompatActivity;
import androidx.core.util.Consumer;
import androidx.core.util.Supplier;
import androidx.core.view.DisplayCompat;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import info.guardianproject.netcipher.NetCipher;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.Disposable;
@@ -497,6 +508,31 @@ public final class Utils {
app.loadWithGlide(context).apply(iconRequestOptions).into(iv);
}
@Deprecated
public static void setIconFromRepoOrPM(@NonNull AppOverviewItem app, ImageView iv, Context context) {
String iconPath = app.getIcon(App.systemLocaleList);
if (iconPath == null) return;
if (iconRequestOptions == null) {
iconRequestOptions = new RequestOptions()
.error(R.drawable.ic_repo_app_default)
.fallback(R.drawable.ic_repo_app_default);
}
iconRequestOptions.onlyRetrieveFromCache(!Preferences.get().isBackgroundDownloadAllowed());
Repository repo = FDroidApp.getRepo(app.getRepoId());
if (repo == null) return;
if (repo.getAddress().startsWith("content://")) {
// TODO check if this works
String uri = repo.getAddress() + TreeUriDownloader.ESCAPED_SLASH + iconPath;
Glide.with(context).load(uri).apply(iconRequestOptions).into(iv);
} else {
List<Mirror> mirrors = repo.getMirrors();
Proxy proxy = NetCipher.getProxy();
DownloadRequest request = new DownloadRequest(iconPath, mirrors, proxy, null, null);
Glide.with(context).load(request).apply(iconRequestOptions).into(iv);
}
}
/**
* Get the checksum hash of the file {@code file} using the algorithm in {@code hashAlgo}.
* {@code file} must exist on the filesystem and {@code hashAlgo} must be supported
@@ -642,8 +678,20 @@ public final class Utils {
return (int) TimeUnit.MILLISECONDS.toDays(msDiff);
}
/**
* Calculate the number of days since the given date.
*/
public static int daysSince(long ms) {
long msDiff = Calendar.getInstance().getTimeInMillis() - ms;
return (int) TimeUnit.MILLISECONDS.toDays(msDiff);
}
public static String formatLastUpdated(@NonNull Resources res, @NonNull Date date) {
long msDiff = Calendar.getInstance().getTimeInMillis() - date.getTime();
return formatLastUpdated(res, date.getTime());
}
public static String formatLastUpdated(@NonNull Resources res, long date) {
long msDiff = Calendar.getInstance().getTimeInMillis() - date;
long days = msDiff / DateUtils.DAY_IN_MILLIS;
long weeks = msDiff / (DateUtils.DAY_IN_MILLIS * 7);
long months = msDiff / (DateUtils.DAY_IN_MILLIS * 30);
@@ -920,6 +968,33 @@ public final class Utils {
.doOnError(throwable -> Log.e(TAG, "Could not encode QR as bitmap", throwable));
}
public static <T> Disposable runOffUiThread(Supplier<T> supplier, Consumer<T> consumer) {
return Single.fromCallable(supplier::get)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(consumer::accept);
}
public static Disposable runOffUiThread(Runnable runnable) {
return Single.fromCallable(() -> {
runnable.run();
return true;
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe();
}
public static <T> void observeOnce(LiveData<T> liveData, LifecycleOwner lifecycleOwner, Consumer<T> consumer) {
liveData.observe(lifecycleOwner, new Observer<T>() {
@Override
public void onChanged(T t) {
consumer.accept(t);
liveData.removeObserver(this);
}
});
}
/**
* Keep an instance of this class as an field in an AppCompatActivity for figuring out whether the on
* screen keyboard is currently visible or not.

View File

@@ -355,6 +355,7 @@ public class InstalledAppProviderService extends JobIntentService {
* into the database when under test.
*/
static void insertAppIntoDb(Context context, PackageInfo packageInfo, String hashType, String hash) {
if (true) return;
Log.d(TAG, "insertAppIntoDb " + packageInfo.packageName);
Uri uri = InstalledAppProvider.getContentUri();
ContentValues contentValues = new ContentValues();

View File

@@ -3,8 +3,10 @@ package org.fdroid.fdroid.net;
import android.content.ContentResolver;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.fdroid.database.Repository;
import org.fdroid.download.DownloadRequest;
import org.fdroid.download.Downloader;
import org.fdroid.download.HttpDownloader;
@@ -12,7 +14,6 @@ import org.fdroid.download.HttpManager;
import org.fdroid.download.Mirror;
import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.Repo;
import java.io.File;
import java.io.IOException;
@@ -21,32 +22,25 @@ import java.util.List;
import info.guardianproject.netcipher.NetCipher;
public class DownloaderFactory {
public class DownloaderFactory extends org.fdroid.download.DownloaderFactory {
private static final String TAG = "DownloaderFactory";
// TODO move to application object or inject where needed
public static final DownloaderFactory INSTANCE = new DownloaderFactory();
public static final HttpManager HTTP_MANAGER =
new HttpManager(Utils.getUserAgent(), FDroidApp.queryString, NetCipher.getProxy());
/**
* Same as {@link #create(Repo, Uri, File)}, but trying canonical address first.
* <p>
* See https://gitlab.com/fdroid/fdroidclient/-/issues/1708 for why this is still needed.
*/
public static Downloader createWithTryFirstMirror(Repo repo, Uri uri, File destFile)
throws IOException {
Mirror tryFirst = new Mirror(repo.address);
List<Mirror> mirrors = Mirror.fromStrings(repo.getMirrorList());
return create(repo, mirrors, uri, destFile, tryFirst);
}
public static Downloader create(Repo repo, Uri uri, File destFile) throws IOException {
List<Mirror> mirrors = Mirror.fromStrings(repo.getMirrorList());
@NonNull
@Override
public Downloader create(Repository repo, @NonNull Uri uri, @NonNull File destFile) throws IOException {
List<Mirror> mirrors = repo.getMirrors();
return create(repo, mirrors, uri, destFile, null);
}
private static Downloader create(Repo repo, List<Mirror> mirrors, Uri uri, File destFile,
@Nullable Mirror tryFirst) throws IOException {
@NonNull
@Override
protected Downloader create(@NonNull Repository repo, @NonNull List<Mirror> mirrors, @NonNull Uri uri,
@NonNull File destFile, @Nullable Mirror tryFirst) throws IOException {
Downloader downloader;
String scheme = uri.getScheme();
@@ -57,11 +51,11 @@ public class DownloaderFactory {
} else if (ContentResolver.SCHEME_FILE.equals(scheme)) {
downloader = new LocalFileDownloader(uri, destFile);
} else {
String path = uri.toString().replace(repo.address, "");
String path = uri.toString().replace(repo.getAddress(), "");
Utils.debugLog(TAG, "Using suffix " + path + " with mirrors " + mirrors);
Proxy proxy = NetCipher.getProxy();
DownloadRequest request =
new DownloadRequest(path, mirrors, proxy, repo.username, repo.password, tryFirst);
new DownloadRequest(path, mirrors, proxy, repo.getUsername(), repo.getPassword(), tryFirst);
downloader = new HttpDownloader(HTTP_MANAGER, request, destFile);
}
return downloader;

View File

@@ -33,12 +33,14 @@ import android.text.TextUtils;
import android.util.Log;
import android.util.LogPrinter;
import org.fdroid.database.FDroidDatabase;
import org.fdroid.database.Repository;
import org.fdroid.download.Downloader;
import org.fdroid.fdroid.BuildConfig;
import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.ProgressListener;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.Repo;
import org.fdroid.fdroid.data.RepoProvider;
import org.fdroid.fdroid.data.DBHelper;
import org.fdroid.fdroid.data.SanitizedFile;
import org.fdroid.fdroid.installer.ApkCache;
@@ -248,8 +250,14 @@ public class DownloaderService extends Service {
try {
activeCanonicalUrl = canonicalUrl.toString();
final Repo repo = RepoProvider.Helper.findById(this, repoId);
downloader = DownloaderFactory.create(repo, canonicalUrl, localFile);
Repository repo = FDroidApp.getRepo(repoId);
if (repo == null) {
// right after the app gets re-recreated downloads get re-triggered, so repo can still be null
FDroidDatabase db = DBHelper.getDb(getApplicationContext());
repo = db.getRepositoryDao().getRepository(repoId);
if (repo == null) return; // repo might have been deleted in the meantime
}
downloader = DownloaderFactory.INSTANCE.create(repo, canonicalUrl, localFile);
downloader.setListener(new ProgressListener() {
@Override
public void onProgress(long bytesRead, long totalBytes) {

View File

@@ -24,6 +24,8 @@ public class PackageManagerReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
// TODO might not be needed anymore
if (true) return;
if (intent != null) {
String action = intent.getAction();
if (Intent.ACTION_PACKAGE_ADDED.equals(action)) {

View File

@@ -11,10 +11,11 @@ import android.util.AttributeSet;
import android.view.Gravity;
import android.view.View;
import org.fdroid.database.Repository;
import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.Preferences;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.UpdateService;
import org.fdroid.fdroid.data.Repo;
import org.fdroid.fdroid.net.ConnectivityMonitorService;
import java.util.Arrays;
@@ -47,7 +48,6 @@ public class StatusBanner extends androidx.appcompat.widget.AppCompatTextView {
private int networkState = ConnectivityMonitorService.FLAG_NET_NO_LIMIT;
private int overDataState;
private int overWiFiState;
private List<Repo> localRepos;
private final SharedPreferences preferences;
@@ -86,7 +86,6 @@ public class StatusBanner extends androidx.appcompat.widget.AppCompatTextView {
overDataState = Preferences.get().getOverData();
overWiFiState = Preferences.get().getOverWifi();
localRepos = UpdateService.getLocalRepos(context);
preferences.registerOnSharedPreferenceChangeListener(dataWifiChangeListener);
setBannerTextAndVisibility();
@@ -108,8 +107,6 @@ public class StatusBanner extends androidx.appcompat.widget.AppCompatTextView {
* mirror on a USB OTG thumb drive. Local repos on system partitions are
* not treated as local mirrors here, they are shipped as part of the
* device, and users are generally not aware of them.
*
* @see org.fdroid.fdroid.data.DBHelper#loadAdditionalRepos(String)
*/
private void setBannerTextAndVisibility() {
if (updateServiceStatus == UpdateService.STATUS_INFO) {
@@ -121,11 +118,11 @@ public class StatusBanner extends androidx.appcompat.widget.AppCompatTextView {
setVisibility(View.VISIBLE);
} else if (overDataState == Preferences.OVER_NETWORK_NEVER
&& overWiFiState == Preferences.OVER_NETWORK_NEVER) {
localRepos = UpdateService.getLocalRepos(getContext());
List<Repository> localRepos = UpdateService.getLocalRepos(FDroidApp.repos);
boolean hasLocalNonSystemRepos = true;
final List<String> systemPartitions = Arrays.asList("odm", "oem", "product", "system", "vendor");
for (Repo repo : localRepos) {
for (String segment : Uri.parse(repo.address).getPathSegments()) {
for (Repository repo : localRepos) {
for (String segment : Uri.parse(repo.getAddress()).getPathSegments()) {
if (systemPartitions.contains(segment)) {
hasLocalNonSystemRepos = false;
}

View File

@@ -1,6 +1,7 @@
package org.fdroid.fdroid.views.categories;
import android.content.Intent;
import android.content.res.Resources;
import android.os.Bundle;
import android.view.View;
import android.widget.ImageView;
@@ -16,10 +17,13 @@ import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityOptionsCompat;
import androidx.core.content.ContextCompat;
import androidx.core.os.ConfigurationCompat;
import androidx.core.util.Pair;
import androidx.core.view.ViewCompat;
import androidx.recyclerview.widget.RecyclerView;
import org.fdroid.database.AppOverviewItem;
/**
* The {@link AppCardController} can bind an app to several different layouts, as long as the layout
* contains the following elements:
@@ -51,7 +55,7 @@ public class AppCardController extends RecyclerView.ViewHolder
private final TextView newTag;
@Nullable
private App currentApp;
private AppOverviewItem currentApp;
private final AppCompatActivity activity;
@@ -68,10 +72,16 @@ public class AppCardController extends RecyclerView.ViewHolder
itemView.setOnClickListener(this);
}
public void bindApp(@NonNull App app) {
public void bindApp(@NonNull AppOverviewItem app) {
if (App.systemLocaleList == null) {
App.systemLocaleList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration());
}
currentApp = app;
summary.setText(Utils.formatAppNameAndSummary(app.name, app.summary));
String name = app.getName(App.systemLocaleList);
summary.setText(
Utils.formatAppNameAndSummary(name == null ? "" : name, app.getSummary(App.systemLocaleList))
);
if (newTag != null) {
if (isConsideredNew(app)) {
@@ -83,13 +93,11 @@ public class AppCardController extends RecyclerView.ViewHolder
Utils.setIconFromRepoOrPM(app, icon, icon.getContext());
}
private boolean isConsideredNew(@NonNull App app) {
//noinspection SimplifiableIfStatement
if (app.added == null || app.lastUpdated == null || !app.added.equals(app.lastUpdated)) {
private boolean isConsideredNew(@NonNull AppOverviewItem app) {
if (app.getAdded() != app.getLastUpdated()) {
return false;
}
return Utils.daysSince(app.added) <= DAYS_TO_CONSIDER_NEW;
return Utils.daysSince(app.getAdded()) <= DAYS_TO_CONSIDER_NEW;
}
/**
@@ -102,7 +110,7 @@ public class AppCardController extends RecyclerView.ViewHolder
}
Intent intent = new Intent(activity, AppDetailsActivity.class);
intent.putExtra(AppDetailsActivity.EXTRA_APPID, currentApp.packageName);
intent.putExtra(AppDetailsActivity.EXTRA_APPID, currentApp.getPackageName());
Pair<View, String> iconTransitionPair = Pair.create((View) icon,
activity.getString(R.string.transition_app_item_icon));

View File

@@ -1,18 +1,20 @@
package org.fdroid.fdroid.views.categories;
import android.database.Cursor;
import android.view.ViewGroup;
import org.fdroid.database.AppOverviewItem;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.data.App;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.RecyclerView;
import java.util.Collections;
import java.util.List;
class AppPreviewAdapter extends RecyclerView.Adapter<AppCardController> {
private Cursor cursor;
private List<AppOverviewItem> items = Collections.emptyList();
private final AppCompatActivity activity;
AppPreviewAdapter(AppCompatActivity activity) {
@@ -28,21 +30,20 @@ class AppPreviewAdapter extends RecyclerView.Adapter<AppCardController> {
@Override
public void onBindViewHolder(@NonNull AppCardController holder, int position) {
cursor.moveToPosition(position);
holder.bindApp(new App(cursor));
holder.bindApp(items.get(position));
}
@Override
public int getItemCount() {
return cursor == null ? 0 : cursor.getCount();
return items.size();
}
public void setAppCursor(Cursor cursor) {
if (this.cursor == cursor) {
void setAppCursor(List<AppOverviewItem> items) {
if (this.items == items) {
//don't notify when the cursor did not change
return;
}
this.cursor = cursor;
this.items = items;
notifyDataSetChanged();
}
}

View File

@@ -2,22 +2,26 @@ package org.fdroid.fdroid.views.categories;
import android.view.ViewGroup;
import org.fdroid.database.AppOverviewItem;
import org.fdroid.database.FDroidDatabase;
import org.fdroid.fdroid.R;
import java.util.HashMap;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.loader.app.LoaderManager;
import androidx.lifecycle.LiveData;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
public class CategoryAdapter extends ListAdapter<String, CategoryController> {
private final AppCompatActivity activity;
private final LoaderManager loaderManager;
private final FDroidDatabase db;
private final HashMap<String, LiveData<List<AppOverviewItem>>> liveData = new HashMap<>();
public CategoryAdapter(AppCompatActivity activity, LoaderManager loaderManager) {
public CategoryAdapter(AppCompatActivity activity, FDroidDatabase db) {
super(new DiffUtil.ItemCallback<String>() {
@Override
public boolean areItemsTheSame(String oldItem, String newItem) {
@@ -31,23 +35,30 @@ public class CategoryAdapter extends ListAdapter<String, CategoryController> {
});
this.activity = activity;
this.loaderManager = loaderManager;
this.db = db;
}
@NonNull
@Override
public CategoryController onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new CategoryController(activity, loaderManager, activity.getLayoutInflater()
return new CategoryController(activity, activity.getLayoutInflater()
.inflate(R.layout.category_item, parent, false));
}
@Override
public void onBindViewHolder(@NonNull CategoryController holder, int position) {
holder.bindModel(getItem(position));
String categoryName = getItem(position);
holder.bindModel(categoryName, liveData.get(categoryName));
}
public void setCategories(@NonNull List<String> unlocalizedCategoryNames) {
submitList(unlocalizedCategoryNames);
for (String name: unlocalizedCategoryNames) {
int num = CategoryController.NUM_OF_APPS_PER_CATEGORY_ON_OVERVIEW;
// we are getting the LiveData here and not in the ViewHolder, so the data gets cached here
// this prevents reloads when scrolling
liveData.put(name, db.getAppDao().getAppOverviewItems(name, num));
}
}
}

View File

@@ -3,10 +3,8 @@ package org.fdroid.fdroid.views.categories;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.Color;
import android.graphics.Rect;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.FrameLayout;
@@ -14,28 +12,33 @@ import android.widget.TextView;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.core.view.ViewCompat;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import org.fdroid.database.AppOverviewItem;
import org.fdroid.database.FDroidDatabase;
import org.fdroid.database.FDroidDatabaseHolder;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.AppProvider;
import org.fdroid.fdroid.data.Schema;
import org.fdroid.fdroid.data.Schema.AppMetadataTable.Cols;
import org.fdroid.fdroid.views.apps.AppListActivity;
import org.fdroid.fdroid.views.apps.FeatureImage;
import java.util.List;
import java.util.Locale;
import java.util.Random;
public class CategoryController extends RecyclerView.ViewHolder implements LoaderManager.LoaderCallbacks<Cursor> {
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class CategoryController extends RecyclerView.ViewHolder {
private final Button viewAll;
private final TextView heading;
private final FeatureImage image;
@@ -43,16 +46,18 @@ public class CategoryController extends RecyclerView.ViewHolder implements Loade
private final FrameLayout background;
private final AppCompatActivity activity;
private final LoaderManager loaderManager;
private static final int NUM_OF_APPS_PER_CATEGORY_ON_OVERVIEW = 20;
private final FDroidDatabase db;
static final int NUM_OF_APPS_PER_CATEGORY_ON_OVERVIEW = 20;
private String currentCategory;
@Nullable
private Disposable disposable;
CategoryController(final AppCompatActivity activity, LoaderManager loaderManager, View itemView) {
CategoryController(final AppCompatActivity activity, View itemView) {
super(itemView);
this.activity = activity;
this.loaderManager = loaderManager;
db = FDroidDatabaseHolder.getDb(activity);
appCardsAdapter = new AppPreviewAdapter(activity);
@@ -73,7 +78,8 @@ public class CategoryController extends RecyclerView.ViewHolder implements Loade
return categoryNameId == 0 ? categoryName : context.getString(categoryNameId);
}
void bindModel(@NonNull String categoryName) {
void bindModel(@NonNull String categoryName, LiveData<List<AppOverviewItem>> liveData) {
loadAppItems(liveData);
currentCategory = categoryName;
String translatedName = translateCategory(activity, categoryName);
@@ -81,9 +87,7 @@ public class CategoryController extends RecyclerView.ViewHolder implements Loade
heading.setContentDescription(activity.getString(R.string.tts_category_name, translatedName));
viewAll.setVisibility(View.INVISIBLE);
loaderManager.initLoader(currentCategory.hashCode(), null, this);
loaderManager.initLoader(currentCategory.hashCode() + 1, null, this);
loadNumAppsInCategory();
@ColorInt int backgroundColour = getBackgroundColour(activity, categoryName);
background.setBackgroundColor(backgroundColour);
@@ -98,6 +102,26 @@ public class CategoryController extends RecyclerView.ViewHolder implements Loade
}
}
private void loadAppItems(LiveData<List<AppOverviewItem>> liveData) {
setIsRecyclable(false);
liveData.observe(activity, new Observer<List<AppOverviewItem>>() {
@Override
public void onChanged(List<AppOverviewItem> items) {
appCardsAdapter.setAppCursor(items);
setIsRecyclable(true);
liveData.removeObserver(this);
}
});
}
private void loadNumAppsInCategory() {
if (disposable != null) disposable.dispose();
disposable = Single.fromCallable(() -> db.getAppDao().getNumberOfAppsInCategory(currentCategory))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::setNumAppsInCategory);
}
/**
* @param requiresLowerCaseId Previously categories were translated using strings such as "category_Reading"
* for the "Reading" category. Now we also need to have drawable resources such as
@@ -130,94 +154,13 @@ public class CategoryController extends RecyclerView.ViewHolder implements Loade
return Color.HSVToColor(hsv);
}
/**
* Return either the total apps in the category, or the entries to display
* for a category, depending on the value of {@code id}. This uses a sort
* similar to the one in {@link org.fdroid.fdroid.views.main.LatestViewBinder#onCreateLoader(int, Bundle)}.
* The difference is that this does not treat "new" app any differently.
*
* @see AppProvider#getCategoryUri(String)
* @see AppProvider#getTopFromCategoryUri(String, int)
* @see AppProvider#query(android.net.Uri, String[], String, String[], String)
* @see AppProvider#TOP_FROM_CATEGORY
* @see org.fdroid.fdroid.views.main.LatestViewBinder#onCreateLoader(int, Bundle)
*/
@NonNull
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
final String table = Schema.AppMetadataTable.NAME;
final String added = table + "." + Cols.ADDED;
final String lastUpdated = table + "." + Cols.LAST_UPDATED;
if (id == currentCategory.hashCode() + 1) {
return new CursorLoader(
activity,
AppProvider.getCategoryUri(currentCategory),
new String[]{Schema.AppMetadataTable.Cols._COUNT},
Utils.getAntifeatureSQLFilter(activity),
null,
null
);
} else {
return new CursorLoader(
activity,
AppProvider.getTopFromCategoryUri(currentCategory, NUM_OF_APPS_PER_CATEGORY_ON_OVERVIEW),
new String[]{
Schema.AppMetadataTable.Cols.NAME,
Schema.AppMetadataTable.Cols.Package.PACKAGE_NAME,
Schema.AppMetadataTable.Cols.SUMMARY,
Schema.AppMetadataTable.Cols.ICON_URL,
Schema.AppMetadataTable.Cols.ICON,
Schema.AppMetadataTable.Cols.REPO_ID,
},
Utils.getAntifeatureSQLFilter(activity),
null,
table + "." + Cols.IS_LOCALIZED + " DESC"
+ ", " + table + "." + Cols.NAME + " IS NULL ASC"
+ ", CASE WHEN " + table + "." + Cols.ICON + " IS NULL"
+ " AND " + table + "." + Cols.ICON_URL + " IS NULL"
+ " THEN 1 ELSE 0 END"
+ ", " + table + "." + Cols.SUMMARY + " IS NULL ASC"
+ ", " + table + "." + Cols.DESCRIPTION + " IS NULL ASC"
+ ", CASE WHEN " + table + "." + Cols.PHONE_SCREENSHOTS + " IS NULL"
+ " AND " + table + "." + Cols.SEVEN_INCH_SCREENSHOTS + " IS NULL"
+ " AND " + table + "." + Cols.TEN_INCH_SCREENSHOTS + " IS NULL"
+ " AND " + table + "." + Cols.TV_SCREENSHOTS + " IS NULL"
+ " AND " + table + "." + Cols.WEAR_SCREENSHOTS + " IS NULL"
+ " AND " + table + "." + Cols.FEATURE_GRAPHIC + " IS NULL"
+ " AND " + table + "." + Cols.PROMO_GRAPHIC + " IS NULL"
+ " AND " + table + "." + Cols.TV_BANNER + " IS NULL"
+ " THEN 1 ELSE 0 END"
+ ", " + lastUpdated + " DESC"
+ ", " + added + " ASC"
);
}
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor cursor) {
int topAppsId = currentCategory.hashCode();
int countAllAppsId = topAppsId + 1;
// Anything other than these IDs indicates that the loader which just finished
// is no longer the one this view holder is interested in, due to the user having
// scrolled away already during the asynchronous query being run.
if (loader.getId() == topAppsId) {
appCardsAdapter.setAppCursor(cursor);
} else if (loader.getId() == countAllAppsId) {
cursor.moveToFirst();
int numAppsInCategory = cursor.getInt(0);
viewAll.setVisibility(View.VISIBLE);
Resources r = activity.getResources();
viewAll.setText(r.getQuantityString(R.plurals.button_view_all_apps_in_category, numAppsInCategory,
numAppsInCategory));
viewAll.setContentDescription(r.getQuantityString(R.plurals.tts_view_all_in_category, numAppsInCategory,
numAppsInCategory, currentCategory));
}
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
appCardsAdapter.setAppCursor(null);
private void setNumAppsInCategory(int numAppsInCategory) {
viewAll.setVisibility(View.VISIBLE);
Resources r = activity.getResources();
viewAll.setText(r.getQuantityString(R.plurals.button_view_all_apps_in_category, numAppsInCategory,
numAppsInCategory));
viewAll.setContentDescription(r.getQuantityString(R.plurals.tts_view_all_in_category, numAppsInCategory,
numAppsInCategory, currentCategory));
}
@SuppressWarnings("FieldCanBeLocal")

View File

@@ -2,25 +2,28 @@ package org.fdroid.fdroid.views.main;
import android.content.Context;
import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.Rect;
import android.view.View;
import android.view.ViewGroup;
import org.fdroid.fdroid.BuildConfig;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.data.App;
import org.fdroid.fdroid.views.categories.AppCardController;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.view.ViewCompat;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import org.fdroid.database.AppOverviewItem;
import java.util.List;
public class LatestAdapter extends RecyclerView.Adapter<AppCardController> {
private Cursor cursor;
private List<AppOverviewItem> apps;
private final AppCompatActivity activity;
private final RecyclerView.ItemDecoration appListDecorator;
@@ -95,22 +98,21 @@ public class LatestAdapter extends RecyclerView.Adapter<AppCardController> {
@Override
public void onBindViewHolder(@NonNull AppCardController holder, int position) {
cursor.moveToPosition(position);
final App app = new App(cursor);
final AppOverviewItem app = apps.get(position);
holder.bindApp(app);
}
@Override
public int getItemCount() {
return cursor == null ? 0 : cursor.getCount();
return apps == null ? 0 : apps.size();
}
public void setAppsCursor(Cursor cursor) {
if (this.cursor == cursor) {
//don't notify when the cursor did not change
public void setApps(@Nullable List<AppOverviewItem> apps) {
if (this.apps == apps) {
//don't notify when the apps did not change
return;
}
this.cursor = cursor;
this.apps = apps;
notifyDataSetChanged();
}

View File

@@ -1,8 +1,6 @@
package org.fdroid.fdroid.views.main;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
@@ -11,26 +9,31 @@ import android.widget.TextView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import org.fdroid.database.AppOverviewItem;
import org.fdroid.database.FDroidDatabase;
import org.fdroid.database.Repository;
import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.Preferences;
import org.fdroid.fdroid.Preferences.ChangeListener;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.UpdateService;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.AppProvider;
import org.fdroid.fdroid.data.RepoProvider;
import org.fdroid.fdroid.data.Schema.AppMetadataTable;
import org.fdroid.fdroid.data.Schema.AppMetadataTable.Cols;
import org.fdroid.fdroid.data.Schema.RepoTable;
import org.fdroid.fdroid.data.DBHelper;
import org.fdroid.fdroid.panic.HidingManager;
import org.fdroid.fdroid.views.apps.AppListActivity;
import org.fdroid.fdroid.views.categories.AppCardController;
import java.util.Date;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.Transformations;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
@@ -38,19 +41,31 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
/**
* Loads a list of newly added or recently updated apps and displays them to the user.
*/
class LatestViewBinder implements LoaderManager.LoaderCallbacks<Cursor> {
private static final int LOADER_ID = 978015789;
class LatestViewBinder implements Observer<List<AppOverviewItem>>, ChangeListener {
private final LatestAdapter latestAdapter;
private final AppCompatActivity activity;
private final TextView emptyState;
private final RecyclerView appList;
private final FDroidDatabase db;
private ProgressBar progressBar;
LatestViewBinder(final AppCompatActivity activity, FrameLayout parent) {
this.activity = activity;
activity.getLifecycle().addObserver(new DefaultLifecycleObserver() {
@Override
public void onCreate(@NonNull LifecycleOwner owner) {
Preferences.get().registerAppsRequiringAntiFeaturesChangeListener(LatestViewBinder.this);
}
@Override
public void onDestroy(@NonNull LifecycleOwner owner) {
Preferences.get().unregisterAppsRequiringAntiFeaturesChangeListener(LatestViewBinder.this);
}
});
db = DBHelper.getDb(activity);
Transformations.distinctUntilChanged(db.getAppDao().getAppOverviewItems(200)).observe(activity, this);
View latestView = activity.getLayoutInflater().inflate(R.layout.main_tab_latest, parent, true);
@@ -95,69 +110,13 @@ class LatestViewBinder implements LoaderManager.LoaderCallbacks<Cursor> {
}
}
});
activity.getSupportLoaderManager().initLoader(LOADER_ID, null, this);
}
/**
* Sort by localized first so users see entries in their language,
* then sort by highlighted fields, then sort by whether the app is new,
* then if it has WhatsNew/Changelog entries, then by when it was last
* updated. Last, it sorts by the date the app was added, putting older
* ones first, to give preference to apps that have been maintained in
* F-Droid longer.
*
* @see AppProvider#getLatestTabUri()
*/
@NonNull
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
if (id != LOADER_ID) {
return null;
}
final String table = AppMetadataTable.NAME;
final String added = table + "." + Cols.ADDED;
final String lastUpdated = table + "." + Cols.LAST_UPDATED;
return new CursorLoader(
activity,
AppProvider.getLatestTabUri(),
AppMetadataTable.Cols.ALL,
Utils.getAntifeatureSQLFilter(activity),
null,
table + "." + Cols.IS_LOCALIZED + " DESC"
+ ", " + table + "." + Cols.NAME + " IS NULL ASC"
+ ", CASE WHEN " + table + "." + Cols.ICON + " IS NULL"
+ " AND " + table + "." + Cols.ICON_URL + " IS NULL"
+ " THEN 1 ELSE 0 END"
+ ", " + table + "." + Cols.SUMMARY + " IS NULL ASC"
+ ", " + table + "." + Cols.DESCRIPTION + " IS NULL ASC"
+ ", CASE WHEN " + table + "." + Cols.PHONE_SCREENSHOTS + " IS NULL"
+ " AND " + table + "." + Cols.SEVEN_INCH_SCREENSHOTS + " IS NULL"
+ " AND " + table + "." + Cols.TEN_INCH_SCREENSHOTS + " IS NULL"
+ " AND " + table + "." + Cols.TV_SCREENSHOTS + " IS NULL"
+ " AND " + table + "." + Cols.WEAR_SCREENSHOTS + " IS NULL"
+ " AND " + table + "." + Cols.FEATURE_GRAPHIC + " IS NULL"
+ " AND " + table + "." + Cols.PROMO_GRAPHIC + " IS NULL"
+ " AND " + table + "." + Cols.TV_BANNER + " IS NULL"
+ " THEN 1 ELSE 0 END"
+ ", CASE WHEN date(" + added + ") >= date(" + lastUpdated + ")"
+ " AND date((SELECT " + RepoTable.Cols.LAST_UPDATED + " FROM " + RepoTable.NAME
+ " WHERE _id=" + table + "." + Cols.REPO_ID
+ " ),'-" + AppCardController.DAYS_TO_CONSIDER_NEW + " days') "
+ " < date(" + lastUpdated + ")"
+ " THEN 0 ELSE 1 END"
+ ", " + table + "." + Cols.WHATSNEW + " IS NULL ASC"
+ ", " + lastUpdated + " DESC"
+ ", " + added + " ASC");
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor cursor) {
if (loader.getId() != LOADER_ID) {
return;
}
latestAdapter.setAppsCursor(cursor);
public void onChanged(List<AppOverviewItem> items) {
// filter out anti-features first
filterApps(items);
latestAdapter.setApps(items);
if (latestAdapter.getItemCount() == 0) {
emptyState.setVisibility(View.VISIBLE);
@@ -169,6 +128,48 @@ class LatestViewBinder implements LoaderManager.LoaderCallbacks<Cursor> {
}
}
@Override
public void onPreferenceChange() {
// reload and re-filter apps from DB when anti-feature settings change
LiveData<List<AppOverviewItem>> liveData = db.getAppDao().getAppOverviewItems(200);
liveData.observe(activity, new Observer<List<AppOverviewItem>>() {
@Override
public void onChanged(List<AppOverviewItem> items) {
LatestViewBinder.this.onChanged(items);
liveData.removeObserver(this);
}
});
}
private void filterApps(List<AppOverviewItem> items) {
List<String> antiFeatures = Arrays.asList(activity.getResources().getStringArray(R.array.antifeaturesValues));
Set<String> shownAntiFeatures = Preferences.get().showAppsWithAntiFeatures();
String otherAntiFeatures = activity.getResources().getString(R.string.antiothers_key);
boolean showOtherAntiFeatures = shownAntiFeatures.contains(otherAntiFeatures);
Iterator<AppOverviewItem> iterator = items.iterator();
while (iterator.hasNext()) {
AppOverviewItem item = iterator.next();
if (isFilteredByAntiFeature(item, antiFeatures, shownAntiFeatures, showOtherAntiFeatures)) {
iterator.remove();
}
}
}
private boolean isFilteredByAntiFeature(AppOverviewItem item, List<String> antiFeatures,
Set<String> showAntiFeatures, boolean showOther) {
for (String antiFeature : item.getAntiFeatureKeys()) {
// is it part of the known anti-features?
if (antiFeatures.contains(antiFeature)) {
// it gets filtered not part of the ones that we show
if (!showAntiFeatures.contains(antiFeature)) return true;
} else if (!showOther) {
// gets filtered if we should no show unknown anti-features
return true;
}
}
return false;
}
private void explainEmptyStateToUser() {
if (Preferences.get().isIndexNeverUpdated() && UpdateService.isUpdating()) {
if (progressBar != null) {
@@ -187,11 +188,20 @@ class LatestViewBinder implements LoaderManager.LoaderCallbacks<Cursor> {
emptyStateText.append(activity.getString(R.string.latest__empty_state__no_recent_apps));
emptyStateText.append("\n\n");
int repoCount = RepoProvider.Helper.countEnabledRepos(activity);
int repoCount = 0;
Long lastUpdate = null;
for (Repository repo : FDroidApp.repos) {
if (repo.getEnabled()) {
repoCount++;
if (lastUpdate == null && repo.getLastUpdated() != null) lastUpdate = repo.getLastUpdated();
else if (lastUpdate != null && repo.getLastUpdated() != null && repo.getLastUpdated() > lastUpdate) {
lastUpdate = repo.getLastUpdated();
}
}
}
if (repoCount == 0) {
emptyStateText.append(activity.getString(R.string.latest__empty_state__no_enabled_repos));
} else {
Date lastUpdate = RepoProvider.Helper.lastUpdate(activity);
if (lastUpdate == null) {
emptyStateText.append(activity.getString(R.string.latest__empty_state__never_updated));
} else {
@@ -201,13 +211,4 @@ class LatestViewBinder implements LoaderManager.LoaderCallbacks<Cursor> {
emptyState.setText(emptyStateText.toString());
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
if (loader.getId() != LOADER_ID) {
return;
}
latestAdapter.setAppsCursor(null);
}
}