fix: Prevent externalAudioReader from hogging the main thread

This commit is contained in:
le-firehawk 2025-10-04 23:02:12 +09:30
parent 1357c5c062
commit 620fba0a14
12 changed files with 242 additions and 90 deletions

View file

@ -11,6 +11,7 @@ import android.widget.Filterable;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.content.res.AppCompatResources;
import androidx.lifecycle.LifecycleOwner;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser; import androidx.media3.session.MediaBrowser;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
@ -25,6 +26,7 @@ import com.cappielloantonio.tempo.subsonic.models.DiscTitle;
import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.ExternalAudioReader; import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.Preferences;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
@ -90,7 +92,7 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
} }
}; };
public SongHorizontalAdapter(ClickCallback click, boolean showCoverArt, boolean showAlbum, AlbumID3 album) { public SongHorizontalAdapter(LifecycleOwner lifecycleOwner, ClickCallback click, boolean showCoverArt, boolean showAlbum, AlbumID3 album) {
this.click = click; this.click = click;
this.showCoverArt = showCoverArt; this.showCoverArt = showCoverArt;
this.showAlbum = showAlbum; this.showAlbum = showAlbum;
@ -99,6 +101,10 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
this.currentFilter = ""; this.currentFilter = "";
this.album = album; this.album = album;
setHasStableIds(false); setHasStableIds(false);
if (lifecycleOwner != null) {
MappingUtil.observeExternalAudioRefresh(lifecycleOwner, this::handleExternalAudioRefresh);
}
} }
@NonNull @NonNull
@ -204,6 +210,12 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
bindPlaybackState(holder, song); bindPlaybackState(holder, song);
} }
private void handleExternalAudioRefresh() {
if (Preferences.getDownloadDirectoryUri() != null) {
notifyDataSetChanged();
}
}
private void bindPlaybackState(@NonNull ViewHolder holder, @NonNull Child song) { private void bindPlaybackState(@NonNull ViewHolder holder, @NonNull Child song) {
boolean isCurrent = currentPlayingId != null && currentPlayingId.equals(song.getId()); boolean isCurrent = currentPlayingId != null && currentPlayingId.equals(song.getId());

View file

@ -289,7 +289,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.songRecyclerView.setHasFixedSize(true); bind.songRecyclerView.setHasFixedSize(true);
songHorizontalAdapter = new SongHorizontalAdapter(this, false, false, album); songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, false, false, album);
bind.songRecyclerView.setAdapter(songHorizontalAdapter); bind.songRecyclerView.setAdapter(songHorizontalAdapter);
setMediaBrowserListenableFuture(); setMediaBrowserListenableFuture();
reapplyPlayback(); reapplyPlayback();

View file

@ -178,7 +178,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
private void initTopSongsView() { private void initTopSongsView() {
bind.mostStreamedSongRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); bind.mostStreamedSongRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
songHorizontalAdapter = new SongHorizontalAdapter(this, true, true, null); songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, true, null);
bind.mostStreamedSongRecyclerView.setAdapter(songHorizontalAdapter); bind.mostStreamedSongRecyclerView.setAdapter(songHorizontalAdapter);
setMediaBrowserListenableFuture(); setMediaBrowserListenableFuture();
reapplyPlayback(); reapplyPlayback();

View file

@ -600,7 +600,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind.topSongsRecyclerView.setHasFixedSize(true); bind.topSongsRecyclerView.setHasFixedSize(true);
topSongAdapter = new SongHorizontalAdapter(this, true, false, null); topSongAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
bind.topSongsRecyclerView.setAdapter(topSongAdapter); bind.topSongsRecyclerView.setAdapter(topSongAdapter);
setTopSongsMediaBrowserListenableFuture(); setTopSongsMediaBrowserListenableFuture();
reapplyTopSongsPlayback(); reapplyTopSongsPlayback();
@ -641,7 +641,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind.starredTracksRecyclerView.setHasFixedSize(true); bind.starredTracksRecyclerView.setHasFixedSize(true);
starredSongAdapter = new SongHorizontalAdapter(this, true, false, null); starredSongAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
bind.starredTracksRecyclerView.setAdapter(starredSongAdapter); bind.starredTracksRecyclerView.setAdapter(starredSongAdapter);
setStarredSongsMediaBrowserListenableFuture(); setStarredSongsMediaBrowserListenableFuture();
reapplyStarredSongsPlayback(); reapplyStarredSongsPlayback();

View file

@ -264,7 +264,7 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.songRecyclerView.setHasFixedSize(true); bind.songRecyclerView.setHasFixedSize(true);
songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null); songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
bind.songRecyclerView.setAdapter(songHorizontalAdapter); bind.songRecyclerView.setAdapter(songHorizontalAdapter);
setMediaBrowserListenableFuture(); setMediaBrowserListenableFuture();
reapplyPlayback(); reapplyPlayback();

View file

@ -121,7 +121,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
bind.searchResultTracksRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); bind.searchResultTracksRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.searchResultTracksRecyclerView.setHasFixedSize(true); bind.searchResultTracksRecyclerView.setHasFixedSize(true);
songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null); songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
setMediaBrowserListenableFuture(); setMediaBrowserListenableFuture();
reapplyPlayback(); reapplyPlayback();

View file

@ -201,7 +201,7 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
bind.songListRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); bind.songListRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.songListRecyclerView.setHasFixedSize(true); bind.songListRecyclerView.setHasFixedSize(true);
songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null); songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
bind.songListRecyclerView.setAdapter(songHorizontalAdapter); bind.songListRecyclerView.setAdapter(songHorizontalAdapter);
setMediaBrowserListenableFuture(); setMediaBrowserListenableFuture();
reapplyPlayback(); reapplyPlayback();

View file

@ -13,6 +13,7 @@ import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
import android.widget.ToggleButton; import android.widget.ToggleButton;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem;
@ -56,6 +57,10 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
private AlbumBottomSheetViewModel albumBottomSheetViewModel; private AlbumBottomSheetViewModel albumBottomSheetViewModel;
private AlbumID3 album; private AlbumID3 album;
private TextView removeAllTextView;
private List<Child> currentAlbumTracks = Collections.emptyList();
private List<MediaItem> currentAlbumMediaItems = Collections.emptyList();
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture; private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
@Nullable @Nullable
@ -74,6 +79,12 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
return view; return view;
} }
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
MappingUtil.observeExternalAudioRefresh(getViewLifecycleOwner(), this::updateRemoveAllVisibility);
}
@Override @Override
public void onStart() { public void onStart() {
super.onStart(); super.onStart();
@ -188,23 +199,23 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
}); });
}); });
TextView removeAll = view.findViewById(R.id.remove_all_text_view); removeAllTextView = view.findViewById(R.id.remove_all_text_view);
albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> { albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> {
List<MediaItem> mediaItems = MappingUtil.mapDownloads(songs); currentAlbumTracks = songs != null ? songs : Collections.emptyList();
List<Download> downloads = songs.stream().map(Download::new).collect(Collectors.toList()); currentAlbumMediaItems = MappingUtil.mapDownloads(currentAlbumTracks);
removeAll.setOnClickListener(v -> { removeAllTextView.setOnClickListener(v -> {
if (Preferences.getDownloadDirectoryUri() == null) { if (Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads); List<Download> downloads = currentAlbumTracks.stream().map(Download::new).collect(Collectors.toList());
DownloadUtil.getDownloadTracker(requireContext()).remove(currentAlbumMediaItems, downloads);
} else { } else {
songs.forEach(ExternalAudioReader::delete); currentAlbumTracks.forEach(ExternalAudioReader::delete);
} }
dismissBottomSheet(); dismissBottomSheet();
}); });
updateRemoveAllVisibility();
}); });
initDownloadUI(removeAll);
TextView goToArtist = view.findViewById(R.id.go_to_artist_text_view); TextView goToArtist = view.findViewById(R.id.go_to_artist_text_view);
goToArtist.setOnClickListener(v -> albumBottomSheetViewModel.getArtist().observe(getViewLifecycleOwner(), artist -> { goToArtist.setOnClickListener(v -> albumBottomSheetViewModel.getArtist().observe(getViewLifecycleOwner(), artist -> {
if (artist != null) { if (artist != null) {
@ -244,21 +255,29 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
dismiss(); dismiss();
} }
private void initDownloadUI(TextView removeAll) { private void updateRemoveAllVisibility() {
albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> { if (removeAllTextView == null) {
List<MediaItem> mediaItems = MappingUtil.mapDownloads(songs); return;
}
if (currentAlbumTracks == null || currentAlbumTracks.isEmpty()) {
removeAllTextView.setVisibility(View.GONE);
return;
}
if (Preferences.getDownloadDirectoryUri() == null) { if (Preferences.getDownloadDirectoryUri() == null) {
if (DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) { List<MediaItem> mediaItems = currentAlbumMediaItems;
removeAll.setVisibility(View.VISIBLE); if (mediaItems == null || mediaItems.isEmpty()) {
removeAllTextView.setVisibility(View.GONE);
} else if (DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) {
removeAllTextView.setVisibility(View.VISIBLE);
} else { } else {
removeAll.setVisibility(View.GONE); removeAllTextView.setVisibility(View.GONE);
} }
} else { } else {
boolean hasLocal = songs.stream().anyMatch(song -> ExternalAudioReader.getUri(song) != null); boolean hasLocal = currentAlbumTracks.stream().anyMatch(song -> ExternalAudioReader.getUri(song) != null);
removeAll.setVisibility(hasLocal ? View.VISIBLE : View.GONE); removeAllTextView.setVisibility(hasLocal ? View.VISIBLE : View.GONE);
} }
});
} }
private void initializeMediaBrowser() { private void initializeMediaBrowser() {

View file

@ -13,6 +13,7 @@ import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
import android.widget.ToggleButton; import android.widget.ToggleButton;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
@ -53,6 +54,9 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
private SongBottomSheetViewModel songBottomSheetViewModel; private SongBottomSheetViewModel songBottomSheetViewModel;
private Child song; private Child song;
private TextView downloadButton;
private TextView removeButton;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture; private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
@Nullable @Nullable
@ -71,6 +75,12 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
return view; return view;
} }
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
MappingUtil.observeExternalAudioRefresh(getViewLifecycleOwner(), this::updateDownloadButtons);
}
@Override @Override
public void onStart() { public void onStart() {
super.onStart(); super.onStart();
@ -162,8 +172,8 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
dismissBottomSheet(); dismissBottomSheet();
}); });
TextView download = view.findViewById(R.id.download_text_view); downloadButton = view.findViewById(R.id.download_text_view);
download.setOnClickListener(v -> { downloadButton.setOnClickListener(v -> {
if (Preferences.getDownloadDirectoryUri() == null) { if (Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(requireContext()).download( DownloadUtil.getDownloadTracker(requireContext()).download(
MappingUtil.mapDownload(song), MappingUtil.mapDownload(song),
@ -175,8 +185,8 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
dismissBottomSheet(); dismissBottomSheet();
}); });
TextView remove = view.findViewById(R.id.remove_text_view); removeButton = view.findViewById(R.id.remove_text_view);
remove.setOnClickListener(v -> { removeButton.setOnClickListener(v -> {
if (Preferences.getDownloadDirectoryUri() == null) { if (Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(requireContext()).remove( DownloadUtil.getDownloadTracker(requireContext()).remove(
MappingUtil.mapDownload(song), MappingUtil.mapDownload(song),
@ -188,7 +198,7 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
dismissBottomSheet(); dismissBottomSheet();
}); });
initDownloadUI(download, remove); updateDownloadButtons();
TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view); TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view);
addToPlaylist.setOnClickListener(v -> { addToPlaylist.setOnClickListener(v -> {
@ -256,21 +266,19 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
dismiss(); dismiss();
} }
private void initDownloadUI(TextView download, TextView remove) { private void updateDownloadButtons() {
if (downloadButton == null || removeButton == null) {
return;
}
if (Preferences.getDownloadDirectoryUri() == null) { if (Preferences.getDownloadDirectoryUri() == null) {
if (DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId())) { boolean downloaded = DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId());
remove.setVisibility(View.VISIBLE); downloadButton.setVisibility(downloaded ? View.GONE : View.VISIBLE);
removeButton.setVisibility(downloaded ? View.VISIBLE : View.GONE);
} else { } else {
download.setVisibility(View.VISIBLE); boolean hasLocal = ExternalAudioReader.getUri(song) != null;
remove.setVisibility(View.GONE); downloadButton.setVisibility(hasLocal ? View.GONE : View.VISIBLE);
} removeButton.setVisibility(hasLocal ? View.VISIBLE : View.GONE);
} else {
if (ExternalAudioReader.getUri(song) != null) {
remove.setVisibility(View.VISIBLE);
} else {
download.setVisibility(View.VISIBLE);
remove.setVisibility(View.GONE);
}
} }
} }

View file

@ -1,8 +1,12 @@
package com.cappielloantonio.tempo.util; package com.cappielloantonio.tempo.util;
import android.net.Uri; import android.net.Uri;
import android.os.Looper;
import android.os.SystemClock;
import androidx.documentfile.provider.DocumentFile; import androidx.documentfile.provider.DocumentFile;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Child;
@ -14,11 +18,20 @@ import java.util.HashSet;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExternalAudioReader { public class ExternalAudioReader {
private static final Map<String, DocumentFile> cache = new HashMap<>(); private static final Map<String, DocumentFile> cache = new ConcurrentHashMap<>();
private static String cachedDirUri; private static final Object LOCK = new Object();
private static final ExecutorService REFRESH_EXECUTOR = Executors.newSingleThreadExecutor();
private static final MutableLiveData<Long> refreshEvents = new MutableLiveData<>();
private static volatile String cachedDirUri;
private static volatile boolean refreshInProgress = false;
private static volatile boolean refreshQueued = false;
private static String sanitizeFileName(String name) { private static String sanitizeFileName(String name) {
String sanitized = name.replaceAll("[\\/:*?\\\"<>|]", "_"); String sanitized = name.replaceAll("[\\/:*?\\\"<>|]", "_");
@ -33,60 +46,60 @@ public class ExternalAudioReader {
return s.toLowerCase(Locale.ROOT); return s.toLowerCase(Locale.ROOT);
} }
private static synchronized void ensureCache() { private static void ensureCache() {
String uriString = Preferences.getDownloadDirectoryUri(); String uriString = Preferences.getDownloadDirectoryUri();
if (uriString == null) { if (uriString == null) {
synchronized (LOCK) {
cache.clear(); cache.clear();
cachedDirUri = null; cachedDirUri = null;
}
ExternalDownloadMetadataStore.clear(); ExternalDownloadMetadataStore.clear();
return; return;
} }
if (uriString.equals(cachedDirUri)) return; if (uriString.equals(cachedDirUri)) {
return;
}
cache.clear(); boolean runSynchronously = false;
DocumentFile directory = DocumentFile.fromTreeUri(App.getContext(), Uri.parse(uriString)); synchronized (LOCK) {
Map<String, Long> expectedSizes = ExternalDownloadMetadataStore.snapshot(); if (refreshInProgress) {
Set<String> verifiedKeys = new HashSet<>(); return;
if (directory != null && directory.canRead()) { }
for (DocumentFile file : directory.listFiles()) {
if (file == null || file.isDirectory()) continue;
String existing = file.getName();
if (existing == null) continue;
String base = existing.replaceFirst("\\.[^\\.]+$", ""); if (Looper.myLooper() == Looper.getMainLooper()) {
String key = normalizeForComparison(base); scheduleRefreshLocked();
Long expected = expectedSizes.get(key); return;
long actualLength = file.length(); }
if (expected != null && expected > 0 && actualLength == expected) { refreshInProgress = true;
cache.put(key, file); runSynchronously = true;
verifiedKeys.add(key); }
} else {
ExternalDownloadMetadataStore.remove(key); if (runSynchronously) {
try {
rebuildCache();
} finally {
onRefreshFinished();
} }
} }
} }
if (!expectedSizes.isEmpty()) { public static void refreshCache() {
if (verifiedKeys.isEmpty()) { refreshCacheAsync();
ExternalDownloadMetadataStore.clear();
} else {
for (String key : expectedSizes.keySet()) {
if (!verifiedKeys.contains(key)) {
ExternalDownloadMetadataStore.remove(key);
}
}
}
} }
cachedDirUri = uriString; public static void refreshCacheAsync() {
} synchronized (LOCK) {
public static synchronized void refreshCache() {
cachedDirUri = null; cachedDirUri = null;
cache.clear(); cache.clear();
} }
requestRefresh();
}
public static LiveData<Long> getRefreshEvents() {
return refreshEvents;
}
private static String buildKey(String artist, String title, String album) { private static String buildKey(String artist, String title, String album) {
String name = artist != null && !artist.isEmpty() ? artist + " - " + title : title; String name = artist != null && !artist.isEmpty() ? artist + " - " + title : title;
@ -136,4 +149,96 @@ public class ExternalAudioReader {
} }
return deleted; return deleted;
} }
private static void requestRefresh() {
synchronized (LOCK) {
scheduleRefreshLocked();
}
}
private static void scheduleRefreshLocked() {
if (refreshInProgress) {
refreshQueued = true;
return;
}
refreshInProgress = true;
REFRESH_EXECUTOR.execute(() -> {
try {
rebuildCache();
} finally {
onRefreshFinished();
}
});
}
private static void rebuildCache() {
String uriString = Preferences.getDownloadDirectoryUri();
if (uriString == null) {
synchronized (LOCK) {
cache.clear();
cachedDirUri = null;
}
ExternalDownloadMetadataStore.clear();
return;
}
DocumentFile directory = DocumentFile.fromTreeUri(App.getContext(), Uri.parse(uriString));
Map<String, Long> expectedSizes = ExternalDownloadMetadataStore.snapshot();
Set<String> verifiedKeys = new HashSet<>();
Map<String, DocumentFile> newEntries = new HashMap<>();
if (directory != null && directory.canRead()) {
for (DocumentFile file : directory.listFiles()) {
if (file == null || file.isDirectory()) continue;
String existing = file.getName();
if (existing == null) continue;
String base = existing.replaceFirst("\\.[^\\.]+$", "");
String key = normalizeForComparison(base);
Long expected = expectedSizes.get(key);
long actualLength = file.length();
if (expected != null && expected > 0 && actualLength == expected) {
newEntries.put(key, file);
verifiedKeys.add(key);
} else {
ExternalDownloadMetadataStore.remove(key);
}
}
}
if (!expectedSizes.isEmpty()) {
if (verifiedKeys.isEmpty()) {
ExternalDownloadMetadataStore.clear();
} else {
for (String key : expectedSizes.keySet()) {
if (!verifiedKeys.contains(key)) {
ExternalDownloadMetadataStore.remove(key);
}
}
}
}
synchronized (LOCK) {
cache.clear();
cache.putAll(newEntries);
cachedDirUri = uriString;
}
}
private static void onRefreshFinished() {
boolean runAgain;
synchronized (LOCK) {
refreshInProgress = false;
runAgain = refreshQueued;
refreshQueued = false;
}
refreshEvents.postValue(SystemClock.elapsedRealtime());
if (runAgain) {
requestRefresh();
}
}
} }

View file

@ -159,7 +159,7 @@ public class ExternalAudioWriter {
if (matches) { if (matches) {
ExternalDownloadMetadataStore.recordSize(metadataKey, localLength); ExternalDownloadMetadataStore.recordSize(metadataKey, localLength);
recordDownload(child, existingFile.getUri()); recordDownload(child, existingFile.getUri());
ExternalAudioReader.refreshCache(); ExternalAudioReader.refreshCacheAsync();
notifyExists(context, fileName); notifyExists(context, fileName);
return; return;
} else { } else {
@ -209,7 +209,7 @@ public class ExternalAudioWriter {
ExternalDownloadMetadataStore.recordSize(metadataKey, total); ExternalDownloadMetadataStore.recordSize(metadataKey, total);
recordDownload(child, targetUri); recordDownload(child, targetUri);
notifySuccess(context, fileName, child, targetUri); notifySuccess(context, fileName, child, targetUri);
ExternalAudioReader.refreshCache(); ExternalAudioReader.refreshCacheAsync();
} }
} catch (Exception e) { } catch (Exception e) {
if (targetFile != null) { if (targetFile != null) {

View file

@ -4,6 +4,7 @@ import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import androidx.annotation.OptIn; import androidx.annotation.OptIn;
import androidx.lifecycle.LifecycleOwner;
import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata; import androidx.media3.common.MediaMetadata;
import androidx.media3.common.MimeTypes; import androidx.media3.common.MimeTypes;
@ -240,4 +241,11 @@ public class MappingUtil {
Download download = new DownloadRepository().getDownload(id); Download download = new DownloadRepository().getDownload(id);
return download != null && !download.getDownloadUri().isEmpty() ? Uri.parse(download.getDownloadUri()) : MusicUtil.getDownloadUri(id); return download != null && !download.getDownloadUri().isEmpty() ? Uri.parse(download.getDownloadUri()) : MusicUtil.getDownloadUri(id);
} }
public static void observeExternalAudioRefresh(LifecycleOwner owner, Runnable onRefresh) {
if (owner == null || onRefresh == null) {
return;
}
ExternalAudioReader.getRefreshEvents().observe(owner, event -> onRefresh.run());
}
} }