mirror of
https://github.com/f-droid/fdroidclient.git
synced 2026-01-28 00:37:58 -05:00
Move (Http)Downloader into download library
This commit is contained in:
@@ -10,8 +10,12 @@ import android.util.Log;
|
||||
|
||||
import androidx.core.util.Pair;
|
||||
|
||||
import org.fdroid.download.DownloadManager;
|
||||
import org.fdroid.download.HttpDownloader;
|
||||
import org.fdroid.download.Mirror;
|
||||
import org.fdroid.fdroid.FDroidApp;
|
||||
import org.fdroid.fdroid.ProgressListener;
|
||||
import org.fdroid.fdroid.Utils;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.File;
|
||||
@@ -27,6 +31,7 @@ import java.util.concurrent.TimeUnit;
|
||||
public class HttpDownloaderTest {
|
||||
private static final String TAG = "HttpDownloaderTest";
|
||||
|
||||
private final DownloadManager downloadManager = new DownloadManager(Utils.getUserAgent(), FDroidApp.queryString);
|
||||
private static final Collection<Pair<String, String>> URLS;
|
||||
|
||||
// https://developer.android.com/reference/javax/net/ssl/SSLContext
|
||||
@@ -58,7 +63,8 @@ public class HttpDownloaderTest {
|
||||
Log.i(TAG, "URL: " + pair.first + pair.second);
|
||||
File destFile = File.createTempFile("dl-", "");
|
||||
List<Mirror> mirrors = Mirror.fromStrings(Collections.singletonList(pair.first));
|
||||
HttpDownloader httpDownloader = new HttpDownloader(pair.second, destFile, mirrors);
|
||||
HttpDownloader httpDownloader =
|
||||
new HttpDownloader(downloadManager, pair.second, destFile, mirrors, null, null);
|
||||
httpDownloader.download();
|
||||
assertTrue(destFile.exists());
|
||||
assertTrue(destFile.canRead());
|
||||
@@ -70,10 +76,11 @@ public class HttpDownloaderTest {
|
||||
public void downloadUninterruptedTestWithProgress() throws IOException, InterruptedException {
|
||||
final CountDownLatch latch = new CountDownLatch(1);
|
||||
String path = "index.jar";
|
||||
List<Mirror> mirrors = Mirror.fromStrings(Collections.singletonList("https://f-droid.org/repo/"));
|
||||
List<Mirror> mirrors = Mirror.fromStrings(Collections.singletonList("https://ftp.fau.de/fdroid/repo/"));
|
||||
receivedProgress = false;
|
||||
File destFile = File.createTempFile("dl-", "");
|
||||
final HttpDownloader httpDownloader = new HttpDownloader(path, destFile, mirrors);
|
||||
final HttpDownloader httpDownloader =
|
||||
new HttpDownloader(downloadManager, path, destFile, mirrors, null, null);
|
||||
httpDownloader.setListener(new ProgressListener() {
|
||||
@Override
|
||||
public void onProgress(long bytesRead, long totalBytes) {
|
||||
@@ -105,7 +112,7 @@ public class HttpDownloaderTest {
|
||||
List<Mirror> mirrors = Mirror.fromStrings(Collections.singletonList("https://httpbin.org/basic-auth/"));
|
||||
File destFile = File.createTempFile("dl-", "");
|
||||
HttpDownloader httpDownloader =
|
||||
new HttpDownloader(path, destFile, mirrors, "myusername", "supersecretpassword");
|
||||
new HttpDownloader(downloadManager, path, destFile, mirrors, "myusername", "supersecretpassword");
|
||||
httpDownloader.download();
|
||||
assertTrue(destFile.exists());
|
||||
assertTrue(destFile.canRead());
|
||||
@@ -117,7 +124,8 @@ public class HttpDownloaderTest {
|
||||
String path = "myusername/supersecretpassword";
|
||||
List<Mirror> mirrors = Mirror.fromStrings(Collections.singletonList("https://httpbin.org/basic-auth/"));
|
||||
File destFile = File.createTempFile("dl-", "");
|
||||
HttpDownloader httpDownloader = new HttpDownloader(path, destFile, mirrors, "myusername", "wrongpassword");
|
||||
HttpDownloader httpDownloader =
|
||||
new HttpDownloader(downloadManager, path, destFile, mirrors, "myusername", "wrongpassword");
|
||||
httpDownloader.download();
|
||||
assertFalse(destFile.exists());
|
||||
destFile.deleteOnExit();
|
||||
@@ -129,7 +137,7 @@ public class HttpDownloaderTest {
|
||||
List<Mirror> mirrors = Mirror.fromStrings(Collections.singletonList("https://httpbin.org/basic-auth/"));
|
||||
File destFile = File.createTempFile("dl-", "");
|
||||
HttpDownloader httpDownloader =
|
||||
new HttpDownloader(path, destFile, mirrors, "wrongusername", "supersecretpassword");
|
||||
new HttpDownloader(downloadManager, path, destFile, mirrors, "wrongusername", "supersecretpassword");
|
||||
httpDownloader.download();
|
||||
assertFalse(destFile.exists());
|
||||
destFile.deleteOnExit();
|
||||
@@ -141,7 +149,8 @@ public class HttpDownloaderTest {
|
||||
String path = "index.jar";
|
||||
List<Mirror> mirrors = Mirror.fromStrings(Collections.singletonList("https://f-droid.org/repo/"));
|
||||
File destFile = File.createTempFile("dl-", "");
|
||||
final HttpDownloader httpDownloader = new HttpDownloader(path, destFile, mirrors);
|
||||
final HttpDownloader httpDownloader =
|
||||
new HttpDownloader(downloadManager, path, destFile, mirrors, null, null);
|
||||
httpDownloader.setListener(new ProgressListener() {
|
||||
@Override
|
||||
public void onProgress(long bytesRead, long totalBytes) {
|
||||
|
||||
@@ -26,7 +26,7 @@ import org.fdroid.fdroid.data.Repo;
|
||||
import org.fdroid.fdroid.data.RepoProvider;
|
||||
import org.fdroid.fdroid.data.Schema;
|
||||
import org.fdroid.fdroid.nearby.peers.Peer;
|
||||
import org.fdroid.fdroid.net.Downloader;
|
||||
import org.fdroid.download.Downloader;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
|
||||
@@ -47,7 +47,7 @@ import org.fdroid.fdroid.data.Repo;
|
||||
import org.fdroid.fdroid.data.Schema.AppMetadataTable;
|
||||
import org.fdroid.fdroid.installer.InstallManagerService;
|
||||
import org.fdroid.fdroid.installer.Installer;
|
||||
import org.fdroid.fdroid.net.Downloader;
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.fdroid.net.DownloaderService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -40,6 +40,8 @@ import com.google.android.material.switchmaterial.SwitchMaterial;
|
||||
import com.google.zxing.integration.android.IntentIntegrator;
|
||||
import com.google.zxing.integration.android.IntentResult;
|
||||
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.download.HttpDownloader;
|
||||
import org.fdroid.fdroid.BuildConfig;
|
||||
import org.fdroid.fdroid.FDroidApp;
|
||||
import org.fdroid.fdroid.NfcHelper;
|
||||
@@ -53,8 +55,6 @@ import org.fdroid.fdroid.data.RepoProvider;
|
||||
import org.fdroid.fdroid.nearby.peers.BluetoothPeer;
|
||||
import org.fdroid.fdroid.nearby.peers.Peer;
|
||||
import org.fdroid.fdroid.net.BluetoothDownloader;
|
||||
import org.fdroid.fdroid.net.Downloader;
|
||||
import org.fdroid.fdroid.net.HttpDownloader;
|
||||
import org.fdroid.fdroid.qr.CameraCharacteristicsChecker;
|
||||
import org.fdroid.fdroid.views.main.MainActivity;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.content.pm.PackageManager;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.fdroid.data.Apk;
|
||||
import org.fdroid.fdroid.data.App;
|
||||
import org.fdroid.fdroid.data.AppProvider;
|
||||
@@ -291,7 +292,7 @@ public final class AppUpdateStatusManager {
|
||||
private void notifyAdd(AppUpdateStatus entry) {
|
||||
if (!isBatchUpdating) {
|
||||
Intent broadcastIntent = new Intent(BROADCAST_APPSTATUS_ADDED);
|
||||
broadcastIntent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, entry.getCanonicalUrl());
|
||||
broadcastIntent.putExtra(Downloader.EXTRA_CANONICAL_URL, entry.getCanonicalUrl());
|
||||
broadcastIntent.putExtra(EXTRA_STATUS, entry.copy());
|
||||
localBroadcastManager.sendBroadcast(broadcastIntent);
|
||||
}
|
||||
@@ -300,7 +301,7 @@ public final class AppUpdateStatusManager {
|
||||
private void notifyChange(AppUpdateStatus entry, boolean isStatusUpdate) {
|
||||
if (!isBatchUpdating) {
|
||||
Intent broadcastIntent = new Intent(BROADCAST_APPSTATUS_CHANGED);
|
||||
broadcastIntent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, entry.getCanonicalUrl());
|
||||
broadcastIntent.putExtra(Downloader.EXTRA_CANONICAL_URL, entry.getCanonicalUrl());
|
||||
broadcastIntent.putExtra(EXTRA_STATUS, entry.copy());
|
||||
broadcastIntent.putExtra(EXTRA_IS_STATUS_UPDATE, isStatusUpdate);
|
||||
localBroadcastManager.sendBroadcast(broadcastIntent);
|
||||
@@ -310,7 +311,7 @@ public final class AppUpdateStatusManager {
|
||||
private void notifyRemove(AppUpdateStatus entry) {
|
||||
if (!isBatchUpdating) {
|
||||
Intent broadcastIntent = new Intent(BROADCAST_APPSTATUS_REMOVED);
|
||||
broadcastIntent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, entry.getCanonicalUrl());
|
||||
broadcastIntent.putExtra(Downloader.EXTRA_CANONICAL_URL, entry.getCanonicalUrl());
|
||||
broadcastIntent.putExtra(EXTRA_STATUS, entry.copy());
|
||||
localBroadcastManager.sendBroadcast(broadcastIntent);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ import org.acra.ReportField;
|
||||
import org.acra.ReportingInteractionMode;
|
||||
import org.acra.annotation.ReportsCrashes;
|
||||
import org.apache.commons.net.util.SubnetUtils;
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.fdroid.Preferences.ChangeListener;
|
||||
import org.fdroid.fdroid.Preferences.Theme;
|
||||
import org.fdroid.fdroid.compat.PRNGFixes;
|
||||
@@ -63,7 +64,6 @@ import org.fdroid.fdroid.nearby.PublicSourceDirProvider;
|
||||
import org.fdroid.fdroid.nearby.SDCardScannerService;
|
||||
import org.fdroid.fdroid.nearby.WifiStateChangeService;
|
||||
import org.fdroid.fdroid.net.ConnectivityMonitorService;
|
||||
import org.fdroid.fdroid.net.Downloader;
|
||||
import org.fdroid.fdroid.panic.HidingManager;
|
||||
import org.fdroid.fdroid.work.CleanCacheWorker;
|
||||
|
||||
@@ -78,7 +78,6 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.collection.LongSparseArray;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import info.guardianproject.netcipher.NetCipher;
|
||||
import info.guardianproject.netcipher.proxy.OrbotHelper;
|
||||
|
||||
@@ -26,11 +26,16 @@ package org.fdroid.fdroid;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.res.Resources.NotFoundException;
|
||||
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;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.fdroid.data.Apk;
|
||||
import org.fdroid.fdroid.data.ApkProvider;
|
||||
import org.fdroid.fdroid.data.App;
|
||||
@@ -43,7 +48,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.Downloader;
|
||||
import org.fdroid.fdroid.net.DownloaderFactory;
|
||||
import org.xml.sax.InputSource;
|
||||
import org.xml.sax.SAXException;
|
||||
@@ -66,8 +70,6 @@ import javax.xml.parsers.ParserConfigurationException;
|
||||
import javax.xml.parsers.SAXParser;
|
||||
import javax.xml.parsers.SAXParserFactory;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
// TODO move to org.fdroid.fdroid.updater
|
||||
// TODO reduce visibility of methods once in .updater package (.e.g tests need it public now)
|
||||
|
||||
@@ -126,18 +128,21 @@ public class IndexUpdater {
|
||||
return hasChanged;
|
||||
}
|
||||
|
||||
private Downloader downloadIndex() throws UpdateException {
|
||||
private Pair<Downloader, File> downloadIndex() throws UpdateException {
|
||||
File destFile = null;
|
||||
Downloader downloader = null;
|
||||
try {
|
||||
downloader = DownloaderFactory.create(context, repo, indexUrl);
|
||||
destFile = File.createTempFile("dl-", "", context.getCacheDir());
|
||||
destFile.deleteOnExit(); // this probably does nothing, but maybe...
|
||||
downloader = DownloaderFactory.createWithoutMirrors(repo, Uri.parse(indexUrl), destFile);
|
||||
downloader.setCacheTag(repo.lastetag);
|
||||
downloader.setListener(downloadListener);
|
||||
downloader.download();
|
||||
|
||||
} catch (IOException e) {
|
||||
if (downloader != null && downloader.outputFile != null) {
|
||||
if (!downloader.outputFile.delete()) {
|
||||
Log.w(TAG, "Couldn't delete file: " + downloader.outputFile.getAbsolutePath());
|
||||
if (destFile != null) {
|
||||
if (!destFile.delete()) {
|
||||
Log.w(TAG, "Couldn't delete file: " + destFile.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,8 +150,8 @@ public class IndexUpdater {
|
||||
} catch (InterruptedException e) {
|
||||
// ignored if canceled, the local database just won't be updated
|
||||
e.printStackTrace();
|
||||
}
|
||||
return downloader;
|
||||
} // TODO is it safe to delete destFile in finally block?
|
||||
return new Pair<>(downloader, destFile);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,14 +163,16 @@ public class IndexUpdater {
|
||||
* @throws UpdateException All error states will come from here.
|
||||
*/
|
||||
public boolean update() throws UpdateException {
|
||||
final Downloader downloader = downloadIndex();
|
||||
final Pair<Downloader, File> pair = downloadIndex();
|
||||
final Downloader downloader = pair.first;
|
||||
final File destFile = pair.second;
|
||||
hasChanged = downloader.hasChanged();
|
||||
|
||||
if (hasChanged) {
|
||||
// Don't worry about checking the status code for 200. If it was a
|
||||
// successful download, then we will have a file ready to use:
|
||||
cacheTag = downloader.getCacheTag();
|
||||
processDownloadedFile(downloader.outputFile);
|
||||
processDownloadedFile(destFile);
|
||||
processRepoPushRequests(repoPushRequestList);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -25,6 +25,7 @@ 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;
|
||||
|
||||
@@ -40,6 +41,7 @@ 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;
|
||||
@@ -47,18 +49,11 @@ 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.Downloader;
|
||||
import org.fdroid.fdroid.net.DownloaderFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.ConnectException;
|
||||
import java.net.HttpRetryException;
|
||||
import java.net.NoRouteToHostException;
|
||||
import java.net.ProtocolException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
@@ -70,13 +65,6 @@ import java.util.Map;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
import javax.net.ssl.SSLKeyException;
|
||||
import javax.net.ssl.SSLPeerUnverifiedException;
|
||||
import javax.net.ssl.SSLProtocolException;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
/**
|
||||
* Receives the index data about all available apps and packages via the V1
|
||||
* JSON data {@link #DATA_FILE_NAME}, embedded in a signed jar
|
||||
@@ -121,31 +109,31 @@ public class IndexV1Updater extends IndexUpdater {
|
||||
// swap repos do not support index-v1
|
||||
return false;
|
||||
}
|
||||
Downloader downloader = null;
|
||||
File destFile = null;
|
||||
Downloader downloader;
|
||||
try {
|
||||
destFile = File.createTempFile("dl-", "", context.getCacheDir());
|
||||
destFile.deleteOnExit(); // this probably does nothing, but maybe...
|
||||
// read file name from file
|
||||
downloader = DownloaderFactory.create(context, repo, indexUrl);
|
||||
downloader = DownloaderFactory.createWithoutMirrors(repo, Uri.parse(indexUrl), destFile);
|
||||
downloader.setCacheTag(repo.lastetag);
|
||||
downloader.setListener(downloadListener);
|
||||
downloader.download();
|
||||
if (downloader.isNotFound()) {
|
||||
return false;
|
||||
}
|
||||
hasChanged = downloader.hasChanged();
|
||||
|
||||
if (!hasChanged) {
|
||||
return true;
|
||||
}
|
||||
|
||||
processDownloadedIndex(downloader.outputFile, downloader.getCacheTag());
|
||||
processDownloadedIndex(destFile, downloader.getCacheTag());
|
||||
} catch (IOException e) {
|
||||
if (downloader != null) {
|
||||
FileUtils.deleteQuietly(downloader.outputFile);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import org.fdroid.download.Downloader;
|
||||
|
||||
/**
|
||||
* For security purposes we need to ensure that all Intent objects we give to a PendingIntent are
|
||||
* explicitly set to be delivered to an F-Droid class.
|
||||
@@ -14,7 +16,7 @@ public class NotificationBroadcastReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
AppUpdateStatusManager manager = AppUpdateStatusManager.getInstance(context);
|
||||
String canonicalUrl = intent.getStringExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL);
|
||||
String canonicalUrl = intent.getStringExtra(Downloader.EXTRA_CANONICAL_URL);
|
||||
switch (intent.getAction()) {
|
||||
case NotificationHelper.BROADCAST_NOTIFICATIONS_ALL_UPDATES_CLEARED:
|
||||
manager.clearAllUpdates();
|
||||
|
||||
@@ -30,6 +30,7 @@ import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.request.target.CustomTarget;
|
||||
import com.bumptech.glide.request.transition.Transition;
|
||||
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.fdroid.data.App;
|
||||
import org.fdroid.fdroid.views.AppDetailsActivity;
|
||||
import org.fdroid.fdroid.views.main.MainActivity;
|
||||
@@ -113,14 +114,14 @@ public class NotificationHelper {
|
||||
case AppUpdateStatusManager.BROADCAST_APPSTATUS_ADDED:
|
||||
updateStatusLists();
|
||||
createSummaryNotifications();
|
||||
url = intent.getStringExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL);
|
||||
url = intent.getStringExtra(Downloader.EXTRA_CANONICAL_URL);
|
||||
entry = appUpdateStatusManager.get(url);
|
||||
if (entry != null) {
|
||||
createNotification(entry);
|
||||
}
|
||||
break;
|
||||
case AppUpdateStatusManager.BROADCAST_APPSTATUS_CHANGED:
|
||||
url = intent.getStringExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL);
|
||||
url = intent.getStringExtra(Downloader.EXTRA_CANONICAL_URL);
|
||||
entry = appUpdateStatusManager.get(url);
|
||||
updateStatusLists();
|
||||
if (entry != null) {
|
||||
@@ -131,7 +132,7 @@ public class NotificationHelper {
|
||||
}
|
||||
break;
|
||||
case AppUpdateStatusManager.BROADCAST_APPSTATUS_REMOVED:
|
||||
url = intent.getStringExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL);
|
||||
url = intent.getStringExtra(Downloader.EXTRA_CANONICAL_URL);
|
||||
notificationManager.cancel(url, NOTIFY_ID_INSTALLED);
|
||||
notificationManager.cancel(url, NOTIFY_ID_UPDATES);
|
||||
updateStatusLists();
|
||||
@@ -364,7 +365,7 @@ public class NotificationHelper {
|
||||
}
|
||||
|
||||
Intent intentDeleted = new Intent(BROADCAST_NOTIFICATIONS_UPDATE_CLEARED);
|
||||
intentDeleted.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, entry.getCanonicalUrl());
|
||||
intentDeleted.putExtra(Downloader.EXTRA_CANONICAL_URL, entry.getCanonicalUrl());
|
||||
intentDeleted.setClass(context, NotificationBroadcastReceiver.class);
|
||||
PendingIntent piDeleted = PendingIntent.getBroadcast(context, 0, intentDeleted, PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
builder.setDeleteIntent(piDeleted);
|
||||
@@ -452,7 +453,7 @@ public class NotificationHelper {
|
||||
}
|
||||
|
||||
Intent intentDeleted = new Intent(BROADCAST_NOTIFICATIONS_INSTALLED_CLEARED);
|
||||
intentDeleted.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, entry.getCanonicalUrl());
|
||||
intentDeleted.putExtra(Downloader.EXTRA_CANONICAL_URL, entry.getCanonicalUrl());
|
||||
intentDeleted.setClass(context, NotificationBroadcastReceiver.class);
|
||||
PendingIntent piDeleted = PendingIntent.getBroadcast(context, 0, intentDeleted, PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
builder.setDeleteIntent(piDeleted);
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package org.fdroid.fdroid;
|
||||
|
||||
import java.net.URL;
|
||||
|
||||
/**
|
||||
* This is meant only to send download progress for any URL (e.g. index
|
||||
* updates, APKs, etc). This also keeps this class pure Java so that classes
|
||||
* that use {@code ProgressListener} can be tested on the JVM, without requiring
|
||||
* an Android device or emulator.
|
||||
* <p/>
|
||||
* The full URL of a download is used as the unique identifier throughout
|
||||
* F-Droid. I can take a few forms:
|
||||
* <ul>
|
||||
* <li>{@link URL} instances
|
||||
* <li>{@link android.net.Uri} instances
|
||||
* <li>{@code String} instances, i.e. {@link URL#toString()}
|
||||
* <li>{@code int}s, i.e. {@link String#hashCode()}
|
||||
* </ul>
|
||||
*/
|
||||
public interface ProgressListener {
|
||||
|
||||
void onProgress(long bytesRead, long totalBytes);
|
||||
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.fdroid.data.Apk;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -49,7 +50,7 @@ public class DefaultInstaller extends Installer {
|
||||
|
||||
Intent installIntent = new Intent(context, DefaultInstallerActivity.class);
|
||||
installIntent.setAction(DefaultInstallerActivity.ACTION_INSTALL_PACKAGE);
|
||||
installIntent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri.toString());
|
||||
installIntent.putExtra(Downloader.EXTRA_CANONICAL_URL, canonicalUri.toString());
|
||||
installIntent.putExtra(Installer.EXTRA_APK, apk);
|
||||
installIntent.setData(localApkUri);
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.fdroid.R;
|
||||
import org.fdroid.fdroid.data.Apk;
|
||||
|
||||
@@ -68,7 +69,7 @@ public class DefaultInstallerActivity extends FragmentActivity {
|
||||
installer = new DefaultInstaller(this, apk);
|
||||
if (ACTION_INSTALL_PACKAGE.equals(action)) {
|
||||
Uri localApkUri = intent.getData();
|
||||
canonicalUri = Uri.parse(intent.getStringExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL));
|
||||
canonicalUri = Uri.parse(intent.getStringExtra(Downloader.EXTRA_CANONICAL_URL));
|
||||
installPackage(localApkUri);
|
||||
} else if (ACTION_UNINSTALL_PACKAGE.equals(action)) {
|
||||
uninstallPackage(apk.packageName);
|
||||
|
||||
@@ -24,6 +24,7 @@ import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.fdroid.data.Apk;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -53,7 +54,7 @@ public class FileInstaller extends Installer {
|
||||
protected void installPackageInternal(Uri localApkUri, Uri canonicalUri) {
|
||||
Intent installIntent = new Intent(context, FileInstallerActivity.class);
|
||||
installIntent.setAction(FileInstallerActivity.ACTION_INSTALL_FILE);
|
||||
installIntent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri.toString());
|
||||
installIntent.putExtra(Downloader.EXTRA_CANONICAL_URL, canonicalUri.toString());
|
||||
installIntent.putExtra(Installer.EXTRA_APK, apk);
|
||||
installIntent.setData(localApkUri);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.view.ContextThemeWrapper;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.fdroid.FDroidApp;
|
||||
import org.fdroid.fdroid.R;
|
||||
import org.fdroid.fdroid.Utils;
|
||||
@@ -58,7 +59,7 @@ public class FileInstallerActivity extends FragmentActivity {
|
||||
apk = intent.getParcelableExtra(Installer.EXTRA_APK);
|
||||
installer = new FileInstaller(this, apk);
|
||||
if (ACTION_INSTALL_FILE.equals(action)) {
|
||||
canonicalUri = Uri.parse(intent.getStringExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL));
|
||||
canonicalUri = Uri.parse(intent.getStringExtra(Downloader.EXTRA_CANONICAL_URL));
|
||||
if (hasStoragePermission()) {
|
||||
installPackage(localApkUri, canonicalUri, apk);
|
||||
} else {
|
||||
|
||||
@@ -15,13 +15,12 @@ import android.util.Log;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.commons.io.filefilter.WildcardFileFilter;
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.fdroid.AppUpdateStatusManager;
|
||||
import org.fdroid.fdroid.FDroidApp;
|
||||
import org.fdroid.fdroid.Utils;
|
||||
import org.fdroid.fdroid.compat.PackageManagerCompat;
|
||||
import org.fdroid.fdroid.data.Apk;
|
||||
import org.fdroid.fdroid.data.App;
|
||||
import org.fdroid.fdroid.net.Downloader;
|
||||
import org.fdroid.fdroid.net.DownloaderService;
|
||||
|
||||
import java.io.File;
|
||||
@@ -70,7 +69,7 @@ import static vendored.org.apache.commons.codec.digest.MessageDigestAlgorithms.S
|
||||
* <li>for a {@code String} ID, use {@code canonicalUrl}, {@link Uri#toString()}, or
|
||||
* {@link Intent#getDataString()}
|
||||
* <li>for an {@code int} ID, use {@link String#hashCode()} or {@link Uri#hashCode()}
|
||||
* <li>for an {@link Intent} extra, use {@link org.fdroid.fdroid.net.Downloader#EXTRA_CANONICAL_URL} and include a
|
||||
* <li>for an {@link Intent} extra, use {@link Downloader#EXTRA_CANONICAL_URL} and include a
|
||||
* {@link String} instance
|
||||
* </ul></p>
|
||||
* The implementations of {@link Uri#toString()} and {@link Intent#getDataString()} both
|
||||
@@ -205,8 +204,6 @@ public class InstallManagerService extends Service {
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
DownloaderService.setTimeout(FDroidApp.getTimeout());
|
||||
|
||||
appUpdateStatusManager.addApk(apk, AppUpdateStatusManager.Status.Downloading, null);
|
||||
|
||||
registerPackageDownloaderReceivers(canonicalUrl);
|
||||
@@ -328,7 +325,6 @@ public class InstallManagerService extends Service {
|
||||
}
|
||||
Uri canonicalUri = intent.getData();
|
||||
String canonicalUrl = intent.getDataString();
|
||||
long repoId = intent.getLongExtra(Downloader.EXTRA_REPO_ID, 0);
|
||||
|
||||
switch (intent.getAction()) {
|
||||
case Downloader.ACTION_STARTED:
|
||||
|
||||
@@ -27,6 +27,7 @@ import android.os.Build;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.commons.io.filefilter.WildcardFileFilter;
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.fdroid.Utils;
|
||||
import org.fdroid.fdroid.data.Apk;
|
||||
import org.fdroid.fdroid.data.App;
|
||||
@@ -76,7 +77,7 @@ public class InstallerService extends JobIntentService {
|
||||
|
||||
if (ACTION_INSTALL.equals(intent.getAction())) {
|
||||
Uri uri = intent.getData();
|
||||
Uri canonicalUri = Uri.parse(intent.getStringExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL));
|
||||
Uri canonicalUri = Uri.parse(intent.getStringExtra(Downloader.EXTRA_CANONICAL_URL));
|
||||
installer.installPackage(uri, canonicalUri);
|
||||
} else if (ACTION_UNINSTALL.equals(intent.getAction())) {
|
||||
installer.uninstallPackage();
|
||||
@@ -126,7 +127,7 @@ public class InstallerService extends JobIntentService {
|
||||
Intent intent = new Intent(context, InstallerService.class);
|
||||
intent.setAction(ACTION_INSTALL);
|
||||
intent.setData(localApkUri);
|
||||
intent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri.toString());
|
||||
intent.putExtra(Downloader.EXTRA_CANONICAL_URL, canonicalUri.toString());
|
||||
intent.putExtra(Installer.EXTRA_APK, apk);
|
||||
enqueueWork(context, intent);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import android.util.Log;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.fdroid.data.Apk;
|
||||
|
||||
import java.io.File;
|
||||
@@ -40,7 +41,7 @@ public class ObfInstallerService extends IntentService {
|
||||
public static void install(Context context, Uri canonicalUri, Apk apk, File path) {
|
||||
Intent intent = new Intent(context, ObfInstallerService.class);
|
||||
intent.setAction(ACTION_INSTALL_OBF);
|
||||
intent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri.toString());
|
||||
intent.putExtra(Downloader.EXTRA_CANONICAL_URL, canonicalUri.toString());
|
||||
intent.putExtra(Installer.EXTRA_APK, apk);
|
||||
intent.putExtra(EXTRA_OBF_PATH, path.getAbsolutePath());
|
||||
context.startService(intent);
|
||||
@@ -52,7 +53,7 @@ public class ObfInstallerService extends IntentService {
|
||||
Log.e(TAG, "received invalid intent: " + intent);
|
||||
return;
|
||||
}
|
||||
Uri canonicalUri = Uri.parse(intent.getStringExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL));
|
||||
Uri canonicalUri = Uri.parse(intent.getStringExtra(Downloader.EXTRA_CANONICAL_URL));
|
||||
final Apk apk = intent.getParcelableExtra(Installer.EXTRA_APK);
|
||||
final String path = intent.getStringExtra(EXTRA_OBF_PATH);
|
||||
final String extension = MimeTypeMap.getFileExtensionFromUrl(path);
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import org.apache.commons.io.input.BoundedInputStream;
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.fdroid.Utils;
|
||||
import org.fdroid.fdroid.nearby.BluetoothClient;
|
||||
import org.fdroid.fdroid.nearby.BluetoothConnection;
|
||||
@@ -16,6 +17,7 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
/**
|
||||
@@ -38,14 +40,15 @@ public class BluetoothDownloader extends Downloader {
|
||||
}
|
||||
|
||||
public BluetoothDownloader(Uri uri, File destFile) throws IOException {
|
||||
super(uri, destFile);
|
||||
super(destFile);
|
||||
String macAddress = uri.getHost().replace("-", ":");
|
||||
this.connection = new BluetoothClient(macAddress).openConnection();
|
||||
this.sourcePath = uri.getPath();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
protected InputStream getDownloadersInputStream(boolean resumable) throws IOException {
|
||||
protected InputStream getInputStream(boolean resumable) throws IOException {
|
||||
Request request = Request.createGET(sourcePath, connection);
|
||||
Response response = request.send();
|
||||
fileDetails = response.toFileDetails();
|
||||
@@ -99,7 +102,7 @@ public class BluetoothDownloader extends Downloader {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void close() {
|
||||
public void close() {
|
||||
if (connection != null) {
|
||||
connection.closeQuietly();
|
||||
}
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
package org.fdroid.fdroid.net;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.text.format.DateUtils;
|
||||
|
||||
import org.fdroid.fdroid.ProgressListener;
|
||||
import org.fdroid.fdroid.Utils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.ConnectException;
|
||||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public abstract class Downloader {
|
||||
|
||||
private static final String TAG = "Downloader";
|
||||
|
||||
public static final String ACTION_STARTED = "org.fdroid.fdroid.net.Downloader.action.STARTED";
|
||||
public static final String ACTION_PROGRESS = "org.fdroid.fdroid.net.Downloader.action.PROGRESS";
|
||||
public static final String ACTION_INTERRUPTED = "org.fdroid.fdroid.net.Downloader.action.INTERRUPTED";
|
||||
public static final String ACTION_CONNECTION_FAILED = "org.fdroid.fdroid.net.Downloader.action.CONNECTION_FAILED";
|
||||
public static final String ACTION_COMPLETE = "org.fdroid.fdroid.net.Downloader.action.COMPLETE";
|
||||
|
||||
public static final String EXTRA_DOWNLOAD_PATH = "org.fdroid.fdroid.net.Downloader.extra.DOWNLOAD_PATH";
|
||||
public static final String EXTRA_BYTES_READ = "org.fdroid.fdroid.net.Downloader.extra.BYTES_READ";
|
||||
public static final String EXTRA_TOTAL_BYTES = "org.fdroid.fdroid.net.Downloader.extra.TOTAL_BYTES";
|
||||
public static final String EXTRA_ERROR_MESSAGE = "org.fdroid.fdroid.net.Downloader.extra.ERROR_MESSAGE";
|
||||
public static final String EXTRA_REPO_ID = "org.fdroid.fdroid.net.Downloader.extra.REPO_ID";
|
||||
public static final String EXTRA_MIRROR_URL = "org.fdroid.fdroid.net.Downloader.extra.MIRROR_URL";
|
||||
/**
|
||||
* Unique ID used to represent this specific package's install process,
|
||||
* including {@link android.app.Notification}s, also known as {@code canonicalUrl}.
|
||||
* Careful about types, this should always be a {@link String}, so it can
|
||||
* be handled on the receiving side by {@link android.content.Intent#getStringArrayExtra(String)}.
|
||||
*
|
||||
* @see org.fdroid.fdroid.installer.InstallManagerService
|
||||
* @see android.content.Intent#EXTRA_ORIGINATING_URI
|
||||
*/
|
||||
public static final String EXTRA_CANONICAL_URL = "org.fdroid.fdroid.net.Downloader.extra.CANONICAL_URL";
|
||||
|
||||
public static final int DEFAULT_TIMEOUT = 10000;
|
||||
public static final int SECOND_TIMEOUT = (int) DateUtils.MINUTE_IN_MILLIS;
|
||||
public static final int LONGEST_TIMEOUT = 600000; // 10 minutes
|
||||
|
||||
private volatile boolean cancelled = false;
|
||||
private volatile long bytesRead;
|
||||
private volatile long totalBytes;
|
||||
|
||||
public final File outputFile;
|
||||
|
||||
final String urlString;
|
||||
String cacheTag;
|
||||
boolean notFound;
|
||||
|
||||
private volatile int timeout = DEFAULT_TIMEOUT;
|
||||
|
||||
/**
|
||||
* For sending download progress, should only be called in {@link #progressTask}
|
||||
*/
|
||||
private volatile ProgressListener downloaderProgressListener;
|
||||
|
||||
protected abstract InputStream getDownloadersInputStream(boolean resumable) throws IOException;
|
||||
|
||||
protected abstract void close();
|
||||
|
||||
Downloader(Uri uri, File destFile) {
|
||||
this.urlString = uri.toString();
|
||||
outputFile = destFile;
|
||||
}
|
||||
|
||||
public final InputStream getInputStream(boolean resumable) throws IOException {
|
||||
return new WrappedInputStream(getDownloadersInputStream(resumable));
|
||||
}
|
||||
|
||||
public void setListener(ProgressListener listener) {
|
||||
this.downloaderProgressListener = listener;
|
||||
}
|
||||
|
||||
public void setTimeout(int ms) {
|
||||
timeout = ms;
|
||||
}
|
||||
|
||||
public int getTimeout() {
|
||||
return timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* If you ask for the cacheTag before calling download(), you will get the
|
||||
* same one you passed in (if any). If you call it after download(), you
|
||||
* will get the new cacheTag from the server, or null if there was none.
|
||||
*/
|
||||
public String getCacheTag() {
|
||||
return cacheTag;
|
||||
}
|
||||
|
||||
/**
|
||||
* If this cacheTag matches that returned by the server, then no download will
|
||||
* take place, and a status code of 304 will be returned by download().
|
||||
*/
|
||||
public void setCacheTag(String cacheTag) {
|
||||
this.cacheTag = cacheTag;
|
||||
}
|
||||
|
||||
public abstract boolean hasChanged();
|
||||
|
||||
protected abstract long totalDownloadSize();
|
||||
|
||||
public abstract void download() throws ConnectException, IOException, InterruptedException;
|
||||
|
||||
/**
|
||||
* @return whether the requested file was not found in the repo (e.g. HTTP 404 Not Found)
|
||||
*/
|
||||
public boolean isNotFound() {
|
||||
return notFound;
|
||||
}
|
||||
|
||||
void downloadFromStream(boolean resumable) throws IOException, InterruptedException {
|
||||
Utils.debugLog(TAG, "Downloading from stream");
|
||||
InputStream input = null;
|
||||
OutputStream outputStream = new FileOutputStream(outputFile, resumable);
|
||||
try {
|
||||
input = getInputStream(resumable);
|
||||
|
||||
// Getting the input stream is slow(ish) for HTTP downloads, so we'll check if
|
||||
// we were interrupted before proceeding to the download.
|
||||
throwExceptionIfInterrupted();
|
||||
|
||||
copyInputToOutputStream(input, 8192, outputStream);
|
||||
} finally {
|
||||
Utils.closeQuietly(outputStream);
|
||||
Utils.closeQuietly(input);
|
||||
}
|
||||
|
||||
// Even if we have completely downloaded the file, we should probably respect
|
||||
// the wishes of the user who wanted to cancel us.
|
||||
throwExceptionIfInterrupted();
|
||||
}
|
||||
|
||||
/**
|
||||
* After every network operation that could take a while, we will check if an
|
||||
* interrupt occurred during that blocking operation. The goal is to ensure we
|
||||
* don't move onto another slow, network operation if we have cancelled the
|
||||
* download.
|
||||
*
|
||||
* @throws InterruptedException
|
||||
*/
|
||||
private void throwExceptionIfInterrupted() throws InterruptedException {
|
||||
if (cancelled) {
|
||||
Utils.debugLog(TAG, "Received interrupt, cancelling download");
|
||||
throw new InterruptedException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running download, triggering an {@link InterruptedException}
|
||||
*/
|
||||
public void cancelDownload() {
|
||||
cancelled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* This copies the downloaded data from the InputStream to the OutputStream,
|
||||
* keeping track of the number of bytes that have flowed through for the
|
||||
* progress counter.
|
||||
*/
|
||||
private void copyInputToOutputStream(InputStream input, int bufferSize, OutputStream output)
|
||||
throws IOException, InterruptedException {
|
||||
Timer timer = new Timer();
|
||||
try {
|
||||
bytesRead = outputFile.length();
|
||||
totalBytes = totalDownloadSize();
|
||||
byte[] buffer = new byte[bufferSize];
|
||||
|
||||
timer.scheduleAtFixedRate(progressTask, 0, 100);
|
||||
|
||||
// Getting the total download size could potentially take time, depending on how
|
||||
// it is implemented, so we may as well check this before we proceed.
|
||||
throwExceptionIfInterrupted();
|
||||
|
||||
while (true) {
|
||||
|
||||
int count;
|
||||
if (input.available() > 0) {
|
||||
int readLength = Math.min(input.available(), buffer.length);
|
||||
count = input.read(buffer, 0, readLength);
|
||||
} else {
|
||||
count = input.read(buffer);
|
||||
}
|
||||
|
||||
throwExceptionIfInterrupted();
|
||||
|
||||
if (count == -1) {
|
||||
Utils.debugLog(TAG, "Finished downloading from stream");
|
||||
break;
|
||||
}
|
||||
bytesRead += count;
|
||||
output.write(buffer, 0, count);
|
||||
}
|
||||
} finally {
|
||||
downloaderProgressListener = null;
|
||||
timer.cancel();
|
||||
timer.purge();
|
||||
output.flush();
|
||||
output.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send progress updates on a timer to avoid flooding receivers with pointless events.
|
||||
*/
|
||||
private final TimerTask progressTask = new TimerTask() {
|
||||
private long lastBytesRead = Long.MIN_VALUE;
|
||||
private long lastTotalBytes = Long.MIN_VALUE;
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
if (downloaderProgressListener != null
|
||||
&& (bytesRead != lastBytesRead || totalBytes != lastTotalBytes)) {
|
||||
downloaderProgressListener.onProgress(bytesRead, totalBytes);
|
||||
lastBytesRead = bytesRead;
|
||||
lastTotalBytes = totalBytes;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Overrides every method in {@link InputStream} and delegates to the wrapped stream.
|
||||
* The only difference is that when we call the {@link WrappedInputStream#close()} method,
|
||||
* after delegating to the wrapped stream we invoke the {@link Downloader#close()} method
|
||||
* on the {@link Downloader} which created this.
|
||||
*/
|
||||
private class WrappedInputStream extends InputStream {
|
||||
|
||||
private final InputStream toWrap;
|
||||
|
||||
WrappedInputStream(InputStream toWrap) {
|
||||
super();
|
||||
this.toWrap = toWrap;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
toWrap.close();
|
||||
Downloader.this.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int available() throws IOException {
|
||||
return toWrap.available();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void mark(int readlimit) {
|
||||
toWrap.mark(readlimit);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean markSupported() {
|
||||
return toWrap.markSupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(@NonNull byte[] buffer) throws IOException {
|
||||
return toWrap.read(buffer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(@NonNull byte[] buffer, int byteOffset, int byteCount) throws IOException {
|
||||
return toWrap.read(buffer, byteOffset, byteCount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void reset() throws IOException {
|
||||
toWrap.reset();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long skip(long byteCount) throws IOException {
|
||||
return toWrap.skip(byteCount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
return toWrap.read();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,45 @@
|
||||
package org.fdroid.fdroid.net;
|
||||
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
|
||||
import org.fdroid.download.DownloadManager;
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.download.HttpDownloader;
|
||||
import org.fdroid.download.Mirror;
|
||||
import org.fdroid.fdroid.FDroidApp;
|
||||
import org.fdroid.fdroid.Utils;
|
||||
import org.fdroid.fdroid.data.Repo;
|
||||
import org.fdroid.fdroid.data.Schema;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class DownloaderFactory {
|
||||
|
||||
private static final String TAG = "DownloaderFactory";
|
||||
// TODO move to application object or inject where needed
|
||||
private static final DownloadManager DOWNLOAD_MANAGER =
|
||||
new DownloadManager(Utils.getUserAgent(), FDroidApp.queryString);
|
||||
|
||||
/**
|
||||
* Downloads to a temporary file, which *you must delete yourself when
|
||||
* you are done. It is stored in {@link Context#getCacheDir()} and starts
|
||||
* with the prefix {@code dl-}.
|
||||
* Same as {@link #create(Repo, Uri, File)}, but not using mirrors for download.
|
||||
*
|
||||
* See https://gitlab.com/fdroid/fdroidclient/-/issues/1708 for why this is still needed.
|
||||
*/
|
||||
public static Downloader create(Context context, Repo repo, String urlString)
|
||||
public static Downloader createWithoutMirrors(Repo repo, Uri uri, File destFile)
|
||||
throws IOException {
|
||||
File destFile = File.createTempFile("dl-", "", context.getCacheDir());
|
||||
destFile.deleteOnExit(); // this probably does nothing, but maybe...
|
||||
Uri uri = Uri.parse(urlString);
|
||||
return create(context, repo, uri, destFile);
|
||||
List<Mirror> mirrors = Collections.singletonList(new Mirror(repo.address));
|
||||
return create(repo, mirrors, uri, destFile);
|
||||
}
|
||||
|
||||
public static Downloader create(Context context, Repo repo, Uri uri, File destFile)
|
||||
throws IOException {
|
||||
public static Downloader create(Repo repo, Uri uri, File destFile) throws IOException {
|
||||
List<Mirror> mirrors = Mirror.fromStrings(repo.getMirrorList());
|
||||
return create(repo, mirrors, uri, destFile);
|
||||
}
|
||||
|
||||
private static Downloader create(Repo repo, List<Mirror> mirrors, Uri uri, File destFile) throws IOException {
|
||||
Downloader downloader;
|
||||
|
||||
String scheme = uri.getScheme();
|
||||
@@ -43,10 +51,9 @@ public class DownloaderFactory {
|
||||
downloader = new LocalFileDownloader(uri, destFile);
|
||||
} else {
|
||||
String urlSuffix = uri.toString().replace(repo.address, "");
|
||||
List<Mirror> mirrors = Mirror.fromStrings(repo.getMirrorList());
|
||||
Utils.debugLog(TAG, "Using suffix " + urlSuffix + " with mirrors " + mirrors);
|
||||
downloader =
|
||||
new HttpDownloader(urlSuffix, destFile, mirrors, repo.username, repo.password);
|
||||
new HttpDownloader(DOWNLOAD_MANAGER, urlSuffix, destFile, mirrors, repo.username, repo.password);
|
||||
}
|
||||
return downloader;
|
||||
}
|
||||
|
||||
@@ -35,9 +35,9 @@ import android.util.LogPrinter;
|
||||
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.fdroid.BuildConfig;
|
||||
import org.fdroid.fdroid.ProgressListener;
|
||||
import org.fdroid.fdroid.R;
|
||||
import org.fdroid.fdroid.Utils;
|
||||
import org.fdroid.fdroid.data.Repo;
|
||||
import org.fdroid.fdroid.data.RepoProvider;
|
||||
@@ -104,7 +104,6 @@ public class DownloaderService extends Service {
|
||||
private static volatile Downloader downloader;
|
||||
private static volatile String activeCanonicalUrl;
|
||||
private LocalBroadcastManager localBroadcastManager;
|
||||
private static volatile int timeout;
|
||||
|
||||
private final class ServiceHandler extends Handler {
|
||||
static final String TAG = "ServiceHandler";
|
||||
@@ -227,7 +226,7 @@ public class DownloaderService extends Service {
|
||||
try {
|
||||
activeCanonicalUrl = canonicalUrl.toString();
|
||||
final Repo repo = RepoProvider.Helper.findById(this, repoId);
|
||||
downloader = DownloaderFactory.create(this, repo, canonicalUrl, localFile);
|
||||
downloader = DownloaderFactory.create(repo, canonicalUrl, localFile);
|
||||
downloader.setListener(new ProgressListener() {
|
||||
@Override
|
||||
public void onProgress(long bytesRead, long totalBytes) {
|
||||
@@ -238,14 +237,8 @@ public class DownloaderService extends Service {
|
||||
localBroadcastManager.sendBroadcast(intent);
|
||||
}
|
||||
});
|
||||
downloader.setTimeout(timeout);
|
||||
downloader.download();
|
||||
if (downloader.isNotFound()) {
|
||||
sendBroadcast(uri, Downloader.ACTION_INTERRUPTED, localFile, getString(R.string.download_404),
|
||||
repoId, canonicalUrl);
|
||||
} else {
|
||||
sendBroadcast(uri, Downloader.ACTION_COMPLETE, localFile, repoId, canonicalUrl);
|
||||
}
|
||||
sendBroadcast(uri, Downloader.ACTION_COMPLETE, localFile, repoId, canonicalUrl);
|
||||
} catch (InterruptedException e) {
|
||||
sendBroadcast(uri, Downloader.ACTION_INTERRUPTED, localFile, repoId, canonicalUrl);
|
||||
} catch (ConnectException | HttpRetryException | NoRouteToHostException | SocketTimeoutException
|
||||
@@ -358,10 +351,6 @@ public class DownloaderService extends Service {
|
||||
return downloader != null && TextUtils.equals(downloadUrl, activeCanonicalUrl);
|
||||
}
|
||||
|
||||
public static void setTimeout(int ms) {
|
||||
timeout = ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a prepared {@link IntentFilter} for use for matching this service's action events.
|
||||
*
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2014-2017 Peter Serwylo <peter@serwylo.com>
|
||||
* Copyright (C) 2014-2018 Hans-Christoph Steiner <hans@eds.org>
|
||||
* Copyright (C) 2015-2016 Daniel Martí <mvdan@mvdan.cc>
|
||||
* Copyright (c) 2018 Senecto Limited
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License
|
||||
* as published by the Free Software Foundation; either version 3
|
||||
* of the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
*/
|
||||
|
||||
package org.fdroid.fdroid.net;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.fdroid.download.DownloadRequest;
|
||||
import org.fdroid.download.HeadInfo;
|
||||
import org.fdroid.download.JvmDownloadManager;
|
||||
import org.fdroid.download.Mirror;
|
||||
import org.fdroid.fdroid.FDroidApp;
|
||||
import org.fdroid.fdroid.Utils;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Download files over HTTP, with support for proxies, {@code .onion} addresses,
|
||||
* HTTP Basic Auth, etc. This is not a full HTTP client! This is only using
|
||||
* the bits of HTTP that F-Droid needs to operate. It does not support things
|
||||
* like redirects or other HTTP tricks. This keeps the security model and code
|
||||
* a lot simpler.
|
||||
*/
|
||||
public class HttpDownloader extends Downloader {
|
||||
private static final String TAG = "HttpDownloader";
|
||||
|
||||
private final JvmDownloadManager downloadManager =
|
||||
new JvmDownloadManager(Utils.getUserAgent(), FDroidApp.queryString);
|
||||
private final String path;
|
||||
private final String username;
|
||||
private final String password;
|
||||
private final List<Mirror> mirrors;
|
||||
|
||||
private boolean newFileAvailableOnServer;
|
||||
private long fileFullSize = -1L;
|
||||
|
||||
HttpDownloader(String path, File destFile, List<Mirror> mirrors) {
|
||||
this(path, destFile, mirrors, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a downloader that can authenticate via HTTP Basic Auth using the supplied
|
||||
* {@code username} and {@code password}.
|
||||
*
|
||||
* @param path The path to the file to download
|
||||
* @param destFile Where the download is saved
|
||||
* @param mirrors The repo base URLs where the file can be found
|
||||
* @param username Username for HTTP Basic Auth, use {@code null} to ignore
|
||||
* @param password Password for HTTP Basic Auth, use {@code null} to ignore
|
||||
*/
|
||||
HttpDownloader(String path, File destFile, List<Mirror> mirrors, @Nullable String username,
|
||||
@Nullable String password) {
|
||||
super(Uri.EMPTY, destFile);
|
||||
this.path = path;
|
||||
this.mirrors = mirrors;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected InputStream getDownloadersInputStream(boolean resumable) throws IOException {
|
||||
DownloadRequest request = new DownloadRequest(path, mirrors, username, password);
|
||||
Long skipBytes = resumable ? outputFile.length() : null;
|
||||
// TODO why do we need to wrap this in a BufferedInputStream here?
|
||||
return new BufferedInputStream(downloadManager.getBlocking(request, skipBytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a remote file, checking the HTTP response code, if it has changed since
|
||||
* the last time a download was tried.
|
||||
* <p>
|
||||
* If the {@code ETag} does not match, it could be caused by the previous
|
||||
* download of the same file coming from a mirror running on a different
|
||||
* webserver, e.g. Apache vs Nginx. {@code Content-Length} and
|
||||
* {@code Last-Modified} are used to check whether the file has changed since
|
||||
* those are more standardized than {@code ETag}. Plus, Nginx and Apache 2.4
|
||||
* defaults use only those two values to generate the {@code ETag} anyway.
|
||||
* Unfortunately, other webservers and CDNs have totally different methods
|
||||
* for generating the {@code ETag}. And mirrors that are syncing using a
|
||||
* method other than {@code rsync} could easily have different {@code Last-Modified}
|
||||
* times on the exact same file. On top of that, some services like GitHub's
|
||||
* raw file support {@code raw.githubusercontent.com} and GitLab's raw file
|
||||
* support do not set the {@code Last-Modified} header at all. So ultimately,
|
||||
* then {@code ETag} needs to be used first and foremost, then this calculated
|
||||
* {@code ETag} can serve as a common fallback.
|
||||
* <p>
|
||||
* In order to prevent the {@code ETag} from being used as a form of tracking
|
||||
* cookie, this code never sends the {@code ETag} to the server. Instead, it
|
||||
* uses a {@code HEAD} request to get the {@code ETag} from the server, then
|
||||
* only issues a {@code GET} if the {@code ETag} has changed.
|
||||
* <p>
|
||||
* This uses a integer value for {@code Last-Modified} to avoid enabling the
|
||||
* use of that value as some kind of "cookieless cookie". One second time
|
||||
* resolution should be plenty since these files change more on the time
|
||||
* space of minutes or hours.
|
||||
*
|
||||
* @see <a href="https://gitlab.com/fdroid/fdroidclient/issues/1708">update index from any available mirror</a>
|
||||
* @see <a href="http://lucb1e.com/rp/cookielesscookies">Cookieless cookies</a>
|
||||
*/
|
||||
@Override
|
||||
public void download() throws IOException, InterruptedException {
|
||||
// boolean isSwap = isSwapUrl(sourceUrl);
|
||||
DownloadRequest request = new DownloadRequest(path, mirrors, username, password);
|
||||
HeadInfo headInfo = downloadManager.headBlocking(request, cacheTag);
|
||||
fileFullSize = headInfo.getContentLength() == null ? -1 : headInfo.getContentLength();
|
||||
if (!headInfo.getETagChanged()) {
|
||||
// ETag has not changed, don't download again
|
||||
Utils.debugLog(TAG, path + " cached, not downloading.");
|
||||
newFileAvailableOnServer = false;
|
||||
return;
|
||||
}
|
||||
newFileAvailableOnServer = true;
|
||||
|
||||
boolean resumable = false;
|
||||
long fileLength = outputFile.length();
|
||||
if (fileLength > fileFullSize) {
|
||||
FileUtils.deleteQuietly(outputFile);
|
||||
} else if (fileLength == fileFullSize && outputFile.isFile()) {
|
||||
Utils.debugLog(TAG, "Already have outputFile, not download. " + outputFile.getAbsolutePath());
|
||||
return; // already have it!
|
||||
} else if (fileLength > 0) {
|
||||
resumable = true;
|
||||
}
|
||||
Utils.debugLog(TAG, "downloading " + path + " (is resumable: " + resumable + ")");
|
||||
downloadFromStream(resumable);
|
||||
}
|
||||
|
||||
public static boolean isSwapUrl(Uri uri) {
|
||||
return isSwapUrl(uri.getHost(), uri.getPort());
|
||||
}
|
||||
|
||||
static boolean isSwapUrl(URL url) {
|
||||
return isSwapUrl(url.getHost(), url.getPort());
|
||||
}
|
||||
|
||||
static boolean isSwapUrl(String host, int port) {
|
||||
return port > 1023 // only root can use <= 1023, so never a swap repo
|
||||
&& host.matches("[0-9.]+") // host must be an IP address
|
||||
&& FDroidApp.subnetInfo.isInRange(host); // on the same subnet as we are
|
||||
}
|
||||
|
||||
// Testing in the emulator for me, showed that figuring out the
|
||||
// filesize took about 1 to 1.5 seconds.
|
||||
// To put this in context, downloading a repo of:
|
||||
// - 400k takes ~6 seconds
|
||||
// - 5k takes ~3 seconds
|
||||
// on my connection. I think the 1/1.5 seconds is worth it,
|
||||
// because as the repo grows, the tradeoff will
|
||||
// become more worth it.
|
||||
@Override
|
||||
@TargetApi(24)
|
||||
public long totalDownloadSize() {
|
||||
if (Build.VERSION.SDK_INT < 24) {
|
||||
return (int) fileFullSize;
|
||||
} else {
|
||||
return fileFullSize;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasChanged() {
|
||||
return newFileAvailableOnServer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// TODO abort ongoing download somehow
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,26 @@
|
||||
package org.fdroid.fdroid.net;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import org.fdroid.download.Mirror;
|
||||
import org.fdroid.fdroid.FDroidApp;
|
||||
import org.fdroid.fdroid.Utils;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.util.Collections;
|
||||
|
||||
import info.guardianproject.netcipher.NetCipher;
|
||||
|
||||
/**
|
||||
* HTTP POST a JSON string to the URL configured in the constructor.
|
||||
*/
|
||||
// TODO don't extend HttpDownloader
|
||||
public class HttpPoster extends HttpDownloader {
|
||||
public class HttpPoster {
|
||||
|
||||
private final String urlString;
|
||||
|
||||
public HttpPoster(String url) {
|
||||
this(Uri.parse(url), null);
|
||||
}
|
||||
|
||||
private HttpPoster(Uri uri, File destFile) {
|
||||
super("", destFile, Collections.singletonList(new Mirror(uri.toString())));
|
||||
urlString = url;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,6 +44,7 @@ public class HttpPoster extends HttpDownloader {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO user download library instead
|
||||
private HttpURLConnection getConnection() throws IOException {
|
||||
HttpURLConnection connection;
|
||||
if (FDroidApp.queryString != null) {
|
||||
|
||||
@@ -2,8 +2,11 @@ package org.fdroid.fdroid.net;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.fdroid.download.Downloader;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
@@ -27,7 +30,7 @@ public class LocalFileDownloader extends Downloader {
|
||||
private final File sourceFile;
|
||||
|
||||
LocalFileDownloader(Uri uri, File destFile) {
|
||||
super(uri, destFile);
|
||||
super(destFile);
|
||||
sourceFile = new File(uri.getPath());
|
||||
}
|
||||
|
||||
@@ -39,10 +42,10 @@ public class LocalFileDownloader extends Downloader {
|
||||
* file as it is being downloaded and written to disk. Things can fail
|
||||
* here if the SDCard is not longer mounted, the files were deleted by
|
||||
* some other process, etc.
|
||||
* @param resumable
|
||||
*/
|
||||
@NonNull
|
||||
@Override
|
||||
protected InputStream getDownloadersInputStream(boolean resumable) throws IOException {
|
||||
protected InputStream getInputStream(boolean resumable) throws IOException {
|
||||
try {
|
||||
inputStream = new FileInputStream(sourceFile);
|
||||
return inputStream;
|
||||
@@ -52,7 +55,7 @@ public class LocalFileDownloader extends Downloader {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void close() {
|
||||
public void close() {
|
||||
IOUtils.closeQuietly(inputStream);
|
||||
}
|
||||
|
||||
@@ -67,9 +70,8 @@ public class LocalFileDownloader extends Downloader {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void download() throws ConnectException, IOException, InterruptedException {
|
||||
public void download() throws IOException, InterruptedException {
|
||||
if (!sourceFile.exists()) {
|
||||
notFound = true;
|
||||
throw new ConnectException(sourceFile + " does not exist, try a mirror");
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.annotation.TargetApi;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.fdroid.FDroidApp;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
@@ -11,9 +12,9 @@ import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.ProtocolException;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
|
||||
/**
|
||||
@@ -46,9 +47,8 @@ public class TreeUriDownloader extends Downloader {
|
||||
private final Uri treeUri;
|
||||
private final DocumentFile documentFile;
|
||||
|
||||
TreeUriDownloader(Uri uri, File destFile)
|
||||
throws FileNotFoundException, MalformedURLException {
|
||||
super(uri, destFile);
|
||||
TreeUriDownloader(Uri uri, File destFile) {
|
||||
super(destFile);
|
||||
context = FDroidApp.getInstance();
|
||||
String path = uri.getEncodedPath();
|
||||
int lastEscapedSlash = path.lastIndexOf(ESCAPED_SLASH);
|
||||
@@ -71,14 +71,14 @@ public class TreeUriDownloader extends Downloader {
|
||||
* 6EED-6A10:: java.io.File NotFoundException: No root for 6EED-6A10}
|
||||
* <p>
|
||||
* Example:
|
||||
* @param resumable
|
||||
*/
|
||||
@NonNull
|
||||
@Override
|
||||
protected InputStream getDownloadersInputStream(boolean resumable) throws IOException {
|
||||
protected InputStream getInputStream(boolean resumable) throws IOException {
|
||||
try {
|
||||
InputStream inputStream = context.getContentResolver().openInputStream(treeUri);
|
||||
if (inputStream == null) {
|
||||
return null;
|
||||
throw new IOException("InputStream was null");
|
||||
} else {
|
||||
return new BufferedInputStream(inputStream);
|
||||
}
|
||||
@@ -103,6 +103,6 @@ public class TreeUriDownloader extends Downloader {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void close() {
|
||||
public void close() {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
import com.google.android.material.badge.BadgeDrawable;
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView;
|
||||
|
||||
import org.fdroid.download.Downloader;
|
||||
import org.fdroid.fdroid.AppUpdateStatusManager;
|
||||
import org.fdroid.fdroid.AppUpdateStatusManager.AppUpdateStatus;
|
||||
import org.fdroid.fdroid.FDroidApp;
|
||||
@@ -430,7 +431,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
// Check if we have moved into the ReadyToInstall or Installed state.
|
||||
AppUpdateStatus status = manager.get(
|
||||
intent.getStringExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL));
|
||||
intent.getStringExtra(Downloader.EXTRA_CANONICAL_URL));
|
||||
boolean isStatusChange = intent.getBooleanExtra(AppUpdateStatusManager.EXTRA_IS_STATUS_UPDATE, false);
|
||||
if (isStatusChange
|
||||
&& status != null
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package org.fdroid.fdroid.net;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import org.apache.commons.net.util.SubnetUtils;
|
||||
import org.fdroid.fdroid.FDroidApp;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@SuppressWarnings("LineLength")
|
||||
public class HttpDownloaderTest {
|
||||
|
||||
@Test
|
||||
public void testIsSwapUri() throws MalformedURLException {
|
||||
FDroidApp.subnetInfo = new SubnetUtils("192.168.0.112/24").getInfo();
|
||||
String urlString = "http://192.168.0.112:8888/fdroid/repo?fingerprint=113F56CBFA967BA825DD13685A06E35730E0061C6BB046DF88A";
|
||||
assertTrue(HttpDownloader.isSwapUrl("192.168.0.112", 8888)); // NOPMD
|
||||
assertTrue(HttpDownloader.isSwapUrl(Uri.parse(urlString)));
|
||||
assertTrue(HttpDownloader.isSwapUrl(new URL(urlString)));
|
||||
|
||||
assertFalse(HttpDownloader.isSwapUrl("192.168.1.112", 8888)); // NOPMD
|
||||
assertFalse(HttpDownloader.isSwapUrl("192.168.0.112", 80)); // NOPMD
|
||||
assertFalse(HttpDownloader.isSwapUrl(Uri.parse("https://malware.com:8888")));
|
||||
assertFalse(HttpDownloader.isSwapUrl(new URL("https://www.google.com")));
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,6 @@ import android.text.TextUtils;
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.fdroid.fdroid.Utils;
|
||||
import org.fdroid.fdroid.net.HttpDownloader;
|
||||
import org.junit.After;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
|
||||
@@ -7,13 +7,10 @@ group = 'org.fdroid'
|
||||
version = '0.1'
|
||||
|
||||
kotlin {
|
||||
jvm {
|
||||
android {
|
||||
compilations.all {
|
||||
kotlinOptions.jvmTarget = '1.8'
|
||||
}
|
||||
testRuns["test"].executionTask.configure {
|
||||
useJUnit()
|
||||
}
|
||||
}
|
||||
def hostOs = System.getProperty("os.name")
|
||||
def isMingwX64 = hostOs.startsWith("Windows")
|
||||
@@ -27,11 +24,16 @@ kotlin {
|
||||
ktor_version = "1.6.7" //"2.0.0-beta-1"
|
||||
}
|
||||
|
||||
android()
|
||||
sourceSets {
|
||||
all {
|
||||
languageSettings {
|
||||
optIn('kotlin.RequiresOptIn')
|
||||
}
|
||||
}
|
||||
commonMain {
|
||||
dependencies {
|
||||
implementation "io.ktor:ktor-client-core:$ktor_version"
|
||||
implementation 'io.github.microutils:kotlin-logging:2.1.21'
|
||||
}
|
||||
}
|
||||
commonTest {
|
||||
@@ -40,8 +42,8 @@ kotlin {
|
||||
implementation "io.ktor:ktor-client-mock:$ktor_version"
|
||||
}
|
||||
}
|
||||
// JVM is disabled for now, because Android app is including it instead of Android library
|
||||
jvmMain {
|
||||
kotlin.srcDir('src/commonJvmAndroid/kotlin')
|
||||
dependencies {
|
||||
implementation "io.ktor:ktor-client-cio:$ktor_version"
|
||||
}
|
||||
@@ -52,9 +54,9 @@ kotlin {
|
||||
}
|
||||
}
|
||||
androidMain {
|
||||
kotlin.srcDir('src/commonJvmAndroid/kotlin')
|
||||
dependencies {
|
||||
implementation "io.ktor:ktor-client-okhttp:$ktor_version"
|
||||
implementation 'ch.qos.logback:logback-classic:1.2.5'
|
||||
}
|
||||
}
|
||||
androidTest {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package org.fdroid.download
|
||||
|
||||
import mu.KotlinLogging
|
||||
import org.fdroid.fdroid.ProgressListener
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
abstract class Downloader constructor(
|
||||
@JvmField
|
||||
protected val outputFile: File,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
val log = KotlinLogging.logger {}
|
||||
|
||||
const val ACTION_STARTED = "org.fdroid.fdroid.net.Downloader.action.STARTED"
|
||||
const val ACTION_PROGRESS = "org.fdroid.fdroid.net.Downloader.action.PROGRESS"
|
||||
const val ACTION_INTERRUPTED = "org.fdroid.fdroid.net.Downloader.action.INTERRUPTED"
|
||||
const val ACTION_CONNECTION_FAILED = "org.fdroid.fdroid.net.Downloader.action.CONNECTION_FAILED"
|
||||
const val ACTION_COMPLETE = "org.fdroid.fdroid.net.Downloader.action.COMPLETE"
|
||||
const val EXTRA_DOWNLOAD_PATH = "org.fdroid.fdroid.net.Downloader.extra.DOWNLOAD_PATH"
|
||||
const val EXTRA_BYTES_READ = "org.fdroid.fdroid.net.Downloader.extra.BYTES_READ"
|
||||
const val EXTRA_TOTAL_BYTES = "org.fdroid.fdroid.net.Downloader.extra.TOTAL_BYTES"
|
||||
const val EXTRA_ERROR_MESSAGE = "org.fdroid.fdroid.net.Downloader.extra.ERROR_MESSAGE"
|
||||
const val EXTRA_REPO_ID = "org.fdroid.fdroid.net.Downloader.extra.REPO_ID"
|
||||
const val EXTRA_MIRROR_URL = "org.fdroid.fdroid.net.Downloader.extra.MIRROR_URL"
|
||||
|
||||
/**
|
||||
* Unique ID used to represent this specific package's install process,
|
||||
* including [android.app.Notification]s, also known as `canonicalUrl`.
|
||||
* Careful about types, this should always be a [String], so it can
|
||||
* be handled on the receiving side by [android.content.Intent.getStringArrayExtra].
|
||||
*
|
||||
* @see android.content.Intent.EXTRA_ORIGINATING_URI
|
||||
*/
|
||||
const val EXTRA_CANONICAL_URL = "org.fdroid.fdroid.net.Downloader.extra.CANONICAL_URL"
|
||||
|
||||
const val DEFAULT_TIMEOUT = 10000
|
||||
const val LONGEST_TIMEOUT = 600000 // 10 minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* If you ask for the cacheTag before calling download(), you will get the
|
||||
* same one you passed in (if any). If you call it after download(), you
|
||||
* will get the new cacheTag from the server, or null if there was none.
|
||||
*
|
||||
* If this cacheTag matches that returned by the server, then no download will
|
||||
* take place, and a status code of 304 will be returned by download().
|
||||
*/
|
||||
var cacheTag: String? = null
|
||||
|
||||
@Volatile
|
||||
private var cancelled = false
|
||||
|
||||
@Volatile
|
||||
private var progressListener: ProgressListener? = null
|
||||
|
||||
/**
|
||||
* Call this to start the download.
|
||||
* Never call this more than once. Create a new [Downloader], if you need to download again!
|
||||
*/
|
||||
@Throws(IOException::class, InterruptedException::class)
|
||||
abstract fun download()
|
||||
|
||||
@Throws(IOException::class)
|
||||
protected abstract fun getInputStream(resumable: Boolean): InputStream
|
||||
protected open suspend fun getBytes(resumable: Boolean, receiver: (ByteArray) -> Unit) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the size of the file to be downloaded in bytes.
|
||||
* Note this is -1 when the size is unknown.
|
||||
* Used only for progress reporting.
|
||||
*/
|
||||
protected abstract fun totalDownloadSize(): Long
|
||||
|
||||
/**
|
||||
* After calling [download], this returns true if a new file was downloaded and
|
||||
* false if the file on the server has not changed and thus was not downloaded.
|
||||
*/
|
||||
abstract fun hasChanged(): Boolean
|
||||
abstract fun close()
|
||||
|
||||
fun setListener(listener: ProgressListener) {
|
||||
progressListener = listener
|
||||
}
|
||||
|
||||
@Throws(IOException::class, InterruptedException::class)
|
||||
protected fun downloadFromStream(isResume: Boolean) {
|
||||
log.debug { "Downloading from stream" }
|
||||
try {
|
||||
FileOutputStream(outputFile, isResume).use { outputStream ->
|
||||
getInputStream(isResume).use { input ->
|
||||
// Getting the input stream is slow(ish) for HTTP downloads, so we'll check if
|
||||
// we were interrupted before proceeding to the download.
|
||||
throwExceptionIfInterrupted()
|
||||
copyInputToOutputStream(input, outputStream)
|
||||
}
|
||||
}
|
||||
// Even if we have completely downloaded the file, we should probably respect
|
||||
// the wishes of the user who wanted to cancel us.
|
||||
throwExceptionIfInterrupted()
|
||||
} finally {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
protected suspend fun downloadFromBytesReceiver(isResume: Boolean) {
|
||||
try {
|
||||
FileOutputStream(outputFile, isResume).use { outputStream ->
|
||||
var bytesCopied = outputFile.length()
|
||||
var lastTimeReported = 0L
|
||||
val bytesTotal = totalDownloadSize()
|
||||
getBytes(isResume) { bytes ->
|
||||
// Getting the input stream is slow(ish) for HTTP downloads, so we'll check if
|
||||
// we were interrupted before proceeding to the download.
|
||||
throwExceptionIfInterrupted()
|
||||
outputStream.write(bytes)
|
||||
bytesCopied += bytes.size
|
||||
lastTimeReported = reportProgress(lastTimeReported, bytesCopied, bytesTotal)
|
||||
}
|
||||
// force progress reporting at the end
|
||||
reportProgress(0L, bytesCopied, bytesTotal)
|
||||
}
|
||||
// Even if we have completely downloaded the file, we should probably respect
|
||||
// the wishes of the user who wanted to cancel us.
|
||||
throwExceptionIfInterrupted()
|
||||
} finally {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This copies the downloaded data from the [InputStream] to the [OutputStream],
|
||||
* keeping track of the number of bytes that have flown through for the [progressListener].
|
||||
*
|
||||
* Attention: The caller is responsible for closing the streams.
|
||||
*/
|
||||
@Throws(IOException::class, InterruptedException::class)
|
||||
private fun copyInputToOutputStream(input: InputStream, output: OutputStream) {
|
||||
try {
|
||||
var bytesCopied = outputFile.length()
|
||||
var lastTimeReported = 0L
|
||||
val bytesTotal = totalDownloadSize()
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var numBytes = input.read(buffer)
|
||||
while (numBytes >= 0) {
|
||||
throwExceptionIfInterrupted()
|
||||
output.write(buffer, 0, numBytes)
|
||||
bytesCopied += numBytes
|
||||
lastTimeReported = reportProgress(lastTimeReported, bytesCopied, bytesTotal)
|
||||
numBytes = input.read(buffer)
|
||||
}
|
||||
// force progress reporting at the end
|
||||
reportProgress(0L, bytesCopied, bytesTotal)
|
||||
} finally {
|
||||
output.flush()
|
||||
progressListener = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportProgress(lastTimeReported: Long, bytesRead: Long, bytesTotal: Long): Long {
|
||||
val now = System.currentTimeMillis()
|
||||
return if (now - lastTimeReported > 100) {
|
||||
log.debug { "onProgress: $bytesRead/$bytesTotal" }
|
||||
progressListener?.onProgress(bytesRead, bytesTotal)
|
||||
now
|
||||
} else {
|
||||
lastTimeReported
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running download, triggering an [InterruptedException]
|
||||
*/
|
||||
fun cancelDownload() {
|
||||
cancelled = true
|
||||
}
|
||||
|
||||
/**
|
||||
* After every network operation that could take a while, we will check if an
|
||||
* interrupt occurred during that blocking operation. The goal is to ensure we
|
||||
* don't move onto another slow, network operation if we have cancelled the
|
||||
* download.
|
||||
*
|
||||
* @throws InterruptedException
|
||||
*/
|
||||
@Throws(InterruptedException::class)
|
||||
private fun throwExceptionIfInterrupted() {
|
||||
if (cancelled) {
|
||||
log.debug { "Received interrupt, cancelling download" }
|
||||
Thread.currentThread().interrupt()
|
||||
throw InterruptedException()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright (C) 2014-2017 Peter Serwylo <peter@serwylo.com>
|
||||
* Copyright (C) 2014-2018 Hans-Christoph Steiner <hans@eds.org>
|
||||
* Copyright (C) 2015-2016 Daniel Martí <mvdan@mvdan.cc>
|
||||
* Copyright (c) 2018 Senecto Limited
|
||||
* Copyright (C) 2022 Torsten Grote <t at grobox.de>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License
|
||||
* as published by the Free Software Foundation; either version 3
|
||||
* of the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
*/
|
||||
package org.fdroid.download
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.net.Uri
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import io.ktor.client.features.ResponseException
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import mu.KotlinLogging
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Download files over HTTP, with support for proxies, `.onion` addresses, HTTP Basic Auth, etc.
|
||||
*/
|
||||
class HttpDownloader @JvmOverloads constructor(
|
||||
private val downloadManager: DownloadManager,
|
||||
private val path: String,
|
||||
destFile: File,
|
||||
private val mirrors: List<Mirror>,
|
||||
private val username: String? = null,
|
||||
private val password: String? = null,
|
||||
) : Downloader(destFile) {
|
||||
|
||||
companion object {
|
||||
val log = KotlinLogging.logger {}
|
||||
|
||||
@JvmStatic
|
||||
fun isSwapUrl(uri: Uri): Boolean {
|
||||
return isSwapUrl(uri.host, uri.port)
|
||||
}
|
||||
|
||||
fun isSwapUrl(host: String?, port: Int): Boolean {
|
||||
return (port > 1023 // only root can use <= 1023, so never a swap repo
|
||||
&& host!!.matches(Regex("[0-9.]+")) // host must be an IP address
|
||||
)
|
||||
// TODO check if is local
|
||||
}
|
||||
}
|
||||
|
||||
private var hasChanged = false
|
||||
private var fileSize = -1L
|
||||
|
||||
override fun getInputStream(resumable: Boolean): InputStream {
|
||||
throw NotImplementedError("Use getInputStreamSuspend instead.")
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override suspend fun getBytes(resumable: Boolean, receiver: (ByteArray) -> Unit) {
|
||||
val request = DownloadRequest(path, mirrors, username, password)
|
||||
val skipBytes = if (resumable) outputFile.length() else null
|
||||
return try {
|
||||
downloadManager.get(request, skipBytes, receiver)
|
||||
} catch (e: ResponseException) {
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a remote file, checking the HTTP response code, if it has changed since
|
||||
* the last time a download was tried.
|
||||
*
|
||||
*
|
||||
* If the `ETag` does not match, it could be caused by the previous
|
||||
* download of the same file coming from a mirror running on a different
|
||||
* webserver, e.g. Apache vs Nginx. `Content-Length` and
|
||||
* `Last-Modified` are used to check whether the file has changed since
|
||||
* those are more standardized than `ETag`. Plus, Nginx and Apache 2.4
|
||||
* defaults use only those two values to generate the `ETag` anyway.
|
||||
* Unfortunately, other webservers and CDNs have totally different methods
|
||||
* for generating the `ETag`. And mirrors that are syncing using a
|
||||
* method other than `rsync` could easily have different `Last-Modified`
|
||||
* times on the exact same file. On top of that, some services like GitHub's
|
||||
* raw file support `raw.githubusercontent.com` and GitLab's raw file
|
||||
* support do not set the `Last-Modified` header at all. So ultimately,
|
||||
* then `ETag` needs to be used first and foremost, then this calculated
|
||||
* `ETag` can serve as a common fallback.
|
||||
*
|
||||
*
|
||||
* In order to prevent the `ETag` from being used as a form of tracking
|
||||
* cookie, this code never sends the `ETag` to the server. Instead, it
|
||||
* uses a `HEAD` request to get the `ETag` from the server, then
|
||||
* only issues a `GET` if the `ETag` has changed.
|
||||
*
|
||||
*
|
||||
* This uses a integer value for `Last-Modified` to avoid enabling the
|
||||
* use of that value as some kind of "cookieless cookie". One second time
|
||||
* resolution should be plenty since these files change more on the time
|
||||
* space of minutes or hours.
|
||||
*
|
||||
* @see [update index from any available mirror](https://gitlab.com/fdroid/fdroidclient/issues/1708)
|
||||
*
|
||||
* @see [Cookieless cookies](http://lucb1e.com/rp/cookielesscookies)
|
||||
*/
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@Throws(IOException::class, InterruptedException::class)
|
||||
override fun download() {
|
||||
// boolean isSwap = isSwapUrl(sourceUrl);
|
||||
val request = DownloadRequest(path, mirrors, username, password)
|
||||
val headInfo = runBlocking {
|
||||
downloadManager.head(request, cacheTag) ?: throw IOException()
|
||||
}
|
||||
val expectedETag = cacheTag
|
||||
cacheTag = headInfo.eTag
|
||||
fileSize = headInfo.contentLength ?: -1
|
||||
|
||||
// If the ETag does not match, it could be because the file is on a mirror
|
||||
// running a different webserver, e.g. Apache vs Nginx.
|
||||
// Content-Length and Last-Modified could be used as well.
|
||||
// Nginx and Apache 2.4 defaults use only those two values to generate the ETag.
|
||||
// Unfortunately, other webservers and CDNs have totally different methods.
|
||||
// And mirrors that are syncing using a method other than rsync
|
||||
// could easily have different Last-Modified times on the exact same file.
|
||||
// On top of that, some services like GitHub's and GitLab's raw file support
|
||||
// do not set the header at all.
|
||||
val lastModified = try {
|
||||
// this method is not available multi-platform, so for now only done in JVM
|
||||
@Suppress("Deprecation")
|
||||
Date.parse(headInfo.lastModified) / 1000
|
||||
} catch (e: Exception) {
|
||||
0L
|
||||
}
|
||||
val calculatedEtag: String =
|
||||
String.format("%x-%x", lastModified, headInfo.contentLength)
|
||||
|
||||
// !headInfo.eTagChanged: expectedETag == headInfo.eTag (the expected ETag was in server response)
|
||||
// calculatedEtag == expectedETag (ETag calculated from server response matches expected ETag)
|
||||
if (!headInfo.eTagChanged || calculatedEtag == expectedETag) {
|
||||
// ETag has not changed, don't download again
|
||||
log.debug { "$path cached, not downloading." }
|
||||
hasChanged = false
|
||||
return
|
||||
}
|
||||
|
||||
hasChanged = true
|
||||
var resumable = false
|
||||
val fileLength = outputFile.length()
|
||||
if (fileLength > fileSize) {
|
||||
if (!outputFile.delete()) log.warn { "Warning: " + outputFile.absolutePath + " not deleted" }
|
||||
} else if (fileLength == fileSize && outputFile.isFile) {
|
||||
log.debug { "Already have outputFile, not download. ${outputFile.absolutePath}" }
|
||||
return // already have it!
|
||||
} else if (fileLength > 0) {
|
||||
resumable = true
|
||||
}
|
||||
log.debug { "downloading $path (is resumable: $resumable)" }
|
||||
runBlocking { downloadFromBytesReceiver(resumable) }
|
||||
}
|
||||
|
||||
@TargetApi(24)
|
||||
public override fun totalDownloadSize(): Long {
|
||||
return if (SDK_INT < 24) {
|
||||
fileSize.toInt().toLong() // TODO why?
|
||||
} else {
|
||||
fileSize
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasChanged(): Boolean {
|
||||
return hasChanged
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package org.fdroid.download
|
||||
|
||||
import io.ktor.client.engine.mock.MockEngine
|
||||
import io.ktor.client.engine.mock.respond
|
||||
import io.ktor.client.engine.mock.respondOk
|
||||
import io.ktor.client.utils.buildHeaders
|
||||
import io.ktor.http.HttpHeaders.ContentLength
|
||||
import io.ktor.http.HttpHeaders.ETag
|
||||
import io.ktor.http.HttpHeaders.LastModified
|
||||
import io.ktor.http.HttpMethod.Companion.Get
|
||||
import io.ktor.http.HttpMethod.Companion.Head
|
||||
import io.ktor.http.HttpStatusCode.Companion.OK
|
||||
import io.ktor.http.HttpStatusCode.Companion.PartialContent
|
||||
import io.ktor.http.headersOf
|
||||
import org.fdroid.getRandomString
|
||||
import org.fdroid.runSuspend
|
||||
import org.junit.Rule
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import kotlin.random.Random
|
||||
import kotlin.test.Ignore
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
class HttpDownloaderTest {
|
||||
|
||||
@get:Rule
|
||||
var folder = TemporaryFolder()
|
||||
|
||||
private val userAgent = getRandomString()
|
||||
private val mirror1 = Mirror("http://example.org")
|
||||
private val mirrors = listOf(mirror1)
|
||||
|
||||
@Test
|
||||
fun testDownload() = runSuspend {
|
||||
val file = folder.newFile()
|
||||
val bytes = Random.nextBytes(1024)
|
||||
|
||||
val mockEngine = MockEngine { respond(bytes) }
|
||||
val downloadManager = DownloadManager(userAgent, null, httpClientEngine = mockEngine)
|
||||
val httpDownloader = HttpDownloader(downloadManager, "foo/bar", file, mirrors)
|
||||
httpDownloader.download()
|
||||
|
||||
assertContentEquals(bytes, file.readBytes())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testResumeSuccess() = runSuspend {
|
||||
val file = folder.newFile()
|
||||
val firstBytes = Random.nextBytes(1024)
|
||||
file.writeBytes(firstBytes)
|
||||
val secondBytes = Random.nextBytes(1024)
|
||||
|
||||
var numRequest = 1
|
||||
val mockEngine = MockEngine {
|
||||
if (numRequest++ == 1) respond("", OK, headers = headersOf(ContentLength, "2048"))
|
||||
else respond(secondBytes, PartialContent)
|
||||
}
|
||||
val downloadManager = DownloadManager(userAgent, null, httpClientEngine = mockEngine)
|
||||
val httpDownloader = HttpDownloader(downloadManager, "foo/bar", file, mirrors)
|
||||
httpDownloader.download()
|
||||
|
||||
assertContentEquals(firstBytes + secondBytes, file.readBytes())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNoETagNotTreatedAsNoChange() = runSuspend {
|
||||
val mockEngine = MockEngine { respondOk() }
|
||||
val downloadManager = DownloadManager(userAgent, null, httpClientEngine = mockEngine)
|
||||
val httpDownloader = HttpDownloader(downloadManager, "foo/bar", folder.newFile(), mirrors)
|
||||
httpDownloader.cacheTag = null
|
||||
httpDownloader.download()
|
||||
|
||||
assertEquals(2, mockEngine.requestHistory.size)
|
||||
val headRequest = mockEngine.requestHistory[0]
|
||||
val getRequest = mockEngine.requestHistory[1]
|
||||
assertEquals(Head, headRequest.method)
|
||||
assertEquals(Get, getRequest.method)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testExpectedETagSkipsDownload() = runSuspend {
|
||||
val eTag = getRandomString()
|
||||
|
||||
val mockEngine = MockEngine { respond("", OK, headers = headersOf(ETag, eTag)) }
|
||||
val downloadManager = DownloadManager(userAgent, null, httpClientEngine = mockEngine)
|
||||
val httpDownloader = HttpDownloader(downloadManager, "foo/bar", folder.newFile(), mirrors)
|
||||
httpDownloader.cacheTag = eTag
|
||||
httpDownloader.download()
|
||||
|
||||
assertEquals(eTag, httpDownloader.cacheTag)
|
||||
assertEquals(1, mockEngine.requestHistory.size)
|
||||
assertEquals(Head, mockEngine.requestHistory[0].method)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Ignore("We can not yet handle this scenario. See: #1708")
|
||||
fun testCalculatedETagSkipsDownload() = runSuspend {
|
||||
val eTag = "61de7e31-60a29a"
|
||||
val headers = buildHeaders {
|
||||
append(ETag, eTag)
|
||||
append(LastModified, "Wed, 12 Jan 2022 07:07:29 GMT")
|
||||
append(ContentLength, "6333082")
|
||||
}
|
||||
|
||||
val mockEngine = MockEngine { respond("", OK, headers = headers) }
|
||||
val downloadManager = DownloadManager(userAgent, null, httpClientEngine = mockEngine)
|
||||
val httpDownloader = HttpDownloader(downloadManager, "foo/bar", folder.newFile(), mirrors)
|
||||
// the ETag is calculated, but we expect a real ETag
|
||||
httpDownloader.cacheTag = "60a29a-5d55d390de574"
|
||||
httpDownloader.download()
|
||||
|
||||
assertEquals(eTag, httpDownloader.cacheTag)
|
||||
assertEquals(1, mockEngine.requestHistory.size)
|
||||
assertEquals(Head, mockEngine.requestHistory[0].method)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package org.fdroid.download
|
||||
|
||||
import io.ktor.client.features.ResponseException
|
||||
import io.ktor.utils.io.jvm.javaio.toInputStream
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.Date
|
||||
|
||||
// FIXME ideally we can get rid of this wrapper, only need it for Java 7 right now (SDK < 24)
|
||||
public class JvmDownloadManager(
|
||||
userAgent: String,
|
||||
queryString: String?,
|
||||
) : DownloadManager(userAgent, queryString) {
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun headBlocking(request: DownloadRequest, eTag: String?): HeadInfo = runBlocking {
|
||||
val headInfo = head(request, eTag) ?: throw IOException()
|
||||
if (eTag == null) return@runBlocking headInfo
|
||||
// If the ETag does not match, it could be because the file is on a mirror
|
||||
// running a different webserver, e.g. Apache vs Nginx.
|
||||
// Content-Length and Last-Modified could be used as well.
|
||||
// Nginx and Apache 2.4 defaults use only those two values to generate the ETag.
|
||||
// Unfortunately, other webservers and CDNs have totally different methods.
|
||||
// And mirrors that are syncing using a method other than rsync
|
||||
// could easily have different Last-Modified times on the exact same file.
|
||||
// On top of that, some services like GitHub's and GitLab's raw file support
|
||||
// do not set the header at all.
|
||||
val lastModified = try {
|
||||
// this method is not available multi-platform, so for now only done in JVM
|
||||
Date.parse(headInfo.lastModified) / 1000
|
||||
} catch (e: Exception) {
|
||||
0L
|
||||
}
|
||||
val calculatedEtag: String =
|
||||
String.format("\"%x-%x\"", lastModified, headInfo.contentLength)
|
||||
if (calculatedEtag == eTag) headInfo.copy(eTagChanged = false)
|
||||
else headInfo
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
@Throws(IOException::class)
|
||||
fun getBlocking(request: DownloadRequest, skipFirstBytes: Long? = null): InputStream =
|
||||
runBlocking {
|
||||
try {
|
||||
get(request, skipFirstBytes).toInputStream()
|
||||
} catch (e: ResponseException) {
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,12 +7,12 @@ import io.ktor.client.features.ResponseException
|
||||
import io.ktor.client.features.ServerResponseException
|
||||
import io.ktor.client.features.UserAgent
|
||||
import io.ktor.client.features.defaultRequest
|
||||
import io.ktor.client.features.onDownload
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.head
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.HttpStatement
|
||||
import io.ktor.http.HttpHeaders.Authorization
|
||||
import io.ktor.http.HttpHeaders.Connection
|
||||
import io.ktor.http.HttpHeaders.ETag
|
||||
@@ -21,9 +21,17 @@ import io.ktor.http.HttpStatusCode.Companion.PartialContent
|
||||
import io.ktor.http.contentLength
|
||||
import io.ktor.util.InternalAPI
|
||||
import io.ktor.util.encodeBase64
|
||||
import io.ktor.util.toByteArray
|
||||
import io.ktor.utils.io.ByteChannel
|
||||
import io.ktor.utils.io.ByteReadChannel
|
||||
import io.ktor.utils.io.charsets.Charsets
|
||||
import io.ktor.utils.io.close
|
||||
import io.ktor.utils.io.core.isEmpty
|
||||
import io.ktor.utils.io.core.readBytes
|
||||
import io.ktor.utils.io.core.toByteArray
|
||||
import io.ktor.utils.io.readRemaining
|
||||
import io.ktor.utils.io.writeFully
|
||||
import mu.KotlinLogging
|
||||
import kotlin.jvm.JvmOverloads
|
||||
|
||||
internal expect fun getHttpClientEngine(): HttpClientEngine
|
||||
@@ -35,6 +43,10 @@ public open class DownloadManager @JvmOverloads constructor(
|
||||
httpClientEngine: HttpClientEngine = getHttpClientEngine(),
|
||||
) {
|
||||
|
||||
companion object {
|
||||
val log = KotlinLogging.logger {}
|
||||
}
|
||||
|
||||
private val httpClient by lazy {
|
||||
HttpClient(httpClientEngine) {
|
||||
followRedirects = false
|
||||
@@ -72,47 +84,71 @@ public open class DownloadManager @JvmOverloads constructor(
|
||||
val authString = constructBasicAuthValue(request)
|
||||
val response: HttpResponse = try {
|
||||
mirrorChooser.mirrorRequest(request) { url ->
|
||||
log.debug { "URL: $url" }
|
||||
httpClient.head(url) {
|
||||
// add authorization header from username / password if set
|
||||
if (authString != null) header(Authorization, authString)
|
||||
}
|
||||
}
|
||||
} catch (e: ResponseException) {
|
||||
println(e)
|
||||
log.warn(e) { "Error getting HEAD" }
|
||||
return null
|
||||
}
|
||||
val contentLength = response.contentLength()
|
||||
val lastModified = response.headers[LastModified]
|
||||
if (eTag != null && response.headers[ETag] == eTag) {
|
||||
return HeadInfo(false, contentLength, lastModified)
|
||||
return HeadInfo(false, response.headers[ETag], contentLength, lastModified)
|
||||
}
|
||||
return HeadInfo(true, contentLength, lastModified)
|
||||
return HeadInfo(true, response.headers[ETag], contentLength, lastModified)
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
suspend fun get(request: DownloadRequest, skipFirstBytes: Long? = null): ByteReadChannel {
|
||||
suspend fun get(
|
||||
request: DownloadRequest,
|
||||
skipFirstBytes: Long? = null,
|
||||
receiver: suspend (ByteArray) -> Unit,
|
||||
) {
|
||||
val authString = constructBasicAuthValue(request)
|
||||
val response: HttpResponse = mirrorChooser.mirrorRequest(request) { url ->
|
||||
httpClient.get(url) {
|
||||
mirrorChooser.mirrorRequest(request) { url ->
|
||||
httpClient.get<HttpStatement>(url) {
|
||||
// add authorization header from username / password if set
|
||||
if (authString != null) header(Authorization, authString)
|
||||
// add range header if set
|
||||
if (skipFirstBytes != null) header("Range", "bytes=${skipFirstBytes}-")
|
||||
// avoid keep-alive for swap due to strange errors observed in the past
|
||||
// TODO still needed?
|
||||
if (request.isSwap) header(Connection, "Close")
|
||||
|
||||
onDownload { bytesSentTotal, contentLength ->
|
||||
println("Received $bytesSentTotal bytes from $contentLength")
|
||||
}
|
||||
}.execute { response ->
|
||||
if (skipFirstBytes != null && response.status != PartialContent) {
|
||||
throw ServerResponseException(response, "expected 206")
|
||||
}
|
||||
val channel: ByteReadChannel = response.receive()
|
||||
while (!channel.isClosedForRead) {
|
||||
val packet = channel.readRemaining(8L * 1024L)
|
||||
while (!packet.isEmpty) {
|
||||
receiver(packet.readBytes())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (skipFirstBytes != null && response.status != PartialContent) {
|
||||
throw ServerResponseException(response, "expected 206")
|
||||
}
|
||||
return response.receive() // 2.0 .bodyAsChannel()
|
||||
}
|
||||
|
||||
@OptIn(InternalAPI::class) // 2.0 remove
|
||||
/**
|
||||
* Same as [get], but returns all bytes.
|
||||
* Use this only when you are sure that a response will be small.
|
||||
* Thus, this is intentionally visible internally only.
|
||||
*/
|
||||
@JvmOverloads
|
||||
internal suspend fun getBytes(request: DownloadRequest, skipFirstBytes: Long? = null): ByteArray {
|
||||
val channel = ByteChannel()
|
||||
get(request, skipFirstBytes) { bytes ->
|
||||
channel.writeFully(bytes)
|
||||
}
|
||||
channel.close()
|
||||
return channel.toByteArray()
|
||||
}
|
||||
|
||||
@OptIn(InternalAPI::class) // ktor 2.0 remove
|
||||
private fun constructBasicAuthValue(request: DownloadRequest): String? {
|
||||
if (request.username == null || request.password == null) return null
|
||||
val authString = "${request.username}:${request.password}"
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.fdroid.download
|
||||
|
||||
data class HeadInfo(
|
||||
val eTagChanged: Boolean,
|
||||
val eTag: String?,
|
||||
val contentLength: Long?,
|
||||
val lastModified: String?,
|
||||
)
|
||||
|
||||
@@ -19,3 +19,10 @@ data class Mirror @JvmOverloads constructor(
|
||||
fun fromStrings(list: List<String>): List<Mirror> = list.map { Mirror(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun Url.isLocal(): Boolean {
|
||||
return (port > 1023 // only root can use <= 1023, so never a swap repo
|
||||
&& host.matches(Regex("[0-9.]+")) // host must be an IP address
|
||||
// TODO check if IP is link or site local
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package org.fdroid.download
|
||||
|
||||
import io.ktor.client.features.ResponseException
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.http.Url
|
||||
import mu.KotlinLogging
|
||||
|
||||
class MirrorChooser {
|
||||
|
||||
companion object {
|
||||
val log = KotlinLogging.logger {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of mirrors with the best mirrors first.
|
||||
*/
|
||||
@@ -18,16 +22,17 @@ class MirrorChooser {
|
||||
/**
|
||||
* Executes the given request on the best mirror and tries the next best ones if that fails.
|
||||
*/
|
||||
internal suspend fun mirrorRequest(
|
||||
internal suspend fun <T> mirrorRequest(
|
||||
downloadRequest: DownloadRequest,
|
||||
request: suspend (url: Url) -> HttpResponse,
|
||||
): HttpResponse {
|
||||
request: suspend (url: Url) -> T,
|
||||
): T {
|
||||
orderMirrors(downloadRequest.mirrors).forEachIndexed { index, mirror ->
|
||||
try {
|
||||
return request(mirror.getUrl(downloadRequest.path))
|
||||
} catch (e: ResponseException) {
|
||||
println(e)
|
||||
if (index == downloadRequest.mirrors.size - 1) throw e
|
||||
val wasLastMirror = index == downloadRequest.mirrors.size - 1
|
||||
log.warn(e) { if (wasLastMirror) "Last mirror, rethrowing..." else "Trying other mirror now..." }
|
||||
if (wasLastMirror) throw e
|
||||
}
|
||||
}
|
||||
error("Reached code that was thought to be unreachable.")
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.fdroid.fdroid
|
||||
|
||||
/**
|
||||
* This is meant only to send download progress for any URL (e.g. index
|
||||
* updates, APKs, etc). This also keeps this class pure Java so that classes
|
||||
* that use `ProgressListener` can be tested on the JVM, without requiring
|
||||
* an Android device or emulator.
|
||||
*
|
||||
*
|
||||
* The full URL of a download is used as the unique identifier throughout
|
||||
* F-Droid. I can take a few forms:
|
||||
*
|
||||
* * [URL] instances
|
||||
* * [android.net.Uri] instances
|
||||
* * `String` instances, i.e. [URL.toString]
|
||||
* * `int`s, i.e. [String.hashCode]
|
||||
*
|
||||
*/
|
||||
interface ProgressListener {
|
||||
fun onProgress(bytesRead: Long, totalBytes: Long)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.fdroid.download
|
||||
|
||||
import io.ktor.util.toByteArray
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.fdroid.getRandomString
|
||||
import kotlin.test.Test
|
||||
@@ -16,7 +15,7 @@ class DownloadManagerIntegrationTest {
|
||||
fun testResumeOnExample() = runBlocking {
|
||||
val downloadManager = DownloadManager(userAgent, null)
|
||||
|
||||
val lastLine = downloadManager.get(downloadRequest, 1248).toByteArray().decodeToString()
|
||||
val lastLine = downloadManager.getBytes(downloadRequest, 1248).decodeToString()
|
||||
assertEquals("</html>\n", lastLine)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import io.ktor.client.engine.mock.MockEngine
|
||||
import io.ktor.client.engine.mock.respond
|
||||
import io.ktor.client.engine.mock.respondError
|
||||
import io.ktor.client.engine.mock.respondOk
|
||||
import io.ktor.client.engine.mock.respondRedirect
|
||||
import io.ktor.client.features.RedirectResponseException
|
||||
import io.ktor.client.features.ServerResponseException
|
||||
import io.ktor.http.HttpHeaders.Authorization
|
||||
import io.ktor.http.HttpHeaders.ETag
|
||||
@@ -12,8 +14,8 @@ import io.ktor.http.HttpHeaders.UserAgent
|
||||
import io.ktor.http.HttpStatusCode.Companion.InternalServerError
|
||||
import io.ktor.http.HttpStatusCode.Companion.OK
|
||||
import io.ktor.http.HttpStatusCode.Companion.PartialContent
|
||||
import io.ktor.http.HttpStatusCode.Companion.TemporaryRedirect
|
||||
import io.ktor.http.headersOf
|
||||
import io.ktor.util.toByteArray
|
||||
import org.fdroid.getRandomString
|
||||
import org.fdroid.runSuspend
|
||||
import kotlin.random.Random
|
||||
@@ -39,7 +41,7 @@ class DownloadManagerTest {
|
||||
val downloadManager = DownloadManager(userAgent, null, httpClientEngine = mockEngine)
|
||||
|
||||
downloadManager.head(downloadRequest)
|
||||
downloadManager.get(downloadRequest)
|
||||
downloadManager.getBytes(downloadRequest)
|
||||
|
||||
mockEngine.requestHistory.forEach { request ->
|
||||
assertEquals(userAgent, request.headers[UserAgent])
|
||||
@@ -55,7 +57,7 @@ class DownloadManagerTest {
|
||||
val downloadManager = DownloadManager(userAgent, queryString, httpClientEngine = mockEngine)
|
||||
|
||||
downloadManager.head(downloadRequest)
|
||||
downloadManager.get(downloadRequest)
|
||||
downloadManager.getBytes(downloadRequest)
|
||||
|
||||
mockEngine.requestHistory.forEach { request ->
|
||||
assertEquals(id, request.url.parameters["id"])
|
||||
@@ -71,7 +73,7 @@ class DownloadManagerTest {
|
||||
val downloadManager = DownloadManager(userAgent, null, httpClientEngine = mockEngine)
|
||||
|
||||
downloadManager.head(downloadRequest)
|
||||
downloadManager.get(downloadRequest)
|
||||
downloadManager.getBytes(downloadRequest)
|
||||
|
||||
mockEngine.requestHistory.forEach { request ->
|
||||
assertEquals("Basic Rm9vOkJhcg==", request.headers[Authorization])
|
||||
@@ -103,7 +105,7 @@ class DownloadManagerTest {
|
||||
val mockEngine = MockEngine { respond(content) }
|
||||
val downloadManager = DownloadManager(userAgent, null, httpClientEngine = mockEngine)
|
||||
|
||||
assertContentEquals(content, downloadManager.get(downloadRequest).toByteArray())
|
||||
assertContentEquals(content, downloadManager.getBytes(downloadRequest))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -126,12 +128,13 @@ class DownloadManagerTest {
|
||||
|
||||
// first request gets only the skipped bytes
|
||||
assertContentEquals(content.copyOfRange(skipBytes, content.size),
|
||||
downloadManager.get(downloadRequest, skipBytes.toLong()).toByteArray())
|
||||
downloadManager.getBytes(downloadRequest, skipBytes.toLong()))
|
||||
// second request fails, because it responds with OK and full content
|
||||
val exception = assertFailsWith<ServerResponseException> {
|
||||
downloadManager.get(downloadRequest, skipBytes.toLong())
|
||||
downloadManager.getBytes(downloadRequest, skipBytes.toLong())
|
||||
}
|
||||
assertEquals("Server error(http://example.net/foo: 200 OK. Text: \"expected 206\"", exception.message)
|
||||
val url = mockEngine.requestHistory.last().url
|
||||
assertEquals("Server error($url: 200 OK. Text: \"expected 206\"", exception.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -143,7 +146,7 @@ class DownloadManagerTest {
|
||||
|
||||
assertNull(downloadManager.head(downloadRequest))
|
||||
assertFailsWith<ServerResponseException> {
|
||||
downloadManager.get(downloadRequest)
|
||||
downloadManager.getBytes(downloadRequest)
|
||||
}
|
||||
|
||||
// assert that URLs for each mirror get tried
|
||||
@@ -159,7 +162,7 @@ class DownloadManagerTest {
|
||||
val downloadManager = DownloadManager(userAgent, null, httpClientEngine = mockEngine)
|
||||
|
||||
assertNotNull(downloadManager.head(downloadRequest))
|
||||
downloadManager.get(downloadRequest)
|
||||
downloadManager.getBytes(downloadRequest)
|
||||
|
||||
// assert there is only one request per API call using one of the mirrors
|
||||
assertEquals(2, mockEngine.requestHistory.size)
|
||||
@@ -169,4 +172,23 @@ class DownloadManagerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNoRedirect() = runSuspend {
|
||||
val downloadRequest = DownloadRequest("foo", mirrors, "Foo", "Bar")
|
||||
|
||||
val mockEngine = MockEngine { respondRedirect("http://example.com") }
|
||||
val downloadManager = DownloadManager(userAgent, null, httpClientEngine = mockEngine)
|
||||
|
||||
assertNull(downloadManager.head(downloadRequest))
|
||||
assertFailsWith<RedirectResponseException> {
|
||||
downloadManager.getBytes(downloadRequest)
|
||||
}
|
||||
|
||||
// HEAD tries another mirror, but GET throws, so no retry
|
||||
assertEquals(3, mockEngine.requestHistory.size)
|
||||
mockEngine.responseHistory.forEach { response ->
|
||||
assertEquals(TemporaryRedirect, response.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.fdroid.download
|
||||
|
||||
/**
|
||||
* This class is not to test actual mirror behavior (done elsewhere), but to test the [Mirror] class.
|
||||
*/
|
||||
/*
|
||||
class MirrorTest {
|
||||
@Test
|
||||
fun testIsSwapUri() {
|
||||
FDroidApp.subnetInfo = SubnetUtils("192.168.0.112/24").getInfo()
|
||||
val urlString =
|
||||
"http://192.168.0.112:8888/fdroid/repo?fingerprint=113F56CBFA967BA825DD13685A06E35730E0061C6BB046DF88A"
|
||||
assertTrue(HttpDownloader.isSwapUrl("192.168.0.112", 8888))
|
||||
assertTrue(HttpDownloader.isSwapUrl(Uri.parse(urlString)))
|
||||
assertTrue(HttpDownloader.isSwapUrl(URL(urlString)))
|
||||
assertFalse(HttpDownloader.isSwapUrl("192.168.1.112", 8888))
|
||||
assertFalse(HttpDownloader.isSwapUrl("192.168.0.112", 80))
|
||||
assertFalse(HttpDownloader.isSwapUrl(Uri.parse("https://malware.com:8888")))
|
||||
assertFalse(HttpDownloader.isSwapUrl(URL("https://www.google.com")))
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -46,7 +46,9 @@
|
||||
<trusted-key id="475f3b8e59e6e63aa78067482c7b12f2a511e325">
|
||||
<trusting group="org.slf4j"/>
|
||||
<trusting group="org.slf4j" name="slf4j-api"/>
|
||||
<trusting group="ch.qos.logback"/>
|
||||
</trusted-key>
|
||||
<trusted-key id="47eb6836245d2d40e89dfb4136d4e9618f3adab5" group="io.github.microutils"/>
|
||||
<trusted-key id="49977dad0140e24894f9b955354214e5e508c045" group="com.hannesdorfmann" name="adapterdelegates3" version="3.0.1"/>
|
||||
<trusted-key id="4cf4b443734c0aed8dc93a1f6132aae95d8e9fe0" group="org.nanohttpd"/>
|
||||
<trusted-key id="4db1a49729b053caf015cee9a6adfc93ef34893e" group="org.hamcrest"/>
|
||||
@@ -2141,6 +2143,16 @@
|
||||
<sha256 value="e2b8153dd1bd760c1a8521cd500205dbf36b6fe89526a190e1ced65193cd51d3" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="io.github.microutils" name="kotlin-logging" version="2.1.21">
|
||||
<artifact name="kotlin-logging-metadata-2.1.21-all.jar">
|
||||
<sha256 value="03815406ca6f4cf7ca49432a353a0072ddd616b68c39afddb1239bac1877e315" origin="Generated by Gradle because artifact wasn't signed"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="io.github.microutils" name="kotlin-logging-linuxx64" version="2.1.21">
|
||||
<artifact name="kotlin-logging.klib">
|
||||
<sha256 value="308b7c34dbfef24f39f3aff02540a407edfd59d9bae14d1e3a0de76438142788" origin="Generated by Gradle because artifact wasn't signed"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="io.ktor" name="ktor-client-android" version="2.0.0-beta-1">
|
||||
<artifact name="ktor-client-android-metadata-2.0.0-beta-1-all.jar">
|
||||
<sha256 value="4a84b904c161615270d5a9c0936fbff9cdad7e19a144f9ae5542e6cc0f37665f" origin="Generated by Gradle because artifact wasn't signed"/>
|
||||
@@ -3564,6 +3576,19 @@
|
||||
<sha256 value="bd5e3639e853cc1e8dca4db696ab29f95924453da93db96e20f30979e9463ef2" origin="Generated by Gradle because artifact wasn't signed"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.jetbrains.kotlinx" name="kotlinx-datetime-linuxx64" version="0.3.1">
|
||||
<artifact name="kotlinx-datetime-cinterop-date.klib">
|
||||
<sha256 value="b7a3f64fb70f8931cab585eb58ebc6471da23712e964f243e5299b51a0876ffb" origin="Generated by Gradle because artifact wasn't signed"/>
|
||||
</artifact>
|
||||
<artifact name="kotlinx-datetime.klib">
|
||||
<sha256 value="aedb7bc0b0669b51fb20c6f0ca2f6a7b3e7b76ebf46f64a5b2a842eaaa386913" origin="Generated by Gradle because artifact wasn't signed"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.jetbrains.kotlinx" name="kotlinx-serialization-core-linuxx64" version="1.3.0">
|
||||
<artifact name="kotlinx-serialization-core.klib">
|
||||
<sha256 value="ad49d11305253b69fdfab52bb88b2181701916871442081010c82950650021ad" origin="Generated by Gradle because artifact wasn't signed"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.jetbrains.trove4j" name="trove4j" version="20160824">
|
||||
<artifact name="trove4j-20160824.jar">
|
||||
<pgp value="33fd4bfd33554634053d73c0c2148900bcd3c2af"/>
|
||||
|
||||
Reference in New Issue
Block a user