SAF is fixed

This commit is contained in:
h4h13 2019-07-31 22:12:19 +05:30
parent 8789eeb854
commit 570a235836
25 changed files with 907 additions and 142 deletions

View file

@ -57,7 +57,6 @@ public final class FileUtil {
stream.close();
return baos.toByteArray();
}
@NonNull
public static Observable<ArrayList<Song>> matchFilesWithMediaStore(@NonNull Context context,
@Nullable List<File> files) {
@ -263,4 +262,6 @@ public final class FileUtil {
return file.getAbsoluteFile();
}
}
}

View file

@ -26,7 +26,6 @@ import android.os.Environment;
import android.provider.BaseColumns;
import android.provider.MediaStore;
import android.text.TextUtils;
import android.util.Log;
import android.widget.Toast;
import androidx.annotation.NonNull;
@ -263,64 +262,80 @@ public class MusicUtil {
}
public static void deleteTracks(@NonNull final Activity activity,
@NonNull final List<Song> songs) {
@NonNull final List<Song> songs,
@Nullable final List<Uri> safUris,
@Nullable final Runnable callback) {
final String[] projection = new String[]{
BaseColumns._ID, MediaStore.MediaColumns.DATA
};
final StringBuilder selection = new StringBuilder();
selection.append(BaseColumns._ID + " IN (");
for (int i = 0; i < songs.size(); i++) {
selection.append(songs.get(i).getId());
if (i < songs.size() - 1) {
// Split the query into multiple batches, and merge the resulting cursors
int batchStart = 0;
int batchEnd = 0;
final int batchSize = 1000000 / 10; // 10^6 being the SQLite limite on the query lenth in bytes, 10 being the max number of digits in an int, used to store the track ID
final int songCount = songs.size();
while (batchEnd < songCount) {
batchStart = batchEnd;
final StringBuilder selection = new StringBuilder();
selection.append(BaseColumns._ID + " IN (");
for (int i = 0; (i < batchSize - 1) && (batchEnd < songCount - 1); i++, batchEnd++) {
selection.append(songs.get(batchEnd).getId());
selection.append(",");
}
}
selection.append(")");
// The last element of a batch
selection.append(songs.get(batchEnd).getId());
batchEnd++;
selection.append(")");
try {
final Cursor cursor = activity.getContentResolver().query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, selection.toString(),
null, null);
if (cursor != null) {
// Step 1: Remove selected tracks from the current playlist, as well
// as from the album art cache
cursor.moveToFirst();
while (!cursor.isAfterLast()) {
final int id = cursor.getInt(0);
Song song = SongLoader.INSTANCE.getSong(activity, id).blockingFirst();
MusicPlayerRemote.INSTANCE.removeFromQueue(song);
cursor.moveToNext();
}
try {
final Cursor cursor = activity.getContentResolver().query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, selection.toString(),
null, null);
// TODO: At this point, there is no guarantee that the size of the cursor is the same as the size of the selection string.
// Despite that, the Step 3 assumes that the safUris elements are tracking closely the content of the cursor.
// Step 2: Remove selected tracks from the database
activity.getContentResolver().delete(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
selection.toString(), null);
// Step 3: Remove files from card
cursor.moveToFirst();
while (!cursor.isAfterLast()) {
final String name = cursor.getString(1);
try { // File.delete can throw a security exception
final File f = new File(name);
if (!f.delete()) {
// I'm not sure if we'd ever get here (deletion would
// have to fail, but no exception thrown)
Log.e("MusicUtils", "Failed to delete file " + name);
}
if (cursor != null) {
// Step 1: Remove selected tracks from the current playlist, as well
// as from the album art cache
cursor.moveToFirst();
while (!cursor.isAfterLast()) {
final int id = cursor.getInt(0);
final Song song = SongLoader.INSTANCE.getSong(activity, id).blockingFirst();
MusicPlayerRemote.INSTANCE.removeFromQueue(song);
cursor.moveToNext();
} catch (@NonNull final SecurityException ex) {
cursor.moveToNext();
} catch (NullPointerException e) {
Log.e("MusicUtils", "Failed to find file " + name);
}
// Step 2: Remove selected tracks from the database
activity.getContentResolver().delete(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
selection.toString(), null);
// Step 3: Remove files from card
cursor.moveToFirst();
int i = batchStart;
while (!cursor.isAfterLast()) {
final String name = cursor.getString(1);
final Uri safUri = safUris == null || safUris.size() <= i ? null : safUris.get(i);
SAFUtil.delete(activity, name, safUri);
i++;
cursor.moveToNext();
}
cursor.close();
}
cursor.close();
} catch (SecurityException ignored) {
}
activity.getContentResolver().notifyChange(Uri.parse("content://media"), null);
Toast.makeText(activity, activity.getString(R.string.deleted_x_songs, songs.size()),
Toast.LENGTH_SHORT).show();
} catch (SecurityException ignored) {
}
activity.getContentResolver().notifyChange(Uri.parse("content://media"), null);
activity.runOnUiThread(() -> {
Toast.makeText(activity, activity.getString(R.string.deleted_x_songs, songCount), Toast.LENGTH_SHORT).show();
if (callback != null) {
callback.run();
}
});
}
public static void deleteAlbumArt(@NonNull Context context, int albumId) {

View file

@ -19,6 +19,7 @@ import android.content.Context;
import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
import android.content.res.TypedArray;
import android.net.Uri;
import android.preference.PreferenceManager;
import androidx.annotation.LayoutRes;
@ -87,6 +88,7 @@ public final class PreferenceUtil {
public static final String ALBUM_COVER_STYLE = "album_cover_style_id";
public static final String ALBUM_COVER_TRANSFORM = "album_cover_transform";
public static final String TAB_TEXT_MODE = "tab_text_mode";
public static final String SAF_SDCARD_URI = "saf_sdcard_uri";
private static final String GENRE_SORT_ORDER = "genre_sort_order";
private static final String LAST_PAGE = "last_start_page";
private static final String LAST_MUSIC_CHOOSER = "last_music_chooser";
@ -281,7 +283,6 @@ public final class PreferenceUtil {
return Integer.parseInt(mPreferences.getString(DEFAULT_START_PAGE, "-1"));
}
public final int getLastPage() {
return mPreferences.getInt(LAST_PAGE, R.id.action_song);
}
@ -292,7 +293,6 @@ public final class PreferenceUtil {
editor.apply();
}
public void setLastLyricsType(int group) {
final SharedPreferences.Editor editor = mPreferences.edit();
editor.putInt(LAST_KNOWN_LYRICS_TYPE, group);
@ -388,7 +388,6 @@ public final class PreferenceUtil {
return mPreferences.getBoolean(IGNORE_MEDIA_STORE_ARTWORK, false);
}
public int getLastSleepTimerValue() {
return mPreferences.getInt(LAST_SLEEP_TIMER_VALUE, 30);
}
@ -673,7 +672,6 @@ public final class PreferenceUtil {
return mPreferences.getBoolean(TOGGLE_HEADSET, false);
}
public boolean isDominantColor() {
return mPreferences.getBoolean(DOMINANT_COLOR, false);
}
@ -698,7 +696,6 @@ public final class PreferenceUtil {
mPreferences.edit().putBoolean(CIRCULAR_ALBUM_ART, false).apply();
}
public String getAlbumDetailsStyle() {
return mPreferences.getString(ALBUM_DETAIL_STYLE, "0");
}
@ -738,7 +735,6 @@ public final class PreferenceUtil {
return mPreferences.getBoolean(PAUSE_ON_ZERO_VOLUME, false);
}
public ViewPager.PageTransformer getAlbumCoverTransform() {
int style = Integer.parseInt(Objects.requireNonNull(mPreferences.getString(ALBUM_COVER_TRANSFORM, "0")));
switch (style) {
@ -859,4 +855,12 @@ public final class PreferenceUtil {
defaultCategoryInfos.add(new CategoryInfo(CategoryInfo.Category.GENRES, false));
return defaultCategoryInfos;
}
public final String getSAFSDCardUri() {
return mPreferences.getString(SAF_SDCARD_URI, "");
}
public final void setSAFSDCardUri(Uri uri) {
mPreferences.edit().putString(SAF_SDCARD_URI, uri.toString()).apply();
}
}

View file

@ -0,0 +1,303 @@
/*
* Copyright (c) 2019 Hemanth Savarala.
*
* Licensed under the GNU General Public License v3
*
* This is free software: you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by
* the Free Software Foundation either version 3 of the License, or (at your option) any later version.
*
* This software is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU General Public License for more details.
*/
package code.name.monkey.retromusic.util;
import android.annotation.TargetApi;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.UriPermission;
import android.net.Uri;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import android.provider.DocumentsContract;
import android.text.TextUtils;
import android.util.Log;
import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.documentfile.provider.DocumentFile;
import androidx.fragment.app.Fragment;
import org.jaudiotagger.audio.AudioFile;
import org.jaudiotagger.audio.exceptions.CannotWriteException;
import org.jaudiotagger.audio.generic.Utils;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import code.name.monkey.retromusic.R;
import code.name.monkey.retromusic.model.Song;
public class SAFUtil {
public static final String TAG = SAFUtil.class.getSimpleName();
public static final String SEPARATOR = "###/SAF/###";
public static final int REQUEST_SAF_PICK_FILE = 42;
public static final int REQUEST_SAF_PICK_TREE = 43;
public static boolean isSAFRequired(File file) {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && !file.canWrite();
}
public static boolean isSAFRequired(String path) {
return isSAFRequired(new File(path));
}
public static boolean isSAFRequired(AudioFile audio) {
return isSAFRequired(audio.getFile());
}
public static boolean isSAFRequired(Song song) {
return isSAFRequired(song.getData());
}
public static boolean isSAFRequired(List<String> paths) {
for (String path : paths) {
if (isSAFRequired(path)) return true;
}
return false;
}
public static boolean isSAFRequiredForSongs(List<Song> songs) {
for (Song song : songs) {
if (isSAFRequired(song)) return true;
}
return false;
}
@TargetApi(Build.VERSION_CODES.KITKAT)
public static void openFilePicker(Activity activity) {
Intent i = new Intent(Intent.ACTION_CREATE_DOCUMENT);
i.addCategory(Intent.CATEGORY_OPENABLE);
i.setType("audio/*");
i.putExtra("android.content.extra.SHOW_ADVANCED", true);
activity.startActivityForResult(i, SAFUtil.REQUEST_SAF_PICK_FILE);
}
@TargetApi(Build.VERSION_CODES.KITKAT)
public static void openFilePicker(Fragment fragment) {
Intent i = new Intent(Intent.ACTION_CREATE_DOCUMENT);
i.addCategory(Intent.CATEGORY_OPENABLE);
i.setType("audio/*");
i.putExtra("android.content.extra.SHOW_ADVANCED", true);
fragment.startActivityForResult(i, SAFUtil.REQUEST_SAF_PICK_FILE);
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public static void openTreePicker(Activity activity) {
Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
i.putExtra("android.content.extra.SHOW_ADVANCED", true);
activity.startActivityForResult(i, SAFUtil.REQUEST_SAF_PICK_TREE);
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public static void openTreePicker(Fragment fragment) {
Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
i.putExtra("android.content.extra.SHOW_ADVANCED", true);
fragment.startActivityForResult(i, SAFUtil.REQUEST_SAF_PICK_TREE);
}
@TargetApi(Build.VERSION_CODES.KITKAT)
public static void saveTreeUri(Context context, Intent data) {
Uri uri = data.getData();
context.getContentResolver().takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
PreferenceUtil.getInstance().setSAFSDCardUri(uri);
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public static boolean isTreeUriSaved(Context context) {
return !TextUtils.isEmpty(PreferenceUtil.getInstance().getSAFSDCardUri());
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public static boolean isSDCardAccessGranted(Context context) {
if (!isTreeUriSaved(context)) return false;
String sdcardUri = PreferenceUtil.getInstance().getSAFSDCardUri();
List<UriPermission> perms = context.getContentResolver().getPersistedUriPermissions();
for (UriPermission perm : perms) {
if (perm.getUri().toString().equals(sdcardUri) && perm.isWritePermission()) return true;
}
return false;
}
/**
* https://github.com/vanilla-music/vanilla-music-tag-editor/commit/e00e87fef289f463b6682674aa54be834179ccf0#diff-d436417358d5dfbb06846746d43c47a5R359
* Finds needed file through Document API for SAF. It's not optimized yet - you can still gain wrong URI on
* files such as "/a/b/c.mp3" and "/b/a/c.mp3", but I consider it complete enough to be usable.
*
* @param dir - document file representing current dir of search
* @param segments - path segments that are left to find
* @return URI for found file. Null if nothing found.
*/
@Nullable
public static Uri findDocument(DocumentFile dir, List<String> segments) {
for (DocumentFile file : dir.listFiles()) {
int index = segments.indexOf(file.getName());
if (index == -1) {
continue;
}
if (file.isDirectory()) {
segments.remove(file.getName());
return findDocument(file, segments);
}
if (file.isFile() && index == segments.size() - 1) {
// got to the last part
return file.getUri();
}
}
return null;
}
public static void write(Context context, AudioFile audio, Uri safUri) {
if (isSAFRequired(audio)) {
writeSAF(context, audio, safUri);
} else {
try {
writeFile(audio);
} catch (CannotWriteException e) {
e.printStackTrace();
}
}
}
public static void writeFile(AudioFile audio) throws CannotWriteException {
audio.commit();
}
public static void writeSAF(Context context, AudioFile audio, Uri safUri) {
Uri uri = null;
if (context == null) {
Log.e(TAG, "writeSAF: context == null");
return;
}
if (isTreeUriSaved(context)) {
List<String> pathSegments = new ArrayList<>(Arrays.asList(audio.getFile().getAbsolutePath().split("/")));
Uri sdcard = Uri.parse(PreferenceUtil.getInstance().getSAFSDCardUri());
uri = findDocument(DocumentFile.fromTreeUri(context, sdcard), pathSegments);
}
if (uri == null) {
uri = safUri;
}
if (uri == null) {
Log.e(TAG, "writeSAF: Can't get SAF URI");
toast(context, context.getString(R.string.saf_error_uri));
return;
}
try {
// copy file to app folder to use jaudiotagger
final File original = audio.getFile();
File temp = File.createTempFile("tmp-media", '.' + Utils.getExtension(original));
Utils.copy(original, temp);
temp.deleteOnExit();
audio.setFile(temp);
writeFile(audio);
ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "rw");
if (pfd == null) {
Log.e(TAG, "writeSAF: SAF provided incorrect URI: " + uri);
return;
}
// now read persisted data and write it to real FD provided by SAF
FileInputStream fis = new FileInputStream(temp);
byte[] audioContent = FileUtil.readBytes(fis);
FileOutputStream fos = new FileOutputStream(pfd.getFileDescriptor());
fos.write(audioContent);
fos.close();
temp.delete();
} catch (final Exception e) {
Log.e(TAG, "writeSAF: Failed to write to file descriptor provided by SAF", e);
toast(context, String.format(context.getString(R.string.saf_write_failed), e.getLocalizedMessage()));
}
}
public static void delete(Context context, String path, Uri safUri) {
if (isSAFRequired(path)) {
deleteSAF(context, path, safUri);
} else {
try {
deleteFile(path);
} catch (NullPointerException e) {
Log.e("MusicUtils", "Failed to find file " + path);
} catch (Exception e) {
e.printStackTrace();
}
}
}
public static void deleteFile(String path) {
new File(path).delete();
}
@TargetApi(Build.VERSION_CODES.KITKAT)
public static void deleteSAF(Context context, String path, Uri safUri) {
Uri uri = null;
if (context == null) {
Log.e(TAG, "deleteSAF: context == null");
return;
}
if (isTreeUriSaved(context)) {
List<String> pathSegments = new ArrayList<>(Arrays.asList(path.split("/")));
Uri sdcard = Uri.parse(PreferenceUtil.getInstance().getSAFSDCardUri());
uri = findDocument(DocumentFile.fromTreeUri(context, sdcard), pathSegments);
}
if (uri == null) {
uri = safUri;
}
if (uri == null) {
Log.e(TAG, "deleteSAF: Can't get SAF URI");
toast(context, context.getString(R.string.saf_error_uri));
return;
}
try {
DocumentsContract.deleteDocument(context.getContentResolver(), uri);
} catch (final Exception e) {
Log.e(TAG, "deleteSAF: Failed to delete a file descriptor provided by SAF", e);
toast(context, String.format(context.getString(R.string.saf_delete_failed), e.getLocalizedMessage()));
}
}
private static void toast(final Context context, final String message) {
if (context instanceof Activity) {
((Activity) context).runOnUiThread(() -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show());
}
}
}