Make loading DocumentFiles faster

This commit is contained in:
ByteHamster 2022-10-19 20:15:09 +02:00
parent 504002c48f
commit 25dd4902ba
4 changed files with 150 additions and 226 deletions

View File

@ -8,7 +8,6 @@ import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.documentfile.provider.DocumentFile;
import java.io.IOException;
import java.io.InputStream;
@ -24,7 +23,10 @@ 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.core.R;
import de.danoeh.antennapod.core.util.FastDocumentFile;
import de.danoeh.antennapod.model.download.DownloadStatus;
import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.storage.DBTasks;
@ -46,12 +48,22 @@ 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" };
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 {
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)) {
reportSuccess(feed);
@ -62,19 +74,9 @@ public class LocalFeedUpdater {
}
}
private static void tryUpdateFeed(Feed feed, Context context, UpdaterProgressListener updaterProgressListener)
throws IOException {
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.");
}
@VisibleForTesting
static void tryUpdateFeed(Feed feed, Context context, Uri folderUri,
UpdaterProgressListener updaterProgressListener) {
if (feed.getItems() == null) {
feed.setItems(new ArrayList<>());
}
@ -82,9 +84,10 @@ public class LocalFeedUpdater {
feed = DBTasks.updateFeed(context, feed, false);
// 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<>();
for (DocumentFile file : documentFolder.listFiles()) {
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) {
@ -117,7 +120,7 @@ public class LocalFeedUpdater {
}
}
feed.setImageUrl(getImageUrl(documentFolder));
feed.setImageUrl(getImageUrl(allFiles, folderUri));
feed.getPreferences().setAutoDownload(false);
feed.getPreferences().setAutoDeleteAction(FeedPreferences.AutoDeleteAction.NO);
@ -135,17 +138,18 @@ public class LocalFeedUpdater {
* Returns the image URL for the local feed.
*/
@NonNull
static String getImageUrl(@NonNull DocumentFile documentFolder) {
static String getImageUrl(List<FastDocumentFile> files, Uri folderUri) {
// look for special file names
for (String iconLocation : PREFERRED_FEED_IMAGE_FILENAMES) {
DocumentFile image = documentFolder.findFile(iconLocation);
if (image != null) {
return image.getUri().toString();
for (FastDocumentFile file : files) {
if (iconLocation.equals(file.getName())) {
return file.getUri().toString();
}
}
}
// use the first image in the folder if existing
for (DocumentFile file : documentFolder.listFiles()) {
for (FastDocumentFile file : files) {
String mime = file.getType();
if (mime != null && (mime.startsWith("image/jpeg") || mime.startsWith("image/png"))) {
return file.getUri().toString();
@ -153,7 +157,7 @@ public class LocalFeedUpdater {
}
// 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) {
@ -166,12 +170,12 @@ public class LocalFeedUpdater {
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(),
file.getName(), new Date(file.lastModified()), FeedItem.UNPLAYED, feed);
file.getName(), new Date(file.getLastModified()), FeedItem.UNPLAYED, feed);
item.disableAutoDownload();
long size = file.length();
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);
@ -185,7 +189,7 @@ public class LocalFeedUpdater {
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.setDataSource(context, file.getUri());
@ -211,6 +215,7 @@ public class LocalFeedUpdater {
item.getMedia().setDuration((int) Long.parseLong(durationStr));
item.getMedia().setHasEmbeddedPicture(mediaMetadataRetriever.getEmbeddedPicture() != null);
mediaMetadataRetriever.close();
try (InputStream inputStream = context.getContentResolver().openInputStream(file.getUri())) {
Id3MetadataReader reader = new Id3MetadataReader(new CountingInputStream(inputStream));

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 androidx.annotation.NonNull;
import androidx.documentfile.provider.AssetsDocumentFile;
import androidx.documentfile.provider.DocumentFile;
import androidx.test.platform.app.InstrumentationRegistry;
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.FeedItem;
import de.danoeh.antennapod.storage.database.PodDBAdapter;
@ -24,11 +23,12 @@ import org.mockito.Mockito;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.shadows.ShadowMediaMetadataRetriever;
import java.io.IOException;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import de.danoeh.antennapod.core.ApplicationCallbacks;
import de.danoeh.antennapod.core.ClientConfig;
@ -60,8 +60,8 @@ public class LocalFeedUpdaterTest {
*/
private static final String FEED_URL =
"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_DIR2 = "local-feed2";
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;
@ -172,74 +172,61 @@ public class LocalFeedUpdaterTest {
Feed feed = verifySingleFeedInDatabase();
List<FeedItem> feedItems = DBReader.getFeedItemList(feed);
FeedItem feedItem = feedItems.get(0);
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));
assertEquals("track1.mp3", feedItems.get(0).getTitle());
}
@Test
public void testGetImageUrl_EmptyFolder() {
DocumentFile documentFolder = mockDocumentFolder();
String imageUrl = LocalFeedUpdater.getImageUrl(documentFolder);
String imageUrl = LocalFeedUpdater.getImageUrl(Collections.emptyList(), Uri.EMPTY);
assertThat(imageUrl, startsWith(Feed.PREFIX_GENERATIVE_COVER));
}
@Test
public void testGetImageUrl_NoImageButAudioFiles() {
DocumentFile documentFolder = mockDocumentFolder(mockDocumentFile("audio.mp3", "audio/mp3"));
String imageUrl = LocalFeedUpdater.getImageUrl(documentFolder);
List<FastDocumentFile> folder = Collections.singletonList(mockDocumentFile("audio.mp3", "audio/mp3"));
String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
assertThat(imageUrl, startsWith(Feed.PREFIX_GENERATIVE_COVER));
}
@Test
public void testGetImageUrl_PreferredImagesFilenames() {
for (String filename : LocalFeedUpdater.PREFERRED_FEED_IMAGE_FILENAMES) {
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
String imageUrl = LocalFeedUpdater.getImageUrl(documentFolder);
String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
assertThat(imageUrl, endsWith(filename));
}
}
@Test
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"));
String imageUrl = LocalFeedUpdater.getImageUrl(documentFolder);
String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
assertThat(imageUrl, endsWith("my-image.jpg"));
}
@Test
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"));
String imageUrl = LocalFeedUpdater.getImageUrl(documentFolder);
String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
assertThat(imageUrl, endsWith("my-image.jpeg"));
}
@Test
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"));
String imageUrl = LocalFeedUpdater.getImageUrl(documentFolder);
String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
assertThat(imageUrl, endsWith("my-image.png"));
}
@Test
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"));
String imageUrl = LocalFeedUpdater.getImageUrl(documentFolder);
String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
assertThat(imageUrl, startsWith(Feed.PREFIX_GENERATIVE_COVER));
}
@ -248,9 +235,8 @@ public class LocalFeedUpdaterTest {
*
* @param localFeedDir assets local feed folder with media files
*/
private void mapDummyMetadata(@NonNull String localFeedDir) throws IOException {
String[] fileNames = context.getAssets().list(localFeedDir);
for (String fileName : fileNames) {
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");
@ -259,24 +245,21 @@ public class LocalFeedUpdaterTest {
ShadowMediaMetadataRetriever.addMetadata(path,
MediaMetadataRetriever.METADATA_KEY_DATE, "20200601T222324");
}
}
/**
* Calls the method {@link LocalFeedUpdater#updateFeed(Feed, Context)} with
* the given local feed folder.
* 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) {
DocumentFile documentFile = new AssetsDocumentFile(localFeedDir, context.getAssets());
try (MockedStatic<DocumentFile> dfMock = Mockito.mockStatic(DocumentFile.class)) {
try (MockedStatic<FastDocumentFile> dfMock = Mockito.mockStatic(FastDocumentFile.class)) {
// mock external storage
dfMock.when(() -> DocumentFile.fromTreeUri(any(), any())).thenReturn(documentFile);
dfMock.when(() -> FastDocumentFile.list(any(), any())).thenReturn(mockLocalFolder(localFeedDir));
// call method to test
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.
*/
@NonNull
private static DocumentFile mockDocumentFile(@NonNull String fileName, @NonNull String mimeType) {
DocumentFile file = mock(DocumentFile.class);
when(file.getName()).thenReturn(fileName);
when(file.getUri()).thenReturn(Uri.parse("file:///path/" + fileName));
when(file.getType()).thenReturn(mimeType);
return file;
private static FastDocumentFile mockDocumentFile(@NonNull String fileName, @NonNull String mimeType) {
return new FastDocumentFile(fileName, mimeType, Uri.parse("file:///path/" + fileName), 0, 0);
}
/**
* Create a DocumentFile folder mock object with a list of files.
*/
@NonNull
private static DocumentFile mockDocumentFolder(DocumentFile... files) {
DocumentFile documentFolder = mock(DocumentFile.class);
when(documentFolder.listFiles()).thenReturn(files);
return documentFolder;
private static List<FastDocumentFile> mockLocalFolder(String folderName) {
List<FastDocumentFile> files = new ArrayList<>();
for (File f : Objects.requireNonNull(new File(folderName).listFiles())) {
String extension = MimeTypeMap.getFileExtensionFromUrl(f.getPath());
String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
files.add(new FastDocumentFile(f.getName(), mimeType,
Uri.parse(f.toURI().toString()), f.length(), f.lastModified()));
}
return files;
}
}