From cce645695171f9a82daf68df48f57076974c279f Mon Sep 17 00:00:00 2001 From: le-firehawk Date: Tue, 5 Aug 2025 22:31:13 +0930 Subject: [PATCH 1/6] feat: Support user-defined download directory for media --- .../dialog/DownloadDirectoryPickerDialog.java | 61 ++++++ .../ui/dialog/DownloadStorageDialog.java | 15 ++ .../tempo/ui/dialog/StarredSyncDialog.java | 2 +- .../tempo/ui/fragment/AlbumPageFragment.java | 14 +- .../tempo/ui/fragment/DirectoryFragment.java | 17 +- .../tempo/ui/fragment/DownloadFragment.java | 26 +++ .../ui/fragment/HomeTabMusicFragment.java | 4 +- .../ui/fragment/PlayerCoverFragment.java | 16 +- .../ui/fragment/PlaylistPageFragment.java | 27 ++- .../tempo/ui/fragment/SettingsFragment.java | 105 ++++++++++- .../AlbumBottomSheetDialog.java | 12 +- .../SongBottomSheetDialog.java | 18 +- .../tempo/util/DownloadUtil.java | 8 +- .../tempo/util/ExternalAudioWriter.java | 176 ++++++++++++++++++ .../tempo/util/Preferences.kt | 11 ++ .../viewmodel/PlayerBottomSheetViewModel.java | 2 +- .../viewmodel/SongBottomSheetViewModel.java | 2 +- app/src/main/res/drawable/ic_folder.xml | 9 + app/src/main/res/menu/download_popup_menu.xml | 3 + app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/global_preferences.xml | 8 + 21 files changed, 500 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadDirectoryPickerDialog.java create mode 100644 app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java create mode 100644 app/src/main/res/drawable/ic_folder.xml diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadDirectoryPickerDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadDirectoryPickerDialog.java new file mode 100644 index 00000000..33311280 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadDirectoryPickerDialog.java @@ -0,0 +1,61 @@ +package com.cappielloantonio.tempo.ui.dialog; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.cappielloantonio.tempo.R; +import com.cappielloantonio.tempo.util.Preferences; + +public class DownloadDirectoryPickerDialog extends DialogFragment { + + private ActivityResultLauncher folderPickerLauncher; + + @NonNull + @Override + public android.app.Dialog onCreateDialog(Bundle savedInstanceState) { + // Register launcher *before* button triggers + folderPickerLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == android.app.Activity.RESULT_OK) { + Intent data = result.getData(); + if (data != null) { + Uri uri = data.getData(); + if (uri != null) { + requireContext().getContentResolver().takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ); + + Preferences.setDownloadDirectoryUri(uri.toString()); + + Toast.makeText(requireContext(), "Download directory set:\n" + uri.toString(), Toast.LENGTH_LONG).show(); + } + } + } + } + ); + + return new MaterialAlertDialogBuilder(requireContext()) + .setTitle("Set Download Directory") + .setMessage("Choose a folder where downloaded songs will be stored.") + .setPositiveButton("Choose Folder", (dialog, which) -> { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + folderPickerLauncher.launch(intent); + }) + .setNegativeButton(android.R.string.cancel, null) + .create(); + } +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadStorageDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadStorageDialog.java index f5ff1577..766064e2 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadStorageDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadStorageDialog.java @@ -34,6 +34,7 @@ public class DownloadStorageDialog extends DialogFragment { .setTitle(R.string.download_storage_dialog_title) .setPositiveButton(R.string.download_storage_external_dialog_positive_button, null) .setNegativeButton(R.string.download_storage_internal_dialog_negative_button, null) + .setNeutralButton(R.string.download_storage_directory_dialog_neutral_button, null) .create(); } @@ -74,6 +75,20 @@ public class DownloadStorageDialog extends DialogFragment { dialog.dismiss(); }); + + Button neutralButton = dialog.getButton(Dialog.BUTTON_NEUTRAL); + neutralButton.setOnClickListener(v -> { + int currentPreference = Preferences.getDownloadStoragePreference(); + int newPreference = 2; + + if (currentPreference != newPreference) { + Preferences.setDownloadStoragePreference(newPreference); + DownloadUtil.getDownloadTracker(requireContext()).removeAll(); + dialogClickCallback.onNeutralClick(); + } + + dialog.dismiss(); + }); } } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredSyncDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredSyncDialog.java index 3d9b07a9..d3edfdf7 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredSyncDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredSyncDialog.java @@ -61,7 +61,7 @@ public class StarredSyncDialog extends DialogFragment { Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE); positiveButton.setOnClickListener(v -> { starredSyncViewModel.getStarredTracks(requireActivity()).observe(requireActivity(), songs -> { - if (songs != null) { + if (songs != null && Preferences.getDownloadDirectoryUri() == null) { DownloadUtil.getDownloadTracker(context).download( MappingUtil.mapDownloads(songs), songs.stream().map(Download::new).collect(Collectors.toList()) diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java index 03ea9100..2897fb3c 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java @@ -39,6 +39,8 @@ import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.MusicUtil; +import com.cappielloantonio.tempo.util.ExternalAudioWriter; +import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.viewmodel.AlbumPageViewModel; import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel; import com.google.common.util.concurrent.ListenableFuture; @@ -130,7 +132,17 @@ public class AlbumPageFragment extends Fragment implements ClickCallback { if (item.getItemId() == R.id.action_download_album) { albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> { - DownloadUtil.getDownloadTracker(requireContext()).download(MappingUtil.mapDownloads(songs), songs.stream().map(Download::new).collect(Collectors.toList())); + if (Preferences.getDownloadDirectoryUri() == null) { + DownloadUtil.getDownloadTracker(requireContext()).download( + MappingUtil.mapDownloads(songs), + songs.stream().map(Download::new).collect(Collectors.toList()) + ); + } else { + MappingUtil.mapMediaItems(songs).forEach(media -> { + String title = media.mediaMetadata.title != null ? media.mediaMetadata.title.toString() : media.mediaId; + ExternalAudioWriter.downloadToUserDirectory(requireContext(), media, title); + }); + } }); return true; } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DirectoryFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DirectoryFragment.java index 2423011e..eeed14ce 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DirectoryFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DirectoryFragment.java @@ -33,7 +33,9 @@ import com.cappielloantonio.tempo.ui.adapter.MusicDirectoryAdapter; import com.cappielloantonio.tempo.ui.dialog.DownloadDirectoryDialog; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.DownloadUtil; +import com.cappielloantonio.tempo.util.ExternalAudioWriter; import com.cappielloantonio.tempo.util.MappingUtil; +import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.viewmodel.DirectoryViewModel; import com.google.common.util.concurrent.ListenableFuture; @@ -109,10 +111,17 @@ public class DirectoryFragment extends Fragment implements ClickCallback { directoryViewModel.loadMusicDirectory(getArguments().getString(Constants.MUSIC_DIRECTORY_ID)).observe(getViewLifecycleOwner(), directory -> { if (isVisible() && getActivity() != null) { List songs = directory.getChildren().stream().filter(child -> !child.isDir()).collect(Collectors.toList()); - DownloadUtil.getDownloadTracker(requireContext()).download( - MappingUtil.mapDownloads(songs), - songs.stream().map(Download::new).collect(Collectors.toList()) - ); + if (Preferences.getDownloadDirectoryUri() == null) { + DownloadUtil.getDownloadTracker(requireContext()).download( + MappingUtil.mapDownloads(songs), + songs.stream().map(Download::new).collect(Collectors.toList()) + ); + } else { + MappingUtil.mapMediaItems(songs).forEach(media -> { + String title = media.mediaMetadata.title != null ? media.mediaMetadata.title.toString() : media.mediaId; + ExternalAudioWriter.downloadToUserDirectory(requireContext(), media, title); + }); + } } }); } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java index b8108fe3..da6e265e 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java @@ -33,6 +33,11 @@ import com.cappielloantonio.tempo.viewmodel.DownloadViewModel; import com.google.android.material.appbar.MaterialToolbar; import com.google.common.util.concurrent.ListenableFuture; +import android.content.Intent; +import android.app.Activity; +import android.net.Uri; +import android.widget.Toast; + import java.util.Collections; import java.util.List; import java.util.Objects; @@ -40,6 +45,7 @@ import java.util.Objects; @UnstableApi public class DownloadFragment extends Fragment implements ClickCallback { private static final String TAG = "DownloadFragment"; + private static final int REQUEST_CODE_PICK_DIRECTORY = 1002; private FragmentDownloadBinding bind; private MainActivity activity; @@ -216,6 +222,10 @@ public class DownloadFragment extends Fragment implements ClickCallback { downloadViewModel.initViewStack(new DownloadStack(Constants.DOWNLOAD_TYPE_YEAR, null)); Preferences.setDefaultDownloadViewType(Constants.DOWNLOAD_TYPE_YEAR); return true; + } else if (menuItem.getItemId() == R.id.menu_download_set_directory) { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + startActivityForResult(intent, REQUEST_CODE_PICK_DIRECTORY); + return true; } return false; @@ -267,4 +277,20 @@ public class DownloadFragment extends Fragment implements ClickCallback { public void onDownloadGroupLongClick(Bundle bundle) { Navigation.findNavController(requireView()).navigate(R.id.downloadBottomSheetDialog, bundle); } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_CODE_PICK_DIRECTORY && resultCode == Activity.RESULT_OK) { + Uri uri = data.getData(); + if (uri != null) { + requireContext().getContentResolver().takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ); + Preferences.setDownloadDirectoryUri(uri.toString()); + Toast.makeText(requireContext(), "Download directory set", Toast.LENGTH_SHORT).show(); + } + } + } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java index bf6b7d6c..4fb6938f 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java @@ -66,6 +66,8 @@ import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel; import com.google.android.material.snackbar.Snackbar; import com.google.common.util.concurrent.ListenableFuture; +import androidx.media3.common.MediaItem; + import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @@ -277,7 +279,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback { } private void initSyncStarredView() { - if (Preferences.isStarredSyncEnabled()) { + if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) { homeViewModel.getAllStarredTracks().observeForever(new Observer>() { @Override public void onChanged(List songs) { diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerCoverFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerCoverFragment.java index 392c5786..854bbe3b 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerCoverFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerCoverFragment.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; +import androidx.media3.common.MediaItem; import androidx.media3.common.MediaMetadata; import androidx.media3.common.Player; import androidx.media3.common.util.UnstableApi; @@ -31,6 +32,7 @@ import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.Preferences; +import com.cappielloantonio.tempo.util.ExternalAudioWriter; import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel; import com.cappielloantonio.tempo.subsonic.models.Child; import com.google.android.material.snackbar.Snackbar; @@ -115,10 +117,16 @@ public class PlayerCoverFragment extends Fragment { playerBottomSheetViewModel.getLiveMedia().observe(getViewLifecycleOwner(), song -> { if (song != null && bind != null) { bind.innerButtonTopLeft.setOnClickListener(view -> { - DownloadUtil.getDownloadTracker(requireContext()).download( - MappingUtil.mapDownload(song), - new Download(song) - ); + if (Preferences.getDownloadDirectoryUri() == null) { + DownloadUtil.getDownloadTracker(requireContext()).download( + MappingUtil.mapDownload(song), + new Download(song) + ); + } else { + MediaItem item = MappingUtil.mapMediaItem(song); + String title = item.mediaMetadata.title != null ? item.mediaMetadata.title.toString() : item.mediaId; + ExternalAudioWriter.downloadToUserDirectory(requireContext(), item, title); + } }); bind.innerButtonTopRight.setOnClickListener(view -> { diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java index 7fefe7db..ec6dcbbb 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java @@ -37,6 +37,8 @@ import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.MusicUtil; +import com.cappielloantonio.tempo.util.ExternalAudioWriter; +import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel; import com.cappielloantonio.tempo.viewmodel.PlaylistPageViewModel; import com.google.common.util.concurrent.ListenableFuture; @@ -140,15 +142,22 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback { if (item.getItemId() == R.id.action_download_playlist) { playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> { if (isVisible() && getActivity() != null) { - DownloadUtil.getDownloadTracker(requireContext()).download( - MappingUtil.mapDownloads(songs), - songs.stream().map(child -> { - Download toDownload = new Download(child); - toDownload.setPlaylistId(playlistPageViewModel.getPlaylist().getId()); - toDownload.setPlaylistName(playlistPageViewModel.getPlaylist().getName()); - return toDownload; - }).collect(Collectors.toList()) - ); + if (Preferences.getDownloadDirectoryUri() == null) { + DownloadUtil.getDownloadTracker(requireContext()).download( + MappingUtil.mapDownloads(songs), + songs.stream().map(child -> { + Download toDownload = new Download(child); + toDownload.setPlaylistId(playlistPageViewModel.getPlaylist().getId()); + toDownload.setPlaylistName(playlistPageViewModel.getPlaylist().getName()); + return toDownload; + }).collect(Collectors.toList()) + ); + } else { + MappingUtil.mapMediaItems(songs).forEach(media -> { + String title = media.mediaMetadata.title != null ? media.mediaMetadata.title.toString() : media.mediaId; + ExternalAudioWriter.downloadToUserDirectory(requireContext(), media, title); + }); + } } }); return true; diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java index ef4f2134..724e77d0 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java @@ -1,17 +1,19 @@ package com.cappielloantonio.tempo.ui.fragment; -import android.content.ComponentName; +import android.app.Activity; import android.content.Context; +import android.content.ComponentName; import android.content.Intent; import android.content.ServiceConnection; import android.media.audiofx.AudioEffect; +import android.net.Uri; import android.os.Bundle; -import android.os.Handler; import android.os.IBinder; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.WindowManager; +import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; @@ -59,7 +61,8 @@ public class SettingsFragment extends PreferenceFragmentCompat { private MainActivity activity; private SettingViewModel settingViewModel; - private ActivityResultLauncher someActivityResultLauncher; + private ActivityResultLauncher equalizerResultLauncher; + private ActivityResultLauncher directoryPickerLauncher; private MediaService.LocalBinder mediaServiceBinder; private boolean isServiceBound = false; @@ -68,9 +71,30 @@ public class SettingsFragment extends PreferenceFragmentCompat { public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - someActivityResultLauncher = registerForActivityResult( + equalizerResultLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> {} + ); + + directoryPickerLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { + if (result.getResultCode() == Activity.RESULT_OK) { + Intent data = result.getData(); + if (data != null) { + Uri uri = data.getData(); + if (uri != null) { + requireContext().getContentResolver().takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ); + + Preferences.setDownloadDirectoryUri(uri.toString()); + Toast.makeText(requireContext(), "Download folder set.", Toast.LENGTH_SHORT).show(); + checkDownloadDirectory(); + } + } + } }); } @@ -102,6 +126,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { checkSystemEqualizer(); checkCacheStorage(); checkStorage(); + checkDownloadDirectory(); setStreamingCacheSize(); setAppLanguage(); @@ -114,6 +139,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { actionSyncStarredArtists(); actionChangeStreamingCacheStorage(); actionChangeDownloadStorage(); + actionSetDownloadDirectory(); actionDeleteDownloadStorage(); actionKeepScreenOn(); actionAutoDownloadLyrics(); @@ -151,7 +177,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { if ((intent.resolveActivity(requireActivity().getPackageManager()) != null)) { equalizer.setOnPreferenceClickListener(preference -> { - someActivityResultLauncher.launch(intent); + equalizerResultLauncher.launch(intent); return true; }); } else { @@ -168,7 +194,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { if (requireContext().getExternalFilesDirs(null)[1] == null) { storage.setVisible(false); } else { - storage.setSummary(Preferences.getDownloadStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button); + storage.setSummary(Preferences.getStreamingCacheStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button); } } catch (Exception exception) { storage.setVisible(false); @@ -184,13 +210,46 @@ public class SettingsFragment extends PreferenceFragmentCompat { if (requireContext().getExternalFilesDirs(null)[1] == null) { storage.setVisible(false); } else { - storage.setSummary(Preferences.getDownloadStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button); + int pref = Preferences.getDownloadStoragePreference(); + if (pref == 0) { + storage.setSummary(R.string.download_storage_internal_dialog_negative_button); + } else if (pref == 1) { + storage.setSummary(R.string.download_storage_external_dialog_positive_button); + } else { + storage.setSummary(R.string.download_storage_directory_dialog_neutral_button); + } } } catch (Exception exception) { storage.setVisible(false); } } + private void checkDownloadDirectory() { + Preference storage = findPreference("download_storage"); + Preference directory = findPreference("set_download_directory"); + + if (directory == null) return; + + String current = Preferences.getDownloadDirectoryUri(); + if (current != null) { + if (storage != null) storage.setVisible(false); + directory.setVisible(true); + directory.setIcon(R.drawable.ic_close); + directory.setTitle("Clear download folder"); + directory.setSummary(current); + } else { + if (storage != null) storage.setVisible(true); + if (Preferences.getDownloadStoragePreference() == 2) { + directory.setVisible(true); + directory.setIcon(R.drawable.ic_folder); + directory.setTitle("Set download folder"); + directory.setSummary("Choose a folder for downloaded music files"); + } else { + directory.setVisible(false); + } + } + } + private void setStreamingCacheSize() { ListPreference streamingCachePreference = findPreference("streaming_cache_size"); @@ -338,11 +397,19 @@ public class SettingsFragment extends PreferenceFragmentCompat { @Override public void onPositiveClick() { findPreference("download_storage").setSummary(R.string.download_storage_external_dialog_positive_button); + checkDownloadDirectory(); } @Override public void onNegativeClick() { findPreference("download_storage").setSummary(R.string.download_storage_internal_dialog_negative_button); + checkDownloadDirectory(); + } + + @Override + public void onNeutralClick() { + findPreference("download_storage").setSummary(R.string.download_storage_directory_dialog_neutral_button); + checkDownloadDirectory(); } }); dialog.show(activity.getSupportFragmentManager(), null); @@ -350,6 +417,30 @@ public class SettingsFragment extends PreferenceFragmentCompat { }); } + private void actionSetDownloadDirectory() { + Preference pref = findPreference("set_download_directory"); + if (pref != null) { + pref.setOnPreferenceClickListener(preference -> { + String current = Preferences.getDownloadDirectoryUri(); + + if (current != null) { + Preferences.setDownloadDirectoryUri(null); + Preferences.setDownloadStoragePreference(0); + Toast.makeText(requireContext(), "Download folder cleared.", Toast.LENGTH_SHORT).show(); + checkStorage(); + checkDownloadDirectory(); + } else { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + directoryPickerLauncher.launch(intent); + } + return true; + }); + } + } + private void actionDeleteDownloadStorage() { findPreference("delete_download_storage").setOnPreferenceClickListener(preference -> { DeleteDownloadStorageDialog dialog = new DeleteDownloadStorageDialog(); diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java index f7b742e4..bb778b95 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java @@ -37,6 +37,7 @@ import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.Preferences; +import com.cappielloantonio.tempo.util.ExternalAudioWriter; import com.cappielloantonio.tempo.viewmodel.AlbumBottomSheetViewModel; import com.cappielloantonio.tempo.viewmodel.HomeViewModel; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; @@ -163,7 +164,14 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements List downloads = songs.stream().map(Download::new).collect(Collectors.toList()); downloadAll.setOnClickListener(v -> { - DownloadUtil.getDownloadTracker(requireContext()).download(mediaItems, downloads); + if (Preferences.getDownloadDirectoryUri() == null) { + DownloadUtil.getDownloadTracker(requireContext()).download(mediaItems, downloads); + } else { + MappingUtil.mapMediaItems(songs).forEach(media -> { + String title = media.mediaMetadata.title != null ? media.mediaMetadata.title.toString() : media.mediaId; + ExternalAudioWriter.downloadToUserDirectory(requireContext(), media, title); + }); + } dismissBottomSheet(); }); }); @@ -238,7 +246,7 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> { List mediaItems = MappingUtil.mapDownloads(songs); - if (DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) { + if (Preferences.getDownloadDirectoryUri() == null && DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) { removeAll.setVisibility(View.VISIBLE); } }); 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 0d15ad2f..8cb3369f 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 @@ -39,6 +39,10 @@ import com.cappielloantonio.tempo.viewmodel.SongBottomSheetViewModel; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import com.google.common.util.concurrent.ListenableFuture; +import android.content.Intent; +import androidx.media3.common.MediaItem; +import com.cappielloantonio.tempo.util.ExternalAudioWriter; + import java.util.ArrayList; import java.util.Collections; @@ -159,10 +163,16 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements TextView download = view.findViewById(R.id.download_text_view); download.setOnClickListener(v -> { - DownloadUtil.getDownloadTracker(requireContext()).download( - MappingUtil.mapDownload(song), - new Download(song) - ); + MediaItem item = MappingUtil.mapMediaItem(song); + String title = item.mediaMetadata.title != null ? item.mediaMetadata.title.toString() : item.mediaId; + if (Preferences.getDownloadDirectoryUri() == null) { + DownloadUtil.getDownloadTracker(requireContext()).download( + MappingUtil.mapDownload(song), + new Download(song) + ); + } else { + ExternalAudioWriter.downloadToUserDirectory(requireContext(), item, title); + } dismissBottomSheet(); }); diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java index 238b4136..6df73eb6 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java @@ -187,19 +187,21 @@ public final class DownloadUtil { private static synchronized File getDownloadDirectory(Context context) { if (downloadDirectory == null) { - if (Preferences.getDownloadStoragePreference() == 0) { + int pref = Preferences.getDownloadStoragePreference(); + if (pref == 0) { downloadDirectory = context.getExternalFilesDirs(null)[0]; if (downloadDirectory == null) { downloadDirectory = context.getFilesDir(); } - } else { + } else if (pref == 1) { try { downloadDirectory = context.getExternalFilesDirs(null)[1]; } catch (Exception exception) { downloadDirectory = context.getExternalFilesDirs(null)[0]; Preferences.setDownloadStoragePreference(0); } - + } else { + downloadDirectory = context.getExternalFilesDirs(null)[0]; } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java new file mode 100644 index 00000000..f513f990 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java @@ -0,0 +1,176 @@ +package com.cappielloantonio.tempo.util; + +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.webkit.MimeTypeMap; +import android.widget.Toast; + +import androidx.core.app.NotificationCompat; +import androidx.documentfile.provider.DocumentFile; +import androidx.media3.common.MediaItem; + +import com.cappielloantonio.tempo.util.Preferences; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.text.Normalizer; +import java.util.Locale; + +public class ExternalAudioWriter { + + private static String sanitizeFileName(String name) { + String sanitized = name.replaceAll("[\\/:*?\\\"<>|]", "_"); + sanitized = sanitized.replaceAll("\\s+", " ").trim(); + return sanitized; + } + + private static String normalizeForComparison(String name) { + String s = sanitizeFileName(name); + s = Normalizer.normalize(s, Normalizer.Form.NFKD); + s = s.replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); + return s.toLowerCase(Locale.ROOT); + } + + private static DocumentFile findFile(DocumentFile dir, String fileName) { + String normalized = normalizeForComparison(fileName); + for (DocumentFile file : dir.listFiles()) { + String existing = file.getName(); + if (existing != null && normalizeForComparison(existing).equals(normalized)) { + return file; + } + } + return null; + } + + public static void downloadToUserDirectory(Context context, MediaItem mediaItem, String fallbackName) { + new Thread(() -> { + String uriString = Preferences.getDownloadDirectoryUri(); + + if (uriString == null) { + notifyUnavailable(context); + return; + } + + Uri treeUri = Uri.parse(uriString); + DocumentFile directory = DocumentFile.fromTreeUri(context, treeUri); + if (directory == null || !directory.canWrite()) { + notifyFailure(context, "Cannot write to folder."); + return; + } + + try { + Uri mediaUri = mediaItem.requestMetadata.mediaUri; + if (mediaUri == null) { + notifyFailure(context, "Invalid media URI."); + return; + } + + HttpURLConnection connection = (HttpURLConnection) new URL(mediaUri.toString()).openConnection(); + connection.connect(); + + String mimeType = connection.getContentType(); + if (mimeType == null) mimeType = "application/octet-stream"; + + String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); + if (extension == null) extension = "bin"; + + String artist = mediaItem.mediaMetadata.artist != null ? mediaItem.mediaMetadata.artist.toString() : ""; + String title = mediaItem.mediaMetadata.title != null ? mediaItem.mediaMetadata.title.toString() : fallbackName; + String album = mediaItem.mediaMetadata.albumTitle != null ? mediaItem.mediaMetadata.albumTitle.toString() : ""; + String name = artist.isEmpty() ? title : artist + " - " + title; + if (!album.isEmpty()) name += " (" + album + ")"; + + String sanitized = sanitizeFileName(name); + String fullName = sanitized + "." + extension; + + DocumentFile existingFile = findFile(directory, fullName); + long remoteLength = connection.getContentLengthLong(); + if (existingFile != null && existingFile.exists()) { + long localLength = existingFile.length(); + if (remoteLength > 0 && localLength == remoteLength) { + notifyExists(context, fullName); + return; + } else { + existingFile.delete(); + } + } + + DocumentFile targetFile = directory.createFile(mimeType, fullName); + if (targetFile == null) { + notifyFailure(context, "Failed to create file."); + return; + } + + try (InputStream in = connection.getInputStream(); + OutputStream out = context.getContentResolver().openOutputStream(targetFile.getUri())) { + if (out == null) { + notifyFailure(context, "Cannot open output stream."); + return; + } + + byte[] buffer = new byte[8192]; + int len; + long total = 0; + while ((len = in.read(buffer)) != -1) { + out.write(buffer, 0, len); + total += len; + } + + if (remoteLength > 0 && total != remoteLength) { + targetFile.delete(); + notifyFailure(context, "Incomplete download."); + } else { + notifySuccess(context, fullName); + } + } + } catch (Exception e) { + notifyFailure(context, e.getMessage()); + } + }).start(); + } + + private static void notifyUnavailable(Context context) { + NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + Intent settingsIntent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.getPackageName(), null)); + PendingIntent openSettings = PendingIntent.getActivity(context, 0, settingsIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID) + .setContentTitle("No download folder set") + .setContentText("Tap to set one in settings") + .setSmallIcon(android.R.drawable.stat_notify_error) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setSilent(true) + .setContentIntent(openSettings) + .setAutoCancel(true); + + manager.notify(1011, builder.build()); + } + + private static void notifyFailure(Context context, String message) { + new Handler(Looper.getMainLooper()).post(() -> + Toast.makeText(context, "External download failed: " + message, Toast.LENGTH_LONG).show() + ); + } + + private static void notifySuccess(Context context, String name) { + new Handler(Looper.getMainLooper()).post(() -> + Toast.makeText(context, "Download success: " + name, Toast.LENGTH_SHORT).show() + ); + } + + private static void notifyExists(Context context, String name) { + new Handler(Looper.getMainLooper()).post(() -> + Toast.makeText(context, "Already exists: " + name, Toast.LENGTH_SHORT).show() + ); + } +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt index 80276319..3ff43cf8 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt @@ -52,6 +52,7 @@ object Preferences { private const val AUDIO_TRANSCODE_PRIORITY = "audio_transcode_priority" private const val STREAMING_CACHE_STORAGE = "streaming_cache_storage" private const val DOWNLOAD_STORAGE = "download_storage" + private const val DOWNLOAD_DIRECTORY_URI = "download_directory_uri" private const val DEFAULT_DOWNLOAD_VIEW_TYPE = "default_download_view_type" private const val AUDIO_TRANSCODE_DOWNLOAD = "audio_transcode_download" private const val AUDIO_TRANSCODE_DOWNLOAD_PRIORITY = "audio_transcode_download_priority" @@ -452,6 +453,16 @@ object Preferences { ).apply() } + @JvmStatic + fun getDownloadDirectoryUri(): String? { + return App.getInstance().preferences.getString(DOWNLOAD_DIRECTORY_URI, null) + } + + @JvmStatic + fun setDownloadDirectoryUri(uri: String?) { + App.getInstance().preferences.edit().putString(DOWNLOAD_DIRECTORY_URI, uri).apply() + } + @JvmStatic fun getDefaultDownloadViewType(): String { return App.getInstance().preferences.getString( diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java index 6f972add..2a100fbf 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java @@ -134,7 +134,7 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel { media.setStarred(new Date()); - if (Preferences.isStarredSyncEnabled()) { + if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) { DownloadUtil.getDownloadTracker(context).download( MappingUtil.mapDownload(media), new Download(media) diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java index 79f1655b..de379dcd 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java @@ -109,7 +109,7 @@ public class SongBottomSheetViewModel extends AndroidViewModel { media.setStarred(new Date()); - if (Preferences.isStarredSyncEnabled()) { + if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) { DownloadUtil.getDownloadTracker(context).download( MappingUtil.mapDownload(media), new Download(media) diff --git a/app/src/main/res/drawable/ic_folder.xml b/app/src/main/res/drawable/ic_folder.xml new file mode 100644 index 00000000..2c6f69f5 --- /dev/null +++ b/app/src/main/res/drawable/ic_folder.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/menu/download_popup_menu.xml b/app/src/main/res/menu/download_popup_menu.xml index 16e0b537..6607d22a 100644 --- a/app/src/main/res/menu/download_popup_menu.xml +++ b/app/src/main/res/menu/download_popup_menu.xml @@ -16,4 +16,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7b461a67..6800d141 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -68,6 +68,7 @@ Download All tracks in this folder will be downloaded. Tracks present in subfolders will not be downloaded. Download the tracks + Set where music is downloaded Once you download a song, you\'ll find it here No downloads yet! %1$s • %2$s items @@ -78,6 +79,7 @@ Select storage option External Internal + Directory Downloads Add to queue Play next diff --git a/app/src/main/res/xml/global_preferences.xml b/app/src/main/res/xml/global_preferences.xml index f240a304..a681f758 100644 --- a/app/src/main/res/xml/global_preferences.xml +++ b/app/src/main/res/xml/global_preferences.xml @@ -180,6 +180,14 @@ android:key="download_storage" app:title="@string/settings_download_storage_title" /> + + Date: Mon, 15 Sep 2025 23:24:20 +0930 Subject: [PATCH 2/6] feat: Load media downloaded as file for offline use --- .../ui/adapter/SongHorizontalAdapter.java | 15 ++- .../dialog/DeleteDownloadStorageDialog.java | 15 +++ .../tempo/ui/fragment/SettingsFragment.java | 2 + .../DownloadedBottomSheetDialog.java | 13 ++- .../SongBottomSheetDialog.java | 30 ++++-- .../tempo/util/ExternalAudioReader.java | 102 ++++++++++++++++++ .../tempo/util/MappingUtil.java | 8 ++ 7 files changed, 170 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java 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 cb10ab4e..dd26443a 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 @@ -24,6 +24,7 @@ import com.cappielloantonio.tempo.subsonic.models.Child; 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.MusicUtil; import com.cappielloantonio.tempo.util.Preferences; import com.google.common.util.concurrent.ListenableFuture; @@ -135,10 +136,18 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter { DownloadUtil.getDownloadTracker(requireContext()).removeAll(); + String uriString = Preferences.getDownloadDirectoryUri(); + if (uriString != null) { + DocumentFile directory = DocumentFile.fromTreeUri(requireContext(), Uri.parse(uriString)); + if (directory != null && directory.canWrite()) { + for (DocumentFile file : directory.listFiles()) { + file.delete(); + } + } + ExternalAudioReader.refreshCache(); + } dialog.dismiss(); }); diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java index 724e77d0..963e7753 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java @@ -49,6 +49,7 @@ import com.cappielloantonio.tempo.ui.dialog.StreamingCacheStorageDialog; import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.UIUtil; +import com.cappielloantonio.tempo.util.ExternalAudioReader; import com.cappielloantonio.tempo.viewmodel.SettingViewModel; import java.util.Locale; @@ -90,6 +91,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { ); Preferences.setDownloadDirectoryUri(uri.toString()); + ExternalAudioReader.refreshCache(); Toast.makeText(requireContext(), "Download folder set.", Toast.LENGTH_SHORT).show(); checkDownloadDirectory(); } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/DownloadedBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/DownloadedBottomSheetDialog.java index ee339342..4a720640 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/DownloadedBottomSheetDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/DownloadedBottomSheetDialog.java @@ -25,6 +25,8 @@ import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.MusicUtil; +import com.cappielloantonio.tempo.util.ExternalAudioReader; +import com.cappielloantonio.tempo.util.Preferences; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import com.google.common.util.concurrent.ListenableFuture; @@ -117,10 +119,13 @@ public class DownloadedBottomSheetDialog extends BottomSheetDialogFragment imple TextView removeAll = view.findViewById(R.id.remove_all_text_view); removeAll.setOnClickListener(v -> { - List mediaItems = MappingUtil.mapDownloads(songs); - List downloads = songs.stream().map(Download::new).collect(Collectors.toList()); - - DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads); + if (Preferences.getDownloadDirectoryUri() == null) { + List mediaItems = MappingUtil.mapDownloads(songs); + List downloads = songs.stream().map(Download::new).collect(Collectors.toList()); + DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads); + } else { + songs.forEach(ExternalAudioReader::delete); + } dismissBottomSheet(); }); 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 8cb3369f..612a3696 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 @@ -31,6 +31,7 @@ import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog; import com.cappielloantonio.tempo.ui.dialog.RatingDialog; 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; @@ -178,10 +179,14 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements TextView remove = view.findViewById(R.id.remove_text_view); remove.setOnClickListener(v -> { - DownloadUtil.getDownloadTracker(requireContext()).remove( - MappingUtil.mapDownload(song), - new Download(song) - ); + if (Preferences.getDownloadDirectoryUri() == null) { + DownloadUtil.getDownloadTracker(requireContext()).remove( + MappingUtil.mapDownload(song), + new Download(song) + ); + } else { + ExternalAudioReader.delete(song); + } dismissBottomSheet(); }); @@ -254,11 +259,20 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements } private void initDownloadUI(TextView download, TextView remove) { - if (DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId())) { - remove.setVisibility(View.VISIBLE); + if (Preferences.getDownloadDirectoryUri() == null) { + if (DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId())) { + remove.setVisibility(View.VISIBLE); + } else { + download.setVisibility(View.VISIBLE); + remove.setVisibility(View.GONE); + } } else { - download.setVisibility(View.VISIBLE); - remove.setVisibility(View.GONE); + if (ExternalAudioReader.getUri(song) != null) { + remove.setVisibility(View.VISIBLE); + } else { + download.setVisibility(View.VISIBLE); + remove.setVisibility(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 new file mode 100644 index 00000000..dd298377 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java @@ -0,0 +1,102 @@ +package com.cappielloantonio.tempo.util; + +import android.net.Uri; + +import androidx.documentfile.provider.DocumentFile; + +import com.cappielloantonio.tempo.App; +import com.cappielloantonio.tempo.subsonic.models.Child; +import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode; + +import java.text.Normalizer; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +public class ExternalAudioReader { + + private static final Map cache = new HashMap<>(); + private static String cachedDirUri; + + private static String sanitizeFileName(String name) { + String sanitized = name.replaceAll("[\\/:*?\\\"<>|]", "_"); + sanitized = sanitized.replaceAll("\\s+", " ").trim(); + return sanitized; + } + + private static String normalizeForComparison(String name) { + String s = sanitizeFileName(name); + s = Normalizer.normalize(s, Normalizer.Form.NFKD); + s = s.replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); + return s.toLowerCase(Locale.ROOT); + } + + private static synchronized void ensureCache() { + String uriString = Preferences.getDownloadDirectoryUri(); + if (uriString == null) { + cache.clear(); + cachedDirUri = null; + return; + } + + if (uriString.equals(cachedDirUri)) return; + + cache.clear(); + DocumentFile directory = DocumentFile.fromTreeUri(App.getContext(), Uri.parse(uriString)); + if (directory != null && directory.canRead()) { + for (DocumentFile file : directory.listFiles()) { + String existing = file.getName(); + if (existing != null) { + String base = existing.replaceFirst("\\.[^\\.]+$", ""); + cache.put(normalizeForComparison(base), file); + } + } + } + + cachedDirUri = uriString; + } + + /** Rebuilds the cache on next access. */ + public static synchronized void refreshCache() { + cachedDirUri = null; + cache.clear(); + } + + private static String buildKey(String artist, String title, String album) { + String name = artist != null && !artist.isEmpty() ? artist + " - " + title : title; + if (album != null && !album.isEmpty()) name += " (" + album + ")"; + return normalizeForComparison(name); + } + + private static Uri findUri(String artist, String title, String album) { + ensureCache(); + if (cachedDirUri == null) return null; + + DocumentFile file = cache.get(buildKey(artist, title, album)); + return file != null && file.exists() ? file.getUri() : null; + } + + public static Uri getUri(Child media) { + return findUri(media.getArtist(), media.getTitle(), media.getAlbum()); + } + + public static Uri getUri(PodcastEpisode episode) { + return findUri(episode.getArtist(), episode.getTitle(), episode.getAlbum()); + } + + public static boolean delete(Child media) { + ensureCache(); + if (cachedDirUri == null) return false; + + String key = buildKey(media.getArtist(), media.getTitle(), media.getAlbum()); + DocumentFile file = cache.get(key); + boolean deleted = false; + if (file != null && file.exists()) { + deleted = file.delete(); + } + if (deleted) { + cache.remove(key); + } + return deleted; + } +} \ No newline at end of file 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 f8f15f07..3f32ea44 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java @@ -217,12 +217,20 @@ public class MappingUtil { } private static Uri getUri(Child media) { + if (Preferences.getDownloadDirectoryUri() != null) { + Uri local = ExternalAudioReader.getUri(media); + return local != null ? local : MusicUtil.getStreamUri(media.getId()); + } return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(media.getId()) ? getDownloadUri(media.getId()) : MusicUtil.getStreamUri(media.getId()); } private static Uri getUri(PodcastEpisode podcastEpisode) { + if (Preferences.getDownloadDirectoryUri() != null) { + Uri local = ExternalAudioReader.getUri(podcastEpisode); + return local != null ? local : MusicUtil.getStreamUri(podcastEpisode.getStreamId()); + } return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(podcastEpisode.getStreamId()) ? getDownloadUri(podcastEpisode.getStreamId()) : MusicUtil.getStreamUri(podcastEpisode.getStreamId()); From 24864637f937c09aeb49cf6399cd63f6fa9a6475 Mon Sep 17 00:00:00 2001 From: le-firehawk Date: Tue, 16 Sep 2025 11:20:48 +0930 Subject: [PATCH 3/6] feat: Hook external audio write into file cache from external audio reader, fix download notifications --- .../tempo/util/ExternalAudioWriter.java | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) 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 f513f990..c5735dc1 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java @@ -5,17 +5,15 @@ import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.net.Uri; -import android.os.Handler; -import android.os.Looper; import android.provider.Settings; import android.webkit.MimeTypeMap; -import android.widget.Toast; import androidx.core.app.NotificationCompat; import androidx.documentfile.provider.DocumentFile; import androidx.media3.common.MediaItem; import com.cappielloantonio.tempo.util.Preferences; +import com.cappielloantonio.tempo.util.ExternalAudioReader; import java.io.InputStream; import java.io.OutputStream; @@ -73,6 +71,12 @@ public class ExternalAudioWriter { return; } + String scheme = mediaUri.getScheme(); + if (scheme == null || (!scheme.equals("http") && !scheme.equals("https"))) { + notifyExists(context, fallbackName); + return; + } + HttpURLConnection connection = (HttpURLConnection) new URL(mediaUri.toString()).openConnection(); connection.connect(); @@ -129,6 +133,7 @@ public class ExternalAudioWriter { notifyFailure(context, "Incomplete download."); } else { notifySuccess(context, fullName); + ExternalAudioReader.refreshCache(); } } } catch (Exception e) { @@ -157,20 +162,32 @@ public class ExternalAudioWriter { } private static void notifyFailure(Context context, String message) { - new Handler(Looper.getMainLooper()).post(() -> - Toast.makeText(context, "External download failed: " + message, Toast.LENGTH_LONG).show() - ); + NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID) + .setContentTitle("Download failed") + .setContentText(message) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setAutoCancel(true); + manager.notify((int) System.currentTimeMillis(), builder.build()); } private static void notifySuccess(Context context, String name) { - new Handler(Looper.getMainLooper()).post(() -> - Toast.makeText(context, "Download success: " + name, Toast.LENGTH_SHORT).show() - ); + NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID) + .setContentTitle("Download complete") + .setContentText(name) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setAutoCancel(true); + manager.notify((int) System.currentTimeMillis(), builder.build()); } private static void notifyExists(Context context, String name) { - new Handler(Looper.getMainLooper()).post(() -> - Toast.makeText(context, "Already exists: " + name, Toast.LENGTH_SHORT).show() - ); + NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID) + .setContentTitle("Already downloaded") + .setContentText(name) + .setSmallIcon(android.R.drawable.stat_sys_warning) + .setAutoCancel(true); + manager.notify((int) System.currentTimeMillis(), builder.build()); } } From 682f63ef385a44c8026d2a9865052f6ab992e479 Mon Sep 17 00:00:00 2001 From: le-firehawk Date: Tue, 16 Sep 2025 23:22:18 +0930 Subject: [PATCH 4/6] feat: Add metadata caching and proper integration for external media files --- .../tempo/service/MediaManager.java | 19 ++ .../tempo/ui/activity/MainActivity.java | 82 +++++ .../dialog/DeleteDownloadStorageDialog.java | 7 +- .../dialog/DownloadDirectoryPickerDialog.java | 2 + .../tempo/ui/fragment/AlbumPageFragment.java | 5 +- .../tempo/ui/fragment/DirectoryFragment.java | 5 +- .../tempo/ui/fragment/DownloadFragment.java | 2 + .../ui/fragment/PlayerCoverFragment.java | 4 +- .../ui/fragment/PlaylistPageFragment.java | 19 +- .../tempo/ui/fragment/SettingsFragment.java | 1 + .../AlbumBottomSheetDialog.java | 23 +- .../SongBottomSheetDialog.java | 4 +- .../cappielloantonio/tempo/util/Constants.kt | 7 + .../tempo/util/ExternalAudioReader.java | 35 +- .../tempo/util/ExternalAudioWriter.java | 309 ++++++++++++------ .../util/ExternalDownloadMetadataStore.java | 123 +++++++ .../tempo/util/Preferences.kt | 4 + 17 files changed, 515 insertions(+), 136 deletions(-) create mode 100644 app/src/main/java/com/cappielloantonio/tempo/util/ExternalDownloadMetadataStore.java diff --git a/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java b/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java index 6c99c2b7..f7cd8a38 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java +++ b/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java @@ -223,6 +223,25 @@ public class MediaManager { } } + public static void playDownloadedMediaItem(ListenableFuture mediaBrowserListenableFuture, MediaItem mediaItem) { + if (mediaBrowserListenableFuture != null && mediaItem != null) { + mediaBrowserListenableFuture.addListener(() -> { + try { + if (mediaBrowserListenableFuture.isDone()) { + MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get(); + mediaBrowser.clearMediaItems(); + mediaBrowser.setMediaItem(mediaItem); + mediaBrowser.prepare(); + mediaBrowser.play(); + clearDatabase(); + } + } catch (ExecutionException | InterruptedException e) { + e.printStackTrace(); + } + }, MoreExecutors.directExecutor()); + } + } + public static void startRadio(ListenableFuture mediaBrowserListenableFuture, InternetRadioStation internetRadioStation) { if (mediaBrowserListenableFuture != null) { mediaBrowserListenableFuture.addListener(() -> { diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java index c9d3bd41..db76b98d 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java @@ -1,11 +1,14 @@ package com.cappielloantonio.tempo.ui.activity; import android.content.Context; +import android.content.Intent; import android.content.IntentFilter; import android.net.ConnectivityManager; import android.net.NetworkInfo; +import android.net.Uri; import android.os.Bundle; import android.os.Handler; +import android.text.TextUtils; import android.util.Log; import android.view.View; @@ -13,7 +16,10 @@ import androidx.annotation.NonNull; import androidx.core.splashscreen.SplashScreen; import androidx.fragment.app.FragmentManager; import androidx.lifecycle.ViewModelProvider; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MediaMetadata; import androidx.media3.common.Player; +import androidx.media3.common.MimeTypes; import androidx.media3.common.util.UnstableApi; import androidx.navigation.NavController; import androidx.navigation.fragment.NavHostFragment; @@ -56,6 +62,7 @@ public class MainActivity extends BaseActivity { private BottomSheetBehavior bottomSheetBehavior; ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver; + private Intent pendingDownloadPlaybackIntent; @Override protected void onCreate(Bundle savedInstanceState) { @@ -77,6 +84,8 @@ public class MainActivity extends BaseActivity { checkConnectionType(); getOpenSubsonicExtensions(); checkTempoUpdate(); + + maybeSchedulePlaybackIntent(getIntent()); } @Override @@ -84,6 +93,7 @@ public class MainActivity extends BaseActivity { super.onStart(); pingServer(); initService(); + consumePendingPlaybackIntent(); } @Override @@ -99,6 +109,14 @@ public class MainActivity extends BaseActivity { bind = null; } + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + maybeSchedulePlaybackIntent(intent); + consumePendingPlaybackIntent(); + } + @Override public void onBackPressed() { if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) @@ -418,4 +436,68 @@ public class MainActivity extends BaseActivity { } } } + + private void maybeSchedulePlaybackIntent(Intent intent) { + if (intent == null) return; + if (Constants.ACTION_PLAY_EXTERNAL_DOWNLOAD.equals(intent.getAction()) + || intent.hasExtra(Constants.EXTRA_DOWNLOAD_URI)) { + pendingDownloadPlaybackIntent = new Intent(intent); + } + } + + private void consumePendingPlaybackIntent() { + if (pendingDownloadPlaybackIntent == null) return; + Intent intent = pendingDownloadPlaybackIntent; + pendingDownloadPlaybackIntent = null; + playDownloadedMedia(intent); + } + + private void playDownloadedMedia(Intent intent) { + String uriString = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_URI); + if (TextUtils.isEmpty(uriString)) { + return; + } + + Uri uri = Uri.parse(uriString); + String mediaId = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_MEDIA_ID); + if (TextUtils.isEmpty(mediaId)) { + mediaId = uri.toString(); + } + + String title = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_TITLE); + String artist = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_ARTIST); + String album = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_ALBUM); + int duration = intent.getIntExtra(Constants.EXTRA_DOWNLOAD_DURATION, 0); + + Bundle extras = new Bundle(); + extras.putString("id", mediaId); + extras.putString("title", title); + extras.putString("artist", artist); + extras.putString("album", album); + extras.putString("uri", uri.toString()); + extras.putString("type", Constants.MEDIA_TYPE_MUSIC); + extras.putInt("duration", duration); + + MediaMetadata.Builder metadataBuilder = new MediaMetadata.Builder() + .setExtras(extras) + .setIsBrowsable(false) + .setIsPlayable(true); + + if (!TextUtils.isEmpty(title)) metadataBuilder.setTitle(title); + if (!TextUtils.isEmpty(artist)) metadataBuilder.setArtist(artist); + if (!TextUtils.isEmpty(album)) metadataBuilder.setAlbumTitle(album); + + MediaItem mediaItem = new MediaItem.Builder() + .setMediaId(mediaId) + .setMediaMetadata(metadataBuilder.build()) + .setUri(uri) + .setMimeType(MimeTypes.BASE_TYPE_AUDIO) + .setRequestMetadata(new MediaItem.RequestMetadata.Builder() + .setMediaUri(uri) + .setExtras(extras) + .build()) + .build(); + + MediaManager.playDownloadedMediaItem(getMediaBrowserListenableFuture(), mediaItem); + } } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DeleteDownloadStorageDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DeleteDownloadStorageDialog.java index 7b01bff1..877831f1 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DeleteDownloadStorageDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DeleteDownloadStorageDialog.java @@ -16,6 +16,7 @@ import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.databinding.DialogDeleteDownloadStorageBinding; import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.ExternalAudioReader; +import com.cappielloantonio.tempo.util.ExternalDownloadMetadataStore; import com.cappielloantonio.tempo.util.Preferences; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -47,7 +48,10 @@ public class DeleteDownloadStorageDialog extends DialogFragment { if (dialog != null) { Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE); positiveButton.setOnClickListener(v -> { - DownloadUtil.getDownloadTracker(requireContext()).removeAll(); + if (Preferences.getDownloadDirectoryUri() == null) { + DownloadUtil.getDownloadTracker(requireContext()).removeAll(); + } + String uriString = Preferences.getDownloadDirectoryUri(); if (uriString != null) { DocumentFile directory = DocumentFile.fromTreeUri(requireContext(), Uri.parse(uriString)); @@ -57,6 +61,7 @@ public class DeleteDownloadStorageDialog extends DialogFragment { } } ExternalAudioReader.refreshCache(); + ExternalDownloadMetadataStore.clear(); } dialog.dismiss(); }); diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadDirectoryPickerDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadDirectoryPickerDialog.java index 33311280..62dcd404 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadDirectoryPickerDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadDirectoryPickerDialog.java @@ -13,6 +13,7 @@ import androidx.fragment.app.DialogFragment; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.cappielloantonio.tempo.R; +import com.cappielloantonio.tempo.util.ExternalAudioReader; import com.cappielloantonio.tempo.util.Preferences; public class DownloadDirectoryPickerDialog extends DialogFragment { @@ -37,6 +38,7 @@ public class DownloadDirectoryPickerDialog extends DialogFragment { ); Preferences.setDownloadDirectoryUri(uri.toString()); + ExternalAudioReader.refreshCache(); Toast.makeText(requireContext(), "Download directory set:\n" + uri.toString(), Toast.LENGTH_LONG).show(); } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java index 2897fb3c..1da90b5c 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java @@ -138,10 +138,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback { songs.stream().map(Download::new).collect(Collectors.toList()) ); } else { - MappingUtil.mapMediaItems(songs).forEach(media -> { - String title = media.mediaMetadata.title != null ? media.mediaMetadata.title.toString() : media.mediaId; - ExternalAudioWriter.downloadToUserDirectory(requireContext(), media, title); - }); + songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child)); } }); return true; diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DirectoryFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DirectoryFragment.java index eeed14ce..104d79f8 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DirectoryFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DirectoryFragment.java @@ -117,10 +117,7 @@ public class DirectoryFragment extends Fragment implements ClickCallback { songs.stream().map(Download::new).collect(Collectors.toList()) ); } else { - MappingUtil.mapMediaItems(songs).forEach(media -> { - String title = media.mediaMetadata.title != null ? media.mediaMetadata.title.toString() : media.mediaId; - ExternalAudioWriter.downloadToUserDirectory(requireContext(), media, title); - }); + songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child)); } } }); diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java index da6e265e..7d4a076a 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java @@ -28,6 +28,7 @@ import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.adapter.DownloadHorizontalAdapter; import com.cappielloantonio.tempo.util.Constants; +import com.cappielloantonio.tempo.util.ExternalAudioReader; import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.viewmodel.DownloadViewModel; import com.google.android.material.appbar.MaterialToolbar; @@ -289,6 +290,7 @@ public class DownloadFragment extends Fragment implements ClickCallback { Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION ); Preferences.setDownloadDirectoryUri(uri.toString()); + ExternalAudioReader.refreshCache(); Toast.makeText(requireContext(), "Download directory set", Toast.LENGTH_SHORT).show(); } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerCoverFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerCoverFragment.java index 854bbe3b..2d73847e 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerCoverFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerCoverFragment.java @@ -123,9 +123,7 @@ public class PlayerCoverFragment extends Fragment { new Download(song) ); } else { - MediaItem item = MappingUtil.mapMediaItem(song); - String title = item.mediaMetadata.title != null ? item.mediaMetadata.title.toString() : item.mediaId; - ExternalAudioWriter.downloadToUserDirectory(requireContext(), item, title); + ExternalAudioWriter.downloadToUserDirectory(requireContext(), song); } }); diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java index ec6dcbbb..daebc809 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java @@ -144,19 +144,16 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback { if (isVisible() && getActivity() != null) { if (Preferences.getDownloadDirectoryUri() == null) { DownloadUtil.getDownloadTracker(requireContext()).download( - MappingUtil.mapDownloads(songs), - songs.stream().map(child -> { - Download toDownload = new Download(child); - toDownload.setPlaylistId(playlistPageViewModel.getPlaylist().getId()); - toDownload.setPlaylistName(playlistPageViewModel.getPlaylist().getName()); - return toDownload; - }).collect(Collectors.toList()) + MappingUtil.mapDownloads(songs), + songs.stream().map(child -> { + Download toDownload = new Download(child); + toDownload.setPlaylistId(playlistPageViewModel.getPlaylist().getId()); + toDownload.setPlaylistName(playlistPageViewModel.getPlaylist().getName()); + return toDownload; + }).collect(Collectors.toList()) ); } else { - MappingUtil.mapMediaItems(songs).forEach(media -> { - String title = media.mediaMetadata.title != null ? media.mediaMetadata.title.toString() : media.mediaId; - ExternalAudioWriter.downloadToUserDirectory(requireContext(), media, title); - }); + songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child)); } } }); diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java index 963e7753..60d2ae26 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java @@ -428,6 +428,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { if (current != null) { Preferences.setDownloadDirectoryUri(null); Preferences.setDownloadStoragePreference(0); + ExternalAudioReader.refreshCache(); Toast.makeText(requireContext(), "Download folder cleared.", Toast.LENGTH_SHORT).show(); checkStorage(); checkDownloadDirectory(); diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java index bb778b95..04ed932b 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java @@ -38,6 +38,7 @@ import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.ExternalAudioWriter; +import com.cappielloantonio.tempo.util.ExternalAudioReader; import com.cappielloantonio.tempo.viewmodel.AlbumBottomSheetViewModel; import com.cappielloantonio.tempo.viewmodel.HomeViewModel; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; @@ -167,10 +168,7 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements if (Preferences.getDownloadDirectoryUri() == null) { DownloadUtil.getDownloadTracker(requireContext()).download(mediaItems, downloads); } else { - MappingUtil.mapMediaItems(songs).forEach(media -> { - String title = media.mediaMetadata.title != null ? media.mediaMetadata.title.toString() : media.mediaId; - ExternalAudioWriter.downloadToUserDirectory(requireContext(), media, title); - }); + songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child)); } dismissBottomSheet(); }); @@ -196,7 +194,11 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements List downloads = songs.stream().map(Download::new).collect(Collectors.toList()); removeAll.setOnClickListener(v -> { - DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads); + if (Preferences.getDownloadDirectoryUri() == null) { + DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads); + } else { + songs.forEach(ExternalAudioReader::delete); + } dismissBottomSheet(); }); }); @@ -246,8 +248,15 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> { List mediaItems = MappingUtil.mapDownloads(songs); - if (Preferences.getDownloadDirectoryUri() == null && DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) { - removeAll.setVisibility(View.VISIBLE); + if (Preferences.getDownloadDirectoryUri() == null) { + if (DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) { + removeAll.setVisibility(View.VISIBLE); + } else { + removeAll.setVisibility(View.GONE); + } + } else { + boolean hasLocal = songs.stream().anyMatch(song -> ExternalAudioReader.getUri(song) != null); + removeAll.setVisibility(hasLocal ? View.VISIBLE : View.GONE); } }); } 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 612a3696..f04fabee 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 @@ -164,15 +164,13 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements TextView download = view.findViewById(R.id.download_text_view); download.setOnClickListener(v -> { - MediaItem item = MappingUtil.mapMediaItem(song); - String title = item.mediaMetadata.title != null ? item.mediaMetadata.title.toString() : item.mediaId; if (Preferences.getDownloadDirectoryUri() == null) { DownloadUtil.getDownloadTracker(requireContext()).download( MappingUtil.mapDownload(song), new Download(song) ); } else { - ExternalAudioWriter.downloadToUserDirectory(requireContext(), item, title); + ExternalAudioWriter.downloadToUserDirectory(requireContext(), song); } dismissBottomSheet(); }); diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt index da8862df..99a3e4b2 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt @@ -85,6 +85,13 @@ object Constants { const val MEDIA_LEAST_RECENTLY_STARRED = "MEDIA_LEAST_RECENTLY_STARRED" const val DOWNLOAD_URI = "rest/download" + const val ACTION_PLAY_EXTERNAL_DOWNLOAD = "com.cappielloantonio.tempo.action.PLAY_EXTERNAL_DOWNLOAD" + const val EXTRA_DOWNLOAD_URI = "EXTRA_DOWNLOAD_URI" + const val EXTRA_DOWNLOAD_MEDIA_ID = "EXTRA_DOWNLOAD_MEDIA_ID" + const val EXTRA_DOWNLOAD_TITLE = "EXTRA_DOWNLOAD_TITLE" + const val EXTRA_DOWNLOAD_ARTIST = "EXTRA_DOWNLOAD_ARTIST" + const val EXTRA_DOWNLOAD_ALBUM = "EXTRA_DOWNLOAD_ALBUM" + const val EXTRA_DOWNLOAD_DURATION = "EXTRA_DOWNLOAD_DURATION" const val DOWNLOAD_TYPE_TRACK = "download_type_track" const val DOWNLOAD_TYPE_ALBUM = "download_type_album" 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 dd298377..42792e31 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java @@ -10,8 +10,10 @@ import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode; import java.text.Normalizer; import java.util.HashMap; +import java.util.HashSet; import java.util.Locale; import java.util.Map; +import java.util.Set; public class ExternalAudioReader { @@ -36,6 +38,7 @@ public class ExternalAudioReader { if (uriString == null) { cache.clear(); cachedDirUri = null; + ExternalDownloadMetadataStore.clear(); return; } @@ -43,12 +46,36 @@ public class ExternalAudioReader { 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) { - String base = existing.replaceFirst("\\.[^\\.]+$", ""); - cache.put(normalizeForComparison(base), file); + 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 (!expectedSizes.isEmpty()) { + if (verifiedKeys.isEmpty()) { + ExternalDownloadMetadataStore.clear(); + } else { + for (String key : expectedSizes.keySet()) { + if (!verifiedKeys.contains(key)) { + ExternalDownloadMetadataStore.remove(key); + } } } } @@ -56,7 +83,6 @@ public class ExternalAudioReader { cachedDirUri = uriString; } - /** Rebuilds the cache on next access. */ public static synchronized void refreshCache() { cachedDirUri = null; cache.clear(); @@ -96,6 +122,7 @@ public class ExternalAudioReader { } if (deleted) { cache.remove(key); + ExternalDownloadMetadataStore.remove(key); } return deleted; } 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 c5735dc1..84708546 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java @@ -12,8 +12,8 @@ import androidx.core.app.NotificationCompat; import androidx.documentfile.provider.DocumentFile; import androidx.media3.common.MediaItem; -import com.cappielloantonio.tempo.util.Preferences; -import com.cappielloantonio.tempo.util.ExternalAudioReader; +import com.cappielloantonio.tempo.subsonic.models.Child; +import com.cappielloantonio.tempo.ui.activity.MainActivity; import java.io.InputStream; import java.io.OutputStream; @@ -21,9 +21,19 @@ import java.net.HttpURLConnection; import java.net.URL; import java.text.Normalizer; import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; public class ExternalAudioWriter { + private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor(); + private static final int BUFFER_SIZE = 8192; + private static final int CONNECT_TIMEOUT_MS = 15_000; + private static final int READ_TIMEOUT_MS = 60_000; + + private ExternalAudioWriter() { + } + private static String sanitizeFileName(String name) { String sanitized = name.replaceAll("[\\/:*?\\\"<>|]", "_"); sanitized = sanitized.replaceAll("\\s+", " ").trim(); @@ -40,6 +50,7 @@ public class ExternalAudioWriter { private static DocumentFile findFile(DocumentFile dir, String fileName) { String normalized = normalizeForComparison(fileName); for (DocumentFile file : dir.listFiles()) { + if (file.isDirectory()) continue; String existing = file.getName(); if (existing != null && normalizeForComparison(existing).equals(normalized)) { return file; @@ -48,115 +59,182 @@ public class ExternalAudioWriter { return null; } - public static void downloadToUserDirectory(Context context, MediaItem mediaItem, String fallbackName) { - new Thread(() -> { - String uriString = Preferences.getDownloadDirectoryUri(); + public static void downloadToUserDirectory(Context context, Child child) { + if (context == null || child == null) { + return; + } + Context appContext = context.getApplicationContext(); + MediaItem mediaItem = MappingUtil.mapDownload(child); + String fallbackName = child.getTitle() != null ? child.getTitle() : child.getId(); + EXECUTOR.execute(() -> performDownload(appContext, mediaItem, fallbackName, child)); + } - if (uriString == null) { - notifyUnavailable(context); + private static void performDownload(Context context, MediaItem mediaItem, String fallbackName, Child child) { + String uriString = Preferences.getDownloadDirectoryUri(); + if (uriString == null) { + notifyUnavailable(context); + return; + } + + DocumentFile directory = DocumentFile.fromTreeUri(context, Uri.parse(uriString)); + if (directory == null || !directory.canWrite()) { + notifyFailure(context, "Cannot write to folder."); + return; + } + + String artist = child.getArtist() != null ? child.getArtist() : ""; + String title = child.getTitle() != null ? child.getTitle() : fallbackName; + String album = child.getAlbum() != null ? child.getAlbum() : ""; + String baseName = artist.isEmpty() ? title : artist + " - " + title; + if (!album.isEmpty()) baseName += " (" + album + ")"; + if (baseName.isEmpty()) { + baseName = fallbackName != null ? fallbackName : "download"; + } + String metadataKey = normalizeForComparison(baseName); + + Uri mediaUri = mediaItem != null && mediaItem.requestMetadata != null + ? mediaItem.requestMetadata.mediaUri + : null; + if (mediaUri == null) { + notifyFailure(context, "Invalid media URI."); + ExternalDownloadMetadataStore.remove(metadataKey); + return; + } + String scheme = mediaUri.getScheme(); + if (scheme == null || (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https"))) { + notifyFailure(context, "Unsupported media URI."); + ExternalDownloadMetadataStore.remove(metadataKey); + return; + } + + HttpURLConnection connection = null; + DocumentFile targetFile = null; + try { + connection = (HttpURLConnection) new URL(mediaUri.toString()).openConnection(); + connection.setConnectTimeout(CONNECT_TIMEOUT_MS); + connection.setReadTimeout(READ_TIMEOUT_MS); + connection.setRequestProperty("Accept-Encoding", "identity"); + connection.connect(); + + int responseCode = connection.getResponseCode(); + if (responseCode >= HttpURLConnection.HTTP_BAD_REQUEST) { + notifyFailure(context, "Server returned " + responseCode); + ExternalDownloadMetadataStore.remove(metadataKey); return; } - Uri treeUri = Uri.parse(uriString); - DocumentFile directory = DocumentFile.fromTreeUri(context, treeUri); - if (directory == null || !directory.canWrite()) { - notifyFailure(context, "Cannot write to folder."); + String mimeType = connection.getContentType(); + if (mimeType == null || mimeType.isEmpty()) { + mimeType = "application/octet-stream"; + } + + String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); + if (extension == null || extension.isEmpty()) { + String suffix = child.getSuffix(); + if (suffix != null && !suffix.isEmpty()) { + extension = suffix; + } else { + extension = "bin"; + } + } + + String sanitized = sanitizeFileName(baseName); + if (sanitized.isEmpty()) sanitized = sanitizeFileName(fallbackName); + if (sanitized.isEmpty()) sanitized = "download"; + String fileName = sanitized + "." + extension; + + DocumentFile existingFile = findFile(directory, fileName); + long remoteLength = connection.getContentLengthLong(); + Long recordedSize = ExternalDownloadMetadataStore.getSize(metadataKey); + if (existingFile != null && existingFile.exists()) { + long localLength = existingFile.length(); + boolean matches = false; + if (remoteLength > 0 && localLength == remoteLength) { + matches = true; + } else if (remoteLength <= 0 && recordedSize != null && localLength == recordedSize) { + matches = true; + } + if (matches) { + ExternalDownloadMetadataStore.recordSize(metadataKey, localLength); + ExternalAudioReader.refreshCache(); + notifyExists(context, fileName); + return; + } else { + existingFile.delete(); + ExternalDownloadMetadataStore.remove(metadataKey); + } + } + + targetFile = directory.createFile(mimeType, fileName); + if (targetFile == null) { + notifyFailure(context, "Failed to create file."); return; } - try { - Uri mediaUri = mediaItem.requestMetadata.mediaUri; - if (mediaUri == null) { - notifyFailure(context, "Invalid media URI."); + Uri targetUri = targetFile.getUri(); + try (InputStream in = connection.getInputStream(); + OutputStream out = context.getContentResolver().openOutputStream(targetUri)) { + if (out == null) { + notifyFailure(context, "Cannot open output stream."); + targetFile.delete(); return; } - String scheme = mediaUri.getScheme(); - if (scheme == null || (!scheme.equals("http") && !scheme.equals("https"))) { - notifyExists(context, fallbackName); + byte[] buffer = new byte[BUFFER_SIZE]; + int len; + long total = 0; + while ((len = in.read(buffer)) != -1) { + out.write(buffer, 0, len); + total += len; + } + out.flush(); + + if (total <= 0) { + targetFile.delete(); + ExternalDownloadMetadataStore.remove(metadataKey); + notifyFailure(context, "Empty download."); return; } - HttpURLConnection connection = (HttpURLConnection) new URL(mediaUri.toString()).openConnection(); - connection.connect(); - - String mimeType = connection.getContentType(); - if (mimeType == null) mimeType = "application/octet-stream"; - - String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); - if (extension == null) extension = "bin"; - - String artist = mediaItem.mediaMetadata.artist != null ? mediaItem.mediaMetadata.artist.toString() : ""; - String title = mediaItem.mediaMetadata.title != null ? mediaItem.mediaMetadata.title.toString() : fallbackName; - String album = mediaItem.mediaMetadata.albumTitle != null ? mediaItem.mediaMetadata.albumTitle.toString() : ""; - String name = artist.isEmpty() ? title : artist + " - " + title; - if (!album.isEmpty()) name += " (" + album + ")"; - - String sanitized = sanitizeFileName(name); - String fullName = sanitized + "." + extension; - - DocumentFile existingFile = findFile(directory, fullName); - long remoteLength = connection.getContentLengthLong(); - if (existingFile != null && existingFile.exists()) { - long localLength = existingFile.length(); - if (remoteLength > 0 && localLength == remoteLength) { - notifyExists(context, fullName); - return; - } else { - existingFile.delete(); - } - } - - DocumentFile targetFile = directory.createFile(mimeType, fullName); - if (targetFile == null) { - notifyFailure(context, "Failed to create file."); + if (remoteLength > 0 && total != remoteLength) { + targetFile.delete(); + ExternalDownloadMetadataStore.remove(metadataKey); + notifyFailure(context, "Incomplete download."); return; } - try (InputStream in = connection.getInputStream(); - OutputStream out = context.getContentResolver().openOutputStream(targetFile.getUri())) { - if (out == null) { - notifyFailure(context, "Cannot open output stream."); - return; - } - - byte[] buffer = new byte[8192]; - int len; - long total = 0; - while ((len = in.read(buffer)) != -1) { - out.write(buffer, 0, len); - total += len; - } - - if (remoteLength > 0 && total != remoteLength) { - targetFile.delete(); - notifyFailure(context, "Incomplete download."); - } else { - notifySuccess(context, fullName); - ExternalAudioReader.refreshCache(); - } - } - } catch (Exception e) { - notifyFailure(context, e.getMessage()); + ExternalDownloadMetadataStore.recordSize(metadataKey, total); + notifySuccess(context, fileName, child, targetUri); + ExternalAudioReader.refreshCache(); } - }).start(); + } catch (Exception e) { + if (targetFile != null) { + targetFile.delete(); + } + ExternalDownloadMetadataStore.remove(metadataKey); + notifyFailure(context, e.getMessage() != null ? e.getMessage() : "Download failed"); + } finally { + if (connection != null) { + connection.disconnect(); + } + } } private static void notifyUnavailable(Context context) { NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); Intent settingsIntent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.fromParts("package", context.getPackageName(), null)); + Uri.fromParts("package", context.getPackageName(), null)); PendingIntent openSettings = PendingIntent.getActivity(context, 0, settingsIntent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID) - .setContentTitle("No download folder set") - .setContentText("Tap to set one in settings") - .setSmallIcon(android.R.drawable.stat_notify_error) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setSilent(true) - .setContentIntent(openSettings) - .setAutoCancel(true); + .setContentTitle("No download folder set") + .setContentText("Tap to set one in settings") + .setSmallIcon(android.R.drawable.stat_notify_error) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setSilent(true) + .setContentIntent(openSettings) + .setAutoCancel(true); manager.notify(1011, builder.build()); } @@ -164,30 +242,63 @@ public class ExternalAudioWriter { private static void notifyFailure(Context context, String message) { NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID) - .setContentTitle("Download failed") - .setContentText(message) - .setSmallIcon(android.R.drawable.stat_notify_error) - .setAutoCancel(true); + .setContentTitle("Download failed") + .setContentText(message) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setAutoCancel(true); manager.notify((int) System.currentTimeMillis(), builder.build()); } - private static void notifySuccess(Context context, String name) { + private static void notifySuccess(Context context, String name, Child child, Uri fileUri) { NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID) - .setContentTitle("Download complete") - .setContentText(name) - .setSmallIcon(android.R.drawable.stat_sys_download_done) - .setAutoCancel(true); + .setContentTitle("Download complete") + .setContentText(name) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setAutoCancel(true); + + PendingIntent playIntent = buildPlayIntent(context, child, fileUri); + if (playIntent != null) { + builder.setContentIntent(playIntent); + } + manager.notify((int) System.currentTimeMillis(), builder.build()); } private static void notifyExists(Context context, String name) { NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID) - .setContentTitle("Already downloaded") - .setContentText(name) - .setSmallIcon(android.R.drawable.stat_sys_warning) - .setAutoCancel(true); + .setContentTitle("Already downloaded") + .setContentText(name) + .setSmallIcon(android.R.drawable.stat_sys_warning) + .setAutoCancel(true); manager.notify((int) System.currentTimeMillis(), builder.build()); } + + private static PendingIntent buildPlayIntent(Context context, Child child, Uri fileUri) { + if (fileUri == null) return null; + Intent intent = new Intent(context, MainActivity.class) + .setAction(Constants.ACTION_PLAY_EXTERNAL_DOWNLOAD) + .putExtra(Constants.EXTRA_DOWNLOAD_URI, fileUri.toString()) + .putExtra(Constants.EXTRA_DOWNLOAD_MEDIA_ID, child.getId()) + .putExtra(Constants.EXTRA_DOWNLOAD_TITLE, child.getTitle()) + .putExtra(Constants.EXTRA_DOWNLOAD_ARTIST, child.getArtist()) + .putExtra(Constants.EXTRA_DOWNLOAD_ALBUM, child.getAlbum()) + .putExtra(Constants.EXTRA_DOWNLOAD_DURATION, child.getDuration() != null ? child.getDuration() : 0) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + + int requestCode; + if (child.getId() != null) { + requestCode = Math.abs(child.getId().hashCode()); + } else { + requestCode = Math.abs(fileUri.toString().hashCode()); + } + + return PendingIntent.getActivity( + context, + requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalDownloadMetadataStore.java b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalDownloadMetadataStore.java new file mode 100644 index 00000000..4bd4089d --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalDownloadMetadataStore.java @@ -0,0 +1,123 @@ +package com.cappielloantonio.tempo.util; + +import android.content.SharedPreferences; + +import androidx.annotation.Nullable; + +import com.cappielloantonio.tempo.App; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +public final class ExternalDownloadMetadataStore { + + private static final String PREF_KEY = "external_download_metadata"; + + private ExternalDownloadMetadataStore() { + } + + private static SharedPreferences preferences() { + return App.getInstance().getPreferences(); + } + + private static JSONObject readAll() { + String raw = preferences().getString(PREF_KEY, "{}"); + try { + return new JSONObject(raw); + } catch (JSONException e) { + return new JSONObject(); + } + } + + private static void writeAll(JSONObject object) { + preferences().edit().putString(PREF_KEY, object.toString()).apply(); + } + + public static synchronized void clear() { + writeAll(new JSONObject()); + } + + public static synchronized void recordSize(String key, long size) { + if (key == null || size <= 0) { + return; + } + JSONObject object = readAll(); + try { + object.put(key, size); + } catch (JSONException ignored) { + } + writeAll(object); + } + + public static synchronized void remove(String key) { + if (key == null) { + return; + } + JSONObject object = readAll(); + object.remove(key); + writeAll(object); + } + + @Nullable + public static synchronized Long getSize(String key) { + if (key == null) { + return null; + } + JSONObject object = readAll(); + if (!object.has(key)) { + return null; + } + long size = object.optLong(key, -1L); + return size > 0 ? size : null; + } + + public static synchronized Map snapshot() { + JSONObject object = readAll(); + if (object.length() == 0) { + return Collections.emptyMap(); + } + Map sizes = new HashMap<>(); + Iterator keys = object.keys(); + while (keys.hasNext()) { + String key = keys.next(); + long size = object.optLong(key, -1L); + if (size > 0) { + sizes.put(key, size); + } + } + return sizes; + } + + public static synchronized void retainOnly(Set keysToKeep) { + if (keysToKeep == null || keysToKeep.isEmpty()) { + clear(); + return; + } + JSONObject object = readAll(); + if (object.length() == 0) { + return; + } + Set keys = new HashSet<>(); + Iterator iterator = object.keys(); + while (iterator.hasNext()) { + keys.add(iterator.next()); + } + boolean changed = false; + for (String key : keys) { + if (!keysToKeep.contains(key)) { + object.remove(key); + changed = true; + } + } + if (changed) { + writeAll(object); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt index 3ff43cf8..6218cad5 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt @@ -460,6 +460,10 @@ object Preferences { @JvmStatic fun setDownloadDirectoryUri(uri: String?) { + val current = App.getInstance().preferences.getString(DOWNLOAD_DIRECTORY_URI, null) + if (current != uri) { + ExternalDownloadMetadataStore.clear() + } App.getInstance().preferences.edit().putString(DOWNLOAD_DIRECTORY_URI, uri).apply() } From 1357c5c0626bfab9194899897728e926411ea72e Mon Sep 17 00:00:00 2001 From: le-firehawk Date: Fri, 3 Oct 2025 22:57:17 +0930 Subject: [PATCH 5/6] feat: Integrate external downloads into downloaded songs view --- .../tempo/database/dao/DownloadDao.java | 6 ++ .../tempo/repository/DownloadRepository.java | 53 +++++++++++++++ .../tempo/ui/fragment/DownloadFragment.java | 19 ++++++ .../tempo/util/ExternalAudioReader.java | 10 +++ .../tempo/util/ExternalAudioWriter.java | 18 ++++++ .../tempo/viewmodel/DownloadViewModel.java | 64 +++++++++++++++++++ app/src/main/res/drawable/ic_refresh.xml | 9 +++ app/src/main/res/layout/fragment_download.xml | 16 ++++- app/src/main/res/values/strings.xml | 7 ++ 9 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/drawable/ic_refresh.xml diff --git a/app/src/main/java/com/cappielloantonio/tempo/database/dao/DownloadDao.java b/app/src/main/java/com/cappielloantonio/tempo/database/dao/DownloadDao.java index 628e9dd6..a2d49f6b 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/database/dao/DownloadDao.java +++ b/app/src/main/java/com/cappielloantonio/tempo/database/dao/DownloadDao.java @@ -15,6 +15,9 @@ public interface DownloadDao { @Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC") LiveData> getAll(); + @Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC") + List getAllSync(); + @Query("SELECT * FROM download WHERE id = :id") Download getOne(String id); @@ -30,6 +33,9 @@ public interface DownloadDao { @Query("DELETE FROM download WHERE id = :id") void delete(String id); + @Query("DELETE FROM download WHERE id IN (:ids)") + void deleteByIds(List ids); + @Query("DELETE FROM download") void deleteAll(); } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/DownloadRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/DownloadRepository.java index c71d2f8c..1d8c935a 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/repository/DownloadRepository.java +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/DownloadRepository.java @@ -18,6 +18,20 @@ public class DownloadRepository { return downloadDao.getAll(); } + public List getAllDownloads() { + GetAllDownloadsThreadSafe getDownloads = new GetAllDownloadsThreadSafe(downloadDao); + Thread thread = new Thread(getDownloads); + thread.start(); + + try { + thread.join(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + return getDownloads.getDownloads(); + } + public Download getDownload(String id) { Download download = null; @@ -35,6 +49,24 @@ public class DownloadRepository { return download; } + private static class GetAllDownloadsThreadSafe implements Runnable { + private final DownloadDao downloadDao; + private List downloads; + + public GetAllDownloadsThreadSafe(DownloadDao downloadDao) { + this.downloadDao = downloadDao; + } + + @Override + public void run() { + downloads = downloadDao.getAllSync(); + } + + public List getDownloads() { + return downloads; + } + } + private static class GetDownloadThreadSafe implements Runnable { private final DownloadDao downloadDao; private final String id; @@ -143,6 +175,12 @@ public class DownloadRepository { thread.start(); } + public void delete(List ids) { + DeleteMultipleThreadSafe delete = new DeleteMultipleThreadSafe(downloadDao, ids); + Thread thread = new Thread(delete); + thread.start(); + } + private static class DeleteThreadSafe implements Runnable { private final DownloadDao downloadDao; private final String id; @@ -157,4 +195,19 @@ public class DownloadRepository { downloadDao.delete(id); } } + + private static class DeleteMultipleThreadSafe implements Runnable { + private final DownloadDao downloadDao; + private final List ids; + + public DeleteMultipleThreadSafe(DownloadDao downloadDao, List ids) { + this.downloadDao = downloadDao; + this.ids = ids; + } + + @Override + public void run() { + downloadDao.deleteByIds(ids); + } + } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java index 7d4a076a..be2b3eb0 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java @@ -136,8 +136,27 @@ public class DownloadFragment extends Fragment implements ClickCallback { } }); + downloadViewModel.getRefreshResult().observe(getViewLifecycleOwner(), count -> { + if (count == null || bind == null) { + return; + } + + if (count == -1) { + Toast.makeText(requireContext(), R.string.download_refresh_no_directory, Toast.LENGTH_SHORT).show(); + } else if (count == 0) { + Toast.makeText(requireContext(), R.string.download_refresh_no_changes, Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText( + requireContext(), + getResources().getQuantityString(R.plurals.download_refresh_removed, count, count), + Toast.LENGTH_SHORT + ).show(); + } + }); + bind.downloadedGroupByImageView.setOnClickListener(view -> showPopupMenu(view, R.menu.download_popup_menu)); bind.downloadedGoBackImageView.setOnClickListener(view -> downloadViewModel.popViewStack()); + bind.downloadedRefreshImageView.setOnClickListener(view -> downloadViewModel.refreshExternalDownloads()); } private void finishDownloadView(List songs) { 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 42792e31..afabcc82 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java @@ -110,6 +110,16 @@ public class ExternalAudioReader { return findUri(episode.getArtist(), episode.getTitle(), episode.getAlbum()); } + public static synchronized void removeMetadata(Child media) { + if (media == null) { + return; + } + + String key = buildKey(media.getArtist(), media.getTitle(), media.getAlbum()); + cache.remove(key); + ExternalDownloadMetadataStore.remove(key); + } + public static boolean delete(Child media) { ensureCache(); if (cachedDirUri == null) return false; 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 84708546..df35e169 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java @@ -12,6 +12,8 @@ import androidx.core.app.NotificationCompat; import androidx.documentfile.provider.DocumentFile; import androidx.media3.common.MediaItem; +import com.cappielloantonio.tempo.model.Download; +import com.cappielloantonio.tempo.repository.DownloadRepository; import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.ui.activity.MainActivity; @@ -156,6 +158,7 @@ public class ExternalAudioWriter { } if (matches) { ExternalDownloadMetadataStore.recordSize(metadataKey, localLength); + recordDownload(child, existingFile.getUri()); ExternalAudioReader.refreshCache(); notifyExists(context, fileName); return; @@ -204,6 +207,7 @@ public class ExternalAudioWriter { } ExternalDownloadMetadataStore.recordSize(metadataKey, total); + recordDownload(child, targetUri); notifySuccess(context, fileName, child, targetUri); ExternalAudioReader.refreshCache(); } @@ -265,6 +269,20 @@ public class ExternalAudioWriter { manager.notify((int) System.currentTimeMillis(), builder.build()); } + private static void recordDownload(Child child, Uri fileUri) { + if (child == null) { + return; + } + + Download download = new Download(child); + download.setDownloadState(1); + if (fileUri != null) { + download.setDownloadUri(fileUri.toString()); + } + + new DownloadRepository().insert(download); + } + private static void notifyExists(Context context, String name) { NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID) diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/DownloadViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/DownloadViewModel.java index 3059eb09..6f69cee1 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/DownloadViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/DownloadViewModel.java @@ -1,6 +1,7 @@ package com.cappielloantonio.tempo.viewmodel; import android.app.Application; +import android.net.Uri; import android.util.Log; import androidx.annotation.NonNull; @@ -8,10 +9,13 @@ import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; +import androidx.documentfile.provider.DocumentFile; +import com.cappielloantonio.tempo.model.Download; import com.cappielloantonio.tempo.model.DownloadStack; import com.cappielloantonio.tempo.repository.DownloadRepository; import com.cappielloantonio.tempo.subsonic.models.Child; +import com.cappielloantonio.tempo.util.ExternalAudioReader; import com.cappielloantonio.tempo.util.Preferences; import java.util.ArrayList; @@ -25,6 +29,7 @@ public class DownloadViewModel extends AndroidViewModel { private final MutableLiveData> downloadedTrackSample = new MutableLiveData<>(null); private final MutableLiveData> viewStack = new MutableLiveData<>(null); + private final MutableLiveData refreshResult = new MutableLiveData<>(); public DownloadViewModel(@NonNull Application application) { super(application); @@ -43,6 +48,10 @@ public class DownloadViewModel extends AndroidViewModel { return viewStack; } + public LiveData getRefreshResult() { + return refreshResult; + } + public void initViewStack(DownloadStack level) { ArrayList stack = new ArrayList<>(); stack.add(level); @@ -60,4 +69,59 @@ public class DownloadViewModel extends AndroidViewModel { stack.remove(stack.size() - 1); viewStack.setValue(stack); } + + public void refreshExternalDownloads() { + new Thread(() -> { + String directoryUri = Preferences.getDownloadDirectoryUri(); + if (directoryUri == null) { + refreshResult.postValue(-1); + return; + } + + List downloads = downloadRepository.getAllDownloads(); + if (downloads == null || downloads.isEmpty()) { + refreshResult.postValue(0); + return; + } + + ArrayList toRemove = new ArrayList<>(); + + for (Download download : downloads) { + String uriString = download.getDownloadUri(); + if (uriString == null || uriString.isEmpty()) { + continue; + } + + Uri uri = Uri.parse(uriString); + if (uri.getScheme() == null || !uri.getScheme().equalsIgnoreCase("content")) { + continue; + } + + DocumentFile file; + try { + file = DocumentFile.fromSingleUri(getApplication(), uri); + } catch (SecurityException exception) { + file = null; + } + + if (file == null || !file.exists()) { + toRemove.add(download); + } + } + + if (!toRemove.isEmpty()) { + ArrayList ids = new ArrayList<>(); + for (Download download : toRemove) { + ids.add(download.getId()); + ExternalAudioReader.removeMetadata(download); + } + + downloadRepository.delete(ids); + ExternalAudioReader.refreshCache(); + refreshResult.postValue(ids.size()); + } else { + refreshResult.postValue(0); + } + }).start(); + } } diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 00000000..f3dcb53a --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_download.xml b/app/src/main/res/layout/fragment_download.xml index 0a454508..68d3f84b 100644 --- a/app/src/main/res/layout/fragment_download.xml +++ b/app/src/main/res/layout/fragment_download.xml @@ -80,7 +80,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:text="@string/download_title_section" - app:layout_constraintEnd_toStartOf="@+id/downloaded_go_back_image_view" + app:layout_constraintEnd_toStartOf="@+id/downloaded_refresh_image_view" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> @@ -94,6 +94,19 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/downloaded_text_view_refreshable"/> + + Internal Directory Downloads + Set a download folder to refresh your downloads. + No missing downloads found. + + Removed %d missing download. + Removed %d missing downloads. + + Refresh downloaded items Add to queue Play next Remove From 620fba0a141b1391ab4c99e419a70184a3f54a65 Mon Sep 17 00:00:00 2001 From: le-firehawk Date: Sat, 4 Oct 2025 23:02:12 +0930 Subject: [PATCH 6/6] 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()); + } }