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" /> + +