From 2fd73b148d012fba7308c86494689103b8aaace4 Mon Sep 17 00:00:00 2001 From: ByteHamster Date: Fri, 29 Mar 2024 19:27:53 +0100 Subject: Move download service to module (#7041) --- net/download/service/src/main/AndroidManifest.xml | 7 + .../service/episode/EpisodeDownloadWorker.java | 311 ++++++++++++++++++++ .../service/episode/MediaDownloadedHandler.java | 119 ++++++++ .../service/feed/DownloadServiceInterfaceImpl.java | 97 +++++++ .../service/feed/FeedUpdateManagerImpl.java | 117 ++++++++ .../download/service/feed/FeedUpdateWorker.java | 217 ++++++++++++++ .../service/feed/NewEpisodesNotification.java | 147 ++++++++++ .../service/feed/local/FastDocumentFile.java | 72 +++++ .../service/feed/local/LocalFeedUpdater.java | 285 +++++++++++++++++++ .../feed/remote/DefaultDownloaderFactory.java | 21 ++ .../download/service/feed/remote/Downloader.java | 62 ++++ .../service/feed/remote/DownloaderFactory.java | 10 + .../service/feed/remote/FeedParserTask.java | 124 ++++++++ .../service/feed/remote/HttpDownloader.java | 315 +++++++++++++++++++++ .../service/feed/remote/InvalidFeedException.java | 12 + net/download/service/src/main/res/values/ids.xml | 6 + .../service/src/test/assets/local-feed1/track1.mp3 | Bin 0 -> 43341 bytes .../service/src/test/assets/local-feed2/folder.png | Bin 0 -> 1589 bytes .../service/src/test/assets/local-feed2/track1.mp3 | Bin 0 -> 43341 bytes .../service/src/test/assets/local-feed2/track2.mp3 | Bin 0 -> 43497 bytes .../service/feed/local/LocalFeedUpdaterTest.java | 306 ++++++++++++++++++++ 21 files changed, 2228 insertions(+) create mode 100644 net/download/service/src/main/AndroidManifest.xml create mode 100644 net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/EpisodeDownloadWorker.java create mode 100644 net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/MediaDownloadedHandler.java create mode 100644 net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/DownloadServiceInterfaceImpl.java create mode 100644 net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateManagerImpl.java create mode 100644 net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateWorker.java create mode 100644 net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/NewEpisodesNotification.java create mode 100644 net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/FastDocumentFile.java create mode 100644 net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdater.java create mode 100644 net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DefaultDownloaderFactory.java create mode 100644 net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/Downloader.java create mode 100644 net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DownloaderFactory.java create mode 100644 net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/FeedParserTask.java create mode 100644 net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/HttpDownloader.java create mode 100644 net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/InvalidFeedException.java create mode 100644 net/download/service/src/main/res/values/ids.xml create mode 100644 net/download/service/src/test/assets/local-feed1/track1.mp3 create mode 100644 net/download/service/src/test/assets/local-feed2/folder.png create mode 100644 net/download/service/src/test/assets/local-feed2/track1.mp3 create mode 100644 net/download/service/src/test/assets/local-feed2/track2.mp3 create mode 100644 net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdaterTest.java (limited to 'net/download/service/src') 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 @@ + + + + + + + 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 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 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 progressCopy; + synchronized (notificationProgress) { + progressCopy = new HashMap<>(notificationProgress); + } + for (Map.Entry 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> 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 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 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 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 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 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 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 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 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 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 allFiles = FastDocumentFile.list(context, folderUri); + List mediaFiles = new ArrayList<>(); + Set 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 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 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 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 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 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 { + 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 { + 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 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 @@ + + + + + + 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 Binary files /dev/null and b/net/download/service/src/test/assets/local-feed1/track1.mp3 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 Binary files /dev/null and b/net/download/service/src/test/assets/local-feed2/folder.png 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 Binary files /dev/null and b/net/download/service/src/test/assets/local-feed2/track1.mp3 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 Binary files /dev/null and b/net/download/service/src/test/assets/local-feed2/track2.mp3 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 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 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 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 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 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 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 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 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 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 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 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 mockLocalFolder(String folderName) { + List 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; + } +} -- cgit v1.2.3