feat: Support user-defined download directory for media

This commit is contained in:
le-firehawk 2025-08-05 22:31:13 +09:30
parent fda586c4d8
commit cce6456951
21 changed files with 500 additions and 38 deletions

View file

@ -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<Intent> 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();
}
}

View file

@ -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();
});
}
}
}

View file

@ -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())

View file

@ -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;
}

View file

@ -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<Child> songs = directory.getChildren().stream().filter(child -> !child.isDir()).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);
});
}
}
});
}

View file

@ -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();
}
}
}
}

View file

@ -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<List<Child>>() {
@Override
public void onChanged(List<Child> songs) {

View file

@ -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 -> {
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 -> {

View file

@ -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,6 +142,7 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
if (item.getItemId() == R.id.action_download_playlist) {
playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> {
if (isVisible() && getActivity() != null) {
if (Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(requireContext()).download(
MappingUtil.mapDownloads(songs),
songs.stream().map(child -> {
@ -149,6 +152,12 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
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;

View file

@ -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<Intent> someActivityResultLauncher;
private ActivityResultLauncher<Intent> equalizerResultLauncher;
private ActivityResultLauncher<Intent> 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();

View file

@ -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<Download> downloads = songs.stream().map(Download::new).collect(Collectors.toList());
downloadAll.setOnClickListener(v -> {
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<MediaItem> mediaItems = MappingUtil.mapDownloads(songs);
if (DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) {
if (Preferences.getDownloadDirectoryUri() == null && DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) {
removeAll.setVisibility(View.VISIBLE);
}
});

View file

@ -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 -> {
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();
});

View file

@ -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];
}
}

View file

@ -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()
);
}
}

View file

@ -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(

View file

@ -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)

View file

@ -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)

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/titleTextColor"
android:pathData="M10,4L12,6H20c1.1,0 2,0.9 2,2v10c0,1.1 -0.9,2 -2,2H4c-1.1,0 -2,-0.9 -2,-2V6c0,-1.1 0.9,-2 2,-2h6z"/>
</vector>

View file

@ -16,4 +16,7 @@
<item
android:id="@+id/menu_download_group_by_year"
android:title="@string/menu_group_by_year" />
<item
android:id="@+id/menu_download_set_directory"
android:title="@string/download_directory_set" />
</menu>

View file

@ -68,6 +68,7 @@
<string name="download_directory_dialog_positive_button">Download</string>
<string name="download_directory_dialog_summary">All tracks in this folder will be downloaded. Tracks present in subfolders will not be downloaded.</string>
<string name="download_directory_dialog_title">Download the tracks</string>
<string name="download_directory_set">Set where music is downloaded</string>
<string name="download_info_empty_subtitle">Once you download a song, you\'ll find it here</string>
<string name="download_info_empty_title">No downloads yet!</string>
<string name="download_item_multiple_subtitle_formatter">%1$s • %2$s items</string>
@ -78,6 +79,7 @@
<string name="download_storage_dialog_title">Select storage option</string>
<string name="download_storage_external_dialog_positive_button">External</string>
<string name="download_storage_internal_dialog_negative_button">Internal</string>
<string name="download_storage_directory_dialog_neutral_button">Directory</string>
<string name="download_title_section">Downloads</string>
<string name="downloaded_bottom_sheet_add_to_queue">Add to queue</string>
<string name="downloaded_bottom_sheet_play_next">Play next</string>

View file

@ -180,6 +180,14 @@
android:key="download_storage"
app:title="@string/settings_download_storage_title" />
<Preference
android:key="set_download_directory"
android:title="Set download folder"
android:summary="Choose a folder for downloaded music files"
android:icon="@drawable/ic_folder"
android:order="104"
app:isPreferenceVisible="false" />
<Preference
android:key="delete_download_storage"
app:title="@string/settings_delete_download_storage_title"