From 620fba0a141b1391ab4c99e419a70184a3f54a65 Mon Sep 17 00:00:00 2001 From: le-firehawk Date: Sat, 4 Oct 2025 23:02:12 +0930 Subject: [PATCH] fix: Prevent externalAudioReader from hogging the main thread --- .../ui/adapter/SongHorizontalAdapter.java | 14 +- .../tempo/ui/fragment/AlbumPageFragment.java | 2 +- .../tempo/ui/fragment/ArtistPageFragment.java | 2 +- .../ui/fragment/HomeTabMusicFragment.java | 4 +- .../ui/fragment/PlaylistPageFragment.java | 2 +- .../tempo/ui/fragment/SearchFragment.java | 2 +- .../ui/fragment/SongListPageFragment.java | 2 +- .../AlbumBottomSheetDialog.java | 59 ++++-- .../SongBottomSheetDialog.java | 44 ++-- .../tempo/util/ExternalAudioReader.java | 189 ++++++++++++++---- .../tempo/util/ExternalAudioWriter.java | 4 +- .../tempo/util/MappingUtil.java | 8 + 12 files changed, 242 insertions(+), 90 deletions(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java index dd26443a..1d78f2ea 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java @@ -11,6 +11,7 @@ import android.widget.Filterable; import androidx.annotation.NonNull; import androidx.appcompat.content.res.AppCompatResources; +import androidx.lifecycle.LifecycleOwner; import androidx.media3.common.util.UnstableApi; import androidx.media3.session.MediaBrowser; 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.DownloadUtil; import com.cappielloantonio.tempo.util.ExternalAudioReader; +import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.Preferences; import com.google.common.util.concurrent.ListenableFuture; @@ -90,7 +92,7 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter currentAlbumTracks = Collections.emptyList(); + private List currentAlbumMediaItems = Collections.emptyList(); + private ListenableFuture mediaBrowserListenableFuture; @Nullable @@ -74,6 +79,12 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements return view; } + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + MappingUtil.observeExternalAudioRefresh(getViewLifecycleOwner(), this::updateRemoveAllVisibility); + } + @Override public void 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 -> { - List mediaItems = MappingUtil.mapDownloads(songs); - List downloads = songs.stream().map(Download::new).collect(Collectors.toList()); + currentAlbumTracks = songs != null ? songs : Collections.emptyList(); + currentAlbumMediaItems = MappingUtil.mapDownloads(currentAlbumTracks); - removeAll.setOnClickListener(v -> { + removeAllTextView.setOnClickListener(v -> { if (Preferences.getDownloadDirectoryUri() == null) { - DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads); + List downloads = currentAlbumTracks.stream().map(Download::new).collect(Collectors.toList()); + DownloadUtil.getDownloadTracker(requireContext()).remove(currentAlbumMediaItems, downloads); } else { - songs.forEach(ExternalAudioReader::delete); + currentAlbumTracks.forEach(ExternalAudioReader::delete); } dismissBottomSheet(); }); + updateRemoveAllVisibility(); }); - initDownloadUI(removeAll); - TextView goToArtist = view.findViewById(R.id.go_to_artist_text_view); goToArtist.setOnClickListener(v -> albumBottomSheetViewModel.getArtist().observe(getViewLifecycleOwner(), artist -> { if (artist != null) { @@ -244,21 +255,29 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements dismiss(); } - private void initDownloadUI(TextView removeAll) { - albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> { - List mediaItems = MappingUtil.mapDownloads(songs); + private void updateRemoveAllVisibility() { + if (removeAllTextView == null) { + return; + } - if (Preferences.getDownloadDirectoryUri() == null) { - if (DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) { - removeAll.setVisibility(View.VISIBLE); - } else { - removeAll.setVisibility(View.GONE); - } + if (currentAlbumTracks == null || currentAlbumTracks.isEmpty()) { + removeAllTextView.setVisibility(View.GONE); + return; + } + + if (Preferences.getDownloadDirectoryUri() == null) { + List mediaItems = currentAlbumMediaItems; + if (mediaItems == null || mediaItems.isEmpty()) { + removeAllTextView.setVisibility(View.GONE); + } else if (DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) { + removeAllTextView.setVisibility(View.VISIBLE); } else { - boolean hasLocal = songs.stream().anyMatch(song -> ExternalAudioReader.getUri(song) != null); - removeAll.setVisibility(hasLocal ? View.VISIBLE : View.GONE); + removeAllTextView.setVisibility(View.GONE); } - }); + } else { + boolean hasLocal = currentAlbumTracks.stream().anyMatch(song -> ExternalAudioReader.getUri(song) != null); + removeAllTextView.setVisibility(hasLocal ? View.VISIBLE : View.GONE); + } } private void initializeMediaBrowser() { diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java index f04fabee..90e32793 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java @@ -13,6 +13,7 @@ import android.widget.TextView; import android.widget.Toast; import android.widget.ToggleButton; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.ViewModelProvider; import androidx.media3.common.util.UnstableApi; @@ -53,6 +54,9 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements private SongBottomSheetViewModel songBottomSheetViewModel; private Child song; + private TextView downloadButton; + private TextView removeButton; + private ListenableFuture mediaBrowserListenableFuture; @Nullable @@ -71,6 +75,12 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements return view; } + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + MappingUtil.observeExternalAudioRefresh(getViewLifecycleOwner(), this::updateDownloadButtons); + } + @Override public void onStart() { super.onStart(); @@ -162,8 +172,8 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements dismissBottomSheet(); }); - TextView download = view.findViewById(R.id.download_text_view); - download.setOnClickListener(v -> { + downloadButton = view.findViewById(R.id.download_text_view); + downloadButton.setOnClickListener(v -> { if (Preferences.getDownloadDirectoryUri() == null) { DownloadUtil.getDownloadTracker(requireContext()).download( MappingUtil.mapDownload(song), @@ -175,8 +185,8 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements dismissBottomSheet(); }); - TextView remove = view.findViewById(R.id.remove_text_view); - remove.setOnClickListener(v -> { + removeButton = view.findViewById(R.id.remove_text_view); + removeButton.setOnClickListener(v -> { if (Preferences.getDownloadDirectoryUri() == null) { DownloadUtil.getDownloadTracker(requireContext()).remove( MappingUtil.mapDownload(song), @@ -188,7 +198,7 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements dismissBottomSheet(); }); - initDownloadUI(download, remove); + updateDownloadButtons(); TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view); addToPlaylist.setOnClickListener(v -> { @@ -256,21 +266,19 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements dismiss(); } - private void initDownloadUI(TextView download, TextView remove) { + private void updateDownloadButtons() { + if (downloadButton == null || removeButton == null) { + return; + } + if (Preferences.getDownloadDirectoryUri() == null) { - if (DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId())) { - remove.setVisibility(View.VISIBLE); - } else { - download.setVisibility(View.VISIBLE); - remove.setVisibility(View.GONE); - } + boolean downloaded = DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId()); + downloadButton.setVisibility(downloaded ? View.GONE : View.VISIBLE); + removeButton.setVisibility(downloaded ? View.VISIBLE : View.GONE); } else { - if (ExternalAudioReader.getUri(song) != null) { - remove.setVisibility(View.VISIBLE); - } else { - download.setVisibility(View.VISIBLE); - remove.setVisibility(View.GONE); - } + boolean hasLocal = ExternalAudioReader.getUri(song) != null; + downloadButton.setVisibility(hasLocal ? View.GONE : View.VISIBLE); + removeButton.setVisibility(hasLocal ? View.VISIBLE : View.GONE); } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java index afabcc82..b8679f13 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java @@ -1,8 +1,12 @@ package com.cappielloantonio.tempo.util; import android.net.Uri; +import android.os.Looper; +import android.os.SystemClock; import androidx.documentfile.provider.DocumentFile; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.subsonic.models.Child; @@ -14,11 +18,20 @@ import java.util.HashSet; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; public class ExternalAudioReader { - private static final Map cache = new HashMap<>(); - private static String cachedDirUri; + private static final Map cache = new ConcurrentHashMap<>(); + private static final Object LOCK = new Object(); + private static final ExecutorService REFRESH_EXECUTOR = Executors.newSingleThreadExecutor(); + private static final MutableLiveData 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) { String sanitized = name.replaceAll("[\\/:*?\\\"<>|]", "_"); @@ -33,59 +46,59 @@ public class ExternalAudioReader { return s.toLowerCase(Locale.ROOT); } - private static synchronized void ensureCache() { + private static void ensureCache() { String uriString = Preferences.getDownloadDirectoryUri(); if (uriString == null) { - cache.clear(); - cachedDirUri = null; + synchronized (LOCK) { + cache.clear(); + cachedDirUri = null; + } ExternalDownloadMetadataStore.clear(); return; } - if (uriString.equals(cachedDirUri)) return; - - cache.clear(); - DocumentFile directory = DocumentFile.fromTreeUri(App.getContext(), Uri.parse(uriString)); - Map expectedSizes = ExternalDownloadMetadataStore.snapshot(); - Set verifiedKeys = new HashSet<>(); - 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) { - cache.put(key, file); - verifiedKeys.add(key); - } else { - ExternalDownloadMetadataStore.remove(key); - } - } + if (uriString.equals(cachedDirUri)) { + return; } - if (!expectedSizes.isEmpty()) { - if (verifiedKeys.isEmpty()) { - ExternalDownloadMetadataStore.clear(); - } else { - for (String key : expectedSizes.keySet()) { - if (!verifiedKeys.contains(key)) { - ExternalDownloadMetadataStore.remove(key); - } - } + boolean runSynchronously = false; + synchronized (LOCK) { + if (refreshInProgress) { + return; } + + if (Looper.myLooper() == Looper.getMainLooper()) { + scheduleRefreshLocked(); + return; + } + + refreshInProgress = true; + runSynchronously = true; } - cachedDirUri = uriString; + if (runSynchronously) { + try { + rebuildCache(); + } finally { + onRefreshFinished(); + } + } } - public static synchronized void refreshCache() { - cachedDirUri = null; - cache.clear(); + public static void refreshCache() { + refreshCacheAsync(); + } + + public static void refreshCacheAsync() { + synchronized (LOCK) { + cachedDirUri = null; + cache.clear(); + } + requestRefresh(); + } + + public static LiveData getRefreshEvents() { + return refreshEvents; } private static String buildKey(String artist, String title, String album) { @@ -136,4 +149,96 @@ public class ExternalAudioReader { } 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 expectedSizes = ExternalDownloadMetadataStore.snapshot(); + Set verifiedKeys = new HashSet<>(); + Map 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(); + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java index df35e169..cf0f768e 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java @@ -159,7 +159,7 @@ public class ExternalAudioWriter { if (matches) { ExternalDownloadMetadataStore.recordSize(metadataKey, localLength); recordDownload(child, existingFile.getUri()); - ExternalAudioReader.refreshCache(); + ExternalAudioReader.refreshCacheAsync(); notifyExists(context, fileName); return; } else { @@ -209,7 +209,7 @@ public class ExternalAudioWriter { ExternalDownloadMetadataStore.recordSize(metadataKey, total); recordDownload(child, targetUri); notifySuccess(context, fileName, child, targetUri); - ExternalAudioReader.refreshCache(); + ExternalAudioReader.refreshCacheAsync(); } } catch (Exception e) { if (targetFile != null) { diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java index 3f32ea44..7ed156f6 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java @@ -4,6 +4,7 @@ import android.net.Uri; import android.os.Bundle; import androidx.annotation.OptIn; +import androidx.lifecycle.LifecycleOwner; import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; import androidx.media3.common.MimeTypes; @@ -240,4 +241,11 @@ public class MappingUtil { Download download = new DownloadRepository().getDownload(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()); + } }