summaryrefslogtreecommitdiff
path: root/net/download/service
diff options
context:
space:
mode:
Diffstat (limited to 'net/download/service')
-rw-r--r--net/download/service/README.md3
-rw-r--r--net/download/service/build.gradle47
-rw-r--r--net/download/service/src/main/AndroidManifest.xml7
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/EpisodeDownloadWorker.java311
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/MediaDownloadedHandler.java119
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/DownloadServiceInterfaceImpl.java97
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateManagerImpl.java117
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateWorker.java217
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/NewEpisodesNotification.java147
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/FastDocumentFile.java72
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdater.java285
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DefaultDownloaderFactory.java21
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/Downloader.java62
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DownloaderFactory.java10
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/FeedParserTask.java124
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/HttpDownloader.java315
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/InvalidFeedException.java12
-rw-r--r--net/download/service/src/main/res/values/ids.xml6
-rw-r--r--net/download/service/src/test/assets/local-feed1/track1.mp3bin0 -> 43341 bytes
-rw-r--r--net/download/service/src/test/assets/local-feed2/folder.pngbin0 -> 1589 bytes
-rw-r--r--net/download/service/src/test/assets/local-feed2/track1.mp3bin0 -> 43341 bytes
-rw-r--r--net/download/service/src/test/assets/local-feed2/track2.mp3bin0 -> 43497 bytes
-rw-r--r--net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdaterTest.java306
23 files changed, 2278 insertions, 0 deletions
diff --git a/net/download/service/README.md b/net/download/service/README.md
new file mode 100644
index 000000000..ecee50402
--- /dev/null
+++ b/net/download/service/README.md
@@ -0,0 +1,3 @@
+# :net:download:service
+
+The download service.
diff --git a/net/download/service/build.gradle b/net/download/service/build.gradle
new file mode 100644
index 000000000..75d6b26de
--- /dev/null
+++ b/net/download/service/build.gradle
@@ -0,0 +1,47 @@
+plugins {
+ id("com.android.library")
+ id("java-test-fixtures")
+}
+apply from: "../../../common.gradle"
+apply from: "../../../playFlavor.gradle"
+
+android {
+ namespace "de.danoeh.antennapod.net.download.service"
+}
+
+dependencies {
+ implementation project(":core")
+ implementation project(':event')
+ implementation project(':model')
+ implementation project(':net:common')
+ implementation project(':net:download:service-interface')
+ implementation project(':net:sync:model')
+ implementation project(':net:sync:service-interface')
+ implementation project(':parser:media')
+ implementation project(':parser:feed')
+ implementation project(':storage:database')
+ implementation project(':ui:notifications')
+ implementation project(':storage:preferences')
+ implementation project(':ui:app-start-intent')
+
+ annotationProcessor "androidx.annotation:annotation:$annotationVersion"
+ implementation "androidx.core:core:$coreVersion"
+ implementation 'androidx.documentfile:documentfile:1.0.1'
+ implementation "androidx.work:work-runtime:$workManagerVersion"
+ implementation "com.google.android.material:material:$googleMaterialVersion"
+
+ implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
+ implementation "com.squareup.okhttp3:okhttp-urlconnection:$okhttpVersion"
+ implementation "commons-io:commons-io:$commonsioVersion"
+ implementation "org.apache.commons:commons-lang3:$commonslangVersion"
+ implementation "org.greenrobot:eventbus:$eventbusVersion"
+ implementation "com.github.bumptech.glide:glide:$glideVersion"
+ implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion"
+ implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion"
+ implementation "com.google.guava:guava:31.0.1-android"
+
+ testImplementation "junit:junit:$junitVersion"
+ testImplementation "org.robolectric:robolectric:$robolectricVersion"
+ testImplementation "org.awaitility:awaitility:$awaitilityVersion"
+ testImplementation 'org.mockito:mockito-core:5.11.0'
+}
diff --git a/net/download/service/src/main/AndroidManifest.xml b/net/download/service/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..1319b5274
--- /dev/null
+++ b/net/download/service/src/main/AndroidManifest.xml
@@ -0,0 +1,7 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+ <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+</manifest>
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/EpisodeDownloadWorker.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/EpisodeDownloadWorker.java
new file mode 100644
index 000000000..ff548c039
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/EpisodeDownloadWorker.java
@@ -0,0 +1,311 @@
+package de.danoeh.antennapod.net.download.service.episode;
+
+import android.Manifest;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.wifi.WifiManager;
+import android.os.Build;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.core.app.NotificationCompat;
+import androidx.core.content.ContextCompat;
+import androidx.work.Data;
+import androidx.work.ForegroundInfo;
+import androidx.work.Worker;
+import androidx.work.WorkerParameters;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import de.danoeh.antennapod.net.download.service.R;
+import de.danoeh.antennapod.net.download.service.feed.remote.DefaultDownloaderFactory;
+import de.danoeh.antennapod.net.download.service.feed.remote.Downloader;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadRequestCreator;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.event.MessageEvent;
+import de.danoeh.antennapod.model.download.DownloadError;
+import de.danoeh.antennapod.model.download.DownloadResult;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.model.download.DownloadRequest;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadServiceInterface;
+import de.danoeh.antennapod.ui.appstartintent.MainActivityStarter;
+import de.danoeh.antennapod.ui.notifications.NotificationUtils;
+import org.apache.commons.io.FileUtils;
+import org.greenrobot.eventbus.EventBus;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+
+public class EpisodeDownloadWorker extends Worker {
+ private static final String TAG = "EpisodeDownloadWorker";
+ private static final Map<String, Integer> notificationProgress = new HashMap<>();
+
+ private Downloader downloader = null;
+
+ public EpisodeDownloadWorker(@NonNull Context context, @NonNull WorkerParameters params) {
+ super(context, params);
+ }
+
+ @Override
+ @NonNull
+ public Result doWork() {
+ long mediaId = getInputData().getLong(DownloadServiceInterface.WORK_DATA_MEDIA_ID, 0);
+ FeedMedia media = DBReader.getFeedMedia(mediaId);
+ if (media == null) {
+ return Result.failure();
+ }
+
+ DownloadRequest request = DownloadRequestCreator.create(media).build();
+ Thread progressUpdaterThread = new Thread() {
+ @Override
+ public void run() {
+ while (true) {
+ try {
+ synchronized (notificationProgress) {
+ if (isInterrupted()) {
+ return;
+ }
+ notificationProgress.put(media.getEpisodeTitle(), request.getProgressPercent());
+ }
+ setProgressAsync(
+ new Data.Builder()
+ .putInt(DownloadServiceInterface.WORK_DATA_PROGRESS, request.getProgressPercent())
+ .build())
+ .get();
+ NotificationManager nm = (NotificationManager) getApplicationContext()
+ .getSystemService(Context.NOTIFICATION_SERVICE);
+ if (ContextCompat.checkSelfPermission(getApplicationContext(),
+ Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
+ nm.notify(R.id.notification_downloading, generateProgressNotification());
+ }
+ Thread.sleep(1000);
+ } catch (InterruptedException | ExecutionException e) {
+ return;
+ }
+ }
+ }
+ };
+ progressUpdaterThread.start();
+ Result result;
+ try {
+ result = performDownload(media, request);
+ } catch (Exception e) {
+ e.printStackTrace();
+ result = Result.failure();
+ }
+ if (result.equals(Result.failure()) && downloader != null) {
+ FileUtils.deleteQuietly(new File(downloader.getDownloadRequest().getDestination()));
+ }
+ progressUpdaterThread.interrupt();
+ try {
+ progressUpdaterThread.join();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+ synchronized (notificationProgress) {
+ notificationProgress.remove(media.getEpisodeTitle());
+ if (notificationProgress.isEmpty()) {
+ NotificationManager nm = (NotificationManager) getApplicationContext()
+ .getSystemService(Context.NOTIFICATION_SERVICE);
+ nm.cancel(R.id.notification_downloading);
+ }
+ }
+ Log.d(TAG, "Worker for " + media.getDownloadUrl() + " returned.");
+ return result;
+ }
+
+ @Override
+ public void onStopped() {
+ super.onStopped();
+ if (downloader != null) {
+ downloader.cancel();
+ }
+ }
+
+ @NonNull
+ @Override
+ public ListenableFuture<ForegroundInfo> getForegroundInfoAsync() {
+ return Futures.immediateFuture(
+ new ForegroundInfo(R.id.notification_downloading, generateProgressNotification()));
+ }
+
+ private Result performDownload(FeedMedia media, DownloadRequest request) {
+ File dest = new File(request.getDestination());
+ if (!dest.exists()) {
+ try {
+ dest.createNewFile();
+ } catch (IOException e) {
+ Log.e(TAG, "Unable to create file");
+ }
+ }
+
+ if (dest.exists()) {
+ media.setLocalFileUrl(request.getDestination());
+ try {
+ DBWriter.setFeedMedia(media).get();
+ } catch (Exception e) {
+ Log.e(TAG, "ExecutionException in writeFileUrl: " + e.getMessage());
+ }
+ }
+
+ downloader = new DefaultDownloaderFactory().create(request);
+ if (downloader == null) {
+ Log.d(TAG, "Unable to create downloader");
+ return Result.failure();
+ }
+
+ WifiManager wifiManager = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE);
+ WifiManager.WifiLock wifiLock = null;
+ if (wifiManager != null) {
+ wifiLock = wifiManager.createWifiLock(TAG);
+ wifiLock.acquire();
+ }
+ try {
+ downloader.call();
+ } catch (Exception e) {
+ DBWriter.addDownloadStatus(downloader.getResult());
+ sendErrorNotification(request.getTitle());
+ return Result.failure();
+ } finally {
+ if (wifiLock != null) {
+ wifiLock.release();
+ }
+ }
+
+ if (downloader.cancelled) {
+ // This also happens when the worker was preempted, not just when the user cancelled it
+ return Result.success();
+ }
+
+ DownloadResult status = downloader.getResult();
+ if (status.isSuccessful()) {
+ MediaDownloadedHandler handler = new MediaDownloadedHandler(
+ getApplicationContext(), downloader.getResult(), request);
+ handler.run();
+ DBWriter.addDownloadStatus(handler.getUpdatedStatus());
+ return Result.success();
+ }
+
+ if (status.getReason() == DownloadError.ERROR_HTTP_DATA_ERROR
+ && Integer.parseInt(status.getReasonDetailed()) == 416) {
+ Log.d(TAG, "Requested invalid range, restarting download from the beginning");
+ FileUtils.deleteQuietly(new File(downloader.getDownloadRequest().getDestination()));
+ sendMessage(request.getTitle(), false);
+ return retry3times();
+ }
+
+ Log.e(TAG, "Download failed");
+ DBWriter.addDownloadStatus(status);
+ if (status.getReason() == DownloadError.ERROR_FORBIDDEN
+ || status.getReason() == DownloadError.ERROR_NOT_FOUND
+ || status.getReason() == DownloadError.ERROR_UNAUTHORIZED
+ || status.getReason() == DownloadError.ERROR_IO_BLOCKED) {
+ // Fail fast, these are probably unrecoverable
+ sendErrorNotification(request.getTitle());
+ return Result.failure();
+ }
+ sendMessage(request.getTitle(), false);
+ return retry3times();
+ }
+
+ private Result retry3times() {
+ if (isLastRunAttempt()) {
+ sendErrorNotification(downloader.getDownloadRequest().getTitle());
+ return Result.failure();
+ } else {
+ return Result.retry();
+ }
+ }
+
+ private boolean isLastRunAttempt() {
+ return getRunAttemptCount() >= 2;
+ }
+
+ private void sendMessage(String episodeTitle, boolean isImmediateFail) {
+ boolean retrying = !isLastRunAttempt() && !isImmediateFail;
+ if (episodeTitle.length() > 20) {
+ episodeTitle = episodeTitle.substring(0, 19) + "…";
+ }
+ EventBus.getDefault().post(new MessageEvent(
+ getApplicationContext().getString(
+ retrying ? R.string.download_error_retrying : R.string.download_error_not_retrying,
+ episodeTitle), (ctx) -> new MainActivityStarter(ctx).withDownloadLogsOpen().start(),
+ getApplicationContext().getString(R.string.download_error_details)));
+ }
+
+ private PendingIntent getDownloadLogsIntent(Context context) {
+ Intent intent = new MainActivityStarter(context).withDownloadLogsOpen().getIntent();
+ return PendingIntent.getActivity(context, R.id.pending_intent_download_service_report, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0));
+ }
+
+ private PendingIntent getDownloadsIntent(Context context) {
+ Intent intent = new MainActivityStarter(context).withFragmentLoaded("DownloadsFragment").getIntent();
+ return PendingIntent.getActivity(context, R.id.pending_intent_download_service_notification, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0));
+ }
+
+ private void sendErrorNotification(String title) {
+ if (EventBus.getDefault().hasSubscriberForEvent(MessageEvent.class)) {
+ sendMessage(title, false);
+ return;
+ }
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(getApplicationContext(),
+ NotificationUtils.CHANNEL_ID_DOWNLOAD_ERROR);
+ builder.setTicker(getApplicationContext().getString(R.string.download_report_title))
+ .setContentTitle(getApplicationContext().getString(R.string.download_report_title))
+ .setContentText(getApplicationContext().getString(R.string.download_error_tap_for_details))
+ .setSmallIcon(R.drawable.ic_notification_sync_error)
+ .setContentIntent(getDownloadLogsIntent(getApplicationContext()))
+ .setAutoCancel(true);
+ builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC);
+ NotificationManager nm = (NotificationManager) getApplicationContext()
+ .getSystemService(Context.NOTIFICATION_SERVICE);
+ if (ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.POST_NOTIFICATIONS)
+ == PackageManager.PERMISSION_GRANTED) {
+ nm.notify(R.id.notification_download_report, builder.build());
+ }
+ }
+
+ private Notification generateProgressNotification() {
+ StringBuilder bigTextB = new StringBuilder();
+ Map<String, Integer> progressCopy;
+ synchronized (notificationProgress) {
+ progressCopy = new HashMap<>(notificationProgress);
+ }
+ for (Map.Entry<String, Integer> entry : progressCopy.entrySet()) {
+ bigTextB.append(String.format(Locale.getDefault(), "%s (%d%%)\n", entry.getKey(), entry.getValue()));
+ }
+ String bigText = bigTextB.toString().trim();
+ String contentText;
+ if (progressCopy.size() == 1) {
+ contentText = bigText;
+ } else {
+ contentText = getApplicationContext().getResources().getQuantityString(R.plurals.downloads_left,
+ progressCopy.size(), progressCopy.size());
+ }
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(getApplicationContext(),
+ NotificationUtils.CHANNEL_ID_DOWNLOADING);
+ builder.setTicker(getApplicationContext().getString(R.string.download_notification_title_episodes))
+ .setContentTitle(getApplicationContext().getString(R.string.download_notification_title_episodes))
+ .setContentText(contentText)
+ .setStyle(new NotificationCompat.BigTextStyle().bigText(bigText))
+ .setContentIntent(getDownloadsIntent(getApplicationContext()))
+ .setAutoCancel(false)
+ .setOngoing(true)
+ .setWhen(0)
+ .setOnlyAlertOnce(true)
+ .setShowWhen(false)
+ .setSmallIcon(R.drawable.ic_notification_sync)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC);
+ return builder.build();
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/MediaDownloadedHandler.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/MediaDownloadedHandler.java
new file mode 100644
index 000000000..dd930c62f
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/MediaDownloadedHandler.java
@@ -0,0 +1,119 @@
+package de.danoeh.antennapod.net.download.service.episode;
+
+import android.content.Context;
+import android.media.MediaMetadataRetriever;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import de.danoeh.antennapod.model.MediaMetadataRetrieverCompat;
+import de.danoeh.antennapod.net.sync.serviceinterface.SynchronizationQueueSink;
+import org.greenrobot.eventbus.EventBus;
+
+import java.io.File;
+import java.io.InterruptedIOException;
+import java.util.concurrent.ExecutionException;
+
+import de.danoeh.antennapod.event.UnreadItemsUpdateEvent;
+import de.danoeh.antennapod.model.download.DownloadRequest;
+import de.danoeh.antennapod.model.download.DownloadResult;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.core.util.ChapterUtils;
+import de.danoeh.antennapod.model.download.DownloadError;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.net.sync.model.EpisodeAction;
+
+/**
+ * Handles a completed media download.
+ */
+public class MediaDownloadedHandler implements Runnable {
+ private static final String TAG = "MediaDownloadedHandler";
+ private final DownloadRequest request;
+ private final Context context;
+ private DownloadResult updatedStatus;
+
+ public MediaDownloadedHandler(@NonNull Context context, @NonNull DownloadResult status,
+ @NonNull DownloadRequest request) {
+ this.request = request;
+ this.context = context;
+ this.updatedStatus = status;
+ }
+
+ @Override
+ public void run() {
+ FeedMedia media = DBReader.getFeedMedia(request.getFeedfileId());
+ if (media == null) {
+ Log.e(TAG, "Could not find downloaded media object in database");
+ return;
+ }
+ // media.setDownloaded modifies played state
+ boolean broadcastUnreadStateUpdate = media.getItem() != null && media.getItem().isNew();
+ media.setDownloaded(true);
+ media.setLocalFileUrl(request.getDestination());
+ media.setSize(new File(request.getDestination()).length());
+ media.checkEmbeddedPicture(); // enforce check
+
+ try {
+ // Cache chapters if file has them
+ if (media.getItem() != null && !media.getItem().hasChapters()) {
+ media.setChapters(ChapterUtils.loadChaptersFromMediaFile(media, context));
+ }
+ if (media.getItem() != null && media.getItem().getPodcastIndexChapterUrl() != null) {
+ ChapterUtils.loadChaptersFromUrl(media.getItem().getPodcastIndexChapterUrl(), false);
+ }
+ } catch (InterruptedIOException ignore) {
+ // Ignore
+ }
+
+ // Get duration
+ String durationStr = null;
+ try (MediaMetadataRetrieverCompat mmr = new MediaMetadataRetrieverCompat()) {
+ mmr.setDataSource(media.getLocalFileUrl());
+ durationStr = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION);
+ media.setDuration(Integer.parseInt(durationStr));
+ Log.d(TAG, "Duration of file is " + media.getDuration());
+ } catch (NumberFormatException e) {
+ Log.d(TAG, "Invalid file duration: " + durationStr);
+ } catch (Exception e) {
+ Log.e(TAG, "Get duration failed", e);
+ }
+
+ final FeedItem item = media.getItem();
+
+ try {
+ DBWriter.setFeedMedia(media).get();
+
+ // we've received the media, we don't want to autodownload it again
+ if (item != null) {
+ item.disableAutoDownload();
+ // setFeedItem() signals (via EventBus) that the item has been updated,
+ // so we do it after the enclosing media has been updated above,
+ // to ensure subscribers will get the updated FeedMedia as well
+ DBWriter.setFeedItem(item).get();
+ if (broadcastUnreadStateUpdate) {
+ EventBus.getDefault().post(new UnreadItemsUpdateEvent());
+ }
+ }
+ } catch (InterruptedException e) {
+ Log.e(TAG, "MediaHandlerThread was interrupted");
+ } catch (ExecutionException e) {
+ Log.e(TAG, "ExecutionException in MediaHandlerThread: " + e.getMessage());
+ updatedStatus = new DownloadResult(media.getEpisodeTitle(), media.getId(),
+ FeedMedia.FEEDFILETYPE_FEEDMEDIA, false, DownloadError.ERROR_DB_ACCESS_ERROR, e.getMessage());
+ }
+
+ if (item != null) {
+ EpisodeAction action = new EpisodeAction.Builder(item, EpisodeAction.DOWNLOAD)
+ .currentTimestamp()
+ .build();
+ SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, action);
+ }
+ }
+
+ @NonNull
+ public DownloadResult getUpdatedStatus() {
+ return updatedStatus;
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/DownloadServiceInterfaceImpl.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/DownloadServiceInterfaceImpl.java
new file mode 100644
index 000000000..37a7f30e0
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/DownloadServiceInterfaceImpl.java
@@ -0,0 +1,97 @@
+package de.danoeh.antennapod.net.download.service.feed;
+
+import android.content.Context;
+import androidx.work.Constraints;
+import androidx.work.Data;
+import androidx.work.ExistingWorkPolicy;
+import androidx.work.NetworkType;
+import androidx.work.OneTimeWorkRequest;
+import androidx.work.OutOfQuotaPolicy;
+import androidx.work.WorkInfo;
+import androidx.work.WorkManager;
+import de.danoeh.antennapod.net.download.service.episode.EpisodeDownloadWorker;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadServiceInterface;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import io.reactivex.Observable;
+import io.reactivex.schedulers.Schedulers;
+
+import java.util.List;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+public class DownloadServiceInterfaceImpl extends DownloadServiceInterface {
+ public void downloadNow(Context context, FeedItem item, boolean ignoreConstraints) {
+ OneTimeWorkRequest.Builder workRequest = getRequest(context, item);
+ workRequest.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST);
+ if (ignoreConstraints) {
+ workRequest.setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build());
+ } else {
+ workRequest.setConstraints(getConstraints());
+ }
+ WorkManager.getInstance(context).enqueueUniqueWork(item.getMedia().getDownloadUrl(),
+ ExistingWorkPolicy.KEEP, workRequest.build());
+ }
+
+ public void download(Context context, FeedItem item) {
+ OneTimeWorkRequest.Builder workRequest = getRequest(context, item);
+ workRequest.setConstraints(getConstraints());
+ WorkManager.getInstance(context).enqueueUniqueWork(item.getMedia().getDownloadUrl(),
+ ExistingWorkPolicy.KEEP, workRequest.build());
+ }
+
+ private static OneTimeWorkRequest.Builder getRequest(Context context, FeedItem item) {
+ OneTimeWorkRequest.Builder workRequest = new OneTimeWorkRequest.Builder(EpisodeDownloadWorker.class)
+ .setInitialDelay(0L, TimeUnit.MILLISECONDS)
+ .addTag(DownloadServiceInterface.WORK_TAG)
+ .addTag(DownloadServiceInterface.WORK_TAG_EPISODE_URL + item.getMedia().getDownloadUrl());
+ if (!item.isTagged(FeedItem.TAG_QUEUE) && UserPreferences.enqueueDownloadedEpisodes()) {
+ DBWriter.addQueueItem(context, false, item.getId());
+ workRequest.addTag(DownloadServiceInterface.WORK_DATA_WAS_QUEUED);
+ }
+ workRequest.setInputData(new Data.Builder().putLong(WORK_DATA_MEDIA_ID, item.getMedia().getId()).build());
+ return workRequest;
+ }
+
+ private static Constraints getConstraints() {
+ Constraints.Builder constraints = new Constraints.Builder();
+ if (UserPreferences.isAllowMobileEpisodeDownload()) {
+ constraints.setRequiredNetworkType(NetworkType.CONNECTED);
+ } else {
+ constraints.setRequiredNetworkType(NetworkType.UNMETERED);
+ }
+ return constraints.build();
+ }
+
+ @Override
+ public void cancel(Context context, FeedMedia media) {
+ // This needs to be done here, not in the worker. Reason: The worker might or might not be running.
+ if (media.fileExists()) {
+ DBWriter.deleteFeedMediaOfItem(context, media); // Remove partially downloaded file
+ }
+ String tag = WORK_TAG_EPISODE_URL + media.getDownloadUrl();
+ Future<List<WorkInfo>> future = WorkManager.getInstance(context).getWorkInfosByTag(tag);
+ Observable.fromFuture(future)
+ .subscribeOn(Schedulers.io())
+ .observeOn(Schedulers.io())
+ .subscribe(
+ workInfos -> {
+ for (WorkInfo info : workInfos) {
+ if (info.getTags().contains(DownloadServiceInterface.WORK_DATA_WAS_QUEUED)) {
+ DBWriter.removeQueueItem(context, false, media.getItem());
+ }
+ }
+ WorkManager.getInstance(context).cancelAllWorkByTag(tag);
+ }, exception -> {
+ WorkManager.getInstance(context).cancelAllWorkByTag(tag);
+ exception.printStackTrace();
+ });
+ }
+
+ @Override
+ public void cancelAll(Context context) {
+ WorkManager.getInstance(context).cancelAllWorkByTag(WORK_TAG);
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateManagerImpl.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateManagerImpl.java
new file mode 100644
index 000000000..ae9b25564
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateManagerImpl.java
@@ -0,0 +1,117 @@
+package de.danoeh.antennapod.net.download.service.feed;
+
+import android.content.Context;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.work.Constraints;
+import androidx.work.Data;
+import androidx.work.ExistingPeriodicWorkPolicy;
+import androidx.work.ExistingWorkPolicy;
+import androidx.work.NetworkType;
+import androidx.work.OneTimeWorkRequest;
+import androidx.work.OutOfQuotaPolicy;
+import androidx.work.PeriodicWorkRequest;
+import androidx.work.WorkManager;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import de.danoeh.antennapod.core.R;
+import de.danoeh.antennapod.net.common.NetworkUtils;
+import de.danoeh.antennapod.event.MessageEvent;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.net.download.serviceinterface.FeedUpdateManager;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import org.greenrobot.eventbus.EventBus;
+
+import java.util.concurrent.TimeUnit;
+
+public class FeedUpdateManagerImpl extends FeedUpdateManager {
+ public static final String WORK_TAG_FEED_UPDATE = "feedUpdate";
+ private static final String WORK_ID_FEED_UPDATE = "de.danoeh.antennapod.core.service.FeedUpdateWorker";
+ private static final String WORK_ID_FEED_UPDATE_MANUAL = "feedUpdateManual";
+ public static final String EXTRA_FEED_ID = "feed_id";
+ public static final String EXTRA_NEXT_PAGE = "next_page";
+ public static final String EXTRA_EVEN_ON_MOBILE = "even_on_mobile";
+ private static final String TAG = "AutoUpdateManager";
+
+ /**
+ * Start / restart periodic auto feed refresh
+ * @param context Context
+ */
+ public void restartUpdateAlarm(Context context, boolean replace) {
+ if (UserPreferences.isAutoUpdateDisabled()) {
+ WorkManager.getInstance(context).cancelUniqueWork(WORK_ID_FEED_UPDATE);
+ } else {
+ PeriodicWorkRequest workRequest = new PeriodicWorkRequest.Builder(
+ FeedUpdateWorker.class, UserPreferences.getUpdateInterval(), TimeUnit.HOURS)
+ .setConstraints(new Constraints.Builder()
+ .setRequiredNetworkType(UserPreferences.isAllowMobileFeedRefresh()
+ ? NetworkType.CONNECTED : NetworkType.UNMETERED).build())
+ .build();
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_ID_FEED_UPDATE,
+ replace ? ExistingPeriodicWorkPolicy.REPLACE : ExistingPeriodicWorkPolicy.KEEP, workRequest);
+ }
+ }
+
+ public void runOnce(Context context) {
+ runOnce(context, null, false);
+ }
+
+ public void runOnce(Context context, Feed feed) {
+ runOnce(context, feed, false);
+ }
+
+ public void runOnce(Context context, Feed feed, boolean nextPage) {
+ OneTimeWorkRequest.Builder workRequest = new OneTimeWorkRequest.Builder(FeedUpdateWorker.class)
+ .setInitialDelay(0L, TimeUnit.MILLISECONDS)
+ .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+ .addTag(WORK_TAG_FEED_UPDATE);
+ if (feed == null || !feed.isLocalFeed()) {
+ workRequest.setConstraints(new Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED).build());
+ }
+ Data.Builder builder = new Data.Builder();
+ builder.putBoolean(EXTRA_EVEN_ON_MOBILE, true);
+ if (feed != null) {
+ builder.putLong(EXTRA_FEED_ID, feed.getId());
+ builder.putBoolean(EXTRA_NEXT_PAGE, nextPage);
+ }
+ workRequest.setInputData(builder.build());
+ WorkManager.getInstance(context).enqueueUniqueWork(WORK_ID_FEED_UPDATE_MANUAL,
+ ExistingWorkPolicy.REPLACE, workRequest.build());
+ }
+
+ public void runOnceOrAsk(@NonNull Context context) {
+ runOnceOrAsk(context, null);
+ }
+
+ public void runOnceOrAsk(@NonNull Context context, @Nullable Feed feed) {
+ Log.d(TAG, "Run auto update immediately in background.");
+ if (feed != null && feed.isLocalFeed()) {
+ runOnce(context, feed);
+ } else if (!NetworkUtils.networkAvailable()) {
+ EventBus.getDefault().post(new MessageEvent(context.getString(R.string.download_error_no_connection)));
+ } else if (NetworkUtils.isFeedRefreshAllowed()) {
+ runOnce(context, feed);
+ } else {
+ confirmMobileRefresh(context, feed);
+ }
+ }
+
+ private void confirmMobileRefresh(final Context context, @Nullable Feed feed) {
+ MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.feed_refresh_title)
+ .setPositiveButton(R.string.confirm_mobile_streaming_button_once,
+ (dialog, which) -> runOnce(context, feed))
+ .setNeutralButton(R.string.confirm_mobile_streaming_button_always, (dialog, which) -> {
+ UserPreferences.setAllowMobileFeedRefresh(true);
+ runOnce(context, feed);
+ })
+ .setNegativeButton(R.string.no, null);
+ if (NetworkUtils.isNetworkRestricted() && NetworkUtils.isVpnOverWifi()) {
+ builder.setMessage(R.string.confirm_mobile_feed_refresh_dialog_message_vpn);
+ } else {
+ builder.setMessage(R.string.confirm_mobile_feed_refresh_dialog_message);
+ }
+ builder.show();
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateWorker.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateWorker.java
new file mode 100644
index 000000000..d12249f11
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateWorker.java
@@ -0,0 +1,217 @@
+package de.danoeh.antennapod.net.download.service.feed;
+
+import android.Manifest;
+import android.app.Notification;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationManagerCompat;
+import androidx.core.content.ContextCompat;
+import androidx.work.ForegroundInfo;
+import androidx.work.WorkManager;
+import androidx.work.Worker;
+import androidx.work.WorkerParameters;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import de.danoeh.antennapod.net.download.service.R;
+import de.danoeh.antennapod.net.download.service.feed.local.LocalFeedUpdater;
+import de.danoeh.antennapod.net.download.service.feed.remote.DefaultDownloaderFactory;
+import de.danoeh.antennapod.net.download.service.feed.remote.Downloader;
+import de.danoeh.antennapod.net.download.service.feed.remote.FeedParserTask;
+import de.danoeh.antennapod.net.download.serviceinterface.AutoDownloadManager;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadRequestCreator;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.FeedDatabaseWriter;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.net.common.NetworkUtils;
+import de.danoeh.antennapod.model.download.DownloadError;
+import de.danoeh.antennapod.model.download.DownloadResult;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.download.DownloadRequest;
+
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadRequestBuilder;
+import de.danoeh.antennapod.parser.feed.FeedHandlerResult;
+import de.danoeh.antennapod.ui.notifications.NotificationUtils;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+public class FeedUpdateWorker extends Worker {
+ private static final String TAG = "FeedUpdateWorker";
+
+ private final NewEpisodesNotification newEpisodesNotification;
+ private final NotificationManagerCompat notificationManager;
+
+ public FeedUpdateWorker(@NonNull Context context, @NonNull WorkerParameters params) {
+ super(context, params);
+ newEpisodesNotification = new NewEpisodesNotification();
+ notificationManager = NotificationManagerCompat.from(context);
+ }
+
+ @Override
+ @NonNull
+ public Result doWork() {
+ newEpisodesNotification.loadCountersBeforeRefresh();
+
+ List<Feed> toUpdate;
+ long feedId = getInputData().getLong(FeedUpdateManagerImpl.EXTRA_FEED_ID, -1);
+ boolean allAreLocal = true;
+ boolean force = false;
+ if (feedId == -1) { // Update all
+ toUpdate = DBReader.getFeedList();
+ Iterator<Feed> itr = toUpdate.iterator();
+ while (itr.hasNext()) {
+ Feed feed = itr.next();
+ if (!feed.getPreferences().getKeepUpdated()) {
+ itr.remove();
+ }
+ if (!feed.isLocalFeed()) {
+ allAreLocal = false;
+ }
+ }
+ Collections.shuffle(toUpdate); // If the worker gets cancelled early, every feed has a chance to be updated
+ } else {
+ Feed feed = DBReader.getFeed(feedId);
+ if (feed == null) {
+ return Result.success();
+ }
+ if (!feed.isLocalFeed()) {
+ allAreLocal = false;
+ }
+ toUpdate = new ArrayList<>();
+ toUpdate.add(feed); // Needs to be updatable, so no singletonList
+ force = true;
+ }
+
+ if (!getInputData().getBoolean(FeedUpdateManagerImpl.EXTRA_EVEN_ON_MOBILE, false) && !allAreLocal) {
+ if (!NetworkUtils.networkAvailable() || !NetworkUtils.isFeedRefreshAllowed()) {
+ Log.d(TAG, "Blocking automatic update");
+ return Result.retry();
+ }
+ }
+ refreshFeeds(toUpdate, force);
+
+ notificationManager.cancel(R.id.notification_updating_feeds);
+ AutoDownloadManager.getInstance().autodownloadUndownloadedItems(getApplicationContext());
+ return Result.success();
+ }
+
+ @NonNull
+ private Notification createNotification(@Nullable List<Feed> toUpdate) {
+ Context context = getApplicationContext();
+ String contentText = "";
+ StringBuilder bigText = new StringBuilder();
+ if (toUpdate != null) {
+ contentText = context.getResources().getQuantityString(R.plurals.downloads_left,
+ toUpdate.size(), toUpdate.size());
+ for (int i = 0; i < toUpdate.size(); i++) {
+ bigText.append("• ").append(toUpdate.get(i).getTitle());
+ if (i != toUpdate.size() - 1) {
+ bigText.append("\n");
+ }
+ }
+ }
+ return new NotificationCompat.Builder(context, NotificationUtils.CHANNEL_ID_DOWNLOADING)
+ .setContentTitle(context.getString(R.string.download_notification_title_feeds))
+ .setContentText(contentText)
+ .setStyle(new NotificationCompat.BigTextStyle().bigText(bigText))
+ .setSmallIcon(R.drawable.ic_notification_sync)
+ .setOngoing(true)
+ .addAction(R.drawable.ic_notification_cancel, context.getString(R.string.cancel_label),
+ WorkManager.getInstance(context).createCancelPendingIntent(getId()))
+ .build();
+ }
+
+ @NonNull
+ @Override
+ public ListenableFuture getForegroundInfoAsync() {
+ return Futures.immediateFuture(new ForegroundInfo(R.id.notification_updating_feeds, createNotification(null)));
+ }
+
+ private void refreshFeeds(List<Feed> toUpdate, boolean force) {
+ while (!toUpdate.isEmpty()) {
+ if (isStopped()) {
+ return;
+ }
+ if (ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.POST_NOTIFICATIONS)
+ == PackageManager.PERMISSION_GRANTED) {
+ notificationManager.notify(R.id.notification_updating_feeds, createNotification(toUpdate));
+ }
+ Feed feed = toUpdate.get(0);
+ try {
+ if (feed.isLocalFeed()) {
+ LocalFeedUpdater.updateFeed(feed, getApplicationContext(), null);
+ } else {
+ refreshFeed(feed, force);
+ }
+ } catch (Exception e) {
+ DBWriter.setFeedLastUpdateFailed(feed.getId(), true);
+ DownloadResult status = new DownloadResult(feed.getTitle(),
+ feed.getId(), Feed.FEEDFILETYPE_FEED, false,
+ DownloadError.ERROR_IO_ERROR, e.getMessage());
+ DBWriter.addDownloadStatus(status);
+ }
+ toUpdate.remove(0);
+ }
+ }
+
+ void refreshFeed(Feed feed, boolean force) throws Exception {
+ boolean nextPage = getInputData().getBoolean(FeedUpdateManagerImpl.EXTRA_NEXT_PAGE, false)
+ && feed.getNextPageLink() != null;
+ if (nextPage) {
+ feed.setPageNr(feed.getPageNr() + 1);
+ }
+ DownloadRequestBuilder builder = DownloadRequestCreator.create(feed);
+ builder.setForce(force || feed.hasLastUpdateFailed());
+ if (nextPage) {
+ builder.setSource(feed.getNextPageLink());
+ }
+ DownloadRequest request = builder.build();
+
+ Downloader downloader = new DefaultDownloaderFactory().create(request);
+ if (downloader == null) {
+ throw new Exception("Unable to create downloader");
+ }
+
+ downloader.call();
+
+ if (!downloader.getResult().isSuccessful()) {
+ if (downloader.cancelled || downloader.getResult().getReason() == DownloadError.ERROR_DOWNLOAD_CANCELLED) {
+ return;
+ }
+ DBWriter.setFeedLastUpdateFailed(request.getFeedfileId(), true);
+ DBWriter.addDownloadStatus(downloader.getResult());
+ return;
+ }
+
+ FeedParserTask parserTask = new FeedParserTask(request);
+ FeedHandlerResult feedHandlerResult = parserTask.call();
+ if (!parserTask.isSuccessful()) {
+ DBWriter.setFeedLastUpdateFailed(request.getFeedfileId(), true);
+ DBWriter.addDownloadStatus(parserTask.getDownloadStatus());
+ return;
+ }
+ feedHandlerResult.feed.setLastRefreshAttempt(System.currentTimeMillis());
+ Feed savedFeed = FeedDatabaseWriter.updateFeed(getApplicationContext(), feedHandlerResult.feed, false);
+
+ if (request.getFeedfileId() == 0) {
+ return; // No download logs for new subscriptions
+ }
+ // we create a 'successful' download log if the feed's last refresh failed
+ List<DownloadResult> log = DBReader.getFeedDownloadLog(request.getFeedfileId());
+ if (!log.isEmpty() && !log.get(0).isSuccessful()) {
+ DBWriter.addDownloadStatus(parserTask.getDownloadStatus());
+ }
+ newEpisodesNotification.showIfNeeded(getApplicationContext(), savedFeed);
+ if (downloader.permanentRedirectUrl != null) {
+ DBWriter.updateFeedDownloadURL(request.getSource(), downloader.permanentRedirectUrl);
+ } else if (feedHandlerResult.redirectUrl != null
+ && !feedHandlerResult.redirectUrl.equals(request.getSource())) {
+ DBWriter.updateFeedDownloadURL(request.getSource(), feedHandlerResult.redirectUrl);
+ }
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/NewEpisodesNotification.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/NewEpisodesNotification.java
new file mode 100644
index 000000000..e326601ce
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/NewEpisodesNotification.java
@@ -0,0 +1,147 @@
+package de.danoeh.antennapod.net.download.service.feed;
+
+import android.Manifest;
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.res.Resources;
+
+import android.graphics.Bitmap;
+import android.os.Build;
+import android.util.Log;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationManagerCompat;
+import androidx.core.content.ContextCompat;
+
+import com.bumptech.glide.Glide;
+import com.bumptech.glide.request.RequestOptions;
+import de.danoeh.antennapod.core.R;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedCounter;
+import de.danoeh.antennapod.model.feed.FeedPreferences;
+import de.danoeh.antennapod.storage.database.PodDBAdapter;
+
+import de.danoeh.antennapod.ui.notifications.NotificationUtils;
+import java.util.Map;
+
+public class NewEpisodesNotification {
+ private static final String TAG = "NewEpisodesNotification";
+ private static final String GROUP_KEY = "de.danoeh.antennapod.EPISODES";
+
+ private Map<Long, Integer> countersBefore;
+
+ public NewEpisodesNotification() {
+ }
+
+ public void loadCountersBeforeRefresh() {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ countersBefore = adapter.getFeedCounters(FeedCounter.SHOW_NEW);
+ adapter.close();
+ }
+
+ public void showIfNeeded(Context context, Feed feed) {
+ FeedPreferences prefs = feed.getPreferences();
+ if (!prefs.getKeepUpdated() || !prefs.getShowEpisodeNotification()) {
+ return;
+ }
+
+ int newEpisodesBefore = countersBefore.containsKey(feed.getId()) ? countersBefore.get(feed.getId()) : 0;
+ int newEpisodesAfter = getNewEpisodeCount(feed.getId());
+
+ Log.d(TAG, "New episodes before: " + newEpisodesBefore + ", after: " + newEpisodesAfter);
+ if (newEpisodesAfter > newEpisodesBefore) {
+ NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
+ showNotification(newEpisodesAfter, feed, context, notificationManager);
+ }
+ }
+
+ private static void showNotification(int newEpisodes, Feed feed, Context context,
+ NotificationManagerCompat notificationManager) {
+ Resources res = context.getResources();
+ String text = res.getQuantityString(
+ R.plurals.new_episode_notification_message, newEpisodes, newEpisodes, feed.getTitle()
+ );
+ String title = res.getQuantityString(R.plurals.new_episode_notification_title, newEpisodes);
+
+ Intent intent = new Intent();
+ intent.setAction("NewEpisodes" + feed.getId());
+ intent.setComponent(new ComponentName(context, "de.danoeh.antennapod.activity.MainActivity"));
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ intent.putExtra("fragment_feed_id", feed.getId());
+ PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent,
+ (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0));
+
+ Notification notification = new NotificationCompat.Builder(
+ context, NotificationUtils.CHANNEL_ID_EPISODE_NOTIFICATIONS)
+ .setSmallIcon(R.drawable.ic_notification_new)
+ .setContentTitle(title)
+ .setLargeIcon(loadIcon(context, feed))
+ .setContentText(text)
+ .setContentIntent(pendingIntent)
+ .setGroup(GROUP_KEY)
+ .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
+ .setOnlyAlertOnce(true)
+ .setAutoCancel(true)
+ .build();
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
+ == PackageManager.PERMISSION_GRANTED) {
+ notificationManager.notify(NotificationUtils.CHANNEL_ID_EPISODE_NOTIFICATIONS,
+ feed.hashCode(), notification);
+ }
+ showGroupSummaryNotification(context, notificationManager);
+ }
+
+ private static void showGroupSummaryNotification(Context context, NotificationManagerCompat notificationManager) {
+ Intent intent = new Intent();
+ intent.setAction("NewEpisodes");
+ intent.setComponent(new ComponentName(context, "de.danoeh.antennapod.activity.MainActivity"));
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ intent.putExtra("fragment_tag", "NewEpisodesFragment");
+ PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent,
+ (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0));
+
+ Notification notificationGroupSummary = new NotificationCompat.Builder(
+ context, NotificationUtils.CHANNEL_ID_EPISODE_NOTIFICATIONS)
+ .setSmallIcon(R.drawable.ic_notification_new)
+ .setContentTitle(context.getString(R.string.new_episode_notification_group_text))
+ .setContentIntent(pendingIntent)
+ .setGroup(GROUP_KEY)
+ .setGroupSummary(true)
+ .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
+ .setOnlyAlertOnce(true)
+ .setAutoCancel(true)
+ .build();
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
+ == PackageManager.PERMISSION_GRANTED) {
+ notificationManager.notify(NotificationUtils.CHANNEL_ID_EPISODE_NOTIFICATIONS,
+ 0, notificationGroupSummary);
+ }
+ }
+
+ private static Bitmap loadIcon(Context context, Feed feed) {
+ int iconSize = (int) (128 * context.getResources().getDisplayMetrics().density);
+ try {
+ return Glide.with(context)
+ .asBitmap()
+ .load(feed.getImageUrl())
+ .apply(new RequestOptions().centerCrop())
+ .submit(iconSize, iconSize)
+ .get();
+ } catch (Throwable tr) {
+ return null;
+ }
+ }
+
+ private static int getNewEpisodeCount(long feedId) {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ Map<Long, Integer> counters = adapter.getFeedCounters(FeedCounter.SHOW_NEW, feedId);
+ int episodeCount = counters.containsKey(feedId) ? counters.get(feedId) : 0;
+ adapter.close();
+ return episodeCount;
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/FastDocumentFile.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/FastDocumentFile.java
new file mode 100644
index 000000000..13cfaa4bf
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/FastDocumentFile.java
@@ -0,0 +1,72 @@
+package de.danoeh.antennapod.net.download.service.feed.local;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.DocumentsContract;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Android's DocumentFile is slow because every single method call queries the ContentResolver.
+ * This queries the ContentResolver a single time with all the information.
+ */
+public class FastDocumentFile {
+ private final String name;
+ private final String type;
+ private final Uri uri;
+ private final long length;
+ private final long lastModified;
+
+ public static List<FastDocumentFile> list(Context context, Uri folderUri) {
+ Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri,
+ DocumentsContract.getDocumentId(folderUri));
+ Cursor cursor = context.getContentResolver().query(childrenUri, new String[] {
+ DocumentsContract.Document.COLUMN_DOCUMENT_ID,
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+ DocumentsContract.Document.COLUMN_SIZE,
+ DocumentsContract.Document.COLUMN_LAST_MODIFIED,
+ DocumentsContract.Document.COLUMN_MIME_TYPE}, null, null, null);
+ ArrayList<FastDocumentFile> list = new ArrayList<>();
+ while (cursor.moveToNext()) {
+ String id = cursor.getString(0);
+ Uri uri = DocumentsContract.buildDocumentUriUsingTree(folderUri, id);
+ String name = cursor.getString(1);
+ long size = cursor.getLong(2);
+ long lastModified = cursor.getLong(3);
+ String mimeType = cursor.getString(4);
+ list.add(new FastDocumentFile(name, mimeType, uri, size, lastModified));
+ }
+ cursor.close();
+ return list;
+ }
+
+ public FastDocumentFile(String name, String type, Uri uri, long length, long lastModified) {
+ this.name = name;
+ this.type = type;
+ this.uri = uri;
+ this.length = length;
+ this.lastModified = lastModified;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public Uri getUri() {
+ return uri;
+ }
+
+ public long getLength() {
+ return length;
+ }
+
+ public long getLastModified() {
+ return lastModified;
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdater.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdater.java
new file mode 100644
index 000000000..015cd9146
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdater.java
@@ -0,0 +1,285 @@
+package de.danoeh.antennapod.net.download.service.feed.local;
+
+import android.content.Context;
+import android.media.MediaMetadataRetriever;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.UUID;
+
+import androidx.annotation.VisibleForTesting;
+import androidx.documentfile.provider.DocumentFile;
+import de.danoeh.antennapod.model.MediaMetadataRetrieverCompat;
+import de.danoeh.antennapod.model.download.DownloadResult;
+import de.danoeh.antennapod.net.download.service.R;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.FeedDatabaseWriter;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.parser.feed.util.DateUtils;
+import de.danoeh.antennapod.model.download.DownloadError;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.model.playback.MediaType;
+import de.danoeh.antennapod.parser.feed.util.MimeTypeUtils;
+import de.danoeh.antennapod.parser.media.id3.ID3ReaderException;
+import de.danoeh.antennapod.parser.media.id3.Id3MetadataReader;
+import de.danoeh.antennapod.parser.media.vorbis.VorbisCommentMetadataReader;
+import de.danoeh.antennapod.parser.media.vorbis.VorbisCommentReaderException;
+import org.apache.commons.io.input.CountingInputStream;
+
+public class LocalFeedUpdater {
+ private static final String TAG = "LocalFeedUpdater";
+
+ static final String[] PREFERRED_FEED_IMAGE_FILENAMES = {"folder.jpg", "Folder.jpg", "folder.png", "Folder.png"};
+
+ public static void updateFeed(Feed feed, Context context,
+ @Nullable UpdaterProgressListener updaterProgressListener) {
+ try {
+ String uriString = feed.getDownloadUrl().replace(Feed.PREFIX_LOCAL_FOLDER, "");
+ DocumentFile documentFolder = DocumentFile.fromTreeUri(context, Uri.parse(uriString));
+ if (documentFolder == null) {
+ throw new IOException("Unable to retrieve document tree. "
+ + "Try re-connecting the folder on the podcast info page.");
+ }
+ if (!documentFolder.exists() || !documentFolder.canRead()) {
+ throw new IOException("Cannot read local directory. "
+ + "Try re-connecting the folder on the podcast info page.");
+ }
+ tryUpdateFeed(feed, context, documentFolder.getUri(), updaterProgressListener);
+
+ if (mustReportDownloadSuccessful(feed)) {
+ reportSuccess(feed);
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ reportError(feed, e.getMessage());
+ }
+ }
+
+ @VisibleForTesting
+ static void tryUpdateFeed(Feed feed, Context context, Uri folderUri,
+ UpdaterProgressListener updaterProgressListener) throws IOException {
+ if (feed.getItems() == null) {
+ feed.setItems(new ArrayList<>());
+ }
+ //make sure it is the latest 'version' of this feed from the db (all items etc)
+ feed = FeedDatabaseWriter.updateFeed(context, feed, false);
+
+ // list files in feed folder
+ List<FastDocumentFile> allFiles = FastDocumentFile.list(context, folderUri);
+ List<FastDocumentFile> mediaFiles = new ArrayList<>();
+ Set<String> mediaFileNames = new HashSet<>();
+ for (FastDocumentFile file : allFiles) {
+ String mimeType = MimeTypeUtils.getMimeType(file.getType(), file.getUri().toString());
+ MediaType mediaType = MediaType.fromMimeType(mimeType);
+ if (mediaType == MediaType.AUDIO || mediaType == MediaType.VIDEO) {
+ mediaFiles.add(file);
+ mediaFileNames.add(file.getName());
+ }
+ }
+
+ // add new files to feed and update item data
+ List<FeedItem> newItems = feed.getItems();
+ for (int i = 0; i < mediaFiles.size(); i++) {
+ FeedItem oldItem = feedContainsFile(feed, mediaFiles.get(i).getName());
+ FeedItem newItem = createFeedItem(feed, mediaFiles.get(i), context);
+ if (oldItem == null) {
+ newItems.add(newItem);
+ } else {
+ oldItem.updateFromOther(newItem);
+ }
+ if (updaterProgressListener != null) {
+ updaterProgressListener.onLocalFileScanned(i, mediaFiles.size());
+ }
+ }
+
+ // remove feed items without corresponding file
+ Iterator<FeedItem> it = newItems.iterator();
+ while (it.hasNext()) {
+ FeedItem feedItem = it.next();
+ if (!mediaFileNames.contains(feedItem.getLink())) {
+ it.remove();
+ }
+ }
+
+ feed.setImageUrl(getImageUrl(allFiles, folderUri));
+
+ feed.getPreferences().setAutoDownload(false);
+ feed.setDescription(context.getString(R.string.local_feed_description));
+ feed.setAuthor(context.getString(R.string.local_folder));
+
+ FeedDatabaseWriter.updateFeed(context, feed, true);
+ }
+
+ /**
+ * Returns the image URL for the local feed.
+ */
+ @NonNull
+ static String getImageUrl(List<FastDocumentFile> files, Uri folderUri) {
+ // look for special file names
+ for (String iconLocation : PREFERRED_FEED_IMAGE_FILENAMES) {
+ for (FastDocumentFile file : files) {
+ if (iconLocation.equals(file.getName())) {
+ return file.getUri().toString();
+ }
+ }
+ }
+
+ // use the first image in the folder if existing
+ for (FastDocumentFile file : files) {
+ String mime = file.getType();
+ if (mime != null && (mime.startsWith("image/jpeg") || mime.startsWith("image/png"))) {
+ return file.getUri().toString();
+ }
+ }
+
+ // use default icon as fallback
+ return Feed.PREFIX_GENERATIVE_COVER + folderUri;
+ }
+
+ private static FeedItem feedContainsFile(Feed feed, String filename) {
+ List<FeedItem> items = feed.getItems();
+ for (FeedItem i : items) {
+ if (i.getMedia() != null && i.getLink().equals(filename)) {
+ return i;
+ }
+ }
+ return null;
+ }
+
+ private static FeedItem createFeedItem(Feed feed, FastDocumentFile file, Context context) {
+ FeedItem item = new FeedItem(0, file.getName(), UUID.randomUUID().toString(),
+ file.getName(), new Date(file.getLastModified()), FeedItem.UNPLAYED, feed);
+ item.disableAutoDownload();
+
+ long size = file.getLength();
+ FeedMedia media = new FeedMedia(0, item, 0, 0, size, file.getType(),
+ file.getUri().toString(), file.getUri().toString(), false, null, 0, 0);
+ item.setMedia(media);
+
+ for (FeedItem existingItem : feed.getItems()) {
+ if (existingItem.getMedia() != null
+ && existingItem.getMedia().getDownloadUrl().equals(file.getUri().toString())
+ && file.getLength() == existingItem.getMedia().getSize()) {
+ // We found an old file that we already scanned. Re-use metadata.
+ item.updateFromOther(existingItem);
+ return item;
+ }
+ }
+
+ // Did not find existing item. Scan metadata.
+ try {
+ loadMetadata(item, file, context);
+ } catch (Exception e) {
+ item.setDescriptionIfLonger(e.getMessage());
+ }
+ return item;
+ }
+
+ private static void loadMetadata(FeedItem item, FastDocumentFile file, Context context) {
+ try (MediaMetadataRetrieverCompat mediaMetadataRetriever = new MediaMetadataRetrieverCompat()) {
+ mediaMetadataRetriever.setDataSource(context, file.getUri());
+
+ String dateStr = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE);
+ if (!TextUtils.isEmpty(dateStr) && !"19040101T000000.000Z".equals(dateStr)) {
+ try {
+ SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.getDefault());
+ item.setPubDate(simpleDateFormat.parse(dateStr));
+ } catch (ParseException parseException) {
+ Date date = DateUtils.parse(dateStr);
+ if (date != null) {
+ item.setPubDate(date);
+ }
+ }
+ }
+
+ String title = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE);
+ if (!TextUtils.isEmpty(title)) {
+ item.setTitle(title);
+ }
+
+ String durationStr = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION);
+ item.getMedia().setDuration((int) Long.parseLong(durationStr));
+
+ item.getMedia().setHasEmbeddedPicture(mediaMetadataRetriever.getEmbeddedPicture() != null);
+
+ try (InputStream inputStream = context.getContentResolver().openInputStream(file.getUri())) {
+ Id3MetadataReader reader = new Id3MetadataReader(
+ new CountingInputStream(new BufferedInputStream(inputStream)));
+ reader.readInputStream();
+ item.setDescriptionIfLonger(reader.getComment());
+ } catch (IOException | ID3ReaderException e) {
+ Log.d(TAG, "Unable to parse ID3 of " + file.getUri() + ": " + e.getMessage());
+
+ try (InputStream inputStream = context.getContentResolver().openInputStream(file.getUri())) {
+ VorbisCommentMetadataReader reader = new VorbisCommentMetadataReader(inputStream);
+ reader.readInputStream();
+ item.setDescriptionIfLonger(reader.getDescription());
+ } catch (IOException | VorbisCommentReaderException e2) {
+ Log.d(TAG, "Unable to parse vorbis comments of " + file.getUri() + ": " + e2.getMessage());
+ }
+ }
+ }
+ }
+
+ private static void reportError(Feed feed, String reasonDetailed) {
+ DownloadResult status = new DownloadResult(feed.getTitle(), feed.getId(),
+ Feed.FEEDFILETYPE_FEED, false, DownloadError.ERROR_IO_ERROR, reasonDetailed);
+ DBWriter.addDownloadStatus(status);
+ DBWriter.setFeedLastUpdateFailed(feed.getId(), true);
+ }
+
+ /**
+ * Reports a successful download status.
+ */
+ private static void reportSuccess(Feed feed) {
+ DownloadResult status = new DownloadResult(feed.getTitle(), feed.getId(),
+ Feed.FEEDFILETYPE_FEED, true, DownloadError.SUCCESS, null);
+ DBWriter.addDownloadStatus(status);
+ DBWriter.setFeedLastUpdateFailed(feed.getId(), false);
+ }
+
+ /**
+ * Answers if reporting success is needed for the given feed.
+ */
+ private static boolean mustReportDownloadSuccessful(Feed feed) {
+ List<DownloadResult> downloadResults = DBReader.getFeedDownloadLog(feed.getId());
+
+ if (downloadResults.isEmpty()) {
+ // report success if never reported before
+ return true;
+ }
+
+ Collections.sort(downloadResults, (downloadStatus1, downloadStatus2) ->
+ downloadStatus1.getCompletionDate().compareTo(downloadStatus2.getCompletionDate()));
+
+ DownloadResult lastDownloadResult = downloadResults.get(downloadResults.size() - 1);
+
+ // report success if the last update was not successful
+ // (avoid logging success again if the last update was ok)
+ return !lastDownloadResult.isSuccessful();
+ }
+
+ @FunctionalInterface
+ public interface UpdaterProgressListener {
+ void onLocalFileScanned(int scanned, int totalFiles);
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DefaultDownloaderFactory.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DefaultDownloaderFactory.java
new file mode 100644
index 000000000..b87e339e3
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DefaultDownloaderFactory.java
@@ -0,0 +1,21 @@
+package de.danoeh.antennapod.net.download.service.feed.remote;
+
+import android.util.Log;
+import android.webkit.URLUtil;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import de.danoeh.antennapod.model.download.DownloadRequest;
+
+public class DefaultDownloaderFactory implements DownloaderFactory {
+ private static final String TAG = "DefaultDwnldrFactory";
+
+ @Nullable
+ @Override
+ public Downloader create(@NonNull DownloadRequest request) {
+ if (!URLUtil.isHttpUrl(request.getSource()) && !URLUtil.isHttpsUrl(request.getSource())) {
+ Log.e(TAG, "Could not find appropriate downloader for " + request.getSource());
+ return null;
+ }
+ return new HttpDownloader(request);
+ }
+} \ No newline at end of file
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/Downloader.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/Downloader.java
new file mode 100644
index 000000000..329b42805
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/Downloader.java
@@ -0,0 +1,62 @@
+package de.danoeh.antennapod.net.download.service.feed.remote;
+
+import androidx.annotation.NonNull;
+
+import java.util.Date;
+import java.util.concurrent.Callable;
+
+import de.danoeh.antennapod.model.download.DownloadResult;
+import de.danoeh.antennapod.model.download.DownloadRequest;
+import de.danoeh.antennapod.net.download.service.R;
+
+/**
+ * Downloads files
+ */
+public abstract class Downloader implements Callable<Downloader> {
+ private static final String TAG = "Downloader";
+
+ private volatile boolean finished;
+ public volatile boolean cancelled;
+ public String permanentRedirectUrl = null;
+
+ @NonNull
+ final DownloadRequest request;
+ @NonNull
+ final DownloadResult result;
+
+ Downloader(@NonNull DownloadRequest request) {
+ super();
+ this.request = request;
+ this.request.setStatusMsg(R.string.download_pending);
+ this.cancelled = false;
+ this.result = new DownloadResult(0, request.getTitle(), request.getFeedfileId(), request.getFeedfileType(),
+ false, null, new Date(), null);
+ }
+
+ protected abstract void download();
+
+ public final Downloader call() {
+ download();
+ finished = true;
+ return this;
+ }
+
+ @NonNull
+ public DownloadRequest getDownloadRequest() {
+ return request;
+ }
+
+ @NonNull
+ public DownloadResult getResult() {
+ return result;
+ }
+
+ public boolean isFinished() {
+ return finished;
+ }
+
+ public void cancel() {
+ cancelled = true;
+ }
+
+} \ No newline at end of file
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DownloaderFactory.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DownloaderFactory.java
new file mode 100644
index 000000000..cdf32b4a9
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DownloaderFactory.java
@@ -0,0 +1,10 @@
+package de.danoeh.antennapod.net.download.service.feed.remote;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import de.danoeh.antennapod.model.download.DownloadRequest;
+
+public interface DownloaderFactory {
+ @Nullable
+ Downloader create(@NonNull DownloadRequest request);
+} \ No newline at end of file
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/FeedParserTask.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/FeedParserTask.java
new file mode 100644
index 000000000..dde7bc8b5
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/FeedParserTask.java
@@ -0,0 +1,124 @@
+package de.danoeh.antennapod.net.download.service.feed.remote;
+
+import android.text.TextUtils;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedPreferences;
+import de.danoeh.antennapod.model.feed.VolumeAdaptionSetting;
+import de.danoeh.antennapod.model.download.DownloadRequest;
+import de.danoeh.antennapod.model.download.DownloadResult;
+import de.danoeh.antennapod.parser.feed.FeedHandler;
+import de.danoeh.antennapod.parser.feed.FeedHandlerResult;
+import de.danoeh.antennapod.parser.feed.UnsupportedFeedtypeException;
+import de.danoeh.antennapod.model.download.DownloadError;
+import org.xml.sax.SAXException;
+
+import javax.xml.parsers.ParserConfigurationException;
+import java.io.File;
+import java.io.IOException;
+import java.util.Date;
+import java.util.concurrent.Callable;
+
+public class FeedParserTask implements Callable<FeedHandlerResult> {
+ private static final String TAG = "FeedParserTask";
+ private final DownloadRequest request;
+ private DownloadResult downloadResult;
+ private boolean successful = true;
+
+ public FeedParserTask(DownloadRequest request) {
+ this.request = request;
+ downloadResult = new DownloadResult(
+ 0, request.getTitle(), 0, request.getFeedfileType(), false,
+ DownloadError.ERROR_REQUEST_ERROR, new Date(),
+ "Unknown error: Status not set");
+ }
+
+ @Override
+ public FeedHandlerResult call() {
+ Feed feed = new Feed(request.getSource(), request.getLastModified());
+ feed.setLocalFileUrl(request.getDestination());
+ feed.setId(request.getFeedfileId());
+ feed.setPreferences(new FeedPreferences(0, true, FeedPreferences.AutoDeleteAction.GLOBAL,
+ VolumeAdaptionSetting.OFF, FeedPreferences.NewEpisodesAction.GLOBAL, request.getUsername(),
+ request.getPassword()));
+ feed.setPageNr(request.getArguments().getInt(DownloadRequest.REQUEST_ARG_PAGE_NR, 0));
+
+ DownloadError reason = null;
+ String reasonDetailed = null;
+ FeedHandler feedHandler = new FeedHandler();
+
+ FeedHandlerResult result = null;
+ try {
+ result = feedHandler.parseFeed(feed);
+ Log.d(TAG, feed.getTitle() + " parsed");
+ checkFeedData(feed);
+ if (TextUtils.isEmpty(feed.getImageUrl())) {
+ feed.setImageUrl(Feed.PREFIX_GENERATIVE_COVER + feed.getDownloadUrl());
+ }
+ } catch (SAXException | IOException | ParserConfigurationException e) {
+ successful = false;
+ e.printStackTrace();
+ reason = DownloadError.ERROR_PARSER_EXCEPTION;
+ reasonDetailed = e.getMessage();
+ } catch (UnsupportedFeedtypeException e) {
+ e.printStackTrace();
+ successful = false;
+ reason = DownloadError.ERROR_UNSUPPORTED_TYPE;
+ if ("html".equalsIgnoreCase(e.getRootElement())) {
+ reason = DownloadError.ERROR_UNSUPPORTED_TYPE_HTML;
+ }
+ reasonDetailed = e.getMessage();
+ } catch (InvalidFeedException e) {
+ e.printStackTrace();
+ successful = false;
+ reason = DownloadError.ERROR_PARSER_EXCEPTION;
+ reasonDetailed = e.getMessage();
+ } finally {
+ File feedFile = new File(request.getDestination());
+ if (feedFile.exists()) {
+ boolean deleted = feedFile.delete();
+ Log.d(TAG, "Deletion of file '" + feedFile.getAbsolutePath() + "' "
+ + (deleted ? "successful" : "FAILED"));
+ }
+ }
+
+ if (successful) {
+ downloadResult = new DownloadResult(feed.getHumanReadableIdentifier(), feed.getId(),
+ Feed.FEEDFILETYPE_FEED, true, DownloadError.SUCCESS, reasonDetailed);
+ return result;
+ } else {
+ downloadResult = new DownloadResult(feed.getHumanReadableIdentifier(), feed.getId(),
+ Feed.FEEDFILETYPE_FEED, false, reason, reasonDetailed);
+ return null;
+ }
+ }
+
+ public boolean isSuccessful() {
+ return successful;
+ }
+
+ /**
+ * Checks if the feed was parsed correctly.
+ */
+ private void checkFeedData(Feed feed) throws InvalidFeedException {
+ if (feed.getTitle() == null) {
+ throw new InvalidFeedException("Feed has no title");
+ }
+ checkFeedItems(feed);
+ }
+
+ private void checkFeedItems(Feed feed) throws InvalidFeedException {
+ for (FeedItem item : feed.getItems()) {
+ if (item.getTitle() == null) {
+ throw new InvalidFeedException("Item has no title: " + item);
+ }
+ }
+ }
+
+ @NonNull
+ public DownloadResult getDownloadStatus() {
+ return downloadResult;
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/HttpDownloader.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/HttpDownloader.java
new file mode 100644
index 000000000..f3c3e657c
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/HttpDownloader.java
@@ -0,0 +1,315 @@
+package de.danoeh.antennapod.net.download.service.feed.remote;
+
+import androidx.annotation.NonNull;
+import android.text.TextUtils;
+import android.util.Log;
+
+import de.danoeh.antennapod.net.common.NetworkUtils;
+import de.danoeh.antennapod.model.download.DownloadResult;
+import de.danoeh.antennapod.model.download.DownloadRequest;
+import de.danoeh.antennapod.net.common.AntennapodHttpClient;
+import de.danoeh.antennapod.net.download.service.R;
+import okhttp3.CacheControl;
+import okhttp3.internal.http.StatusLine;
+import org.apache.commons.io.IOUtils;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.RandomAccessFile;
+import java.net.HttpURLConnection;
+import java.net.SocketTimeoutException;
+import java.net.URI;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Locale;
+
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.parser.feed.util.DateUtils;
+import de.danoeh.antennapod.model.download.DownloadError;
+import de.danoeh.antennapod.core.util.StorageUtils;
+import de.danoeh.antennapod.net.common.UriUtil;
+import okhttp3.OkHttpClient;
+import okhttp3.Protocol;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+
+public class HttpDownloader extends Downloader {
+ private static final String TAG = "HttpDownloader";
+ private static final int BUFFER_SIZE = 8 * 1024;
+
+ public HttpDownloader(@NonNull DownloadRequest request) {
+ super(request);
+ }
+
+ @Override
+ protected void download() {
+ File destination = new File(request.getDestination());
+ final boolean fileExists = destination.exists();
+
+ RandomAccessFile out = null;
+ InputStream connection;
+ ResponseBody responseBody = null;
+
+ try {
+ final URI uri = UriUtil.getURIFromRequestUrl(request.getSource());
+ Request.Builder httpReq = new Request.Builder().url(uri.toURL());
+ httpReq.tag(request);
+ httpReq.cacheControl(new CacheControl.Builder().noStore().build());
+
+ if (request.getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA) {
+ // set header explicitly so that okhttp doesn't do transparent gzip
+ Log.d(TAG, "addHeader(\"Accept-Encoding\", \"identity\")");
+ httpReq.addHeader("Accept-Encoding", "identity");
+ httpReq.cacheControl(new CacheControl.Builder().noCache().build()); // noStore breaks CDNs
+ }
+
+ if (uri.getScheme().equals("http")) {
+ httpReq.addHeader("Upgrade-Insecure-Requests", "1");
+ }
+
+ if (!TextUtils.isEmpty(request.getLastModified())) {
+ String lastModified = request.getLastModified();
+ Date lastModifiedDate = DateUtils.parse(lastModified);
+ if (lastModifiedDate != null) {
+ long threeDaysAgo = System.currentTimeMillis() - 1000 * 60 * 60 * 24 * 3;
+ if (lastModifiedDate.getTime() > threeDaysAgo) {
+ Log.d(TAG, "addHeader(\"If-Modified-Since\", \"" + lastModified + "\")");
+ httpReq.addHeader("If-Modified-Since", lastModified);
+ }
+ } else {
+ Log.d(TAG, "addHeader(\"If-None-Match\", \"" + lastModified + "\")");
+ httpReq.addHeader("If-None-Match", lastModified);
+ }
+ }
+
+ // add range header if necessary
+ if (fileExists && destination.length() > 0) {
+ request.setSoFar(destination.length());
+ httpReq.addHeader("Range", "bytes=" + request.getSoFar() + "-");
+ Log.d(TAG, "Adding range header: " + request.getSoFar());
+ }
+
+ Response response = newCall(httpReq);
+ responseBody = response.body();
+ String contentEncodingHeader = response.header("Content-Encoding");
+ boolean isGzip = false;
+ if (!TextUtils.isEmpty(contentEncodingHeader)) {
+ isGzip = TextUtils.equals(contentEncodingHeader.toLowerCase(Locale.US), "gzip");
+ }
+
+ Log.d(TAG, "Response code is " + response.code());
+ if (!response.isSuccessful() && response.code() == HttpURLConnection.HTTP_NOT_MODIFIED) {
+ Log.d(TAG, "Feed '" + request.getSource() + "' not modified since last update, Download canceled");
+ onCancelled();
+ return;
+ } else if (!response.isSuccessful() || response.body() == null) {
+ callOnFailByResponseCode(response);
+ return;
+ } else if (request.getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA
+ && isContentTypeTextAndSmallerThan100kb(response)) {
+ onFail(DownloadError.ERROR_FILE_TYPE, null);
+ return;
+ }
+ checkIfRedirect(response);
+
+ connection = new BufferedInputStream(responseBody.byteStream());
+
+ String contentRangeHeader = (fileExists) ? response.header("Content-Range") : null;
+ if (fileExists && response.code() == HttpURLConnection.HTTP_PARTIAL
+ && !TextUtils.isEmpty(contentRangeHeader)) {
+ String start = contentRangeHeader.substring("bytes ".length(),
+ contentRangeHeader.indexOf("-"));
+ request.setSoFar(Long.parseLong(start));
+ Log.d(TAG, "Starting download at position " + request.getSoFar());
+
+ out = new RandomAccessFile(destination, "rw");
+ out.seek(request.getSoFar());
+ } else {
+ boolean success = destination.delete();
+ success |= destination.createNewFile();
+ if (!success) {
+ throw new IOException("Unable to recreate partially downloaded file");
+ }
+ out = new RandomAccessFile(destination, "rw");
+ }
+
+ byte[] buffer = new byte[BUFFER_SIZE];
+ int count;
+ request.setStatusMsg(R.string.download_running);
+ Log.d(TAG, "Getting size of download");
+ request.setSize(responseBody.contentLength() + request.getSoFar());
+ Log.d(TAG, "Size is " + request.getSize());
+ if (request.getSize() < 0) {
+ request.setSize(DownloadResult.SIZE_UNKNOWN);
+ }
+
+ long freeSpace = StorageUtils.getFreeSpaceAvailable();
+ Log.d(TAG, "Free space is " + freeSpace);
+ if (request.getSize() != DownloadResult.SIZE_UNKNOWN && request.getSize() > freeSpace) {
+ onFail(DownloadError.ERROR_NOT_ENOUGH_SPACE, null);
+ return;
+ }
+
+ Log.d(TAG, "Starting download");
+ try {
+ while (!cancelled && (count = connection.read(buffer)) != -1) {
+ out.write(buffer, 0, count);
+ request.setSoFar(request.getSoFar() + count);
+ int progressPercent = (int) (100.0 * request.getSoFar() / request.getSize());
+ request.setProgressPercent(progressPercent);
+ }
+ } catch (IOException e) {
+ Log.e(TAG, Log.getStackTraceString(e));
+ }
+ if (cancelled) {
+ onCancelled();
+ } else {
+ // check if size specified in the response header is the same as the size of the
+ // written file. This check cannot be made if compression was used
+ if (!isGzip && request.getSize() != DownloadResult.SIZE_UNKNOWN
+ && request.getSoFar() != request.getSize()) {
+ onFail(DownloadError.ERROR_IO_WRONG_SIZE, "Download completed but size: "
+ + request.getSoFar() + " does not equal expected size " + request.getSize());
+ return;
+ } else if (request.getSize() > 0 && request.getSoFar() == 0) {
+ onFail(DownloadError.ERROR_IO_ERROR, "Download completed, but nothing was read");
+ return;
+ }
+ String lastModified = response.header("Last-Modified");
+ if (lastModified != null) {
+ request.setLastModified(lastModified);
+ } else {
+ request.setLastModified(response.header("ETag"));
+ }
+ onSuccess();
+ }
+
+ } catch (IllegalArgumentException e) {
+ e.printStackTrace();
+ onFail(DownloadError.ERROR_MALFORMED_URL, e.getMessage());
+ } catch (SocketTimeoutException e) {
+ e.printStackTrace();
+ onFail(DownloadError.ERROR_CONNECTION_ERROR, e.getMessage());
+ } catch (UnknownHostException e) {
+ e.printStackTrace();
+ onFail(DownloadError.ERROR_UNKNOWN_HOST, e.getMessage());
+ } catch (IOException e) {
+ e.printStackTrace();
+ if (NetworkUtils.wasDownloadBlocked(e)) {
+ onFail(DownloadError.ERROR_IO_BLOCKED, e.getMessage());
+ return;
+ }
+ String message = e.getMessage();
+ if (message != null && message.contains("Trust anchor for certification path not found")) {
+ onFail(DownloadError.ERROR_CERTIFICATE, e.getMessage());
+ return;
+ }
+ onFail(DownloadError.ERROR_IO_ERROR, e.getMessage());
+ } catch (NullPointerException e) {
+ // might be thrown by connection.getInputStream()
+ e.printStackTrace();
+ onFail(DownloadError.ERROR_CONNECTION_ERROR, request.getSource());
+ } finally {
+ IOUtils.closeQuietly(out);
+ IOUtils.closeQuietly(responseBody);
+ }
+ }
+
+ private Response newCall(Request.Builder httpReq) throws IOException {
+ OkHttpClient httpClient = AntennapodHttpClient.getHttpClient();
+ try {
+ return httpClient.newCall(httpReq.build()).execute();
+ } catch (IOException e) {
+ Log.e(TAG, e.toString());
+ if (e.getMessage() != null && e.getMessage().contains("PROTOCOL_ERROR")) {
+ // Apparently some servers announce they support SPDY but then actually don't.
+ httpClient = httpClient.newBuilder()
+ .protocols(Collections.singletonList(Protocol.HTTP_1_1))
+ .build();
+ return httpClient.newCall(httpReq.build()).execute();
+ } else {
+ throw e;
+ }
+ }
+ }
+
+ private boolean isContentTypeTextAndSmallerThan100kb(Response response) {
+ int contentLength = -1;
+ String contentLen = response.header("Content-Length");
+ if (contentLen != null) {
+ try {
+ contentLength = Integer.parseInt(contentLen);
+ } catch (NumberFormatException e) {
+ e.printStackTrace();
+ }
+ }
+ Log.d(TAG, "content length: " + contentLength);
+ String contentType = response.header("Content-Type");
+ Log.d(TAG, "content type: " + contentType);
+ return contentType != null && contentType.startsWith("text/") && contentLength < 100 * 1024;
+ }
+
+ private void callOnFailByResponseCode(Response response) {
+ final DownloadError error;
+ final String details;
+ if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) {
+ error = DownloadError.ERROR_UNAUTHORIZED;
+ details = String.valueOf(response.code());
+ } else if (response.code() == HttpURLConnection.HTTP_FORBIDDEN) {
+ error = DownloadError.ERROR_FORBIDDEN;
+ details = String.valueOf(response.code());
+ } else if (response.code() == HttpURLConnection.HTTP_NOT_FOUND
+ || response.code() == HttpURLConnection.HTTP_GONE) {
+ error = DownloadError.ERROR_NOT_FOUND;
+ details = String.valueOf(response.code());
+ } else {
+ error = DownloadError.ERROR_HTTP_DATA_ERROR;
+ details = String.valueOf(response.code());
+ }
+ onFail(error, details);
+ }
+
+ private void checkIfRedirect(Response response) {
+ // detect 301 Moved permanently and 308 Permanent Redirect
+ ArrayList<Response> responses = new ArrayList<>();
+ while (response != null) {
+ responses.add(response);
+ response = response.priorResponse();
+ }
+ if (responses.size() < 2) {
+ return;
+ }
+ Collections.reverse(responses);
+ int firstCode = responses.get(0).code();
+ String firstUrl = responses.get(0).request().url().toString();
+ String secondUrl = responses.get(1).request().url().toString();
+ if (firstCode == HttpURLConnection.HTTP_MOVED_PERM || firstCode == StatusLine.HTTP_PERM_REDIRECT) {
+ Log.d(TAG, "Detected permanent redirect from " + request.getSource() + " to " + secondUrl);
+ permanentRedirectUrl = secondUrl;
+ } else if (secondUrl.equals(firstUrl.replace("http://", "https://"))) {
+ Log.d(TAG, "Treating http->https non-permanent redirect as permanent: " + firstUrl);
+ permanentRedirectUrl = secondUrl;
+ }
+ }
+
+ private void onSuccess() {
+ Log.d(TAG, "Download was successful");
+ result.setSuccessful();
+ }
+
+ private void onFail(DownloadError reason, String reasonDetailed) {
+ Log.d(TAG, "onFail() called with: " + "reason = [" + reason + "], reasonDetailed = [" + reasonDetailed + "]");
+ result.setFailed(reason, reasonDetailed);
+ }
+
+ private void onCancelled() {
+ Log.d(TAG, "Download was cancelled");
+ result.setCancelled();
+ cancelled = true;
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/InvalidFeedException.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/InvalidFeedException.java
new file mode 100644
index 000000000..353b86406
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/InvalidFeedException.java
@@ -0,0 +1,12 @@
+package de.danoeh.antennapod.net.download.service.feed.remote;
+
+/**
+ * Thrown if a feed has invalid attribute values.
+ */
+public class InvalidFeedException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ public InvalidFeedException(String message) {
+ super(message);
+ }
+}
diff --git a/net/download/service/src/main/res/values/ids.xml b/net/download/service/src/main/res/values/ids.xml
new file mode 100644
index 000000000..cfd4c941b
--- /dev/null
+++ b/net/download/service/src/main/res/values/ids.xml
@@ -0,0 +1,6 @@
+<resources>
+ <item name="notification_downloading" type="id"/>
+ <item name="notification_updating_feeds" type="id"/>
+ <item name="notification_download_report" type="id"/>
+ <item name="notification_auto_download_report" type="id"/>
+</resources>
diff --git a/net/download/service/src/test/assets/local-feed1/track1.mp3 b/net/download/service/src/test/assets/local-feed1/track1.mp3
new file mode 100644
index 000000000..b1f993c3f
--- /dev/null
+++ b/net/download/service/src/test/assets/local-feed1/track1.mp3
Binary files differ
diff --git a/net/download/service/src/test/assets/local-feed2/folder.png b/net/download/service/src/test/assets/local-feed2/folder.png
new file mode 100644
index 000000000..9e522a986
--- /dev/null
+++ b/net/download/service/src/test/assets/local-feed2/folder.png
Binary files differ
diff --git a/net/download/service/src/test/assets/local-feed2/track1.mp3 b/net/download/service/src/test/assets/local-feed2/track1.mp3
new file mode 100644
index 000000000..b1f993c3f
--- /dev/null
+++ b/net/download/service/src/test/assets/local-feed2/track1.mp3
Binary files differ
diff --git a/net/download/service/src/test/assets/local-feed2/track2.mp3 b/net/download/service/src/test/assets/local-feed2/track2.mp3
new file mode 100644
index 000000000..310cddd6b
--- /dev/null
+++ b/net/download/service/src/test/assets/local-feed2/track2.mp3
Binary files differ
diff --git a/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdaterTest.java b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdaterTest.java
new file mode 100644
index 000000000..484929136
--- /dev/null
+++ b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdaterTest.java
@@ -0,0 +1,306 @@
+package de.danoeh.antennapod.net.download.service.feed.local;
+
+import android.content.Context;
+import android.media.MediaMetadataRetriever;
+import android.net.Uri;
+import android.webkit.MimeTypeMap;
+
+import androidx.annotation.NonNull;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import de.danoeh.antennapod.storage.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadServiceInterface;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadServiceInterfaceStub;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.storage.database.PodDBAdapter;
+import de.danoeh.antennapod.storage.preferences.SynchronizationSettings;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.shadows.ShadowMediaMetadataRetriever;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.DBWriter;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.endsWith;
+import static org.hamcrest.CoreMatchers.startsWith;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.empty;
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.robolectric.Shadows.shadowOf;
+
+/**
+ * Test local feeds handling in class LocalFeedUpdater.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class LocalFeedUpdaterTest {
+
+ /**
+ * URL to locate the local feed media files on the external storage (SD card).
+ * The exact URL doesn't matter here as access to external storage is mocked
+ * (seems not to be supported by Robolectric).
+ */
+ private static final String FEED_URL =
+ "content://com.android.externalstorage.documents/tree/primary%3ADownload%2Flocal-feed";
+ private static final String LOCAL_FEED_DIR1 = "src/test/assets/local-feed1";
+ private static final String LOCAL_FEED_DIR2 = "src/test/assets/local-feed2";
+
+ private Context context;
+
+ @Before
+ public void setUp() throws Exception {
+ // Initialize environment
+ context = InstrumentationRegistry.getInstrumentation().getContext();
+ UserPreferences.init(context);
+ PlaybackPreferences.init(context);
+ SynchronizationSettings.init(context);
+ DownloadServiceInterface.setImpl(new DownloadServiceInterfaceStub());
+
+ // Initialize database
+ PodDBAdapter.init(context);
+ PodDBAdapter.deleteDatabase();
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.close();
+
+ mapDummyMetadata(LOCAL_FEED_DIR1);
+ mapDummyMetadata(LOCAL_FEED_DIR2);
+ shadowOf(MimeTypeMap.getSingleton()).addExtensionMimeTypMapping("mp3", "audio/mp3");
+ }
+
+ @After
+ public void tearDown() {
+ DBWriter.tearDownTests();
+ PodDBAdapter.tearDownTests();
+ }
+
+ /**
+ * Test adding a new local feed.
+ */
+ @Test
+ public void testUpdateFeed_AddNewFeed() {
+ // check for empty database
+ List<Feed> feedListBefore = DBReader.getFeedList();
+ assertThat(feedListBefore, is(empty()));
+
+ callUpdateFeed(LOCAL_FEED_DIR2);
+
+ // verify new feed in database
+ verifySingleFeedInDatabaseAndItemCount(2);
+ Feed feedAfter = verifySingleFeedInDatabase();
+ assertEquals(FEED_URL, feedAfter.getDownloadUrl());
+ }
+
+ /**
+ * Test adding further items to an existing local feed.
+ */
+ @Test
+ public void testUpdateFeed_AddMoreItems() {
+ // add local feed with 1 item (localFeedDir1)
+ callUpdateFeed(LOCAL_FEED_DIR1);
+
+ // now add another item (by changing to local feed folder localFeedDir2)
+ callUpdateFeed(LOCAL_FEED_DIR2);
+
+ verifySingleFeedInDatabaseAndItemCount(2);
+ }
+
+ /**
+ * Test removing items from an existing local feed without a corresponding media file.
+ */
+ @Test
+ public void testUpdateFeed_RemoveItems() {
+ // add local feed with 2 items (localFeedDir1)
+ callUpdateFeed(LOCAL_FEED_DIR2);
+
+ // now remove an item (by changing to local feed folder localFeedDir1)
+ callUpdateFeed(LOCAL_FEED_DIR1);
+
+ verifySingleFeedInDatabaseAndItemCount(1);
+ }
+
+ /**
+ * Test feed icon defined in the local feed media folder.
+ */
+ @Test
+ public void testUpdateFeed_FeedIconFromFolder() {
+ callUpdateFeed(LOCAL_FEED_DIR2);
+
+ Feed feedAfter = verifySingleFeedInDatabase();
+ assertThat(feedAfter.getImageUrl(), endsWith("local-feed2/folder.png"));
+ }
+
+ /**
+ * Test default feed icon if there is no matching file in the local feed media folder.
+ */
+ @Test
+ public void testUpdateFeed_FeedIconDefault() {
+ callUpdateFeed(LOCAL_FEED_DIR1);
+
+ Feed feedAfter = verifySingleFeedInDatabase();
+ assertThat(feedAfter.getImageUrl(), startsWith(Feed.PREFIX_GENERATIVE_COVER));
+ }
+
+ /**
+ * Test default feed metadata.
+ *
+ * @see #mapDummyMetadata Title and PubDate are dummy values.
+ */
+ @Test
+ public void testUpdateFeed_FeedMetadata() {
+ callUpdateFeed(LOCAL_FEED_DIR1);
+
+ Feed feed = verifySingleFeedInDatabase();
+ List<FeedItem> feedItems = DBReader.getFeedItemList(feed);
+ assertEquals("track1.mp3", feedItems.get(0).getTitle());
+ }
+
+ @Test
+ public void testGetImageUrl_EmptyFolder() {
+ String imageUrl = LocalFeedUpdater.getImageUrl(Collections.emptyList(), Uri.EMPTY);
+ assertThat(imageUrl, startsWith(Feed.PREFIX_GENERATIVE_COVER));
+ }
+
+ @Test
+ public void testGetImageUrl_NoImageButAudioFiles() {
+ List<FastDocumentFile> folder = Collections.singletonList(mockDocumentFile("audio.mp3", "audio/mp3"));
+ String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
+ assertThat(imageUrl, startsWith(Feed.PREFIX_GENERATIVE_COVER));
+ }
+
+ @Test
+ public void testGetImageUrl_PreferredImagesFilenames() {
+ for (String filename : LocalFeedUpdater.PREFERRED_FEED_IMAGE_FILENAMES) {
+ List<FastDocumentFile> folder = Arrays.asList(mockDocumentFile("audio.mp3", "audio/mp3"),
+ mockDocumentFile(filename, "image/jpeg")); // image MIME type doesn't matter
+ String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
+ assertThat(imageUrl, endsWith(filename));
+ }
+ }
+
+ @Test
+ public void testGetImageUrl_OtherImageFilenameJpg() {
+ List<FastDocumentFile> folder = Arrays.asList(mockDocumentFile("audio.mp3", "audio/mp3"),
+ mockDocumentFile("my-image.jpg", "image/jpeg"));
+ String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
+ assertThat(imageUrl, endsWith("my-image.jpg"));
+ }
+
+ @Test
+ public void testGetImageUrl_OtherImageFilenameJpeg() {
+ List<FastDocumentFile> folder = Arrays.asList(mockDocumentFile("audio.mp3", "audio/mp3"),
+ mockDocumentFile("my-image.jpeg", "image/jpeg"));
+ String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
+ assertThat(imageUrl, endsWith("my-image.jpeg"));
+ }
+
+ @Test
+ public void testGetImageUrl_OtherImageFilenamePng() {
+ List<FastDocumentFile> folder = Arrays.asList(mockDocumentFile("audio.mp3", "audio/mp3"),
+ mockDocumentFile("my-image.png", "image/png"));
+ String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
+ assertThat(imageUrl, endsWith("my-image.png"));
+ }
+
+ @Test
+ public void testGetImageUrl_OtherImageFilenameUnsupportedMimeType() {
+ List<FastDocumentFile> folder = Arrays.asList(mockDocumentFile("audio.mp3", "audio/mp3"),
+ mockDocumentFile("my-image.svg", "image/svg+xml"));
+ String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
+ assertThat(imageUrl, startsWith(Feed.PREFIX_GENERATIVE_COVER));
+ }
+
+ /**
+ * Fill ShadowMediaMetadataRetriever with dummy duration and title.
+ *
+ * @param localFeedDir assets local feed folder with media files
+ */
+ private void mapDummyMetadata(@NonNull String localFeedDir) {
+ for (String fileName : Objects.requireNonNull(new File(localFeedDir).list())) {
+ String path = localFeedDir + '/' + fileName;
+ ShadowMediaMetadataRetriever.addMetadata(path,
+ MediaMetadataRetriever.METADATA_KEY_DURATION, "10");
+ ShadowMediaMetadataRetriever.addMetadata(path,
+ MediaMetadataRetriever.METADATA_KEY_TITLE, fileName);
+ ShadowMediaMetadataRetriever.addMetadata(path,
+ MediaMetadataRetriever.METADATA_KEY_DATE, "20200601T222324");
+ }
+ }
+
+ /**
+ * Calls the method LocalFeedUpdater#tryUpdateFeed with the given local feed folder.
+ *
+ * @param localFeedDir assets local feed folder with media files
+ */
+ private void callUpdateFeed(@NonNull String localFeedDir) {
+ try (MockedStatic<FastDocumentFile> dfMock = Mockito.mockStatic(FastDocumentFile.class)) {
+ // mock external storage
+ dfMock.when(() -> FastDocumentFile.list(any(), any())).thenReturn(mockLocalFolder(localFeedDir));
+
+ // call method to test
+ Feed feed = new Feed(FEED_URL, null);
+ try {
+ LocalFeedUpdater.tryUpdateFeed(feed, context, null, null);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ /**
+ * Verify that the database contains exactly one feed and return that feed.
+ */
+ @NonNull
+ private static Feed verifySingleFeedInDatabase() {
+ List<Feed> feedListAfter = DBReader.getFeedList();
+ assertEquals(1, feedListAfter.size());
+ return feedListAfter.get(0);
+ }
+
+ /**
+ * Verify that the database contains exactly one feed and the number of
+ * items in the feed.
+ *
+ * @param expectedItemCount expected number of items in the feed
+ */
+ private static void verifySingleFeedInDatabaseAndItemCount(int expectedItemCount) {
+ Feed feed = verifySingleFeedInDatabase();
+ List<FeedItem> feedItems = DBReader.getFeedItemList(feed);
+ assertEquals(expectedItemCount, feedItems.size());
+ }
+
+ /**
+ * Create a DocumentFile mock object.
+ */
+ @NonNull
+ private static FastDocumentFile mockDocumentFile(@NonNull String fileName, @NonNull String mimeType) {
+ return new FastDocumentFile(fileName, mimeType, Uri.parse("file:///path/" + fileName), 0, 0);
+ }
+
+ private static List<FastDocumentFile> mockLocalFolder(String folderName) {
+ List<FastDocumentFile> files = new ArrayList<>();
+ for (File f : Objects.requireNonNull(new File(folderName).listFiles())) {
+ String extension = MimeTypeMap.getFileExtensionFromUrl(f.getPath());
+ String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
+ files.add(new FastDocumentFile(f.getName(), mimeType,
+ Uri.parse(f.toURI().toString()), f.length(), f.lastModified()));
+ }
+ return files;
+ }
+}