Move (Http)Downloader into download library

This commit is contained in:
Torsten Grote
2022-01-14 17:00:31 -03:00
parent d3089df944
commit 062c870f23
44 changed files with 825 additions and 771 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ package org.fdroid.download
data class HeadInfo(
val eTagChanged: Boolean,
val eTag: String?,
val contentLength: Long?,
val lastModified: String?,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")))
}
}
*/

View File

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