Merge pull request #6153 from ByteHamster/fast-document-file

Speed up local folder refresh
This commit is contained in:
ByteHamster 2022-10-23 12:10:07 +02:00 committed by GitHub
commit cac231a461
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 175 additions and 250 deletions

View File

@ -8,8 +8,8 @@ import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.documentfile.provider.DocumentFile;
import java.io.BufferedInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.text.ParseException; import java.text.ParseException;
@ -24,7 +24,10 @@ import java.util.Locale;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import androidx.annotation.VisibleForTesting;
import androidx.documentfile.provider.DocumentFile;
import de.danoeh.antennapod.core.R; import de.danoeh.antennapod.core.R;
import de.danoeh.antennapod.core.util.FastDocumentFile;
import de.danoeh.antennapod.model.download.DownloadStatus; import de.danoeh.antennapod.model.download.DownloadStatus;
import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.storage.DBTasks; import de.danoeh.antennapod.core.storage.DBTasks;
@ -46,12 +49,22 @@ import org.apache.commons.io.input.CountingInputStream;
public class LocalFeedUpdater { public class LocalFeedUpdater {
private static final String TAG = "LocalFeedUpdater"; private static final String TAG = "LocalFeedUpdater";
static final String[] PREFERRED_FEED_IMAGE_FILENAMES = { "folder.jpg", "Folder.jpg", "folder.png", "Folder.png" }; static final String[] PREFERRED_FEED_IMAGE_FILENAMES = {"folder.jpg", "Folder.jpg", "folder.png", "Folder.png"};
public static void updateFeed(Feed feed, Context context, public static void updateFeed(Feed feed, Context context,
@Nullable UpdaterProgressListener updaterProgressListener) { @Nullable UpdaterProgressListener updaterProgressListener) {
try { try {
tryUpdateFeed(feed, context, updaterProgressListener); String uriString = feed.getDownload_url().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)) { if (mustReportDownloadSuccessful(feed)) {
reportSuccess(feed); reportSuccess(feed);
@ -62,19 +75,9 @@ public class LocalFeedUpdater {
} }
} }
private static void tryUpdateFeed(Feed feed, Context context, UpdaterProgressListener updaterProgressListener) @VisibleForTesting
throws IOException { static void tryUpdateFeed(Feed feed, Context context, Uri folderUri,
String uriString = feed.getDownload_url().replace(Feed.PREFIX_LOCAL_FOLDER, ""); UpdaterProgressListener updaterProgressListener) {
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.");
}
if (feed.getItems() == null) { if (feed.getItems() == null) {
feed.setItems(new ArrayList<>()); feed.setItems(new ArrayList<>());
} }
@ -82,9 +85,10 @@ public class LocalFeedUpdater {
feed = DBTasks.updateFeed(context, feed, false); feed = DBTasks.updateFeed(context, feed, false);
// list files in feed folder // list files in feed folder
List<DocumentFile> mediaFiles = new ArrayList<>(); List<FastDocumentFile> allFiles = FastDocumentFile.list(context, folderUri);
List<FastDocumentFile> mediaFiles = new ArrayList<>();
Set<String> mediaFileNames = new HashSet<>(); Set<String> mediaFileNames = new HashSet<>();
for (DocumentFile file : documentFolder.listFiles()) { for (FastDocumentFile file : allFiles) {
String mimeType = MimeTypeUtils.getMimeType(file.getType(), file.getUri().toString()); String mimeType = MimeTypeUtils.getMimeType(file.getType(), file.getUri().toString());
MediaType mediaType = MediaType.fromMimeType(mimeType); MediaType mediaType = MediaType.fromMimeType(mimeType);
if (mediaType == MediaType.AUDIO || mediaType == MediaType.VIDEO) { if (mediaType == MediaType.AUDIO || mediaType == MediaType.VIDEO) {
@ -117,7 +121,7 @@ public class LocalFeedUpdater {
} }
} }
feed.setImageUrl(getImageUrl(documentFolder)); feed.setImageUrl(getImageUrl(allFiles, folderUri));
feed.getPreferences().setAutoDownload(false); feed.getPreferences().setAutoDownload(false);
feed.getPreferences().setAutoDeleteAction(FeedPreferences.AutoDeleteAction.NO); feed.getPreferences().setAutoDeleteAction(FeedPreferences.AutoDeleteAction.NO);
@ -135,17 +139,18 @@ public class LocalFeedUpdater {
* Returns the image URL for the local feed. * Returns the image URL for the local feed.
*/ */
@NonNull @NonNull
static String getImageUrl(@NonNull DocumentFile documentFolder) { static String getImageUrl(List<FastDocumentFile> files, Uri folderUri) {
// look for special file names // look for special file names
for (String iconLocation : PREFERRED_FEED_IMAGE_FILENAMES) { for (String iconLocation : PREFERRED_FEED_IMAGE_FILENAMES) {
DocumentFile image = documentFolder.findFile(iconLocation); for (FastDocumentFile file : files) {
if (image != null) { if (iconLocation.equals(file.getName())) {
return image.getUri().toString(); return file.getUri().toString();
}
} }
} }
// use the first image in the folder if existing // use the first image in the folder if existing
for (DocumentFile file : documentFolder.listFiles()) { for (FastDocumentFile file : files) {
String mime = file.getType(); String mime = file.getType();
if (mime != null && (mime.startsWith("image/jpeg") || mime.startsWith("image/png"))) { if (mime != null && (mime.startsWith("image/jpeg") || mime.startsWith("image/png"))) {
return file.getUri().toString(); return file.getUri().toString();
@ -153,7 +158,7 @@ public class LocalFeedUpdater {
} }
// use default icon as fallback // use default icon as fallback
return Feed.PREFIX_GENERATIVE_COVER + documentFolder.getUri(); return Feed.PREFIX_GENERATIVE_COVER + folderUri;
} }
private static FeedItem feedContainsFile(Feed feed, String filename) { private static FeedItem feedContainsFile(Feed feed, String filename) {
@ -166,26 +171,36 @@ public class LocalFeedUpdater {
return null; return null;
} }
private static FeedItem createFeedItem(Feed feed, DocumentFile file, Context context) { private static FeedItem createFeedItem(Feed feed, FastDocumentFile file, Context context) {
FeedItem item = new FeedItem(0, file.getName(), UUID.randomUUID().toString(), FeedItem item = new FeedItem(0, file.getName(), UUID.randomUUID().toString(),
file.getName(), new Date(file.lastModified()), FeedItem.UNPLAYED, feed); file.getName(), new Date(file.getLastModified()), FeedItem.UNPLAYED, feed);
item.disableAutoDownload(); item.disableAutoDownload();
long size = file.length(); long size = file.getLength();
FeedMedia media = new FeedMedia(0, item, 0, 0, size, file.getType(), FeedMedia media = new FeedMedia(0, item, 0, 0, size, file.getType(),
file.getUri().toString(), file.getUri().toString(), false, null, 0, 0); file.getUri().toString(), file.getUri().toString(), false, null, 0, 0);
item.setMedia(media); item.setMedia(media);
for (FeedItem existingItem : feed.getItems()) {
if (existingItem.getMedia() != null
&& existingItem.getMedia().getDownload_url().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 { try {
loadMetadata(item, file, context); loadMetadata(item, file, context);
} catch (Exception e) { } catch (Exception e) {
item.setDescriptionIfLonger(e.getMessage()); item.setDescriptionIfLonger(e.getMessage());
} }
return item; return item;
} }
private static void loadMetadata(FeedItem item, DocumentFile file, Context context) { private static void loadMetadata(FeedItem item, FastDocumentFile file, Context context) {
MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever(); MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever();
mediaMetadataRetriever.setDataSource(context, file.getUri()); mediaMetadataRetriever.setDataSource(context, file.getUri());
@ -211,9 +226,11 @@ public class LocalFeedUpdater {
item.getMedia().setDuration((int) Long.parseLong(durationStr)); item.getMedia().setDuration((int) Long.parseLong(durationStr));
item.getMedia().setHasEmbeddedPicture(mediaMetadataRetriever.getEmbeddedPicture() != null); item.getMedia().setHasEmbeddedPicture(mediaMetadataRetriever.getEmbeddedPicture() != null);
mediaMetadataRetriever.close();
try (InputStream inputStream = context.getContentResolver().openInputStream(file.getUri())) { try (InputStream inputStream = context.getContentResolver().openInputStream(file.getUri())) {
Id3MetadataReader reader = new Id3MetadataReader(new CountingInputStream(inputStream)); Id3MetadataReader reader = new Id3MetadataReader(
new CountingInputStream(new BufferedInputStream(inputStream)));
reader.readInputStream(); reader.readInputStream();
item.setDescriptionIfLonger(reader.getComment()); item.setDescriptionIfLonger(reader.getComment());
} catch (IOException | ID3ReaderException e) { } catch (IOException | ID3ReaderException e) {

View File

@ -23,6 +23,7 @@ import okhttp3.Request;
import okhttp3.Response; import okhttp3.Response;
import org.apache.commons.io.input.CountingInputStream; import org.apache.commons.io.input.CountingInputStream;
import java.io.BufferedInputStream;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
@ -120,17 +121,17 @@ public class ChapterUtils {
if (!source.exists()) { if (!source.exists()) {
throw new IOException("Local file does not exist"); throw new IOException("Local file does not exist");
} }
return new CountingInputStream(new FileInputStream(source)); return new CountingInputStream(new BufferedInputStream(new FileInputStream(source)));
} else if (playable.getStreamUrl().startsWith(ContentResolver.SCHEME_CONTENT)) { } else if (playable.getStreamUrl().startsWith(ContentResolver.SCHEME_CONTENT)) {
Uri uri = Uri.parse(playable.getStreamUrl()); Uri uri = Uri.parse(playable.getStreamUrl());
return new CountingInputStream(context.getContentResolver().openInputStream(uri)); return new CountingInputStream(new BufferedInputStream(context.getContentResolver().openInputStream(uri)));
} else { } else {
Request request = new Request.Builder().url(playable.getStreamUrl()).build(); Request request = new Request.Builder().url(playable.getStreamUrl()).build();
Response response = AntennapodHttpClient.getHttpClient().newCall(request).execute(); Response response = AntennapodHttpClient.getHttpClient().newCall(request).execute();
if (response.body() == null) { if (response.body() == null) {
throw new IOException("Body is null"); throw new IOException("Body is null");
} }
return new CountingInputStream(response.body().byteStream()); return new CountingInputStream(new BufferedInputStream(response.body().byteStream()));
} }
} }
@ -171,7 +172,7 @@ public class ChapterUtils {
@NonNull @NonNull
private static List<Chapter> readOggChaptersFromInputStream(InputStream input) throws VorbisCommentReaderException { private static List<Chapter> readOggChaptersFromInputStream(InputStream input) throws VorbisCommentReaderException {
VorbisCommentChapterReader reader = new VorbisCommentChapterReader(input); VorbisCommentChapterReader reader = new VorbisCommentChapterReader(new BufferedInputStream(input));
reader.readInputStream(); reader.readInputStream();
List<Chapter> chapters = reader.getChapters(); List<Chapter> chapters = reader.getChapters();
if (chapters == null) { if (chapters == null) {

View File

@ -0,0 +1,77 @@
package de.danoeh.antennapod.core.util;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.provider.DocumentsContract;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Android's DocumentFile is slow because every single method call queries the ContentResolver.
* This queries the ContentResolver a single time with all the information.
*/
public class FastDocumentFile {
private final String name;
private final String type;
private final Uri uri;
private final long length;
private final long lastModified;
public static List<FastDocumentFile> list(Context context, Uri folderUri) {
if (android.os.Build.VERSION.SDK_INT < 21) {
return Collections.emptyList();
}
Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri,
DocumentsContract.getDocumentId(folderUri));
Cursor cursor = context.getContentResolver().query(childrenUri, new String[] {
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_SIZE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
DocumentsContract.Document.COLUMN_MIME_TYPE}, null, null, null);
ArrayList<FastDocumentFile> list = new ArrayList<>();
while (cursor.moveToNext()) {
String id = cursor.getString(0);
Uri uri = DocumentsContract.buildDocumentUriUsingTree(folderUri, id);
String name = cursor.getString(1);
long size = cursor.getLong(2);
long lastModified = cursor.getLong(3);
String mimeType = cursor.getString(4);
list.add(new FastDocumentFile(name, mimeType, uri, size, lastModified));
}
cursor.close();
return list;
}
public FastDocumentFile(String name, String type, Uri uri, long length, long lastModified) {
this.name = name;
this.type = type;
this.uri = uri;
this.length = length;
this.lastModified = lastModified;
}
public String getName() {
return name;
}
public String getType() {
return type;
}
public Uri getUri() {
return uri;
}
public long getLength() {
return length;
}
public long getLastModified() {
return lastModified;
}
}

View File

@ -1,138 +0,0 @@
package androidx.documentfile.provider;
import android.content.res.AssetManager;
import android.net.Uri;
import android.webkit.MimeTypeMap;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.IOException;
/**
* <p>Wraps an Android assets file or folder as a DocumentFile object.</p>
*
* <p>This is used to emulate access to the external storage.</p>
*/
public class AssetsDocumentFile extends DocumentFile {
/**
* Absolute file path in the assets folder.
*/
@NonNull
private final String fileName;
@NonNull
private final AssetManager assetManager;
public AssetsDocumentFile(@NonNull String fileName, @NonNull AssetManager assetManager) {
super(null);
this.fileName = fileName;
this.assetManager = assetManager;
}
@Nullable
@Override
public DocumentFile createFile(@NonNull String mimeType, @NonNull String displayName) {
return null;
}
@Nullable
@Override
public DocumentFile createDirectory(@NonNull String displayName) {
return null;
}
@NonNull
@Override
public Uri getUri() {
return Uri.parse(fileName);
}
@Nullable
@Override
public String getName() {
int pos = fileName.indexOf('/');
if (pos >= 0) {
return fileName.substring(pos + 1);
} else {
return fileName;
}
}
@Nullable
@Override
public String getType() {
String extension = MimeTypeMap.getFileExtensionFromUrl(fileName);
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
}
@Override
public boolean isDirectory() {
return false;
}
@Override
public boolean isFile() {
return true;
}
@Override
public boolean isVirtual() {
return false;
}
@Override
public long lastModified() {
return 0;
}
@Override
public long length() {
return 0;
}
@Override
public boolean canRead() {
return true;
}
@Override
public boolean canWrite() {
return false;
}
@Override
public boolean delete() {
return false;
}
@Override
public boolean exists() {
return true;
}
@NonNull
@Override
public DocumentFile[] listFiles() {
try {
String[] files = assetManager.list(fileName);
if (files == null) {
return new DocumentFile[0];
}
DocumentFile[] result = new DocumentFile[files.length];
for (int i = 0; i < files.length; i++) {
String subFileName = fileName + '/' + files[i];
result[i] = new AssetsDocumentFile(subFileName, assetManager);
}
return result;
} catch (IOException e) {
return new DocumentFile[0];
}
}
@Override
public boolean renameTo(@NonNull String displayName) {
return false;
}
}

View File

@ -7,11 +7,10 @@ import android.net.Uri;
import android.webkit.MimeTypeMap; import android.webkit.MimeTypeMap;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.documentfile.provider.AssetsDocumentFile;
import androidx.documentfile.provider.DocumentFile;
import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.platform.app.InstrumentationRegistry;
import de.danoeh.antennapod.core.preferences.PlaybackPreferences; import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
import de.danoeh.antennapod.core.util.FastDocumentFile;
import de.danoeh.antennapod.model.feed.Feed; import de.danoeh.antennapod.model.feed.Feed;
import de.danoeh.antennapod.model.feed.FeedItem; import de.danoeh.antennapod.model.feed.FeedItem;
import de.danoeh.antennapod.storage.database.PodDBAdapter; import de.danoeh.antennapod.storage.database.PodDBAdapter;
@ -24,11 +23,12 @@ import org.mockito.Mockito;
import org.robolectric.RobolectricTestRunner; import org.robolectric.RobolectricTestRunner;
import org.robolectric.shadows.ShadowMediaMetadataRetriever; import org.robolectric.shadows.ShadowMediaMetadataRetriever;
import java.io.IOException; import java.io.File;
import java.util.Calendar; import java.util.ArrayList;
import java.util.Date; import java.util.Arrays;
import java.util.GregorianCalendar; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Objects;
import de.danoeh.antennapod.core.ApplicationCallbacks; import de.danoeh.antennapod.core.ApplicationCallbacks;
import de.danoeh.antennapod.core.ClientConfig; import de.danoeh.antennapod.core.ClientConfig;
@ -60,8 +60,8 @@ public class LocalFeedUpdaterTest {
*/ */
private static final String FEED_URL = private static final String FEED_URL =
"content://com.android.externalstorage.documents/tree/primary%3ADownload%2Flocal-feed"; "content://com.android.externalstorage.documents/tree/primary%3ADownload%2Flocal-feed";
private static final String LOCAL_FEED_DIR1 = "local-feed1"; private static final String LOCAL_FEED_DIR1 = "src/test/assets/local-feed1";
private static final String LOCAL_FEED_DIR2 = "local-feed2"; private static final String LOCAL_FEED_DIR2 = "src/test/assets/local-feed2";
private Context context; private Context context;
@ -172,74 +172,61 @@ public class LocalFeedUpdaterTest {
Feed feed = verifySingleFeedInDatabase(); Feed feed = verifySingleFeedInDatabase();
List<FeedItem> feedItems = DBReader.getFeedItemList(feed); List<FeedItem> feedItems = DBReader.getFeedItemList(feed);
FeedItem feedItem = feedItems.get(0); assertEquals("track1.mp3", feedItems.get(0).getTitle());
assertEquals("track1.mp3", feedItem.getTitle());
Date pubDate = feedItem.getPubDate();
Calendar calendar = GregorianCalendar.getInstance();
calendar.setTime(pubDate);
assertEquals(2020, calendar.get(Calendar.YEAR));
assertEquals(6 - 1, calendar.get(Calendar.MONTH));
assertEquals(1, calendar.get(Calendar.DAY_OF_MONTH));
assertEquals(22, calendar.get(Calendar.HOUR_OF_DAY));
assertEquals(23, calendar.get(Calendar.MINUTE));
assertEquals(24, calendar.get(Calendar.SECOND));
} }
@Test @Test
public void testGetImageUrl_EmptyFolder() { public void testGetImageUrl_EmptyFolder() {
DocumentFile documentFolder = mockDocumentFolder(); String imageUrl = LocalFeedUpdater.getImageUrl(Collections.emptyList(), Uri.EMPTY);
String imageUrl = LocalFeedUpdater.getImageUrl(documentFolder);
assertThat(imageUrl, startsWith(Feed.PREFIX_GENERATIVE_COVER)); assertThat(imageUrl, startsWith(Feed.PREFIX_GENERATIVE_COVER));
} }
@Test @Test
public void testGetImageUrl_NoImageButAudioFiles() { public void testGetImageUrl_NoImageButAudioFiles() {
DocumentFile documentFolder = mockDocumentFolder(mockDocumentFile("audio.mp3", "audio/mp3")); List<FastDocumentFile> folder = Collections.singletonList(mockDocumentFile("audio.mp3", "audio/mp3"));
String imageUrl = LocalFeedUpdater.getImageUrl(documentFolder); String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
assertThat(imageUrl, startsWith(Feed.PREFIX_GENERATIVE_COVER)); assertThat(imageUrl, startsWith(Feed.PREFIX_GENERATIVE_COVER));
} }
@Test @Test
public void testGetImageUrl_PreferredImagesFilenames() { public void testGetImageUrl_PreferredImagesFilenames() {
for (String filename : LocalFeedUpdater.PREFERRED_FEED_IMAGE_FILENAMES) { for (String filename : LocalFeedUpdater.PREFERRED_FEED_IMAGE_FILENAMES) {
DocumentFile documentFolder = mockDocumentFolder(mockDocumentFile("audio.mp3", "audio/mp3"), List<FastDocumentFile> folder = Arrays.asList(mockDocumentFile("audio.mp3", "audio/mp3"),
mockDocumentFile(filename, "image/jpeg")); // image MIME type doesn't matter mockDocumentFile(filename, "image/jpeg")); // image MIME type doesn't matter
String imageUrl = LocalFeedUpdater.getImageUrl(documentFolder); String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
assertThat(imageUrl, endsWith(filename)); assertThat(imageUrl, endsWith(filename));
} }
} }
@Test @Test
public void testGetImageUrl_OtherImageFilenameJpg() { public void testGetImageUrl_OtherImageFilenameJpg() {
DocumentFile documentFolder = mockDocumentFolder(mockDocumentFile("audio.mp3", "audio/mp3"), List<FastDocumentFile> folder = Arrays.asList(mockDocumentFile("audio.mp3", "audio/mp3"),
mockDocumentFile("my-image.jpg", "image/jpeg")); mockDocumentFile("my-image.jpg", "image/jpeg"));
String imageUrl = LocalFeedUpdater.getImageUrl(documentFolder); String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
assertThat(imageUrl, endsWith("my-image.jpg")); assertThat(imageUrl, endsWith("my-image.jpg"));
} }
@Test @Test
public void testGetImageUrl_OtherImageFilenameJpeg() { public void testGetImageUrl_OtherImageFilenameJpeg() {
DocumentFile documentFolder = mockDocumentFolder(mockDocumentFile("audio.mp3", "audio/mp3"), List<FastDocumentFile> folder = Arrays.asList(mockDocumentFile("audio.mp3", "audio/mp3"),
mockDocumentFile("my-image.jpeg", "image/jpeg")); mockDocumentFile("my-image.jpeg", "image/jpeg"));
String imageUrl = LocalFeedUpdater.getImageUrl(documentFolder); String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
assertThat(imageUrl, endsWith("my-image.jpeg")); assertThat(imageUrl, endsWith("my-image.jpeg"));
} }
@Test @Test
public void testGetImageUrl_OtherImageFilenamePng() { public void testGetImageUrl_OtherImageFilenamePng() {
DocumentFile documentFolder = mockDocumentFolder(mockDocumentFile("audio.mp3", "audio/mp3"), List<FastDocumentFile> folder = Arrays.asList(mockDocumentFile("audio.mp3", "audio/mp3"),
mockDocumentFile("my-image.png", "image/png")); mockDocumentFile("my-image.png", "image/png"));
String imageUrl = LocalFeedUpdater.getImageUrl(documentFolder); String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
assertThat(imageUrl, endsWith("my-image.png")); assertThat(imageUrl, endsWith("my-image.png"));
} }
@Test @Test
public void testGetImageUrl_OtherImageFilenameUnsupportedMimeType() { public void testGetImageUrl_OtherImageFilenameUnsupportedMimeType() {
DocumentFile documentFolder = mockDocumentFolder(mockDocumentFile("audio.mp3", "audio/mp3"), List<FastDocumentFile> folder = Arrays.asList(mockDocumentFile("audio.mp3", "audio/mp3"),
mockDocumentFile("my-image.svg", "image/svg+xml")); mockDocumentFile("my-image.svg", "image/svg+xml"));
String imageUrl = LocalFeedUpdater.getImageUrl(documentFolder); String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
assertThat(imageUrl, startsWith(Feed.PREFIX_GENERATIVE_COVER)); assertThat(imageUrl, startsWith(Feed.PREFIX_GENERATIVE_COVER));
} }
@ -248,9 +235,8 @@ public class LocalFeedUpdaterTest {
* *
* @param localFeedDir assets local feed folder with media files * @param localFeedDir assets local feed folder with media files
*/ */
private void mapDummyMetadata(@NonNull String localFeedDir) throws IOException { private void mapDummyMetadata(@NonNull String localFeedDir) {
String[] fileNames = context.getAssets().list(localFeedDir); for (String fileName : Objects.requireNonNull(new File(localFeedDir).list())) {
for (String fileName : fileNames) {
String path = localFeedDir + '/' + fileName; String path = localFeedDir + '/' + fileName;
ShadowMediaMetadataRetriever.addMetadata(path, ShadowMediaMetadataRetriever.addMetadata(path,
MediaMetadataRetriever.METADATA_KEY_DURATION, "10"); MediaMetadataRetriever.METADATA_KEY_DURATION, "10");
@ -259,24 +245,21 @@ public class LocalFeedUpdaterTest {
ShadowMediaMetadataRetriever.addMetadata(path, ShadowMediaMetadataRetriever.addMetadata(path,
MediaMetadataRetriever.METADATA_KEY_DATE, "20200601T222324"); MediaMetadataRetriever.METADATA_KEY_DATE, "20200601T222324");
} }
} }
/** /**
* Calls the method {@link LocalFeedUpdater#updateFeed(Feed, Context)} with * Calls the method LocalFeedUpdater#tryUpdateFeed with the given local feed folder.
* the given local feed folder.
* *
* @param localFeedDir assets local feed folder with media files * @param localFeedDir assets local feed folder with media files
*/ */
private void callUpdateFeed(@NonNull String localFeedDir) { private void callUpdateFeed(@NonNull String localFeedDir) {
DocumentFile documentFile = new AssetsDocumentFile(localFeedDir, context.getAssets()); try (MockedStatic<FastDocumentFile> dfMock = Mockito.mockStatic(FastDocumentFile.class)) {
try (MockedStatic<DocumentFile> dfMock = Mockito.mockStatic(DocumentFile.class)) {
// mock external storage // mock external storage
dfMock.when(() -> DocumentFile.fromTreeUri(any(), any())).thenReturn(documentFile); dfMock.when(() -> FastDocumentFile.list(any(), any())).thenReturn(mockLocalFolder(localFeedDir));
// call method to test // call method to test
Feed feed = new Feed(FEED_URL, null); Feed feed = new Feed(FEED_URL, null);
LocalFeedUpdater.updateFeed(feed, context, null); LocalFeedUpdater.tryUpdateFeed(feed, context, null, null);
} }
} }
@ -306,21 +289,18 @@ public class LocalFeedUpdaterTest {
* Create a DocumentFile mock object. * Create a DocumentFile mock object.
*/ */
@NonNull @NonNull
private static DocumentFile mockDocumentFile(@NonNull String fileName, @NonNull String mimeType) { private static FastDocumentFile mockDocumentFile(@NonNull String fileName, @NonNull String mimeType) {
DocumentFile file = mock(DocumentFile.class); return new FastDocumentFile(fileName, mimeType, Uri.parse("file:///path/" + fileName), 0, 0);
when(file.getName()).thenReturn(fileName);
when(file.getUri()).thenReturn(Uri.parse("file:///path/" + fileName));
when(file.getType()).thenReturn(mimeType);
return file;
} }
/** private static List<FastDocumentFile> mockLocalFolder(String folderName) {
* Create a DocumentFile folder mock object with a list of files. List<FastDocumentFile> files = new ArrayList<>();
*/ for (File f : Objects.requireNonNull(new File(folderName).listFiles())) {
@NonNull String extension = MimeTypeMap.getFileExtensionFromUrl(f.getPath());
private static DocumentFile mockDocumentFolder(DocumentFile... files) { String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
DocumentFile documentFolder = mock(DocumentFile.class); files.add(new FastDocumentFile(f.getName(), mimeType,
when(documentFolder.listFiles()).thenReturn(files); Uri.parse(f.toURI().toString()), f.length(), f.lastModified()));
return documentFolder; }
return files;
} }
} }

View File

@ -46,7 +46,7 @@ public class ID3Reader {
} }
protected void readFrame(@NonNull FrameHeader frameHeader) throws IOException, ID3ReaderException { protected void readFrame(@NonNull FrameHeader frameHeader) throws IOException, ID3ReaderException {
Log.d(TAG, "Skipping frame: " + frameHeader.toString()); Log.d(TAG, "Skipping frame: " + frameHeader.getId() + ", size: " + frameHeader.getSize());
skipBytes(frameHeader.getSize()); skipBytes(frameHeader.getSize());
} }
@ -106,7 +106,7 @@ public class ID3Reader {
@NonNull @NonNull
FrameHeader readFrameHeader() throws IOException { FrameHeader readFrameHeader() throws IOException {
String id = readIsoStringFixed(FRAME_ID_LENGTH); String id = readPlainBytesToString(FRAME_ID_LENGTH);
int size = readInt(); int size = readInt();
if (tagHeader != null && tagHeader.getVersion() >= 0x0400) { if (tagHeader != null && tagHeader.getVersion() >= 0x0400) {
size = unsynchsafe(size); size = unsynchsafe(size);
@ -136,15 +136,14 @@ public class ID3Reader {
return readEncodedString(encoding, max - 1); return readEncodedString(encoding, max - 1);
} }
@SuppressWarnings("CharsetObjectCanBeUsed") protected String readPlainBytesToString(int length) throws IOException {
protected String readIsoStringFixed(int length) throws IOException { StringBuilder stringBuilder = new StringBuilder();
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
int bytesRead = 0; int bytesRead = 0;
while (bytesRead < length) { while (bytesRead < length) {
bytes.write(readByte()); stringBuilder.append((char) readByte());
bytesRead++; bytesRead++;
} }
return Charset.forName("ISO-8859-1").newDecoder().decode(ByteBuffer.wrap(bytes.toByteArray())).toString(); return stringBuilder.toString();
} }
protected String readIsoStringNullTerminated(int max) throws IOException { protected String readIsoStringNullTerminated(int max) throws IOException {

View File

@ -1,18 +1,7 @@
package de.danoeh.antennapod.parser.media.id3.model; package de.danoeh.antennapod.parser.media.id3.model;
import androidx.annotation.NonNull;
public class FrameHeader extends Header { public class FrameHeader extends Header {
private final short flags;
public FrameHeader(String id, int size, short flags) { public FrameHeader(String id, int size, short flags) {
super(id, size); super(id, size);
this.flags = flags;
}
@Override
@NonNull
public String toString() {
return String.format("FrameHeader [flags=%s, id=%s, size=%s]", Integer.toBinaryString(flags), id, size);
} }
} }