feat: Integrate external downloads into downloaded songs view

This commit is contained in:
le-firehawk 2025-10-03 22:57:17 +09:30
parent 682f63ef38
commit 1357c5c062
9 changed files with 201 additions and 1 deletions

View file

@ -15,6 +15,9 @@ public interface DownloadDao {
@Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC")
LiveData<List<Download>> getAll();
@Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC")
List<Download> 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<String> ids);
@Query("DELETE FROM download")
void deleteAll();
}

View file

@ -18,6 +18,20 @@ public class DownloadRepository {
return downloadDao.getAll();
}
public List<Download> 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<Download> downloads;
public GetAllDownloadsThreadSafe(DownloadDao downloadDao) {
this.downloadDao = downloadDao;
}
@Override
public void run() {
downloads = downloadDao.getAllSync();
}
public List<Download> 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<String> 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<String> ids;
public DeleteMultipleThreadSafe(DownloadDao downloadDao, List<String> ids) {
this.downloadDao = downloadDao;
this.ids = ids;
}
@Override
public void run() {
downloadDao.deleteByIds(ids);
}
}
}

View file

@ -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<Child> songs) {

View file

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

View file

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

View file

@ -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<List<Child>> downloadedTrackSample = new MutableLiveData<>(null);
private final MutableLiveData<ArrayList<DownloadStack>> viewStack = new MutableLiveData<>(null);
private final MutableLiveData<Integer> refreshResult = new MutableLiveData<>();
public DownloadViewModel(@NonNull Application application) {
super(application);
@ -43,6 +48,10 @@ public class DownloadViewModel extends AndroidViewModel {
return viewStack;
}
public LiveData<Integer> getRefreshResult() {
return refreshResult;
}
public void initViewStack(DownloadStack level) {
ArrayList<DownloadStack> 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<Download> downloads = downloadRepository.getAllDownloads();
if (downloads == null || downloads.isEmpty()) {
refreshResult.postValue(0);
return;
}
ArrayList<Download> 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<String> 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();
}
}

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="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -8,3.58 -8,8h2c0,-3.31 2.69,-6 6,-6 1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35zM19,12c0,3.31 -2.69,6 -6,6 -1.66,0 -3.14,-0.69 -4.22,-1.78L11,13H4v7l2.35,-2.35C7.8,19.1 9.79,20 12,20c4.42,0 8,-3.58 8,-8h-2z" />
</vector>

View file

@ -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"/>
<ImageView
android:id="@+id/downloaded_refresh_image_view"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:background="@drawable/ic_refresh"
android:contentDescription="@string/download_refresh_button_content_description"
app:layout_constraintBottom_toBottomOf="@+id/downloaded_text_view_refreshable"
app:layout_constraintEnd_toStartOf="@id/downloaded_go_back_image_view"
app:layout_constraintStart_toEndOf="@id/downloaded_text_view_refreshable"
app:layout_constraintTop_toTopOf="@+id/downloaded_text_view_refreshable" />
<ImageView
android:id="@+id/downloaded_go_back_image_view"
android:layout_width="24dp"
@ -103,6 +116,7 @@
android:background="@drawable/ic_arrow_back"
app:layout_constraintBottom_toBottomOf="@+id/downloaded_text_view_refreshable"
app:layout_constraintEnd_toStartOf="@id/downloaded_group_by_image_view"
app:layout_constraintStart_toEndOf="@id/downloaded_refresh_image_view"
app:layout_constraintTop_toTopOf="@+id/downloaded_text_view_refreshable" />
<ImageView

View file

@ -81,6 +81,13 @@
<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="download_refresh_no_directory">Set a download folder to refresh your downloads.</string>
<string name="download_refresh_no_changes">No missing downloads found.</string>
<plurals name="download_refresh_removed">
<item quantity="one">Removed %d missing download.</item>
<item quantity="other">Removed %d missing downloads.</item>
</plurals>
<string name="download_refresh_button_content_description">Refresh downloaded items</string>
<string name="downloaded_bottom_sheet_add_to_queue">Add to queue</string>
<string name="downloaded_bottom_sheet_play_next">Play next</string>
<string name="downloaded_bottom_sheet_remove">Remove</string>