diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d0c73348..4ed887e6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -113,6 +113,9 @@
android:name=".ui.activities.intro.AppIntroActivity"
android:label="@string/intro_label"
android:theme="@style/Theme.Intro" />
+
diff --git a/app/src/main/java/com/kabouzeid/gramophone/dialogs/DeleteSongsDialog.java b/app/src/main/java/com/kabouzeid/gramophone/dialogs/DeleteSongsDialog.java
index da7ef178..00b5eb84 100644
--- a/app/src/main/java/com/kabouzeid/gramophone/dialogs/DeleteSongsDialog.java
+++ b/app/src/main/java/com/kabouzeid/gramophone/dialogs/DeleteSongsDialog.java
@@ -1,24 +1,41 @@
package com.kabouzeid.gramophone.dialogs;
+import android.annotation.TargetApi;
+import android.app.Activity;
import android.app.Dialog;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.v4.app.DialogFragment;
+import android.support.v4.app.FragmentActivity;
import android.text.Html;
+import android.widget.Toast;
import com.afollestad.materialdialogs.DialogAction;
import com.afollestad.materialdialogs.MaterialDialog;
import com.kabouzeid.gramophone.R;
+import com.kabouzeid.gramophone.misc.DialogAsyncTask;
import com.kabouzeid.gramophone.model.Song;
+import com.kabouzeid.gramophone.ui.activities.saf.SAFGuideActivity;
import com.kabouzeid.gramophone.util.MusicUtil;
+import com.kabouzeid.gramophone.util.SAFUtil;
+import java.lang.ref.WeakReference;
import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
/**
* @author Karim Abou Zeid (kabouzeid), Aidan Follestad (afollestad)
*/
public class DeleteSongsDialog extends DialogFragment {
+ private ArrayList songsToRemove;
+ private Song currentSong;
+
@NonNull
public static DeleteSongsDialog create(Song song) {
ArrayList list = new ArrayList<>();
@@ -54,14 +71,171 @@ public class DeleteSongsDialog extends DialogFragment {
.content(content)
.positiveText(R.string.delete_action)
.negativeText(android.R.string.cancel)
+ .autoDismiss(false)
.onPositive(new MaterialDialog.SingleButtonCallback() {
@Override
public void onClick(@NonNull MaterialDialog dialog, @NonNull DialogAction which) {
- if (getActivity() == null)
- return;
- MusicUtil.deleteTracks(getActivity(), songs);
+ songsToRemove = songs;
+ new DeleteSongsAsyncTask(DeleteSongsDialog.this).execute(new DeleteSongsAsyncTask.LoadingInfo(songs, null));
+ }
+ })
+ .onNegative(new MaterialDialog.SingleButtonCallback() {
+ @Override
+ public void onClick(@NonNull MaterialDialog materialDialog, @NonNull DialogAction dialogAction) {
+ dismiss();
}
})
.build();
}
+
+ private void deleteSongs(List songs, List safUris) {
+ MusicUtil.deleteTracks(getActivity(), songs, safUris);
+ }
+
+ @TargetApi(Build.VERSION_CODES.KITKAT)
+ private void deleteSongsKitkat() {
+ if (songsToRemove.size() < 1) {
+ dismiss();
+ return;
+ }
+
+ currentSong = songsToRemove.remove(0);
+
+ if (!SAFUtil.isSAFRequired(currentSong)) {
+ deleteSongs(Collections.singletonList(currentSong), null);
+ deleteSongsKitkat();
+ } else {
+ Toast.makeText(getActivity(), String.format(getString(R.string.saf_pick_file), currentSong.data), Toast.LENGTH_LONG).show();
+ SAFUtil.openFilePicker(this);
+ }
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent intent) {
+ super.onActivityResult(requestCode, resultCode, intent);
+ switch (requestCode) {
+ case SAFGuideActivity.REQUEST_CODE_SAF_GUIDE:
+ SAFUtil.openTreePicker(this);
+ break;
+
+ case SAFUtil.REQUEST_SAF_PICK_TREE:
+ case SAFUtil.REQUEST_SAF_PICK_FILE:
+ new DeleteSongsAsyncTask(this).execute(new DeleteSongsAsyncTask.LoadingInfo(requestCode, resultCode, intent));
+ break;
+ }
+ }
+
+ private static class DeleteSongsAsyncTask extends DialogAsyncTask {
+ private WeakReference dialog;
+ private WeakReference activity;
+
+ public DeleteSongsAsyncTask(DeleteSongsDialog dialog) {
+ super(dialog.getActivity());
+ this.dialog = new WeakReference<>(dialog);
+ this.activity = new WeakReference<>(dialog.getActivity());
+ }
+
+ @Override
+ protected Void doInBackground(LoadingInfo... params) {
+ try {
+ LoadingInfo info = params[0];
+
+ DeleteSongsDialog dialog = this.dialog.get();
+ FragmentActivity activity = this.activity.get();
+
+ if (dialog == null || activity == null)
+ return null;
+
+ if (!info.isIntent) {
+ if (!SAFUtil.isSAFRequiredForSongs(info.songs)) {
+ dialog.deleteSongs(info.songs, null);
+ } else {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ if (SAFUtil.isSDCardAccessGranted(activity)) {
+ dialog.deleteSongs(info.songs, null);
+ } else {
+ dialog.startActivityForResult(new Intent(activity, SAFGuideActivity.class), SAFGuideActivity.REQUEST_CODE_SAF_GUIDE);
+ }
+ } else {
+ dialog.deleteSongsKitkat();
+ }
+ }
+ } else {
+ switch (info.requestCode) {
+ case SAFUtil.REQUEST_SAF_PICK_TREE:
+ if (info.resultCode == Activity.RESULT_OK) {
+ SAFUtil.saveTreeUri(activity, info.intent);
+ dialog.deleteSongs(dialog.songsToRemove, null);
+ }
+ break;
+
+ case SAFUtil.REQUEST_SAF_PICK_FILE:
+ if (info.resultCode == Activity.RESULT_OK) {
+ dialog.deleteSongs(Collections.singletonList(dialog.currentSong), Collections.singletonList(info.intent.getData()));
+ }
+ break;
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(Void v) {
+ super.onPostExecute(v);
+
+ DeleteSongsDialog dialog = this.dialog.get();
+ FragmentActivity activity = this.activity.get();
+ if (dialog != null && activity != null && !activity.isFinishing()) {
+ dialog.dismiss();
+ }
+ }
+
+ @Override
+ protected void onCancelled() {
+ super.onCancelled();
+
+ DeleteSongsDialog dialog = this.dialog.get();
+ FragmentActivity activity = this.activity.get();
+ if (dialog != null && activity != null && !activity.isFinishing()) {
+ dialog.dismiss();
+ }
+ }
+
+ @Override
+ protected Dialog createDialog(@NonNull Context context) {
+ return new MaterialDialog.Builder(context)
+ .title(R.string.deleting_songs)
+ .cancelable(false)
+ .progress(true, 0)
+ .build();
+ }
+
+ public static class LoadingInfo {
+ public boolean isIntent;
+
+ public List songs;
+ public List safUris;
+
+ public int requestCode;
+ public int resultCode;
+ public Intent intent;
+
+ public LoadingInfo(List songs, List safUris) {
+ this.isIntent = false;
+ this.songs = songs;
+ this.safUris = safUris;
+ }
+
+ public LoadingInfo(int requestCode, int resultCode, Intent intent) {
+ this.isIntent = true;
+ this.requestCode = requestCode;
+ this.resultCode = resultCode;
+ this.intent = intent;
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/kabouzeid/gramophone/ui/activities/saf/SAFGuideActivity.java b/app/src/main/java/com/kabouzeid/gramophone/ui/activities/saf/SAFGuideActivity.java
new file mode 100644
index 00000000..aff54327
--- /dev/null
+++ b/app/src/main/java/com/kabouzeid/gramophone/ui/activities/saf/SAFGuideActivity.java
@@ -0,0 +1,52 @@
+package com.kabouzeid.gramophone.ui.activities.saf;
+
+import android.os.Build;
+import android.os.Bundle;
+
+import com.heinrichreimersoftware.materialintro.app.IntroActivity;
+import com.heinrichreimersoftware.materialintro.slide.SimpleSlide;
+import com.kabouzeid.gramophone.R;
+
+
+public class SAFGuideActivity extends IntroActivity {
+
+ public static final int REQUEST_CODE_SAF_GUIDE = 98;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setButtonCtaVisible(false);
+ setButtonNextVisible(false);
+ setButtonBackVisible(false);
+
+ setButtonCtaTintMode(BUTTON_CTA_TINT_MODE_TEXT);
+
+ String title = String.format(getString(R.string.saf_guide_slide1_title), getString(R.string.app_name));
+
+ addSlide(new SimpleSlide.Builder()
+ .title(title)
+ .description(Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1 ? R.string.saf_guide_slide1_description_before_o : R.string.saf_guide_slide1_description)
+ .image(R.drawable.saf_guide_1)
+ .background(R.color.md_indigo_300)
+ .backgroundDark(R.color.md_indigo_400)
+ .layout(R.layout.fragment_simple_slide_large_image)
+ .build());
+ addSlide(new SimpleSlide.Builder()
+ .title(R.string.saf_guide_slide2_title)
+ .description(R.string.saf_guide_slide2_description)
+ .image(R.drawable.saf_guide_2)
+ .background(R.color.md_indigo_500)
+ .backgroundDark(R.color.md_indigo_600)
+ .layout(R.layout.fragment_simple_slide_large_image)
+ .build());
+ addSlide(new SimpleSlide.Builder()
+ .title(R.string.saf_guide_slide3_title)
+ .description(R.string.saf_guide_slide3_description)
+ .image(R.drawable.saf_guide_3)
+ .background(R.color.md_indigo_700)
+ .backgroundDark(R.color.md_indigo_800)
+ .layout(R.layout.fragment_simple_slide_large_image)
+ .build());
+ }
+}
diff --git a/app/src/main/java/com/kabouzeid/gramophone/ui/activities/tageditor/AbsTagEditorActivity.java b/app/src/main/java/com/kabouzeid/gramophone/ui/activities/tageditor/AbsTagEditorActivity.java
index cf9ab563..06dfcada 100644
--- a/app/src/main/java/com/kabouzeid/gramophone/ui/activities/tageditor/AbsTagEditorActivity.java
+++ b/app/src/main/java/com/kabouzeid/gramophone/ui/activities/tageditor/AbsTagEditorActivity.java
@@ -1,5 +1,6 @@
package com.kabouzeid.gramophone.ui.activities.tageditor;
+import android.annotation.TargetApi;
import android.app.Activity;
import android.app.Dialog;
import android.app.SearchManager;
@@ -9,6 +10,7 @@ import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.MediaScannerConnection;
import android.net.Uri;
+import android.os.Build;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
@@ -20,6 +22,7 @@ import android.view.View;
import android.view.animation.OvershootInterpolator;
import android.widget.ImageView;
import android.widget.LinearLayout;
+import android.widget.Toast;
import com.afollestad.materialdialogs.MaterialDialog;
import com.github.ksoichiro.android.observablescrollview.ObservableScrollView;
@@ -31,25 +34,25 @@ import com.kabouzeid.gramophone.misc.DialogAsyncTask;
import com.kabouzeid.gramophone.misc.SimpleObservableScrollViewCallbacks;
import com.kabouzeid.gramophone.misc.UpdateToastMediaScannerCompletionListener;
import com.kabouzeid.gramophone.ui.activities.base.AbsBaseActivity;
+import com.kabouzeid.gramophone.ui.activities.saf.SAFGuideActivity;
import com.kabouzeid.gramophone.util.MusicUtil;
+import com.kabouzeid.gramophone.util.SAFUtil;
import com.kabouzeid.gramophone.util.Util;
import org.jaudiotagger.audio.AudioFile;
import org.jaudiotagger.audio.AudioFileIO;
-import org.jaudiotagger.audio.exceptions.CannotReadException;
-import org.jaudiotagger.audio.exceptions.CannotWriteException;
-import org.jaudiotagger.audio.exceptions.InvalidAudioFrameException;
-import org.jaudiotagger.audio.exceptions.ReadOnlyFileException;
import org.jaudiotagger.tag.FieldKey;
import org.jaudiotagger.tag.Tag;
-import org.jaudiotagger.tag.TagException;
import org.jaudiotagger.tag.images.Artwork;
import org.jaudiotagger.tag.images.ArtworkFactory;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -95,6 +98,11 @@ public abstract class AbsTagEditorActivity extends AbsBaseActivity {
};
private List songPaths;
+ private List savedSongPaths;
+ private String currentSongPath;
+ private Map savedTags;
+ private ArtworkInfo savedArtworkInfo;
+
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -257,6 +265,16 @@ public abstract class AbsTagEditorActivity extends AbsBaseActivity {
fab.setEnabled(true);
}
+ private void hideFab() {
+ fab.animate()
+ .setDuration(500)
+ .setInterpolator(new OvershootInterpolator())
+ .scaleX(0)
+ .scaleY(0)
+ .start();
+ fab.setEnabled(false);
+ }
+
protected void setImageBitmap(@Nullable final Bitmap bitmap, int bgColor) {
if (bitmap == null) {
image.setImageResource(R.drawable.default_album_art);
@@ -278,15 +296,52 @@ public abstract class AbsTagEditorActivity extends AbsBaseActivity {
protected void writeValuesToFiles(@NonNull final Map fieldKeyValueMap, @Nullable final ArtworkInfo artworkInfo) {
Util.hideSoftKeyboard(this);
- new WriteTagsAsyncTask(this).execute(new WriteTagsAsyncTask.LoadingInfo(getSongPaths(), fieldKeyValueMap, artworkInfo));
+ hideFab();
+
+ savedSongPaths = getSongPaths();
+ savedTags = fieldKeyValueMap;
+ savedArtworkInfo = artworkInfo;
+
+ if (!SAFUtil.isSAFRequired(savedSongPaths)) {
+ writeTags(savedSongPaths);
+ } else {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ if (SAFUtil.isSDCardAccessGranted(this)) {
+ writeTags(savedSongPaths);
+ } else {
+ startActivityForResult(new Intent(this, SAFGuideActivity.class), SAFGuideActivity.REQUEST_CODE_SAF_GUIDE);
+ }
+ } else {
+ writeTagsKitkat();
+ }
+ }
+ }
+
+ private void writeTags(List paths) {
+ new WriteTagsAsyncTask(this).execute(new WriteTagsAsyncTask.LoadingInfo(paths, savedTags, savedArtworkInfo));
+ }
+
+ @TargetApi(Build.VERSION_CODES.KITKAT)
+ private void writeTagsKitkat() {
+ if (savedSongPaths.size() < 1) return;
+
+ currentSongPath = savedSongPaths.remove(0);
+
+ if (!SAFUtil.isSAFRequired(currentSongPath)) {
+ writeTags(Collections.singletonList(currentSongPath));
+ writeTagsKitkat();
+ } else {
+ Toast.makeText(this, String.format(getString(R.string.saf_pick_file), currentSongPath), Toast.LENGTH_LONG).show();
+ SAFUtil.openFilePicker(this);
+ }
}
private static class WriteTagsAsyncTask extends DialogAsyncTask {
- Context applicationContext;
+ private WeakReference activity;
- public WriteTagsAsyncTask(Context context) {
- super(context);
- applicationContext = context;
+ public WriteTagsAsyncTask(Activity activity) {
+ super(activity);
+ this.activity = new WeakReference<>(activity);
}
@Override
@@ -312,6 +367,14 @@ public abstract class AbsTagEditorActivity extends AbsBaseActivity {
for (String filePath : info.filePaths) {
publishProgress(++counter, info.filePaths.size());
try {
+ Uri safUri = null;
+
+ if (filePath.contains(SAFUtil.SEPARATOR)) {
+ String[] fragments = filePath.split(SAFUtil.SEPARATOR);
+ filePath = fragments[0];
+ safUri = Uri.parse(fragments[1]);
+ }
+
AudioFile audioFile = AudioFileIO.read(new File(filePath));
Tag tag = audioFile.getTagOrCreateAndSetDefault();
@@ -336,8 +399,10 @@ public abstract class AbsTagEditorActivity extends AbsBaseActivity {
}
}
- audioFile.commit();
- } catch (@NonNull CannotReadException | IOException | CannotWriteException | TagException | ReadOnlyFileException | InvalidAudioFrameException e) {
+ Activity activity = this.activity.get();
+
+ SAFUtil.write(activity, audioFile, safUri);
+ } catch (@NonNull Exception e) {
e.printStackTrace();
}
}
@@ -351,7 +416,18 @@ public abstract class AbsTagEditorActivity extends AbsBaseActivity {
}
}
- return info.filePaths.toArray(new String[info.filePaths.size()]);
+ Collection paths = info.filePaths;
+
+ if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) { // remove SAF URI from paths
+ paths = new ArrayList<>(info.filePaths.size());
+ for (String path : info.filePaths) {
+ if (path.contains(SAFUtil.SEPARATOR))
+ path = path.split(SAFUtil.SEPARATOR)[0];
+ paths.add(path);
+ }
+ }
+
+ return paths.toArray(new String[paths.size()]);
} catch (Exception e) {
e.printStackTrace();
return null;
@@ -371,8 +447,10 @@ public abstract class AbsTagEditorActivity extends AbsBaseActivity {
}
private void scan(String[] toBeScanned) {
- Context context = getContext();
- MediaScannerConnection.scanFile(applicationContext, toBeScanned, null, context instanceof Activity ? new UpdateToastMediaScannerCompletionListener((Activity) context, toBeScanned) : null);
+ Activity activity = this.activity.get();
+ if (activity != null) {
+ MediaScannerConnection.scanFile(activity, toBeScanned, null, new UpdateToastMediaScannerCompletionListener(activity, toBeScanned));
+ }
}
@Override
@@ -421,14 +499,32 @@ public abstract class AbsTagEditorActivity extends AbsBaseActivity {
}
@Override
- protected void onActivityResult(int requestCode, int resultCode, @NonNull Intent imageReturnedIntent) {
- super.onActivityResult(requestCode, resultCode, imageReturnedIntent);
+ protected void onActivityResult(int requestCode, int resultCode, @NonNull Intent intent) {
+ super.onActivityResult(requestCode, resultCode, intent);
switch (requestCode) {
case REQUEST_CODE_SELECT_IMAGE:
if (resultCode == RESULT_OK) {
- Uri selectedImage = imageReturnedIntent.getData();
+ Uri selectedImage = intent.getData();
loadImageFromFile(selectedImage);
}
+ break;
+
+ case SAFGuideActivity.REQUEST_CODE_SAF_GUIDE:
+ SAFUtil.openTreePicker(this);
+ break;
+
+ case SAFUtil.REQUEST_SAF_PICK_TREE:
+ if (resultCode == RESULT_OK) {
+ SAFUtil.saveTreeUri(this, intent);
+ writeTags(savedSongPaths);
+ }
+ break;
+
+ case SAFUtil.REQUEST_SAF_PICK_FILE:
+ if (resultCode == RESULT_OK) {
+ writeTags(Collections.singletonList(currentSongPath + SAFUtil.SEPARATOR + intent.getDataString()));
+ }
+ break;
}
}
diff --git a/app/src/main/java/com/kabouzeid/gramophone/util/FileUtil.java b/app/src/main/java/com/kabouzeid/gramophone/util/FileUtil.java
index df54e766..3a5f43e6 100644
--- a/app/src/main/java/com/kabouzeid/gramophone/util/FileUtil.java
+++ b/app/src/main/java/com/kabouzeid/gramophone/util/FileUtil.java
@@ -12,6 +12,7 @@ import com.kabouzeid.gramophone.loader.SortedCursor;
import com.kabouzeid.gramophone.model.Song;
import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileFilter;
import java.io.FileInputStream;
@@ -190,4 +191,15 @@ public final class FileUtil {
fin.close();
return ret;
}
+
+ public static byte[] readBytes(InputStream stream) throws IOException {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ byte[] buffer = new byte[4096];
+ int count;
+ while ((count = stream.read(buffer)) != -1) {
+ baos.write(buffer, 0, count);
+ }
+ stream.close();
+ return baos.toByteArray();
+ }
}
diff --git a/app/src/main/java/com/kabouzeid/gramophone/util/MusicUtil.java b/app/src/main/java/com/kabouzeid/gramophone/util/MusicUtil.java
index 4f299565..8728564c 100644
--- a/app/src/main/java/com/kabouzeid/gramophone/util/MusicUtil.java
+++ b/app/src/main/java/com/kabouzeid/gramophone/util/MusicUtil.java
@@ -1,5 +1,6 @@
package com.kabouzeid.gramophone.util;
+import android.app.Activity;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
@@ -14,7 +15,6 @@ import android.provider.Settings;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.TextUtils;
-import android.util.Log;
import android.widget.Toast;
import com.kabouzeid.gramophone.R;
@@ -23,7 +23,6 @@ import com.kabouzeid.gramophone.loader.PlaylistLoader;
import com.kabouzeid.gramophone.loader.SongLoader;
import com.kabouzeid.gramophone.model.Artist;
import com.kabouzeid.gramophone.model.Playlist;
-import com.kabouzeid.gramophone.model.PlaylistSong;
import com.kabouzeid.gramophone.model.Song;
import com.kabouzeid.gramophone.model.lyrics.AbsSynchronizedLyrics;
@@ -175,7 +174,7 @@ public class MusicUtil {
return albumArtDir;
}
- public static void deleteTracks(@NonNull final Context context, @NonNull final List songs) {
+ public static void deleteTracks(@NonNull final Activity activity, @NonNull final List songs, @Nullable final List safUris) {
final String[] projection = new String[]{
BaseColumns._ID, MediaStore.MediaColumns.DATA
};
@@ -190,7 +189,7 @@ public class MusicUtil {
selection.append(")");
try {
- final Cursor cursor = context.getContentResolver().query(
+ final Cursor cursor = activity.getContentResolver().query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, selection.toString(),
null, null);
if (cursor != null) {
@@ -199,37 +198,35 @@ public class MusicUtil {
cursor.moveToFirst();
while (!cursor.isAfterLast()) {
final int id = cursor.getInt(0);
- final Song song = SongLoader.getSong(context, id);
+ final Song song = SongLoader.getSong(activity, id);
MusicPlayerRemote.removeFromQueue(song);
cursor.moveToNext();
}
// Step 2: Remove selected tracks from the database
- context.getContentResolver().delete(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
+ activity.getContentResolver().delete(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
selection.toString(), null);
// Step 3: Remove files from card
cursor.moveToFirst();
+ int i = 0;
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);
- }
- cursor.moveToNext();
- } catch (@NonNull final SecurityException ex) {
- cursor.moveToNext();
- } catch (NullPointerException e) {
- Log.e("MusicUtils", "Failed to find file " + name);
- }
+ final Uri safUri = safUris == null || safUris.size() <= i ? null : safUris.get(i);
+ SAFUtil.delete(activity, name, safUri);
+ i++;
+ cursor.moveToNext();
}
cursor.close();
}
- context.getContentResolver().notifyChange(Uri.parse("content://media"), null);
- Toast.makeText(context, context.getString(R.string.deleted_x_songs, songs.size()), Toast.LENGTH_SHORT).show();
+ activity.getContentResolver().notifyChange(Uri.parse("content://media"), null);
+
+ activity.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ Toast.makeText(activity, activity.getString(R.string.deleted_x_songs, songs.size()), Toast.LENGTH_SHORT).show();
+ }
+ });
} catch (SecurityException ignored) {
}
}
diff --git a/app/src/main/java/com/kabouzeid/gramophone/util/PreferenceUtil.java b/app/src/main/java/com/kabouzeid/gramophone/util/PreferenceUtil.java
index 565df23e..69df6d5e 100644
--- a/app/src/main/java/com/kabouzeid/gramophone/util/PreferenceUtil.java
+++ b/app/src/main/java/com/kabouzeid/gramophone/util/PreferenceUtil.java
@@ -3,6 +3,7 @@ package com.kabouzeid.gramophone.util;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.SharedPreferences;
+import android.net.Uri;
import android.preference.PreferenceManager;
import android.support.annotation.NonNull;
import android.support.annotation.StyleRes;
@@ -71,6 +72,8 @@ public final class PreferenceUtil {
public static final String SYNCHRONIZED_LYRICS_SHOW = "synchronized_lyrics_show";
+ public static final String SAF_SDCARD_URI = "saf_sdcard_uri";
+
private static PreferenceUtil sInstance;
private final SharedPreferences mPreferences;
@@ -406,4 +409,12 @@ public final class PreferenceUtil {
public final boolean synchronizedLyricsShow() {
return mPreferences.getBoolean(SYNCHRONIZED_LYRICS_SHOW, true);
}
+
+ 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();
+ }
}
diff --git a/app/src/main/java/com/kabouzeid/gramophone/util/SAFUtil.java b/app/src/main/java/com/kabouzeid/gramophone/util/SAFUtil.java
new file mode 100644
index 00000000..80038481
--- /dev/null
+++ b/app/src/main/java/com/kabouzeid/gramophone/util/SAFUtil.java
@@ -0,0 +1,291 @@
+package com.kabouzeid.gramophone.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.support.annotation.Nullable;
+import android.support.v4.app.Fragment;
+import android.support.v4.provider.DocumentFile;
+import android.text.TextUtils;
+import android.util.Log;
+import android.widget.Toast;
+
+import com.kabouzeid.gramophone.R;
+import com.kabouzeid.gramophone.model.Song;
+
+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;
+
+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.data);
+ }
+
+ public static boolean isSAFRequired(List paths) {
+ for (String path : paths) {
+ if (isSAFRequired(path)) return true;
+ }
+ return false;
+ }
+
+ public static boolean isSAFRequiredForSongs(List 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(context).setSAFSDCardUri(uri);
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public static boolean isTreeUriSaved(Context context) {
+ return !TextUtils.isEmpty(PreferenceUtil.getInstance(context).getSAFSDCardUri());
+ }
+
+ @TargetApi(Build.VERSION_CODES.LOLLIPOP)
+ public static boolean isSDCardAccessGranted(Context context) {
+ if (!isTreeUriSaved(context)) return false;
+
+ String sdcardUri = PreferenceUtil.getInstance(context).getSAFSDCardUri();
+
+ List 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 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 pathSegments = new ArrayList<>(Arrays.asList(audio.getFile().getAbsolutePath().split("/")));
+ Uri sdcard = Uri.parse(PreferenceUtil.getInstance(context).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 (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 pathSegments = new ArrayList<>(Arrays.asList(path.split("/")));
+ Uri sdcard = Uri.parse(PreferenceUtil.getInstance(context).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(new Runnable() {
+ @Override
+ public void run() {
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show();
+ }
+ });
+ }
+ }
+
+}
diff --git a/app/src/main/res/drawable-v21/saf_guide_1.png b/app/src/main/res/drawable-v21/saf_guide_1.png
new file mode 100644
index 00000000..d31be5b5
Binary files /dev/null and b/app/src/main/res/drawable-v21/saf_guide_1.png differ
diff --git a/app/src/main/res/drawable-v21/saf_guide_2.png b/app/src/main/res/drawable-v21/saf_guide_2.png
new file mode 100644
index 00000000..24a9c294
Binary files /dev/null and b/app/src/main/res/drawable-v21/saf_guide_2.png differ
diff --git a/app/src/main/res/drawable-v21/saf_guide_3.png b/app/src/main/res/drawable-v21/saf_guide_3.png
new file mode 100644
index 00000000..2317a82c
Binary files /dev/null and b/app/src/main/res/drawable-v21/saf_guide_3.png differ
diff --git a/app/src/main/res/drawable-v26/saf_guide_1.png b/app/src/main/res/drawable-v26/saf_guide_1.png
new file mode 100644
index 00000000..f4904601
Binary files /dev/null and b/app/src/main/res/drawable-v26/saf_guide_1.png differ
diff --git a/app/src/main/res/drawable-v26/saf_guide_2.png b/app/src/main/res/drawable-v26/saf_guide_2.png
new file mode 100644
index 00000000..ddc84b27
Binary files /dev/null and b/app/src/main/res/drawable-v26/saf_guide_2.png differ
diff --git a/app/src/main/res/drawable-v26/saf_guide_3.png b/app/src/main/res/drawable-v26/saf_guide_3.png
new file mode 100644
index 00000000..c3ada22a
Binary files /dev/null and b/app/src/main/res/drawable-v26/saf_guide_3.png differ
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 050cdca7..0f8d58e0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -262,6 +262,7 @@
Copied device info to clipboard.
Your account data is only used for authentication.
You will be forwarded to the issue tracker website.
+ Deleting songs
@string/action_shuffle_all
Shuffle
@@ -272,4 +273,18 @@
Playlist is empty
The playing notification provides actions for play/pause etc.
Playing notification
+
+ Can\'t get SAF URI
+ File write failed: %s
+ File delete failed: %s
+ SD card access required. Please pick root directory of SD card
+ File access required. Pick %s
+
+ %s needs SD card access
+ Enable \'Show SD card\' in overflow menu
+ Open navigation drawer
+ Select your SD card in navigation drawer
+ You need to select your SD card root directory
+ Tap \'select\' button at the bottom of the screen
+ Do not open any subfolders