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