From f6b32823ba525c62241c65ba2327bc10a9cc107a Mon Sep 17 00:00:00 2001 From: kapodamy Date: Fri, 5 Apr 2019 14:45:39 -0300 Subject: [PATCH] Implement Storage Access Framework * re-work finished mission database * re-work DownloadMission and bump it Serializable version * keep the classic Java IO API * SAF Tree API support on Android Lollipop or higher * add wrapper for SAF stream opening * implement Closeable in SharpStream to replace the dispose() method * do required changes for this API: ** remove any file creation logic from DownloadInitializer ** make PostProcessing Serializable and reduce the number of iterations ** update all strings.xml files ** storage helpers: StoredDirectoryHelper & StoredFileHelper ** best effort to handle any kind of SAF errors/exceptions --- .../newpipe/download/DownloadActivity.java | 2 +- .../newpipe/download/DownloadDialog.java | 414 +++++++++++++----- .../newpipe/local/feed/FeedFragment.java | 4 +- .../player/playback/MediaSourceManager.java | 2 +- .../settings/DownloadSettingsFragment.java | 201 +++++++-- .../newpipe/settings/NewPipeSettings.java | 35 +- .../newpipe/streams/Mp4FromDashWriter.java | 2 +- .../schabi/newpipe/streams/WebMWriter.java | 2 +- .../newpipe/streams/io/SharpStream.java | 12 +- .../giga/get/DownloadInitializer.java | 36 +- .../us/shandian/giga/get/DownloadMission.java | 132 +++--- .../shandian/giga/get/DownloadRunnable.java | 11 +- .../giga/get/DownloadRunnableFallback.java | 16 +- .../us/shandian/giga/get/FinishedMission.java | 6 +- .../java/us/shandian/giga/get/Mission.java | 35 +- .../giga/get/sqlite/DownloadDataSource.java | 73 --- .../get/sqlite/DownloadMissionHelper.java | 112 ----- .../giga/get/sqlite/FinishedMissionStore.java | 223 ++++++++++ .../io/ChunkFileInputStream.java | 298 +++++++------ .../io/CircularFileWriter.java | 54 +-- .../java/us/shandian/giga/io/FileStream.java | 131 ++++++ .../us/shandian/giga/io/FileStreamSAF.java | 140 ++++++ .../io/SharpInputStream.java | 122 +++--- .../giga/io/StoredDirectoryHelper.java | 175 ++++++++ .../us/shandian/giga/io/StoredFileHelper.java | 301 +++++++++++++ .../giga/postprocessing/M4aNoDash.java | 6 +- .../giga/postprocessing/Mp4FromDashMuxer.java | 6 +- .../giga/postprocessing/Postprocessing.java | 108 +++-- .../giga/postprocessing/TtmlConverter.java | 9 +- .../giga/postprocessing/WebMMuxer.java | 6 +- .../giga/service/DownloadManager.java | 298 ++++++------- .../giga/service/DownloadManagerService.java | 204 ++++++--- .../shandian/giga/service/MissionState.java | 5 + .../giga/ui/adapter/MissionAdapter.java | 97 +++- .../us/shandian/giga/ui/common/Deleter.java | 52 +-- .../giga/ui/fragment/MissionsFragment.java | 57 ++- .../java/us/shandian/giga/util/Utility.java | 23 +- app/src/main/res/values-ar/strings.xml | 6 +- app/src/main/res/values-ca/strings.xml | 4 +- app/src/main/res/values-cmn/strings.xml | 6 +- app/src/main/res/values-da/strings.xml | 4 +- app/src/main/res/values-de/strings.xml | 6 +- app/src/main/res/values-es/strings.xml | 25 +- app/src/main/res/values-eu/strings.xml | 6 +- app/src/main/res/values-he/strings.xml | 6 +- app/src/main/res/values-id/strings.xml | 6 +- app/src/main/res/values-it/strings.xml | 6 +- app/src/main/res/values-ja/strings.xml | 12 +- app/src/main/res/values-ms/strings.xml | 6 +- app/src/main/res/values-nb-rNO/strings.xml | 6 +- app/src/main/res/values-nl-rBE/strings.xml | 6 +- app/src/main/res/values-nl/strings.xml | 6 +- app/src/main/res/values-pl/strings.xml | 6 +- app/src/main/res/values-pt-rBR/strings.xml | 6 +- app/src/main/res/values-pt/strings.xml | 6 +- app/src/main/res/values-ru/strings.xml | 6 +- app/src/main/res/values-tr/strings.xml | 6 +- app/src/main/res/values-vi/strings.xml | 6 +- app/src/main/res/values-zh-rTW/strings.xml | 6 +- app/src/main/res/values/settings_keys.xml | 17 +- app/src/main/res/values/strings.xml | 21 +- app/src/main/res/xml/download_settings.xml | 18 +- 62 files changed, 2439 insertions(+), 1180 deletions(-) delete mode 100644 app/src/main/java/us/shandian/giga/get/sqlite/DownloadDataSource.java delete mode 100644 app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionHelper.java create mode 100644 app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java rename app/src/main/java/us/shandian/giga/{postprocessing => }/io/ChunkFileInputStream.java (80%) rename app/src/main/java/us/shandian/giga/{postprocessing => }/io/CircularFileWriter.java (89%) create mode 100644 app/src/main/java/us/shandian/giga/io/FileStream.java create mode 100644 app/src/main/java/us/shandian/giga/io/FileStreamSAF.java rename app/src/main/java/us/shandian/giga/{postprocessing => }/io/SharpInputStream.java (91%) create mode 100644 app/src/main/java/us/shandian/giga/io/StoredDirectoryHelper.java create mode 100644 app/src/main/java/us/shandian/giga/io/StoredFileHelper.java create mode 100644 app/src/main/java/us/shandian/giga/service/MissionState.java diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java index 7ee686a66..41971dfd4 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java @@ -55,7 +55,7 @@ public class DownloadActivity extends AppCompatActivity { private void updateFragments() { MissionsFragment fragment = new MissionsFragment(); - getFragmentManager().beginTransaction() + getSupportFragmentManager().beginTransaction() .replace(R.id.frame, fragment, MISSIONS_FRAGMENT_TAG) .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) .commit(); diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java index 0b4767133..4525c5988 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -1,8 +1,14 @@ package org.schabi.newpipe.download; +import android.app.Activity; +import android.content.ComponentName; import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; import android.content.SharedPreferences; +import android.net.Uri; import android.os.Bundle; +import android.os.IBinder; import android.preference.PreferenceManager; import android.support.annotation.IdRes; import android.support.annotation.NonNull; @@ -14,6 +20,7 @@ import android.support.v7.widget.Toolbar; import android.util.Log; import android.util.SparseArray; import android.view.LayoutInflater; +import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; @@ -35,7 +42,8 @@ import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.extractor.utils.Localization; -import org.schabi.newpipe.settings.NewPipeSettings; +import org.schabi.newpipe.report.ErrorActivity; +import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.util.FilenameUtils; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.PermissionHelper; @@ -44,20 +52,27 @@ import org.schabi.newpipe.util.StreamItemAdapter; import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper; import org.schabi.newpipe.util.ThemeHelper; +import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Locale; import icepick.Icepick; import icepick.State; import io.reactivex.disposables.CompositeDisposable; +import us.shandian.giga.io.StoredDirectoryHelper; +import us.shandian.giga.io.StoredFileHelper; import us.shandian.giga.postprocessing.Postprocessing; +import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; -import us.shandian.giga.service.DownloadManagerService.MissionCheck; +import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; +import us.shandian.giga.service.MissionState; public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheckedChangeListener, AdapterView.OnItemSelectedListener { private static final String TAG = "DialogFragment"; private static final boolean DEBUG = MainActivity.DEBUG; + private static final int REQUEST_DOWNLOAD_PATH_SAF = 0x1230; @State protected StreamInfo currentInfo; @@ -82,7 +97,7 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck private EditText nameEditText; private Spinner streamsSpinner; - private RadioGroup radioVideoAudioGroup; + private RadioGroup radioStreamsGroup; private TextView threadsCountTextView; private SeekBar threadsSeekBar; @@ -162,7 +177,11 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck return; } - setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(getContext())); + final Context context = getContext(); + if (context == null) + throw new RuntimeException("Context was null"); + + setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context)); Icepick.restoreInstanceState(this, savedInstanceState); SparseArray> secondaryStreams = new SparseArray<>(4); @@ -179,9 +198,32 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck } } - this.videoStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedVideoStreams, secondaryStreams); - this.audioStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedAudioStreams); - this.subtitleStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedSubtitleStreams); + this.videoStreamsAdapter = new StreamItemAdapter<>(context, wrappedVideoStreams, secondaryStreams); + this.audioStreamsAdapter = new StreamItemAdapter<>(context, wrappedAudioStreams); + this.subtitleStreamsAdapter = new StreamItemAdapter<>(context, wrappedSubtitleStreams); + + Intent intent = new Intent(context, DownloadManagerService.class); + context.startService(intent); + + context.bindService(intent, new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName cname, IBinder service) { + DownloadManagerBinder mgr = (DownloadManagerBinder) service; + + mainStorageAudio = mgr.getMainStorageAudio(); + mainStorageVideo = mgr.getMainStorageVideo(); + downloadManager = mgr.getDownloadManager(); + + okButton.setEnabled(true); + + context.unbindService(this); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + // nothing to do + } + }, Context.BIND_AUTO_CREATE); } @Override @@ -206,8 +248,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck threadsCountTextView = view.findViewById(R.id.threads_count); threadsSeekBar = view.findViewById(R.id.threads); - radioVideoAudioGroup = view.findViewById(R.id.video_audio_group); - radioVideoAudioGroup.setOnCheckedChangeListener(this); + radioStreamsGroup = view.findViewById(R.id.video_audio_group); + radioStreamsGroup.setOnCheckedChangeListener(this); initToolbar(view.findViewById(R.id.toolbar)); setupDownloadOptions(); @@ -242,17 +284,17 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck disposables.clear(); disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedVideoStreams).subscribe(result -> { - if (radioVideoAudioGroup.getCheckedRadioButtonId() == R.id.video_button) { + if (radioStreamsGroup.getCheckedRadioButtonId() == R.id.video_button) { setupVideoSpinner(); } })); disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedAudioStreams).subscribe(result -> { - if (radioVideoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { + if (radioStreamsGroup.getCheckedRadioButtonId() == R.id.audio_button) { setupAudioSpinner(); } })); disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedSubtitleStreams).subscribe(result -> { - if (radioVideoAudioGroup.getCheckedRadioButtonId() == R.id.subtitle_button) { + if (radioStreamsGroup.getCheckedRadioButtonId() == R.id.subtitle_button) { setupSubtitleSpinner(); } })); @@ -270,17 +312,40 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck Icepick.saveInstanceState(this, outState); } + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == REQUEST_DOWNLOAD_PATH_SAF && resultCode == Activity.RESULT_OK) { + if (data.getData() == null) { + showFailedDialog(R.string.general_error); + return; + } + try { + continueSelectedDownload(new StoredFileHelper(getContext(), data.getData(), "")); + } catch (IOException e) { + showErrorActivity(e); + } + } + } + /*////////////////////////////////////////////////////////////////////////// // Inits //////////////////////////////////////////////////////////////////////////*/ private void initToolbar(Toolbar toolbar) { if (DEBUG) Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]"); + + boolean isLight = ThemeHelper.isLightThemeSelected(getActivity()); + okButton = toolbar.findViewById(R.id.okay); + okButton.setEnabled(false);// disable until the download service connection is done + toolbar.setTitle(R.string.download_dialog_title); - toolbar.setNavigationIcon(ThemeHelper.isLightThemeSelected(getActivity()) ? R.drawable.ic_arrow_back_black_24dp : R.drawable.ic_arrow_back_white_24dp); + toolbar.setNavigationIcon(isLight ? R.drawable.ic_arrow_back_black_24dp : R.drawable.ic_arrow_back_white_24dp); toolbar.inflateMenu(R.menu.dialog_url); toolbar.setNavigationOnClickListener(v -> getDialog().dismiss()); + toolbar.setOnMenuItemClickListener(item -> { if (item.getItemId() == R.id.okay) { prepareSelectedDownload(); @@ -348,7 +413,7 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck public void onItemSelected(AdapterView parent, View view, int position, long id) { if (DEBUG) Log.d(TAG, "onItemSelected() called with: parent = [" + parent + "], view = [" + view + "], position = [" + position + "], id = [" + id + "]"); - switch (radioVideoAudioGroup.getCheckedRadioButtonId()) { + switch (radioStreamsGroup.getCheckedRadioButtonId()) { case R.id.audio_button: selectedAudioIndex = position; break; @@ -372,9 +437,9 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck protected void setupDownloadOptions() { setRadioButtonsState(false); - final RadioButton audioButton = radioVideoAudioGroup.findViewById(R.id.audio_button); - final RadioButton videoButton = radioVideoAudioGroup.findViewById(R.id.video_button); - final RadioButton subtitleButton = radioVideoAudioGroup.findViewById(R.id.subtitle_button); + final RadioButton audioButton = radioStreamsGroup.findViewById(R.id.audio_button); + final RadioButton videoButton = radioStreamsGroup.findViewById(R.id.video_button); + final RadioButton subtitleButton = radioStreamsGroup.findViewById(R.id.subtitle_button); final boolean isVideoStreamsAvailable = videoStreamsAdapter.getCount() > 0; final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0; final boolean isSubtitleStreamsAvailable = subtitleStreamsAdapter.getCount() > 0; @@ -399,9 +464,9 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck } private void setRadioButtonsState(boolean enabled) { - radioVideoAudioGroup.findViewById(R.id.audio_button).setEnabled(enabled); - radioVideoAudioGroup.findViewById(R.id.video_button).setEnabled(enabled); - radioVideoAudioGroup.findViewById(R.id.subtitle_button).setEnabled(enabled); + radioStreamsGroup.findViewById(R.id.audio_button).setEnabled(enabled); + radioStreamsGroup.findViewById(R.id.video_button).setEnabled(enabled); + radioStreamsGroup.findViewById(R.id.subtitle_button).setEnabled(enabled); } private int getSubtitleIndexBy(List streams) { @@ -436,119 +501,248 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck return 0; } + StoredDirectoryHelper mainStorageAudio = null; + StoredDirectoryHelper mainStorageVideo = null; + DownloadManager downloadManager = null; + + MenuItem okButton = null; + + private String getNameEditText() { + return nameEditText.getText().toString().trim(); + } + + private void showFailedDialog(@StringRes int msg) { + new AlertDialog.Builder(getContext()) + .setMessage(msg) + .setNegativeButton(android.R.string.ok, null) + .create() + .show(); + } + + private void showErrorActivity(Exception e) { + ErrorActivity.reportError( + getContext(), + Collections.singletonList(e), + null, + null, + ErrorActivity.ErrorInfo.make(UserAction.SOMETHING_ELSE, "-", "-", R.string.general_error) + ); + } + private void prepareSelectedDownload() { final Context context = getContext(); - Stream stream; - String location; - char kind; + StoredDirectoryHelper mainStorage; + MediaFormat format; + String mime; - String fileName = nameEditText.getText().toString().trim(); - if (fileName.isEmpty()) - fileName = FilenameUtils.createFilename(context, currentInfo.getName()); + // first, build the filename and get the output folder (if possible) - switch (radioVideoAudioGroup.getCheckedRadioButtonId()) { + String filename = getNameEditText() + "."; + if (filename.isEmpty()) { + filename = FilenameUtils.createFilename(context, currentInfo.getName()); + } + filename += "."; + + switch (radioStreamsGroup.getCheckedRadioButtonId()) { case R.id.audio_button: - stream = audioStreamsAdapter.getItem(selectedAudioIndex); - location = NewPipeSettings.getAudioDownloadPath(context); - kind = 'a'; + mainStorage = mainStorageAudio; + format = audioStreamsAdapter.getItem(selectedAudioIndex).getFormat(); + mime = format.mimeType; + filename += format.suffix; break; case R.id.video_button: - stream = videoStreamsAdapter.getItem(selectedVideoIndex); - location = NewPipeSettings.getVideoDownloadPath(context); - kind = 'v'; + mainStorage = mainStorageVideo; + format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); + mime = format.mimeType; + filename += format.suffix; break; case R.id.subtitle_button: - stream = subtitleStreamsAdapter.getItem(selectedSubtitleIndex); - location = NewPipeSettings.getVideoDownloadPath(context);// assume that subtitle & video files go together - kind = 's'; + mainStorage = mainStorageVideo;// subtitle & video files go together + format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat(); + mime = format.mimeType; + filename += format == MediaFormat.TTML ? MediaFormat.SRT.suffix : format.suffix; + break; + default: + throw new RuntimeException("No stream selected"); + } + + if (mainStorage == null) { + // this part is called if... + // older android version running with SAF preferred + // save path not defined (via download settings) + + StoredFileHelper.requestSafWithFileCreation(this, REQUEST_DOWNLOAD_PATH_SAF, filename, mime); + return; + } + + // check for existing file with the same name + Uri result = mainStorage.findFile(filename); + + if (result == null) { + // the file does not exists, create + StoredFileHelper storage = mainStorage.createFile(filename, mime); + if (storage == null || !storage.canWrite()) { + showFailedDialog(R.string.error_file_creation); + return; + } + + continueSelectedDownload(storage); + return; + } + + // the target filename is already use, try load + StoredFileHelper storage; + try { + storage = new StoredFileHelper(context, result, mime); + } catch (IOException e) { + showErrorActivity(e); + return; + } + + // check if is our file + MissionState state = downloadManager.checkForExistingMission(storage); + @StringRes int msgBtn; + @StringRes int msgBody; + + switch (state) { + case Finished: + msgBtn = R.string.overwrite; + msgBody = R.string.overwrite_finished_warning; + break; + case Pending: + msgBtn = R.string.overwrite; + msgBody = R.string.download_already_pending; + break; + case PendingRunning: + msgBtn = R.string.generate_unique_name; + msgBody = R.string.download_already_running; + break; + case None: + msgBtn = R.string.overwrite; + msgBody = R.string.overwrite_unrelated_warning; break; default: return; } - int threads; + // handle user answer (overwrite or create another file with different name) + final String finalFilename = filename; + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.download_dialog_title) + .setMessage(msgBody) + .setPositiveButton(msgBtn, (dialog, which) -> { + dialog.dismiss(); - if (radioVideoAudioGroup.getCheckedRadioButtonId() == R.id.subtitle_button) { - threads = 1;// use unique thread for subtitles due small file size - fileName += ".srt";// final subtitle format - } else { - threads = threadsSeekBar.getProgress() + 1; - fileName += "." + stream.getFormat().getSuffix(); - } - - final String finalFileName = fileName; - - DownloadManagerService.checkForRunningMission(context, location, fileName, (MissionCheck result) -> { - @StringRes int msgBtn; - @StringRes int msgBody; - - switch (result) { - case Finished: - msgBtn = R.string.overwrite; - msgBody = R.string.overwrite_warning; - break; - case Pending: - msgBtn = R.string.overwrite; - msgBody = R.string.download_already_pending; - break; - case PendingRunning: - msgBtn = R.string.generate_unique_name; - msgBody = R.string.download_already_running; - break; - default: - downloadSelected(context, stream, location, finalFileName, kind, threads); - return; - } - - // overwrite or unique name actions are done by the download manager - - AlertDialog.Builder builder = new AlertDialog.Builder(context); - builder.setTitle(R.string.download_dialog_title) - .setMessage(msgBody) - .setPositiveButton( - msgBtn, - (dialog, which) -> downloadSelected(context, stream, location, finalFileName, kind, threads) - ) - .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.cancel()) - .create() - .show(); - }); + StoredFileHelper storageNew; + switch (state) { + case Finished: + case Pending: + downloadManager.forgetMission(storage); + case None: + // try take (or steal) the file permissions + try { + storageNew = new StoredFileHelper(context, result, mainStorage.getTag()); + if (storageNew.canWrite()) + continueSelectedDownload(storageNew); + else + showFailedDialog(R.string.error_file_creation); + } catch (IOException e) { + showErrorActivity(e); + } + break; + case PendingRunning: + // FIXME: createUniqueFile() is not tested properly + storageNew = mainStorage.createUniqueFile(finalFilename, mime); + if (storageNew == null) + showFailedDialog(R.string.error_file_creation); + else + continueSelectedDownload(storageNew); + break; + } + }) + .setNegativeButton(android.R.string.cancel, null) + .create() + .show(); } - private void downloadSelected(Context context, Stream selectedStream, String location, String fileName, char kind, int threads) { + private void continueSelectedDownload(@NonNull StoredFileHelper storage) { + final Context context = getContext(); + + if (!storage.canWrite()) { + showFailedDialog(R.string.permission_denied); + return; + } + + // check if the selected file has to be overwritten, by simply checking its length + try { + if (storage.length() > 0) storage.truncate(); + } catch (IOException e) { + Log.e(TAG, "failed to overwrite the file: " + storage.getUri().toString(), e); + //showErrorActivity(e); + showFailedDialog(R.string.overwrite_failed); + return; + } + + Stream selectedStream; + char kind; + int threads = threadsSeekBar.getProgress() + 1; String[] urls; String psName = null; String[] psArgs = null; String secondaryStreamUrl = null; long nearLength = 0; - if (selectedStream instanceof AudioStream) { - if (selectedStream.getFormat() == MediaFormat.M4A) { - psName = Postprocessing.ALGORITHM_M4A_NO_DASH; - } - } else if (selectedStream instanceof VideoStream) { - SecondaryStreamHelper secondaryStream = videoStreamsAdapter - .getAllSecondary() - .get(wrappedVideoStreams.getStreamsList().indexOf(selectedStream)); + // more download logic: select muxer, subtitle converter, etc. + switch (radioStreamsGroup.getCheckedRadioButtonId()) { + case R.id.audio_button: + threads = 1;// use unique thread for subtitles due small file size + kind = 'a'; + selectedStream = audioStreamsAdapter.getItem(selectedAudioIndex); - if (secondaryStream != null) { - secondaryStreamUrl = secondaryStream.getStream().getUrl(); - psName = selectedStream.getFormat() == MediaFormat.MPEG_4 ? Postprocessing.ALGORITHM_MP4_FROM_DASH_MUXER : Postprocessing.ALGORITHM_WEBM_MUXER; - psArgs = null; - long videoSize = wrappedVideoStreams.getSizeInBytes((VideoStream) selectedStream); - - // set nearLength, only, if both sizes are fetched or known. this probably does not work on slow networks - if (secondaryStream.getSizeInBytes() > 0 && videoSize > 0) { - nearLength = secondaryStream.getSizeInBytes() + videoSize; + if (selectedStream.getFormat() == MediaFormat.M4A) { + psName = Postprocessing.ALGORITHM_M4A_NO_DASH; } - } - } else if ((selectedStream instanceof SubtitlesStream) && selectedStream.getFormat() == MediaFormat.TTML) { - psName = Postprocessing.ALGORITHM_TTML_CONVERTER; - psArgs = new String[]{ - selectedStream.getFormat().getSuffix(), - "false",// ignore empty frames - "false",// detect youtube duplicate lines - }; + break; + case R.id.video_button: + kind = 'v'; + selectedStream = videoStreamsAdapter.getItem(selectedVideoIndex); + + SecondaryStreamHelper secondaryStream = videoStreamsAdapter + .getAllSecondary() + .get(wrappedVideoStreams.getStreamsList().indexOf(selectedStream)); + + if (secondaryStream != null) { + secondaryStreamUrl = secondaryStream.getStream().getUrl(); + + if (selectedStream.getFormat() == MediaFormat.MPEG_4) + psName = Postprocessing.ALGORITHM_MP4_FROM_DASH_MUXER; + else + psName = Postprocessing.ALGORITHM_WEBM_MUXER; + + psArgs = null; + long videoSize = wrappedVideoStreams.getSizeInBytes((VideoStream) selectedStream); + + // set nearLength, only, if both sizes are fetched or known. this probably does not work on slow networks + if (secondaryStream.getSizeInBytes() > 0 && videoSize > 0) { + nearLength = secondaryStream.getSizeInBytes() + videoSize; + } + } + break; + case R.id.subtitle_button: + kind = 's'; + selectedStream = subtitleStreamsAdapter.getItem(selectedSubtitleIndex); + + if (selectedStream.getFormat() == MediaFormat.TTML) { + psName = Postprocessing.ALGORITHM_TTML_CONVERTER; + psArgs = new String[]{ + selectedStream.getFormat().getSuffix(), + "false",// ignore empty frames + "false",// detect youtube duplicate lines + }; + } + break; + default: + return; } if (secondaryStreamUrl == null) { @@ -557,8 +751,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck urls = new String[]{selectedStream.getUrl(), secondaryStreamUrl}; } - DownloadManagerService.startMission(context, urls, location, fileName, kind, threads, currentInfo.getUrl(), psName, psArgs, nearLength); + DownloadManagerService.startMission(context, urls, storage, kind, threads, currentInfo.getUrl(), psName, psArgs, nearLength); - getDialog().dismiss(); + dismiss(); } } diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.java b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.java index f1bb01734..475627c08 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.java @@ -21,8 +21,8 @@ import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.fragments.list.BaseListFragment; -import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.local.subscription.SubscriptionService; +import org.schabi.newpipe.report.UserAction; import java.util.Collections; import java.util.HashSet; @@ -262,7 +262,7 @@ public class FeedFragment extends BaseListFragment, Voi * If chosen feed already displayed, then we request another feed from another * subscription, until the subscription table runs out of new items. *

- * This Observer is self-contained and will dispose itself when complete. However, this + * This Observer is self-contained and will close itself when complete. However, this * does not obey the fragment lifecycle and may continue running in the background * until it is complete. This is done due to RxJava2 no longer propagate errors once * an observer is unsubscribed while the thread process is still running. diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java index db8cc797e..fb1a609cc 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java @@ -158,7 +158,7 @@ public class MediaSourceManager { * Dispose the manager and releases all message buses and loaders. * */ public void dispose() { - if (DEBUG) Log.d(TAG, "dispose() called."); + if (DEBUG) Log.d(TAG, "close() called."); debouncedSignal.onComplete(); debouncedLoader.dispose(); diff --git a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java index 82c6853d5..3737d1c17 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java @@ -2,26 +2,42 @@ package org.schabi.newpipe.settings; import android.app.Activity; import android.app.AlertDialog; +import android.content.Context; import android.content.Intent; +import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; +import android.support.annotation.StringRes; import android.support.v7.preference.Preference; import android.util.Log; - -import com.nononsenseapps.filepicker.Utils; +import android.widget.Toast; import org.schabi.newpipe.R; import org.schabi.newpipe.util.FilePickerActivityHelper; import java.io.File; +import java.io.IOException; +import java.net.URI; + +import us.shandian.giga.io.StoredDirectoryHelper; public class DownloadSettingsFragment extends BasePreferenceFragment { - private static final int REQUEST_DOWNLOAD_PATH = 0x1235; + private static final int REQUEST_DOWNLOAD_VIDEO_PATH = 0x1235; private static final int REQUEST_DOWNLOAD_AUDIO_PATH = 0x1236; - private String DOWNLOAD_PATH_PREFERENCE; + private String DOWNLOAD_PATH_VIDEO_PREFERENCE; private String DOWNLOAD_PATH_AUDIO_PREFERENCE; + private String DOWNLOAD_STORAGE_API; + private String DOWNLOAD_STORAGE_API_DEFAULT; + + private Preference prefPathVideo; + private Preference prefPathAudio; + + private Context ctx; + @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -33,16 +49,100 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { addPreferencesFromResource(R.xml.download_settings); + + prefPathVideo = findPreference(DOWNLOAD_PATH_VIDEO_PREFERENCE); + prefPathAudio = findPreference(DOWNLOAD_PATH_AUDIO_PREFERENCE); + + updatePathPickers(usingJavaIO()); + + findPreference(DOWNLOAD_STORAGE_API).setOnPreferenceChangeListener((preference, value) -> { + boolean javaIO = DOWNLOAD_STORAGE_API_DEFAULT.equals(value); + + if (!javaIO && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + Toast.makeText(ctx, R.string.download_pick_path, Toast.LENGTH_LONG).show(); + + // forget save paths + forgetSAFTree(DOWNLOAD_PATH_VIDEO_PREFERENCE); + forgetSAFTree(DOWNLOAD_PATH_AUDIO_PREFERENCE); + + defaultPreferences.edit() + .putString(DOWNLOAD_PATH_VIDEO_PREFERENCE, "") + .putString(DOWNLOAD_PATH_AUDIO_PREFERENCE, "") + .apply(); + + updatePreferencesSummary(); + } + + updatePathPickers(javaIO); + return true; + }); + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + ctx = context; + } + + @Override + public void onDetach() { + super.onDetach(); + ctx = null; + findPreference(DOWNLOAD_STORAGE_API).setOnPreferenceChangeListener(null); } private void initKeys() { - DOWNLOAD_PATH_PREFERENCE = getString(R.string.download_path_key); + DOWNLOAD_PATH_VIDEO_PREFERENCE = getString(R.string.download_path_video_key); DOWNLOAD_PATH_AUDIO_PREFERENCE = getString(R.string.download_path_audio_key); + DOWNLOAD_STORAGE_API = getString(R.string.downloads_storage_api); + DOWNLOAD_STORAGE_API_DEFAULT = getString(R.string.downloads_storage_api_default); } private void updatePreferencesSummary() { - findPreference(DOWNLOAD_PATH_PREFERENCE).setSummary(defaultPreferences.getString(DOWNLOAD_PATH_PREFERENCE, getString(R.string.download_path_summary))); - findPreference(DOWNLOAD_PATH_AUDIO_PREFERENCE).setSummary(defaultPreferences.getString(DOWNLOAD_PATH_AUDIO_PREFERENCE, getString(R.string.download_path_audio_summary))); + prefPathVideo.setSummary( + defaultPreferences.getString(DOWNLOAD_PATH_VIDEO_PREFERENCE, getString(R.string.download_path_summary)) + ); + prefPathAudio.setSummary( + defaultPreferences.getString(DOWNLOAD_PATH_AUDIO_PREFERENCE, getString(R.string.download_path_audio_summary)) + ); + } + + private void updatePathPickers(boolean useJavaIO) { + boolean enabled = useJavaIO || Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP; + prefPathVideo.setEnabled(enabled); + prefPathAudio.setEnabled(enabled); + } + + private boolean usingJavaIO() { + return DOWNLOAD_STORAGE_API_DEFAULT.equals( + defaultPreferences.getString(DOWNLOAD_STORAGE_API, DOWNLOAD_STORAGE_API_DEFAULT) + ); + } + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + private void forgetSAFTree(String prefKey) { + + String oldPath = defaultPreferences.getString(prefKey, ""); + + if (oldPath != null && !oldPath.isEmpty() && oldPath.charAt(0) != File.separatorChar) { + try { + StoredDirectoryHelper mainStorage = new StoredDirectoryHelper(ctx, Uri.parse(oldPath), null); + if (!mainStorage.isDirect()) { + mainStorage.revokePermissions(); + Log.i(TAG, "revokePermissions() [uri=" + oldPath + "] ¡success!"); + } + } catch (IOException err) { + Log.e(TAG, "Error revoking Tree uri permissions [uri=" + oldPath + "]", err); + } + } + } + + private void showMessageDialog(@StringRes int title, @StringRes int message) { + AlertDialog.Builder msg = new AlertDialog.Builder(ctx); + msg.setTitle(title); + msg.setMessage(message); + msg.setPositiveButton(android.R.string.ok, null); + msg.show(); } @Override @@ -51,17 +151,31 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { Log.d(TAG, "onPreferenceTreeClick() called with: preference = [" + preference + "]"); } - if (preference.getKey().equals(DOWNLOAD_PATH_PREFERENCE) - || preference.getKey().equals(DOWNLOAD_PATH_AUDIO_PREFERENCE)) { - Intent i = new Intent(getActivity(), FilePickerActivityHelper.class) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) - .putExtra(FilePickerActivityHelper.EXTRA_MODE, FilePickerActivityHelper.MODE_DIR); - if (preference.getKey().equals(DOWNLOAD_PATH_PREFERENCE)) { - startActivityForResult(i, REQUEST_DOWNLOAD_PATH); - } else if (preference.getKey().equals(DOWNLOAD_PATH_AUDIO_PREFERENCE)) { - startActivityForResult(i, REQUEST_DOWNLOAD_AUDIO_PATH); + String key = preference.getKey(); + + if (key.equals(DOWNLOAD_PATH_VIDEO_PREFERENCE) || key.equals(DOWNLOAD_PATH_AUDIO_PREFERENCE)) { + boolean safPick = !usingJavaIO(); + + int request = 0; + if (key.equals(DOWNLOAD_PATH_VIDEO_PREFERENCE)) { + request = REQUEST_DOWNLOAD_VIDEO_PATH; + } else if (key.equals(DOWNLOAD_PATH_AUDIO_PREFERENCE)) { + request = REQUEST_DOWNLOAD_AUDIO_PATH; } + + Intent i; + if (safPick && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + .putExtra("android.content.extra.SHOW_ADVANCED", true) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | StoredDirectoryHelper.PERMISSION_FLAGS); + } else { + i = new Intent(getActivity(), FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, FilePickerActivityHelper.MODE_DIR); + } + + startActivityForResult(i, request); } return super.onPreferenceTreeClick(preference); @@ -71,25 +185,50 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { public void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (DEBUG) { - Log.d(TAG, "onActivityResult() called with: requestCode = [" + requestCode + "], resultCode = [" + resultCode + "], data = [" + data + "]"); + Log.d(TAG, "onActivityResult() called with: requestCode = [" + requestCode + "], " + + "resultCode = [" + resultCode + "], data = [" + data + "]" + ); } - if ((requestCode == REQUEST_DOWNLOAD_PATH || requestCode == REQUEST_DOWNLOAD_AUDIO_PATH) - && resultCode == Activity.RESULT_OK && data.getData() != null) { - String key = getString(requestCode == REQUEST_DOWNLOAD_PATH ? R.string.download_path_key : R.string.download_path_audio_key); - String path = Utils.getFileForUri(data.getData()).getAbsolutePath(); + if (resultCode != Activity.RESULT_OK) return; - defaultPreferences.edit().putString(key, path).apply(); + String key; + if (requestCode == REQUEST_DOWNLOAD_VIDEO_PATH) + key = DOWNLOAD_PATH_VIDEO_PREFERENCE; + else if (requestCode == REQUEST_DOWNLOAD_AUDIO_PATH) + key = DOWNLOAD_PATH_AUDIO_PREFERENCE; + else + return; + + Uri uri = data.getData(); + if (uri == null) { + showMessageDialog(R.string.general_error, R.string.invalid_directory); + return; + } + + if (!usingJavaIO() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + // steps: + // 1. acquire permissions on the new save path + // 2. save the new path, if step(1) was successful + + try { + StoredDirectoryHelper mainStorage = new StoredDirectoryHelper(ctx, uri, null); + mainStorage.acquirePermissions(); + Log.i(TAG, "acquirePermissions() [uri=" + uri.toString() + "] ¡success!"); + } catch (IOException err) { + Log.e(TAG, "Error acquiring permissions on " + uri.toString()); + showMessageDialog(R.string.general_error, R.string.no_available_dir); + return; + } + + defaultPreferences.edit().putString(key, uri.toString()).apply(); + } else { + defaultPreferences.edit().putString(key, uri.toString()).apply(); updatePreferencesSummary(); - File target = new File(path); - if (!target.canWrite()) { - AlertDialog.Builder msg = new AlertDialog.Builder(getContext()); - msg.setTitle(R.string.download_to_sdcard_error_title); - msg.setMessage(R.string.download_to_sdcard_error_message); - msg.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { }); - msg.show(); - } + File target = new File(URI.create(uri.toString())); + if (!target.canWrite()) + showMessageDialog(R.string.download_to_sdcard_error_title, R.string.download_to_sdcard_error_message); } } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java index 2a0e2645b..f153cf23a 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java +++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java @@ -70,37 +70,23 @@ public class NewPipeSettings { getAudioDownloadFolder(context); } - public static File getVideoDownloadFolder(Context context) { - return getDir(context, R.string.download_path_key, Environment.DIRECTORY_MOVIES); + private static void getVideoDownloadFolder(Context context) { + getDir(context, R.string.download_path_video_key, Environment.DIRECTORY_MOVIES); } - public static String getVideoDownloadPath(Context context) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - final String key = context.getString(R.string.download_path_key); - return prefs.getString(key, Environment.DIRECTORY_MOVIES); + private static void getAudioDownloadFolder(Context context) { + getDir(context, R.string.download_path_audio_key, Environment.DIRECTORY_MUSIC); } - public static File getAudioDownloadFolder(Context context) { - return getDir(context, R.string.download_path_audio_key, Environment.DIRECTORY_MUSIC); - } - - public static String getAudioDownloadPath(Context context) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - final String key = context.getString(R.string.download_path_audio_key); - return prefs.getString(key, Environment.DIRECTORY_MUSIC); - } - - private static File getDir(Context context, int keyID, String defaultDirectoryName) { + private static void getDir(Context context, int keyID, String defaultDirectoryName) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); final String key = context.getString(keyID); String downloadPath = prefs.getString(key, null); - if ((downloadPath != null) && (!downloadPath.isEmpty())) return new File(downloadPath.trim()); + if ((downloadPath != null) && (!downloadPath.isEmpty())) return; - final File dir = getDir(defaultDirectoryName); SharedPreferences.Editor spEditor = prefs.edit(); - spEditor.putString(key, getNewPipeChildFolderPathForDir(dir)); + spEditor.putString(key, getNewPipeChildFolderPathForDir(getDir(defaultDirectoryName))); spEditor.apply(); - return dir; } @NonNull @@ -110,8 +96,13 @@ public class NewPipeSettings { public static void resetDownloadFolders(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + + prefs.edit() + .putString(context.getString(R.string.downloads_storage_api), context.getString(R.string.downloads_storage_api_default)) + .apply(); + resetDownloadFolder(prefs, context.getString(R.string.download_path_audio_key), Environment.DIRECTORY_MUSIC); - resetDownloadFolder(prefs, context.getString(R.string.download_path_key), Environment.DIRECTORY_MOVIES); + resetDownloadFolder(prefs, context.getString(R.string.download_path_video_key), Environment.DIRECTORY_MOVIES); } private static void resetDownloadFolder(SharedPreferences prefs, String key, String defaultDirectoryName) { diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java index 5a4efbe32..61f793e5d 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java @@ -120,7 +120,7 @@ public class Mp4FromDashWriter { parsed = true; for (SharpStream src : sourceTracks) { - src.dispose(); + src.close(); } tracks = null; diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java index eba2bbb87..26b9cbebf 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java @@ -107,7 +107,7 @@ public class WebMWriter { parsed = true; for (SharpStream src : sourceTracks) { - src.dispose(); + src.close(); } sourceTracks = null; diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java b/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java index ea2f60837..5950ba3dd 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java +++ b/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java @@ -1,11 +1,12 @@ package org.schabi.newpipe.streams.io; +import java.io.Closeable; import java.io.IOException; /** * based on c# */ -public abstract class SharpStream { +public abstract class SharpStream implements Closeable { public abstract int read() throws IOException; @@ -19,9 +20,10 @@ public abstract class SharpStream { public abstract void rewind() throws IOException; - public abstract void dispose(); + public abstract boolean isClosed(); - public abstract boolean isDisposed(); + @Override + public abstract void close(); public abstract boolean canRewind(); @@ -54,4 +56,8 @@ public abstract class SharpStream { public void seek(long offset) throws IOException { throw new IOException("Not implemented"); } + + public long length() throws IOException { + throw new UnsupportedOperationException("Unsupported operation"); + } } diff --git a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java index abc934878..1e05983d8 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java @@ -3,10 +3,10 @@ package us.shandian.giga.get; import android.support.annotation.NonNull; import android.util.Log; -import java.io.File; +import org.schabi.newpipe.streams.io.SharpStream; + import java.io.IOException; import java.io.InterruptedIOException; -import java.io.RandomAccessFile; import java.net.HttpURLConnection; import java.nio.channels.ClosedByInterruptException; @@ -111,34 +111,10 @@ public class DownloadInitializer extends Thread { if (!mMission.running || Thread.interrupted()) return; } - File file; - if (mMission.current == 0) { - file = new File(mMission.location); - if (!Utility.mkdir(file, true)) { - mMission.notifyError(DownloadMission.ERROR_PATH_CREATION, null); - return; - } - - file = new File(file, mMission.name); - - // if the name is used by another process, delete it - if (file.exists() && !file.isFile() && !file.delete()) { - mMission.notifyError(DownloadMission.ERROR_FILE_CREATION, null); - return; - } - - if (!file.exists() && !file.createNewFile()) { - mMission.notifyError(DownloadMission.ERROR_FILE_CREATION, null); - return; - } - } else { - file = new File(mMission.location, mMission.name); - } - - RandomAccessFile af = new RandomAccessFile(file, "rw"); - af.setLength(mMission.offsets[mMission.current] + mMission.length); - af.seek(mMission.offsets[mMission.current]); - af.close(); + SharpStream fs = mMission.storage.getStream(); + fs.setLength(mMission.offsets[mMission.current] + mMission.length); + fs.seek(mMission.offsets[mMission.current]); + fs.close(); if (!mMission.running || Thread.interrupted()) return; diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMission.java b/app/src/main/java/us/shandian/giga/get/DownloadMission.java index b8849482a..9ec3418b0 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -2,6 +2,7 @@ package us.shandian.giga.get; import android.os.Handler; import android.os.Message; +import android.support.annotation.NonNull; import android.util.Log; import java.io.File; @@ -17,6 +18,7 @@ import java.util.List; import javax.net.ssl.SSLException; +import us.shandian.giga.io.StoredFileHelper; import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.util.Utility; @@ -24,7 +26,7 @@ import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; public class DownloadMission extends Mission { - private static final long serialVersionUID = 3L;// last bump: 8 november 2018 + private static final long serialVersionUID = 4L;// last bump: 27 march 2019 static final int BUFFER_SIZE = 64 * 1024; final static int BLOCK_SIZE = 512 * 1024; @@ -43,6 +45,7 @@ public class DownloadMission extends Mission { public static final int ERROR_POSTPROCESSING_STOPPED = 1008; public static final int ERROR_POSTPROCESSING_HOLD = 1009; public static final int ERROR_INSUFFICIENT_STORAGE = 1010; + public static final int ERROR_PROGRESS_LOST = 1011; public static final int ERROR_HTTP_NO_CONTENT = 204; public static final int ERROR_HTTP_UNSUPPORTED_RANGE = 206; @@ -71,16 +74,6 @@ public class DownloadMission extends Mission { */ public long[] offsets; - /** - * The post-processing algorithm arguments - */ - public String[] postprocessingArgs; - - /** - * The post-processing algorithm name - */ - public String postprocessingName; - /** * Indicates if the post-processing state: * 0: ready @@ -88,12 +81,12 @@ public class DownloadMission extends Mission { * 2: completed * 3: hold */ - public volatile int postprocessingState; + public volatile int psState; /** - * Indicate if the post-processing algorithm works on the same file + * the post-processing algorithm instance */ - public boolean postprocessingThis; + public transient Postprocessing psAlgorithm; /** * The current resource to download, see {@code urls[current]} and {@code offsets[current]} @@ -138,36 +131,23 @@ public class DownloadMission extends Mission { public transient volatile Thread[] threads = new Thread[0]; private transient Thread init = null; - protected DownloadMission() { } - public DownloadMission(String url, String name, String location, char kind) { - this(new String[]{url}, name, location, kind, null, null); - } - - public DownloadMission(String[] urls, String name, String location, char kind, String postprocessingName, String[] postprocessingArgs) { - if (name == null) throw new NullPointerException("name is null"); - if (name.isEmpty()) throw new IllegalArgumentException("name is empty"); + public DownloadMission(String[] urls, StoredFileHelper storage, char kind, Postprocessing psInstance) { if (urls == null) throw new NullPointerException("urls is null"); if (urls.length < 1) throw new IllegalArgumentException("urls is empty"); - if (location == null) throw new NullPointerException("location is null"); - if (location.isEmpty()) throw new IllegalArgumentException("location is empty"); this.urls = urls; - this.name = name; - this.location = location; this.kind = kind; this.offsets = new long[urls.length]; this.enqueued = true; this.maxRetry = 3; + this.storage = storage; - if (postprocessingName != null) { - Postprocessing algorithm = Postprocessing.getAlgorithm(postprocessingName, null); - this.postprocessingThis = algorithm.worksOnSameFile; - this.offsets[0] = algorithm.recommendedReserve; - this.postprocessingName = postprocessingName; - this.postprocessingArgs = postprocessingArgs; + if (psInstance != null) { + this.psAlgorithm = psInstance; + this.offsets[0] = psInstance.recommendedReserve; } else { if (DEBUG && urls.length > 1) { Log.w(TAG, "mission created with multiple urls ¿missing post-processing algorithm?"); @@ -359,22 +339,12 @@ public class DownloadMission extends Mission { Log.e(TAG, "notifyError() code = " + code, err); if (err instanceof IOException) { - if (err.getMessage().contains("Permission denied")) { + if (storage.canWrite() || err.getMessage().contains("Permission denied")) { code = ERROR_PERMISSION_DENIED; err = null; - } else if (err.getMessage().contains("write failed: ENOSPC")) { + } else if (err.getMessage().contains("ENOSPC")) { code = ERROR_INSUFFICIENT_STORAGE; err = null; - } else { - try { - File storage = new File(location); - if (storage.canWrite() && storage.getUsableSpace() < (getLength() - done)) { - code = ERROR_INSUFFICIENT_STORAGE; - err = null; - } - } catch (SecurityException e) { - // is a permission error - } } } @@ -433,11 +403,11 @@ public class DownloadMission extends Mission { action = "Failed"; } - Log.d(TAG, action + " postprocessing on " + location + File.separator + name); + Log.d(TAG, action + " postprocessing on " + storage.getName()); synchronized (blockState) { // don't return without fully write the current state - postprocessingState = state; + psState = state; Utility.writeToFile(metadata, DownloadMission.this); } } @@ -456,7 +426,7 @@ public class DownloadMission extends Mission { running = true; errCode = ERROR_NOTHING; - if (current >= urls.length && postprocessingName != null) { + if (current >= urls.length && psAlgorithm != null) { runAsync(1, () -> { if (doPostprocessing()) { running = false; @@ -593,7 +563,7 @@ public class DownloadMission extends Mission { * @return true, otherwise, false */ public boolean isFinished() { - return current >= urls.length && (postprocessingName == null || postprocessingState == 2); + return current >= urls.length && (psAlgorithm == null || psState == 2); } /** @@ -602,7 +572,13 @@ public class DownloadMission extends Mission { * @return {@code true} if this mission is unrecoverable */ public boolean isPsFailed() { - return postprocessingName != null && errCode == DownloadMission.ERROR_POSTPROCESSING && postprocessingThis; + switch (errCode) { + case ERROR_POSTPROCESSING: + case ERROR_POSTPROCESSING_STOPPED: + return psAlgorithm.worksOnSameFile; + } + + return false; } /** @@ -611,7 +587,7 @@ public class DownloadMission extends Mission { * @return true, otherwise, false */ public boolean isPsRunning() { - return postprocessingName != null && (postprocessingState == 1 || postprocessingState == 3); + return psAlgorithm != null && (psState == 1 || psState == 3); } /** @@ -625,7 +601,7 @@ public class DownloadMission extends Mission { public long getLength() { long calculated; - if (postprocessingState == 1 || postprocessingState == 3) { + if (psState == 1 || psState == 3) { calculated = length; } else { calculated = offsets[current < offsets.length ? current : (offsets.length - 1)] + length; @@ -652,38 +628,60 @@ public class DownloadMission extends Mission { * @param recover {@code true} to retry, otherwise, {@code false} to cancel */ public void psContinue(boolean recover) { - postprocessingState = 1; + psState = 1; errCode = recover ? ERROR_NOTHING : ERROR_POSTPROCESSING; threads[0].interrupt(); } + /** + * changes the StoredFileHelper for another and saves the changes to the metadata file + * + * @param newStorage the new StoredFileHelper instance to use + */ + public void changeStorage(@NonNull StoredFileHelper newStorage) { + storage = newStorage; + // commit changes on the metadata file + runAsync(-2, this::writeThisToFile); + } + + /** + * Indicates whatever the backed storage is invalid + * + * @return {@code true}, if storage is invalid and cannot be used + */ + public boolean hasInvalidStorage() { + return errCode == ERROR_PROGRESS_LOST || storage == null || storage.isInvalid(); + } + + /** + * Indicates whatever is possible to start the mission + * + * @return {@code true} is this mission is "sane", otherwise, {@code false} + */ + public boolean canDownload() { + return !(isPsFailed() || errCode == ERROR_POSTPROCESSING_HOLD) && !isFinished() && !hasInvalidStorage(); + } + private boolean doPostprocessing() { - if (postprocessingName == null || postprocessingState == 2) return true; + if (psAlgorithm == null || psState == 2) return true; notifyPostProcessing(1); notifyProgress(0); if (DEBUG) - Thread.currentThread().setName("[" + TAG + "] post-processing = " + postprocessingName + " filename = " + name); + Thread.currentThread().setName("[" + TAG + "] ps = " + + psAlgorithm.getClass().getSimpleName() + + " filename = " + storage.getName() + ); threads = new Thread[]{Thread.currentThread()}; Exception exception = null; try { - Postprocessing - .getAlgorithm(postprocessingName, this) - .run(); + psAlgorithm.run(this); } catch (Exception err) { - StringBuilder args = new StringBuilder(" "); - if (postprocessingArgs != null) { - for (String arg : postprocessingArgs) { - args.append(", "); - args.append(arg); - } - args.delete(0, 1); - } - Log.e(TAG, String.format("Post-processing failed. algorithm = %s args = [%s]", postprocessingName, args), err); + Log.e(TAG, "Post-processing failed. " + psAlgorithm.toString(), err); if (errCode == ERROR_NOTHING) errCode = ERROR_POSTPROCESSING; @@ -733,7 +731,7 @@ public class DownloadMission extends Mission { // >=1: any download thread if (DEBUG) { - who.setName(String.format("%s[%s] %s", TAG, id, name)); + who.setName(String.format("%s[%s] %s", TAG, id, storage.getName())); } who.start(); diff --git a/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java b/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java index 244fbd47a..ced579b20 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java @@ -2,9 +2,10 @@ package us.shandian.giga.get; import android.util.Log; -import java.io.FileNotFoundException; +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; import java.io.InputStream; -import java.io.RandomAccessFile; import java.net.HttpURLConnection; import java.nio.channels.ClosedByInterruptException; @@ -40,12 +41,12 @@ public class DownloadRunnable extends Thread { Log.d(TAG, mId + ":recovered: " + mMission.recovered); } - RandomAccessFile f; + SharpStream f; InputStream is = null; try { - f = new RandomAccessFile(mMission.getDownloadedFile(), "rw"); - } catch (FileNotFoundException e) { + f = mMission.storage.getStream(); + } catch (IOException e) { mMission.notifyError(e);// this never should happen return; } diff --git a/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java b/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java index 4bcaeaf85..1a4b5d5b6 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java @@ -4,13 +4,13 @@ import android.annotation.SuppressLint; import android.support.annotation.NonNull; import android.util.Log; +import org.schabi.newpipe.streams.io.SharpStream; + import java.io.IOException; import java.io.InputStream; -import java.io.RandomAccessFile; import java.net.HttpURLConnection; import java.nio.channels.ClosedByInterruptException; - import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; @@ -22,11 +22,10 @@ public class DownloadRunnableFallback extends Thread { private static final String TAG = "DownloadRunnableFallback"; private final DownloadMission mMission; - private final int mId = 1; private int mRetryCount = 0; private InputStream mIs; - private RandomAccessFile mF; + private SharpStream mF; private HttpURLConnection mConn; DownloadRunnableFallback(@NonNull DownloadMission mission) { @@ -43,11 +42,7 @@ public class DownloadRunnableFallback extends Thread { // nothing to do } - try { - if (mF != null) mF.close(); - } catch (IOException e) { - // ¿ejected media storage? ¿file deleted? ¿storage ran out of space? - } + if (mF != null) mF.close(); } @Override @@ -67,6 +62,7 @@ public class DownloadRunnableFallback extends Thread { try { long rangeStart = (mMission.unknownLength || start < 1) ? -1 : start; + int mId = 1; mConn = mMission.openConnection(mId, rangeStart, -1); mMission.establishConnection(mId, mConn); @@ -81,7 +77,7 @@ public class DownloadRunnableFallback extends Thread { if (!mMission.unknownLength) mMission.unknownLength = Utility.getContentLength(mConn) == -1; - mF = new RandomAccessFile(mMission.getDownloadedFile(), "rw"); + mF = mMission.storage.getStream(); mF.seek(mMission.offsets[mMission.current] + start); mIs = mConn.getInputStream(); diff --git a/app/src/main/java/us/shandian/giga/get/FinishedMission.java b/app/src/main/java/us/shandian/giga/get/FinishedMission.java index b7d6908a5..5540b44a1 100644 --- a/app/src/main/java/us/shandian/giga/get/FinishedMission.java +++ b/app/src/main/java/us/shandian/giga/get/FinishedMission.java @@ -1,16 +1,16 @@ package us.shandian.giga.get; +import android.support.annotation.NonNull; + public class FinishedMission extends Mission { public FinishedMission() { } - public FinishedMission(DownloadMission mission) { + public FinishedMission(@NonNull DownloadMission mission) { source = mission.source; length = mission.length;// ¿or mission.done? timestamp = mission.timestamp; - name = mission.name; - location = mission.location; kind = mission.kind; } } diff --git a/app/src/main/java/us/shandian/giga/get/Mission.java b/app/src/main/java/us/shandian/giga/get/Mission.java index 53c81b08b..ce201d960 100644 --- a/app/src/main/java/us/shandian/giga/get/Mission.java +++ b/app/src/main/java/us/shandian/giga/get/Mission.java @@ -1,12 +1,15 @@ package us.shandian.giga.get; -import java.io.File; +import android.net.Uri; +import android.support.annotation.NonNull; + import java.io.Serializable; -import java.text.SimpleDateFormat; import java.util.Calendar; +import us.shandian.giga.io.StoredFileHelper; + public abstract class Mission implements Serializable { - private static final long serialVersionUID = 0L;// last bump: 5 october 2018 + private static final long serialVersionUID = 1L;// last bump: 27 march 2019 /** * Source url of the resource @@ -23,28 +26,23 @@ public abstract class Mission implements Serializable { */ public long timestamp; - /** - * The filename - */ - public String name; - - /** - * The directory to store the download - */ - public String location; - /** * pre-defined content type */ public char kind; + /** + * The downloaded file + */ + public StoredFileHelper storage; + /** * get the target file on the storage * * @return File object */ - public File getDownloadedFile() { - return new File(location, name); + public Uri getDownloadedFileUri() { + return storage.getUri(); } /** @@ -53,8 +51,8 @@ public abstract class Mission implements Serializable { * @return {@code true] if and only if the file is successfully deleted, otherwise, {@code false} */ public boolean delete() { - deleted = true; - return getDownloadedFile().delete(); + if (storage != null) return storage.delete(); + return true; } /** @@ -62,10 +60,11 @@ public abstract class Mission implements Serializable { */ public transient boolean deleted = false; + @NonNull @Override public String toString() { Calendar calendar = Calendar.getInstance(); calendar.setTimeInMillis(timestamp); - return "[" + calendar.getTime().toString() + "] " + location + File.separator + name; + return "[" + calendar.getTime().toString() + "] " + getDownloadedFileUri().getPath(); } } diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/DownloadDataSource.java b/app/src/main/java/us/shandian/giga/get/sqlite/DownloadDataSource.java deleted file mode 100644 index 4b4d5d733..000000000 --- a/app/src/main/java/us/shandian/giga/get/sqlite/DownloadDataSource.java +++ /dev/null @@ -1,73 +0,0 @@ -package us.shandian.giga.get.sqlite; - -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.util.Log; - -import java.util.ArrayList; -import java.util.List; - -import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.get.FinishedMission; -import us.shandian.giga.get.Mission; - -import static us.shandian.giga.get.sqlite.DownloadMissionHelper.KEY_LOCATION; -import static us.shandian.giga.get.sqlite.DownloadMissionHelper.KEY_NAME; -import static us.shandian.giga.get.sqlite.DownloadMissionHelper.MISSIONS_TABLE_NAME; - -public class DownloadDataSource { - - private static final String TAG = "DownloadDataSource"; - private final DownloadMissionHelper downloadMissionHelper; - - public DownloadDataSource(Context context) { - downloadMissionHelper = new DownloadMissionHelper(context); - } - - public ArrayList loadFinishedMissions() { - SQLiteDatabase database = downloadMissionHelper.getReadableDatabase(); - Cursor cursor = database.query(MISSIONS_TABLE_NAME, null, null, - null, null, null, DownloadMissionHelper.KEY_TIMESTAMP); - - int count = cursor.getCount(); - if (count == 0) return new ArrayList<>(1); - - ArrayList result = new ArrayList<>(count); - while (cursor.moveToNext()) { - result.add(DownloadMissionHelper.getMissionFromCursor(cursor)); - } - - return result; - } - - public void addMission(DownloadMission downloadMission) { - if (downloadMission == null) throw new NullPointerException("downloadMission is null"); - SQLiteDatabase database = downloadMissionHelper.getWritableDatabase(); - ContentValues values = DownloadMissionHelper.getValuesOfMission(downloadMission); - database.insert(MISSIONS_TABLE_NAME, null, values); - } - - public void deleteMission(Mission downloadMission) { - if (downloadMission == null) throw new NullPointerException("downloadMission is null"); - SQLiteDatabase database = downloadMissionHelper.getWritableDatabase(); - database.delete(MISSIONS_TABLE_NAME, - KEY_LOCATION + " = ? AND " + - KEY_NAME + " = ?", - new String[]{downloadMission.location, downloadMission.name}); - } - - public void updateMission(DownloadMission downloadMission) { - if (downloadMission == null) throw new NullPointerException("downloadMission is null"); - SQLiteDatabase database = downloadMissionHelper.getWritableDatabase(); - ContentValues values = DownloadMissionHelper.getValuesOfMission(downloadMission); - String whereClause = KEY_LOCATION + " = ? AND " + - KEY_NAME + " = ?"; - int rowsAffected = database.update(MISSIONS_TABLE_NAME, values, - whereClause, new String[]{downloadMission.location, downloadMission.name}); - if (rowsAffected != 1) { - Log.e(TAG, "Expected 1 row to be affected by update but got " + rowsAffected); - } - } -} diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionHelper.java b/app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionHelper.java deleted file mode 100644 index 6dadc98c8..000000000 --- a/app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionHelper.java +++ /dev/null @@ -1,112 +0,0 @@ -package us.shandian.giga.get.sqlite; - -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; - -import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.get.FinishedMission; - -/** - * SQLiteHelper to store finished {@link us.shandian.giga.get.DownloadMission}'s - */ -public class DownloadMissionHelper extends SQLiteOpenHelper { - private final String TAG = "DownloadMissionHelper"; - - // TODO: use NewPipeSQLiteHelper ('s constants) when playlist branch is merged (?) - private static final String DATABASE_NAME = "downloads.db"; - - private static final int DATABASE_VERSION = 3; - - /** - * The table name of download missions - */ - static final String MISSIONS_TABLE_NAME = "download_missions"; - - /** - * The key to the directory location of the mission - */ - static final String KEY_LOCATION = "location"; - /** - * The key to the urls of a mission - */ - static final String KEY_SOURCE_URL = "url"; - /** - * The key to the name of a mission - */ - static final String KEY_NAME = "name"; - - /** - * The key to the done. - */ - static final String KEY_DONE = "bytes_downloaded"; - - static final String KEY_TIMESTAMP = "timestamp"; - - static final String KEY_KIND = "kind"; - - /** - * The statement to create the table - */ - private static final String MISSIONS_CREATE_TABLE = - "CREATE TABLE " + MISSIONS_TABLE_NAME + " (" + - KEY_LOCATION + " TEXT NOT NULL, " + - KEY_NAME + " TEXT NOT NULL, " + - KEY_SOURCE_URL + " TEXT NOT NULL, " + - KEY_DONE + " INTEGER NOT NULL, " + - KEY_TIMESTAMP + " INTEGER NOT NULL, " + - KEY_KIND + " TEXT NOT NULL, " + - " UNIQUE(" + KEY_LOCATION + ", " + KEY_NAME + "));"; - - public DownloadMissionHelper(Context context) { - super(context, DATABASE_NAME, null, DATABASE_VERSION); - } - - @Override - public void onCreate(SQLiteDatabase db) { - db.execSQL(MISSIONS_CREATE_TABLE); - } - - @Override - public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - if (oldVersion == 2) { - db.execSQL("ALTER TABLE " + MISSIONS_TABLE_NAME + " ADD COLUMN " + KEY_KIND + " TEXT;"); - } - } - - /** - * Returns all values of the download mission as ContentValues. - * - * @param downloadMission the download mission - * @return the content values - */ - public static ContentValues getValuesOfMission(DownloadMission downloadMission) { - ContentValues values = new ContentValues(); - values.put(KEY_SOURCE_URL, downloadMission.source); - values.put(KEY_LOCATION, downloadMission.location); - values.put(KEY_NAME, downloadMission.name); - values.put(KEY_DONE, downloadMission.done); - values.put(KEY_TIMESTAMP, downloadMission.timestamp); - values.put(KEY_KIND, String.valueOf(downloadMission.kind)); - return values; - } - - public static FinishedMission getMissionFromCursor(Cursor cursor) { - if (cursor == null) throw new NullPointerException("cursor is null"); - - String kind = cursor.getString(cursor.getColumnIndex(KEY_KIND)); - if (kind == null || kind.isEmpty()) kind = "?"; - - FinishedMission mission = new FinishedMission(); - mission.name = cursor.getString(cursor.getColumnIndexOrThrow(KEY_NAME)); - mission.location = cursor.getString(cursor.getColumnIndexOrThrow(KEY_LOCATION)); - mission.source = cursor.getString(cursor.getColumnIndexOrThrow(KEY_SOURCE_URL));; - mission.length = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE)); - mission.timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP)); - mission.kind = kind.charAt(0); - - return mission; - } -} diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java new file mode 100644 index 000000000..6d63b9ff7 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java @@ -0,0 +1,223 @@ +package us.shandian.giga.get.sqlite; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.util.Log; + +import java.io.File; +import java.util.ArrayList; + +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.get.FinishedMission; +import us.shandian.giga.get.Mission; +import us.shandian.giga.io.StoredFileHelper; + +/** + * SQLite helper to store finished {@link us.shandian.giga.get.FinishedMission}'s + */ +public class FinishedMissionStore extends SQLiteOpenHelper { + + // TODO: use NewPipeSQLiteHelper ('s constants) when playlist branch is merged (?) + private static final String DATABASE_NAME = "downloads.db"; + + private static final int DATABASE_VERSION = 4; + + /** + * The table name of download missions (old) + */ + private static final String MISSIONS_TABLE_NAME_v2 = "download_missions"; + + /** + * The table name of download missions + */ + private static final String FINISHED_MISSIONS_TABLE_NAME = "finished_missions"; + + /** + * The key to the urls of a mission + */ + private static final String KEY_SOURCE = "url"; + + + /** + * The key to the done. + */ + private static final String KEY_DONE = "bytes_downloaded"; + + private static final String KEY_TIMESTAMP = "timestamp"; + + private static final String KEY_KIND = "kind"; + + private static final String KEY_PATH = "path"; + + /** + * The statement to create the table + */ + private static final String MISSIONS_CREATE_TABLE = + "CREATE TABLE " + FINISHED_MISSIONS_TABLE_NAME + " (" + + KEY_PATH + " TEXT NOT NULL, " + + KEY_SOURCE + " TEXT NOT NULL, " + + KEY_DONE + " INTEGER NOT NULL, " + + KEY_TIMESTAMP + " INTEGER NOT NULL, " + + KEY_KIND + " TEXT NOT NULL, " + + " UNIQUE(" + KEY_TIMESTAMP + ", " + KEY_PATH + "));"; + + + private Context context; + + public FinishedMissionStore(Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + this.context = context; + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(MISSIONS_CREATE_TABLE); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + if (oldVersion == 2) { + db.execSQL("ALTER TABLE " + MISSIONS_TABLE_NAME_v2 + " ADD COLUMN " + KEY_KIND + " TEXT;"); + oldVersion++; + } + + if (oldVersion == 3) { + final String KEY_LOCATION = "location"; + final String KEY_NAME = "name"; + + db.execSQL(MISSIONS_CREATE_TABLE); + + Cursor cursor = db.query(MISSIONS_TABLE_NAME_v2, null, null, + null, null, null, KEY_TIMESTAMP); + + int count = cursor.getCount(); + if (count > 0) { + db.beginTransaction(); + while (cursor.moveToNext()) { + ContentValues values = new ContentValues(); + values.put(KEY_SOURCE, cursor.getString(cursor.getColumnIndex(KEY_SOURCE))); + values.put(KEY_DONE, cursor.getString(cursor.getColumnIndex(KEY_DONE))); + values.put(KEY_TIMESTAMP, cursor.getLong(cursor.getColumnIndex(KEY_TIMESTAMP))); + values.put(KEY_KIND, cursor.getString(cursor.getColumnIndex(KEY_KIND))); + values.put(KEY_PATH, Uri.fromFile( + new File( + cursor.getString(cursor.getColumnIndex(KEY_LOCATION)), + cursor.getString(cursor.getColumnIndex(KEY_NAME)) + ) + ).toString()); + + db.insert(FINISHED_MISSIONS_TABLE_NAME, null, values); + } + db.setTransactionSuccessful(); + db.endTransaction(); + } + + cursor.close(); + db.execSQL("DROP TABLE " + MISSIONS_TABLE_NAME_v2); + } + } + + /** + * Returns all values of the download mission as ContentValues. + * + * @param downloadMission the download mission + * @return the content values + */ + private ContentValues getValuesOfMission(@NonNull Mission downloadMission) { + ContentValues values = new ContentValues(); + values.put(KEY_SOURCE, downloadMission.source); + values.put(KEY_PATH, downloadMission.storage.getUri().toString()); + values.put(KEY_DONE, downloadMission.length); + values.put(KEY_TIMESTAMP, downloadMission.timestamp); + values.put(KEY_KIND, String.valueOf(downloadMission.kind)); + return values; + } + + private FinishedMission getMissionFromCursor(Cursor cursor) { + if (cursor == null) throw new NullPointerException("cursor is null"); + + String kind = cursor.getString(cursor.getColumnIndex(KEY_KIND)); + if (kind == null || kind.isEmpty()) kind = "?"; + + String path = cursor.getString(cursor.getColumnIndexOrThrow(KEY_PATH)); + + FinishedMission mission = new FinishedMission(); + + mission.source = cursor.getString(cursor.getColumnIndexOrThrow(KEY_SOURCE)); + mission.length = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE)); + mission.timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP)); + mission.kind = kind.charAt(0); + + try { + mission.storage = new StoredFileHelper(context, Uri.parse(path), ""); + } catch (Exception e) { + Log.e("FinishedMissionStore", "failed to load the storage path of: " + path, e); + mission.storage = new StoredFileHelper(path, "", ""); + } + + return mission; + } + + + ////////////////////////////////// + // Data source methods + /////////////////////////////////// + + public ArrayList loadFinishedMissions() { + SQLiteDatabase database = getReadableDatabase(); + Cursor cursor = database.query(FINISHED_MISSIONS_TABLE_NAME, null, null, + null, null, null, KEY_TIMESTAMP + " DESC"); + + int count = cursor.getCount(); + if (count == 0) return new ArrayList<>(1); + + ArrayList result = new ArrayList<>(count); + while (cursor.moveToNext()) { + result.add(getMissionFromCursor(cursor)); + } + + return result; + } + + public void addFinishedMission(DownloadMission downloadMission) { + if (downloadMission == null) throw new NullPointerException("downloadMission is null"); + SQLiteDatabase database = getWritableDatabase(); + ContentValues values = getValuesOfMission(downloadMission); + database.insert(FINISHED_MISSIONS_TABLE_NAME, null, values); + } + + public void deleteMission(Mission mission) { + if (mission == null) throw new NullPointerException("mission is null"); + String path = mission.getDownloadedFileUri().toString(); + + SQLiteDatabase database = getWritableDatabase(); + + if (mission instanceof FinishedMission) + database.delete(FINISHED_MISSIONS_TABLE_NAME, KEY_TIMESTAMP + " = ?, " + KEY_PATH + " = ?", new String[]{path}); + else + throw new UnsupportedOperationException("DownloadMission"); + } + + public void updateMission(Mission mission) { + if (mission == null) throw new NullPointerException("mission is null"); + SQLiteDatabase database = getWritableDatabase(); + ContentValues values = getValuesOfMission(mission); + String path = mission.getDownloadedFileUri().toString(); + + int rowsAffected; + + if (mission instanceof FinishedMission) + rowsAffected = database.update(FINISHED_MISSIONS_TABLE_NAME, values, KEY_PATH + " = ?", new String[]{path}); + else + throw new UnsupportedOperationException("DownloadMission"); + + if (rowsAffected != 1) { + Log.e("FinishedMissionStore", "Expected 1 row to be affected by update but got " + rowsAffected); + } + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/io/ChunkFileInputStream.java b/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java similarity index 80% rename from app/src/main/java/us/shandian/giga/postprocessing/io/ChunkFileInputStream.java rename to app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java index ee2fcddd5..16a90fcee 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/io/ChunkFileInputStream.java +++ b/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java @@ -1,150 +1,148 @@ -package us.shandian.giga.postprocessing.io; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.File; -import java.io.IOException; -import java.io.RandomAccessFile; - -public class ChunkFileInputStream extends SharpStream { - - private RandomAccessFile source; - private final long offset; - private final long length; - private long position; - - public ChunkFileInputStream(File file, long start, long end, String mode) throws IOException { - source = new RandomAccessFile(file, mode); - offset = start; - length = end - start; - position = 0; - - if (length < 1) { - source.close(); - throw new IOException("The chunk is empty or invalid"); - } - if (source.length() < end) { - try { - throw new IOException(String.format("invalid file length. expected = %s found = %s", end, source.length())); - } finally { - source.close(); - } - } - - source.seek(offset); - } - - /** - * Get absolute position on file - * - * @return the position - */ - public long getFilePointer() { - return offset + position; - } - - @Override - public int read() throws IOException { - if ((position + 1) > length) { - return 0; - } - - int res = source.read(); - if (res >= 0) { - position++; - } - - return res; - } - - @Override - public int read(byte b[]) throws IOException { - return read(b, 0, b.length); - } - - @Override - public int read(byte b[], int off, int len) throws IOException { - if ((position + len) > length) { - len = (int) (length - position); - } - if (len == 0) { - return 0; - } - - int res = source.read(b, off, len); - position += res; - - return res; - } - - @Override - public long skip(long pos) throws IOException { - pos = Math.min(pos + position, length); - - if (pos == 0) { - return 0; - } - - source.seek(offset + pos); - - long oldPos = position; - position = pos; - - return pos - oldPos; - } - - @Override - public long available() { - return (int) (length - position); - } - - @SuppressWarnings("EmptyCatchBlock") - @Override - public void dispose() { - try { - source.close(); - } catch (IOException err) { - } finally { - source = null; - } - } - - @Override - public boolean isDisposed() { - return source == null; - } - - @Override - public void rewind() throws IOException { - position = 0; - source.seek(offset); - } - - @Override - public boolean canRewind() { - return true; - } - - @Override - public boolean canRead() { - return true; - } - - @Override - public boolean canWrite() { - return false; - } - - @Override - public void write(byte value) { - } - - @Override - public void write(byte[] buffer) { - } - - @Override - public void write(byte[] buffer, int offset, int count) { - } - -} +package us.shandian.giga.io; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; + +public class ChunkFileInputStream extends SharpStream { + + private SharpStream source; + private final long offset; + private final long length; + private long position; + + public ChunkFileInputStream(SharpStream target, long start) throws IOException { + this(target, start, target.length()); + } + + public ChunkFileInputStream(SharpStream target, long start, long end) throws IOException { + source = target; + offset = start; + length = end - start; + position = 0; + + if (length < 1) { + source.close(); + throw new IOException("The chunk is empty or invalid"); + } + if (source.length() < end) { + try { + throw new IOException(String.format("invalid file length. expected = %s found = %s", end, source.length())); + } finally { + source.close(); + } + } + + source.seek(offset); + } + + /** + * Get absolute position on file + * + * @return the position + */ + public long getFilePointer() { + return offset + position; + } + + @Override + public int read() throws IOException { + if ((position + 1) > length) { + return 0; + } + + int res = source.read(); + if (res >= 0) { + position++; + } + + return res; + } + + @Override + public int read(byte b[]) throws IOException { + return read(b, 0, b.length); + } + + @Override + public int read(byte b[], int off, int len) throws IOException { + if ((position + len) > length) { + len = (int) (length - position); + } + if (len == 0) { + return 0; + } + + int res = source.read(b, off, len); + position += res; + + return res; + } + + @Override + public long skip(long pos) throws IOException { + pos = Math.min(pos + position, length); + + if (pos == 0) { + return 0; + } + + source.seek(offset + pos); + + long oldPos = position; + position = pos; + + return pos - oldPos; + } + + @Override + public long available() { + return (int) (length - position); + } + + @SuppressWarnings("EmptyCatchBlock") + @Override + public void close() { + source.close(); + source = null; + } + + @Override + public boolean isClosed() { + return source == null; + } + + @Override + public void rewind() throws IOException { + position = 0; + source.seek(offset); + } + + @Override + public boolean canRewind() { + return true; + } + + @Override + public boolean canRead() { + return true; + } + + @Override + public boolean canWrite() { + return false; + } + + @Override + public void write(byte value) { + } + + @Override + public void write(byte[] buffer) { + } + + @Override + public void write(byte[] buffer, int offset, int count) { + } + +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/io/CircularFileWriter.java b/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java similarity index 89% rename from app/src/main/java/us/shandian/giga/postprocessing/io/CircularFileWriter.java rename to app/src/main/java/us/shandian/giga/io/CircularFileWriter.java index 4c4160fa3..650725a76 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/io/CircularFileWriter.java +++ b/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java @@ -1,4 +1,4 @@ -package us.shandian.giga.postprocessing.io; +package us.shandian.giga.io; import android.support.annotation.NonNull; @@ -7,7 +7,6 @@ import org.schabi.newpipe.streams.io.SharpStream; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; -import java.io.RandomAccessFile; public class CircularFileWriter extends SharpStream { @@ -26,7 +25,7 @@ public class CircularFileWriter extends SharpStream { private BufferedFile out; private BufferedFile aux; - public CircularFileWriter(File source, File temp, OffsetChecker checker) throws IOException { + public CircularFileWriter(SharpStream target, File temp, OffsetChecker checker) throws IOException { if (checker == null) { throw new NullPointerException("checker is null"); } @@ -38,7 +37,7 @@ public class CircularFileWriter extends SharpStream { } aux = new BufferedFile(temp); - out = new BufferedFile(source); + out = new BufferedFile(target); callback = checker; @@ -105,7 +104,7 @@ public class CircularFileWriter extends SharpStream { out.target.setLength(length); } - dispose(); + close(); return length; } @@ -114,13 +113,13 @@ public class CircularFileWriter extends SharpStream { * Close the file without flushing any buffer */ @Override - public void dispose() { + public void close() { if (out != null) { - out.dispose(); + out.close(); out = null; } if (aux != null) { - aux.dispose(); + aux.close(); aux = null; } } @@ -256,7 +255,7 @@ public class CircularFileWriter extends SharpStream { } @Override - public boolean isDisposed() { + public boolean isClosed() { return out == null; } @@ -339,30 +338,29 @@ public class CircularFileWriter extends SharpStream { class BufferedFile { - protected final RandomAccessFile target; + protected final SharpStream target; private long offset; protected long length; - private byte[] queue; + private byte[] queue = new byte[QUEUE_BUFFER_SIZE]; private int queueSize; BufferedFile(File file) throws FileNotFoundException { - queue = new byte[QUEUE_BUFFER_SIZE]; - target = new RandomAccessFile(file, "rw"); + this.target = new FileStream(file); + } + + BufferedFile(SharpStream target) { + this.target = target; } protected long getOffset() { return offset + queueSize;// absolute offset in the file } - protected void dispose() { - try { - queue = null; - target.close(); - } catch (IOException e) { - // nothing to do - } + protected void close() { + queue = null; + target.close(); } protected void write(byte b[], int off, int len) throws IOException { @@ -384,7 +382,7 @@ public class CircularFileWriter extends SharpStream { } } - protected void flush() throws IOException { + void flush() throws IOException { writeProof(queue, queueSize); offset += queueSize; queueSize = 0; @@ -404,7 +402,7 @@ public class CircularFileWriter extends SharpStream { return queue.length - queueSize; } - protected void reset() throws IOException { + void reset() throws IOException { offset = 0; length = 0; target.seek(0); @@ -415,7 +413,7 @@ public class CircularFileWriter extends SharpStream { target.seek(absoluteOffset); } - protected void writeProof(byte[] buffer, int length) throws IOException { + void writeProof(byte[] buffer, int length) throws IOException { if (onWriteError == null) { target.write(buffer, 0, length); return; @@ -436,14 +434,8 @@ public class CircularFileWriter extends SharpStream { @NonNull @Override public String toString() { - String absOffset; String absLength; - try { - absOffset = Long.toString(target.getFilePointer()); - } catch (IOException e) { - absOffset = "[" + e.getLocalizedMessage() + "]"; - } try { absLength = Long.toString(target.length()); } catch (IOException e) { @@ -451,8 +443,8 @@ public class CircularFileWriter extends SharpStream { } return String.format( - "offset=%s length=%s queue=%s absOffset=%s absLength=%s", - offset, length, queueSize, absOffset, absLength + "offset=%s length=%s queue=%s absLength=%s", + offset, length, queueSize, absLength ); } } diff --git a/app/src/main/java/us/shandian/giga/io/FileStream.java b/app/src/main/java/us/shandian/giga/io/FileStream.java new file mode 100644 index 000000000..5b2033324 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/FileStream.java @@ -0,0 +1,131 @@ +package us.shandian.giga.io; + +import android.support.annotation.NonNull; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.RandomAccessFile; + +/** + * @author kapodamy + */ +public class FileStream extends SharpStream { + + public RandomAccessFile source; + + public FileStream(@NonNull File target) throws FileNotFoundException { + this.source = new RandomAccessFile(target, "rw"); + } + + public FileStream(@NonNull String path) throws FileNotFoundException { + this.source = new RandomAccessFile(path, "rw"); + } + + @Override + public int read() throws IOException { + return source.read(); + } + + @Override + public int read(byte b[]) throws IOException { + return source.read(b); + } + + @Override + public int read(byte b[], int off, int len) throws IOException { + return source.read(b, off, len); + } + + @Override + public long skip(long pos) throws IOException { + return source.skipBytes((int) pos); + } + + @Override + public long available() { + try { + return source.length() - source.getFilePointer(); + } catch (IOException e) { + return 0; + } + } + + @Override + public void close() { + if (source == null) return; + try { + source.close(); + } catch (IOException err) { + // nothing to do + } + source = null; + } + + @Override + public boolean isClosed() { + return source == null; + } + + @Override + public void rewind() throws IOException { + source.seek(0); + } + + @Override + public boolean canRewind() { + return true; + } + + @Override + public boolean canRead() { + return true; + } + + @Override + public boolean canWrite() { + return true; + } + + @Override + public boolean canSeek() { + return true; + } + + @Override + public boolean canSetLength() { + return true; + } + + @Override + public void write(byte value) throws IOException { + source.write(value); + } + + @Override + public void write(byte[] buffer) throws IOException { + source.write(buffer); + } + + @Override + public void write(byte[] buffer, int offset, int count) throws IOException { + source.write(buffer, offset, count); + } + + @Override + public void setLength(long length) throws IOException { + source.setLength(length); + } + + @Override + public void seek(long offset) throws IOException { + source.seek(offset); + } + + @Override + public long length() throws IOException { + return source.length(); + } +} diff --git a/app/src/main/java/us/shandian/giga/io/FileStreamSAF.java b/app/src/main/java/us/shandian/giga/io/FileStreamSAF.java new file mode 100644 index 000000000..cb4786280 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/FileStreamSAF.java @@ -0,0 +1,140 @@ +package us.shandian.giga.io; + +import android.content.ContentResolver; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.support.annotation.NonNull; +import android.util.Log; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.channels.FileChannel; + +public class FileStreamSAF extends SharpStream { + + private final FileInputStream in; + private final FileOutputStream out; + private final FileChannel channel; + private final ParcelFileDescriptor file; + + private boolean disposed; + + public FileStreamSAF(@NonNull ContentResolver contentResolver, Uri fileUri) throws IOException { + // Notes: + // the file must exists first + // ¡read-write mode must allow seek! + // It is not guaranteed to work with files in the cloud (virtual files), tested in local storage devices + + file = contentResolver.openFileDescriptor(fileUri, "rw"); + + if (file == null) { + throw new IOException("Cannot get the ParcelFileDescriptor for " + fileUri.toString()); + } + + in = new FileInputStream(file.getFileDescriptor()); + out = new FileOutputStream(file.getFileDescriptor()); + channel = out.getChannel();// or use in.getChannel() + } + + @Override + public int read() throws IOException { + return in.read(); + } + + @Override + public int read(byte[] buffer) throws IOException { + return in.read(buffer); + } + + @Override + public int read(byte[] buffer, int offset, int count) throws IOException { + return in.read(buffer, offset, count); + } + + @Override + public long skip(long amount) throws IOException { + return in.skip(amount);// ¿or use channel.position(channel.position() + amount)? + } + + @Override + public long available() { + try { + return in.available(); + } catch (IOException e) { + return 0;// ¡but not -1! + } + } + + @Override + public void rewind() throws IOException { + seek(0); + } + + @Override + public void close() { + try { + disposed = true; + + file.close(); + in.close(); + out.close(); + channel.close(); + } catch (IOException e) { + Log.e("FileStreamSAF", "close() error", e); + } + } + + @Override + public boolean isClosed() { + return disposed; + } + + @Override + public boolean canRewind() { + return true; + } + + @Override + public boolean canRead() { + return true; + } + + @Override + public boolean canWrite() { + return true; + } + + public boolean canSetLength() { + return true; + } + + public boolean canSeek() { + return true; + } + + @Override + public void write(byte value) throws IOException { + out.write(value); + } + + @Override + public void write(byte[] buffer) throws IOException { + out.write(buffer); + } + + @Override + public void write(byte[] buffer, int offset, int count) throws IOException { + out.write(buffer, offset, count); + } + + public void setLength(long length) throws IOException { + channel.truncate(length); + } + + public void seek(long offset) throws IOException { + channel.position(offset); + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/io/SharpInputStream.java b/app/src/main/java/us/shandian/giga/io/SharpInputStream.java similarity index 91% rename from app/src/main/java/us/shandian/giga/postprocessing/io/SharpInputStream.java rename to app/src/main/java/us/shandian/giga/io/SharpInputStream.java index 586456d98..089101dfe 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/io/SharpInputStream.java +++ b/app/src/main/java/us/shandian/giga/io/SharpInputStream.java @@ -1,61 +1,61 @@ -/* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. - */ -package us.shandian.giga.postprocessing.io; - -import android.support.annotation.NonNull; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.IOException; -import java.io.InputStream; - -/** - * Wrapper for the classic {@link java.io.InputStream} - * - * @author kapodamy - */ -public class SharpInputStream extends InputStream { - - private final SharpStream base; - - public SharpInputStream(SharpStream base) throws IOException { - if (!base.canRead()) { - throw new IOException("The provided stream is not readable"); - } - this.base = base; - } - - @Override - public int read() throws IOException { - return base.read(); - } - - @Override - public int read(@NonNull byte[] bytes) throws IOException { - return base.read(bytes); - } - - @Override - public int read(@NonNull byte[] bytes, int i, int i1) throws IOException { - return base.read(bytes, i, i1); - } - - @Override - public long skip(long l) throws IOException { - return base.skip(l); - } - - @Override - public int available() { - long res = base.available(); - return res > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) res; - } - - @Override - public void close() { - base.dispose(); - } -} +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package us.shandian.giga.io; + +import android.support.annotation.NonNull; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Wrapper for the classic {@link java.io.InputStream} + * + * @author kapodamy + */ +public class SharpInputStream extends InputStream { + + private final SharpStream base; + + public SharpInputStream(SharpStream base) throws IOException { + if (!base.canRead()) { + throw new IOException("The provided stream is not readable"); + } + this.base = base; + } + + @Override + public int read() throws IOException { + return base.read(); + } + + @Override + public int read(@NonNull byte[] bytes) throws IOException { + return base.read(bytes); + } + + @Override + public int read(@NonNull byte[] bytes, int i, int i1) throws IOException { + return base.read(bytes, i, i1); + } + + @Override + public long skip(long l) throws IOException { + return base.skip(l); + } + + @Override + public int available() { + long res = base.available(); + return res > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) res; + } + + @Override + public void close() { + base.close(); + } +} diff --git a/app/src/main/java/us/shandian/giga/io/StoredDirectoryHelper.java b/app/src/main/java/us/shandian/giga/io/StoredDirectoryHelper.java new file mode 100644 index 000000000..f5c2fd3f5 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/StoredDirectoryHelper.java @@ -0,0 +1,175 @@ +package us.shandian.giga.io; + +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; +import android.support.v4.provider.DocumentFile; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; + +public class StoredDirectoryHelper { + public final static int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + + private File ioTree; + private DocumentFile docTree; + + private ContentResolver contentResolver; + + private String tag; + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + public StoredDirectoryHelper(@NonNull Context context, @NonNull Uri path, String tag) throws IOException { + this.contentResolver = context.getContentResolver(); + this.tag = tag; + this.docTree = DocumentFile.fromTreeUri(context, path); + + if (this.docTree == null) + throw new IOException("Failed to create the tree from Uri"); + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + public StoredDirectoryHelper(@NonNull String location, String tag) { + ioTree = new File(location); + this.tag = tag; + } + + @Nullable + public StoredFileHelper createFile(String filename, String mime) { + StoredFileHelper storage; + + try { + if (docTree == null) { + storage = new StoredFileHelper(ioTree, filename, tag); + storage.sourceTree = Uri.fromFile(ioTree).toString(); + } else { + storage = new StoredFileHelper(docTree, contentResolver, filename, mime, tag); + storage.sourceTree = docTree.getUri().toString(); + } + } catch (IOException e) { + return null; + } + + storage.tag = tag; + + return storage; + } + + public StoredFileHelper createUniqueFile(String filename, String mime) { + ArrayList existingNames = new ArrayList<>(50); + + String ext; + + int dotIndex = filename.lastIndexOf('.'); + if (dotIndex < 0 || (dotIndex == filename.length() - 1)) { + ext = ""; + } else { + ext = filename.substring(dotIndex); + filename = filename.substring(0, dotIndex - 1); + } + + String name; + if (docTree == null) { + for (File file : ioTree.listFiles()) { + name = file.getName().toLowerCase(); + if (name.startsWith(filename)) existingNames.add(name); + } + } else { + for (DocumentFile file : docTree.listFiles()) { + name = file.getName(); + if (name == null) continue; + name = name.toLowerCase(); + if (name.startsWith(filename)) existingNames.add(name); + } + } + + boolean free = true; + String lwFilename = filename.toLowerCase(); + for (String testName : existingNames) { + if (testName.equals(lwFilename)) { + free = false; + break; + } + } + + if (free) return createFile(filename, mime); + + String[] sortedNames = existingNames.toArray(new String[0]); + Arrays.sort(sortedNames); + + String newName; + int downloadIndex = 0; + do { + newName = filename + " (" + downloadIndex + ")" + ext; + ++downloadIndex; + if (downloadIndex == 1000) { // Probably an error on our side + newName = System.currentTimeMillis() + ext; + break; + } + } while (Arrays.binarySearch(sortedNames, newName) >= 0); + + + return createFile(newName, mime); + } + + public boolean isDirect() { + return docTree == null; + } + + public Uri getUri() { + return docTree == null ? Uri.fromFile(ioTree) : docTree.getUri(); + } + + public boolean exists() { + return docTree == null ? ioTree.exists() : docTree.exists(); + } + + public String getTag() { + return tag; + } + + public void acquirePermissions() throws IOException { + if (docTree == null) return; + + try { + contentResolver.takePersistableUriPermission(docTree.getUri(), PERMISSION_FLAGS); + } catch (Throwable e) { + throw new IOException(e); + } + } + + public void revokePermissions() throws IOException { + if (docTree == null) return; + + try { + contentResolver.releasePersistableUriPermission(docTree.getUri(), PERMISSION_FLAGS); + } catch (Throwable e) { + throw new IOException(e); + } + } + + public Uri findFile(String filename) { + if (docTree == null) + return Uri.fromFile(new File(ioTree, filename)); + + // findFile() method is very slow + DocumentFile file = docTree.findFile(filename); + + return file == null ? null : file.getUri(); + } + + @NonNull + @Override + public String toString() { + return docTree == null ? Uri.fromFile(ioTree).toString() : docTree.getUri().toString(); + } + +} diff --git a/app/src/main/java/us/shandian/giga/io/StoredFileHelper.java b/app/src/main/java/us/shandian/giga/io/StoredFileHelper.java new file mode 100644 index 000000000..0db442f1c --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/StoredFileHelper.java @@ -0,0 +1,301 @@ +package us.shandian.giga.io; + +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.provider.DocumentsContract; +import android.support.annotation.NonNull; +import android.support.v4.app.Fragment; +import android.support.v4.provider.DocumentFile; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.net.URI; + +public class StoredFileHelper implements Serializable { + private static final long serialVersionUID = 0L; + public static final String DEFAULT_MIME = "application/octet-stream"; + + private transient DocumentFile docFile; + private transient DocumentFile docTree; + private transient File ioFile; + private transient ContentResolver contentResolver; + + protected String source; + String sourceTree; + + protected String tag; + + private String srcName; + private String srcType; + + public StoredFileHelper(String filename, String mime, String tag) { + this.source = null;// this instance will be "invalid" see invalidate()/isInvalid() methods + + this.srcName = filename; + this.srcType = mime == null ? DEFAULT_MIME : mime; + + this.tag = tag; + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + StoredFileHelper(DocumentFile tree, ContentResolver contentResolver, String filename, String mime, String tag) throws IOException { + this.docTree = tree; + this.contentResolver = contentResolver; + + // this is very slow, because SAF does not allow overwrite + DocumentFile res = this.docTree.findFile(filename); + + if (res != null && res.exists() && res.isDirectory()) { + if (!res.delete()) + throw new IOException("Directory with the same name found but cannot delete"); + res = null; + } + + if (res == null) { + res = this.docTree.createFile(mime == null ? DEFAULT_MIME : mime, filename); + if (res == null) throw new IOException("Cannot create the file"); + } + + this.docFile = res; + this.source = res.getUri().toString(); + this.srcName = getName(); + this.srcType = getType(); + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + public StoredFileHelper(Context context, @NonNull Uri path, String tag) throws IOException { + this.source = path.toString(); + this.tag = tag; + + if (path.getScheme() == null || path.getScheme().equalsIgnoreCase("file")) { + this.ioFile = new File(URI.create(this.source)); + } else { + DocumentFile file = DocumentFile.fromSingleUri(context, path); + if (file == null) + throw new UnsupportedOperationException("Cannot get the file via SAF"); + + this.contentResolver = context.getContentResolver(); + this.docFile = file; + + try { + this.contentResolver.takePersistableUriPermission(docFile.getUri(), StoredDirectoryHelper.PERMISSION_FLAGS); + } catch (Exception e) { + throw new IOException(e); + } + } + + this.srcName = getName(); + this.srcType = getType(); + } + + public StoredFileHelper(File location, String filename, String tag) throws IOException { + this.ioFile = new File(location, filename); + this.tag = tag; + + if (this.ioFile.exists()) { + if (!this.ioFile.isFile() && !this.ioFile.delete()) + throw new IOException("The filename is already in use by non-file entity and cannot overwrite it"); + } else { + if (!this.ioFile.createNewFile()) + throw new IOException("Cannot create the file"); + } + + this.source = Uri.fromFile(this.ioFile).toString(); + this.srcName = getName(); + this.srcType = getType(); + } + + public static StoredFileHelper deserialize(@NonNull StoredFileHelper storage, Context context) throws IOException { + if (storage.isInvalid()) + return new StoredFileHelper(storage.srcName, storage.srcType, storage.tag); + + StoredFileHelper instance = new StoredFileHelper(context, Uri.parse(storage.source), storage.tag); + + if (storage.sourceTree != null) { + instance.docTree = DocumentFile.fromTreeUri(context, Uri.parse(instance.sourceTree)); + + if (instance.docTree == null) + throw new IOException("Cannot deserialize the tree, ¿revoked permissions?"); + } + + return instance; + } + + public static void requestSafWithFileCreation(@NonNull Fragment who, int requestCode, String filename, String mime) { + // SAF notes: + // ACTION_OPEN_DOCUMENT Do not let you create the file, useful for overwrite files + // ACTION_CREATE_DOCUMENT No overwrite support, useless the file provider resolve the conflict + + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType(mime) + .putExtra(Intent.EXTRA_TITLE, filename) + .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | StoredDirectoryHelper.PERMISSION_FLAGS) + .putExtra("android.content.extra.SHOW_ADVANCED", true);// hack, show all storage disks + + who.startActivityForResult(intent, requestCode); + } + + public SharpStream getStream() throws IOException { + invalid(); + + if (docFile == null) + return new FileStream(ioFile); + else + return new FileStreamSAF(contentResolver, docFile.getUri()); + } + + /** + * Indicates whatever if is possible access using the {@code java.io} API + * + * @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework + */ + public boolean isDirect() { + invalid(); + + return docFile == null; + } + + public boolean isInvalid() { + return source == null; + } + + public Uri getUri() { + invalid(); + + return docFile == null ? Uri.fromFile(ioFile) : docFile.getUri(); + } + + public void truncate() throws IOException { + invalid(); + + try (SharpStream fs = getStream()) { + fs.setLength(0); + } + } + + public boolean delete() { + invalid(); + + if (docFile == null) return ioFile.delete(); + + boolean res = docFile.delete(); + + try { + int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + contentResolver.releasePersistableUriPermission(docFile.getUri(), flags); + } catch (Exception ex) { + // ¿what happen? + } + + return res; + } + + public long length() { + invalid(); + + return docFile == null ? ioFile.length() : docFile.length(); + } + + public boolean canWrite() { + if (source == null) return false; + return docFile == null ? ioFile.canWrite() : docFile.canWrite(); + } + + public File getIOFile() { + return ioFile; + } + + public String getName() { + if (source == null) return srcName; + return docFile == null ? ioFile.getName() : docFile.getName(); + } + + public String getType() { + if (source == null) return srcType; + return docFile == null ? DEFAULT_MIME : docFile.getType();// not obligatory for Java IO + } + + public String getTag() { + return tag; + } + + public boolean existsAsFile() { + if (source == null) return false; + + boolean exists = docFile == null ? ioFile.exists() : docFile.exists(); + boolean asFile = docFile == null ? ioFile.isFile() : docFile.isFile();// ¿docFile.isVirtual() means is no-physical? + + return exists && asFile; + } + + public boolean create() { + invalid(); + + if (docFile == null) { + try { + return ioFile.createNewFile(); + } catch (IOException e) { + return false; + } + } + + if (docTree == null || docFile.getName() == null) return false; + + DocumentFile res = docTree.createFile(docFile.getName(), docFile.getType() == null ? DEFAULT_MIME : docFile.getType()); + if (res == null) return false; + + docFile = res; + return true; + } + + public void invalidate() { + if (source == null) return; + + srcName = getName(); + srcType = getType(); + + source = null; + + sourceTree = null; + docTree = null; + docFile = null; + ioFile = null; + contentResolver = null; + } + + private void invalid() { + if (source == null) + throw new IllegalStateException("In invalid state"); + } + + public boolean equals(StoredFileHelper storage) { + if (this.isInvalid() != storage.isInvalid()) return false; + if (this.isDirect() != storage.isDirect()) return false; + + if (this.isDirect()) + return this.ioFile.getPath().equalsIgnoreCase(storage.ioFile.getPath()); + + return DocumentsContract.getDocumentId( + this.docFile.getUri() + ).equalsIgnoreCase(DocumentsContract.getDocumentId( + storage.docFile.getUri() + )); + } + + @NonNull + @Override + public String toString() { + if (source == null) + return "[Invalid state] name=" + srcName + " type=" + srcType + " tag=" + tag; + else + return "sourceFile=" + source + " treeSource=" + (sourceTree == null ? "" : sourceTree) + " tag=" + tag; + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java b/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java index fa0c2c7ae..ee7e4cba1 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java @@ -6,12 +6,10 @@ import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; -import us.shandian.giga.get.DownloadMission; - public class M4aNoDash extends Postprocessing { - M4aNoDash(DownloadMission mission) { - super(mission, 0, true); + M4aNoDash() { + super(0, true); } @Override diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java index 09f5d9661..98ab29dbb 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java @@ -5,15 +5,13 @@ import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; -import us.shandian.giga.get.DownloadMission; - /** * @author kapodamy */ class Mp4FromDashMuxer extends Postprocessing { - Mp4FromDashMuxer(DownloadMission mission) { - super(mission, 2 * 1024 * 1024/* 2 MiB */, true); + Mp4FromDashMuxer() { + super(2 * 1024 * 1024/* 2 MiB */, true); } @Override diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java index df8549010..7bc32ea05 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java @@ -1,6 +1,7 @@ package us.shandian.giga.postprocessing; import android.os.Message; +import android.support.annotation.NonNull; import android.util.Log; import org.schabi.newpipe.streams.io.SharpStream; @@ -9,9 +10,9 @@ import java.io.File; import java.io.IOException; import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.postprocessing.io.ChunkFileInputStream; -import us.shandian.giga.postprocessing.io.CircularFileWriter; -import us.shandian.giga.postprocessing.io.CircularFileWriter.OffsetChecker; +import us.shandian.giga.io.ChunkFileInputStream; +import us.shandian.giga.io.CircularFileWriter; +import us.shandian.giga.io.CircularFileWriter.OffsetChecker; import us.shandian.giga.service.DownloadManagerService; import static us.shandian.giga.get.DownloadMission.ERROR_NOTHING; @@ -20,30 +21,41 @@ import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_EXCEPTION; public abstract class Postprocessing { - static final byte OK_RESULT = ERROR_NOTHING; + static transient final byte OK_RESULT = ERROR_NOTHING; - public static final String ALGORITHM_TTML_CONVERTER = "ttml"; - public static final String ALGORITHM_WEBM_MUXER = "webm"; - public static final String ALGORITHM_MP4_FROM_DASH_MUXER = "mp4D-mp4"; - public static final String ALGORITHM_M4A_NO_DASH = "mp4D-m4a"; + public transient static final String ALGORITHM_TTML_CONVERTER = "ttml"; + public transient static final String ALGORITHM_WEBM_MUXER = "webm"; + public transient static final String ALGORITHM_MP4_FROM_DASH_MUXER = "mp4D-mp4"; + public transient static final String ALGORITHM_M4A_NO_DASH = "mp4D-m4a"; + + public static Postprocessing getAlgorithm(String algorithmName, String[] args) { + Postprocessing instance; - public static Postprocessing getAlgorithm(String algorithmName, DownloadMission mission) { if (null == algorithmName) { throw new NullPointerException("algorithmName"); } else switch (algorithmName) { case ALGORITHM_TTML_CONVERTER: - return new TtmlConverter(mission); + instance = new TtmlConverter(); + break; case ALGORITHM_WEBM_MUXER: - return new WebMMuxer(mission); + instance = new WebMMuxer(); + break; case ALGORITHM_MP4_FROM_DASH_MUXER: - return new Mp4FromDashMuxer(mission); + instance = new Mp4FromDashMuxer(); + break; case ALGORITHM_M4A_NO_DASH: - return new M4aNoDash(mission); + instance = new M4aNoDash(); + break; /*case "example-algorithm": - return new ExampleAlgorithm(mission);*/ + instance = new ExampleAlgorithm(mission);*/ default: throw new RuntimeException("Unimplemented post-processing algorithm: " + algorithmName); } + + instance.args = args; + instance.name = algorithmName; + + return instance; } /** @@ -61,32 +73,38 @@ public abstract class Postprocessing { /** * the download to post-process */ - protected DownloadMission mission; + protected transient DownloadMission mission; - Postprocessing(DownloadMission mission, int recommendedReserve, boolean worksOnSameFile) { - this.mission = mission; + public transient File cacheDir; + + private String[] args; + + private String name; + + Postprocessing(int recommendedReserve, boolean worksOnSameFile) { this.recommendedReserve = recommendedReserve; this.worksOnSameFile = worksOnSameFile; } - public void run() throws IOException { - File file = mission.getDownloadedFile(); + public void run(DownloadMission target) throws IOException { + this.mission = target; + File temp = null; CircularFileWriter out = null; int result; long finalLength = -1; mission.done = 0; - mission.length = file.length(); + mission.length = mission.storage.length(); if (worksOnSameFile) { ChunkFileInputStream[] sources = new ChunkFileInputStream[mission.urls.length]; try { int i = 0; for (; i < sources.length - 1; i++) { - sources[i] = new ChunkFileInputStream(file, mission.offsets[i], mission.offsets[i + 1], "rw"); + sources[i] = new ChunkFileInputStream(mission.storage.getStream(), mission.offsets[i], mission.offsets[i + 1]); } - sources[i] = new ChunkFileInputStream(file, mission.offsets[i], mission.getDownloadedFile().length(), "rw"); + sources[i] = new ChunkFileInputStream(mission.storage.getStream(), mission.offsets[i]); if (test(sources)) { for (SharpStream source : sources) source.rewind(); @@ -97,7 +115,7 @@ public abstract class Postprocessing { * WARNING: never use rewind() in any chunk after any writing (especially on first chunks) * or the CircularFileWriter can lead to unexpected results */ - if (source.isDisposed() || source.available() < 1) { + if (source.isClosed() || source.available() < 1) { continue;// the selected source is not used anymore } @@ -107,18 +125,19 @@ public abstract class Postprocessing { return -1; }; - temp = new File(mission.location, mission.name + ".tmp"); + // TODO: use Context.getCache() for this operation + temp = new File(cacheDir, mission.storage.getName() + ".tmp"); - out = new CircularFileWriter(file, temp, checker); + out = new CircularFileWriter(mission.storage.getStream(), temp, checker); out.onProgress = this::progressReport; out.onWriteError = (err) -> { - mission.postprocessingState = 3; + mission.psState = 3; mission.notifyError(ERROR_POSTPROCESSING_HOLD, err); try { synchronized (this) { - while (mission.postprocessingState == 3) + while (mission.psState == 3) wait(); } } catch (InterruptedException e) { @@ -138,12 +157,12 @@ public abstract class Postprocessing { } } finally { for (SharpStream source : sources) { - if (source != null && !source.isDisposed()) { - source.dispose(); + if (source != null && !source.isClosed()) { + source.close(); } } if (out != null) { - out.dispose(); + out.close(); } if (temp != null) { //noinspection ResultOfMethodCallIgnored @@ -164,10 +183,9 @@ public abstract class Postprocessing { mission.errObject = new RuntimeException("post-processing algorithm returned " + result); } - if (result != OK_RESULT && worksOnSameFile) { - //noinspection ResultOfMethodCallIgnored - file.delete(); - } + if (result != OK_RESULT && worksOnSameFile) mission.storage.delete(); + + this.mission = null; } /** @@ -192,11 +210,11 @@ public abstract class Postprocessing { abstract int process(SharpStream out, SharpStream... sources) throws IOException; String getArgumentAt(int index, String defaultValue) { - if (mission.postprocessingArgs == null || index >= mission.postprocessingArgs.length) { + if (args == null || index >= args.length) { return defaultValue; } - return mission.postprocessingArgs[index]; + return args[index]; } private void progressReport(long done) { @@ -209,4 +227,22 @@ public abstract class Postprocessing { mission.mHandler.sendMessage(m); } + + @NonNull + @Override + public String toString() { + StringBuilder str = new StringBuilder(); + + str.append("name=").append(name).append('['); + + if (args != null) { + for (String arg : args) { + str.append(", "); + str.append(arg); + } + str.delete(0, 1); + } + + return str.append(']').toString(); + } } diff --git a/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java b/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java index 390061840..bba0b299a 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java @@ -2,8 +2,8 @@ package us.shandian.giga.postprocessing; import android.util.Log; -import org.schabi.newpipe.streams.io.SharpStream; import org.schabi.newpipe.streams.SubtitleConverter; +import org.schabi.newpipe.streams.io.SharpStream; import org.xml.sax.SAXException; import java.io.IOException; @@ -12,18 +12,15 @@ import java.text.ParseException; import javax.xml.parsers.ParserConfigurationException; import javax.xml.xpath.XPathExpressionException; -import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.postprocessing.io.SharpInputStream; - /** * @author kapodamy */ class TtmlConverter extends Postprocessing { private static final String TAG = "TtmlConverter"; - TtmlConverter(DownloadMission mission) { + TtmlConverter() { // due how XmlPullParser works, the xml is fully loaded on the ram - super(mission, 0, true); + super(0, true); } @Override diff --git a/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java index 2ffb0f08d..37295f2e3 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java @@ -7,15 +7,13 @@ import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; -import us.shandian.giga.get.DownloadMission; - /** * @author kapodamy */ class WebMMuxer extends Postprocessing { - WebMMuxer(DownloadMission mission) { - super(mission, 2048 * 1024/* 2 MiB */, true); + WebMMuxer() { + super(2048 * 1024/* 2 MiB */, true); } @Override diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManager.java b/app/src/main/java/us/shandian/giga/service/DownloadManager.java index 58246beb1..3624fb6c2 100644 --- a/app/src/main/java/us/shandian/giga/service/DownloadManager.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManager.java @@ -13,16 +13,15 @@ import org.schabi.newpipe.R; import java.io.File; import java.io.IOException; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.Iterator; import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.FinishedMission; import us.shandian.giga.get.Mission; -import us.shandian.giga.get.sqlite.DownloadDataSource; -import us.shandian.giga.service.DownloadManagerService.DMChecker; -import us.shandian.giga.service.DownloadManagerService.MissionCheck; +import us.shandian.giga.get.sqlite.FinishedMissionStore; +import us.shandian.giga.io.StoredDirectoryHelper; +import us.shandian.giga.io.StoredFileHelper; import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; @@ -36,7 +35,10 @@ public class DownloadManager { public final static int SPECIAL_PENDING = 1; public final static int SPECIAL_FINISHED = 2; - private final DownloadDataSource mDownloadDataSource; + static final String TAG_AUDIO = "audio"; + static final String TAG_VIDEO = "video"; + + private final FinishedMissionStore mFinishedMissionStore; private final ArrayList mMissionsPending = new ArrayList<>(); private final ArrayList mMissionsFinished; @@ -51,6 +53,9 @@ public class DownloadManager { boolean mPrefQueueLimit; private boolean mSelfMissionsControl; + StoredDirectoryHelper mMainStorageAudio; + StoredDirectoryHelper mMainStorageVideo; + /** * Create a new instance * @@ -62,7 +67,7 @@ public class DownloadManager { Log.d(TAG, "new DownloadManager instance. 0x" + Integer.toHexString(this.hashCode())); } - mDownloadDataSource = new DownloadDataSource(context); + mFinishedMissionStore = new FinishedMissionStore(context); mHandler = handler; mMissionsFinished = loadFinishedMissions(); mPendingMissionsDir = getPendingDir(context); @@ -71,7 +76,7 @@ public class DownloadManager { throw new RuntimeException("failed to create pending_downloads in data directory"); } - loadPendingMissions(); + loadPendingMissions(context); } private static File getPendingDir(@NonNull Context context) { @@ -92,29 +97,24 @@ public class DownloadManager { * Loads finished missions from the data source */ private ArrayList loadFinishedMissions() { - ArrayList finishedMissions = mDownloadDataSource.loadFinishedMissions(); + ArrayList finishedMissions = mFinishedMissionStore.loadFinishedMissions(); - // missions always is stored by creation order, simply reverse the list - ArrayList result = new ArrayList<>(finishedMissions.size()); + // check if the files exists, otherwise, forget the download for (int i = finishedMissions.size() - 1; i >= 0; i--) { FinishedMission mission = finishedMissions.get(i); - File file = mission.getDownloadedFile(); - if (!file.isFile()) { - if (DEBUG) { - Log.d(TAG, "downloaded file removed: " + file.getAbsolutePath()); - } - mDownloadDataSource.deleteMission(mission); - continue; + if (!mission.storage.existsAsFile()) { + if (DEBUG) Log.d(TAG, "downloaded file removed: " + mission.storage.getName()); + + mFinishedMissionStore.deleteMission(mission); + finishedMissions.remove(i); } - - result.add(mission); } - return result; + return finishedMissions; } - private void loadPendingMissions() { + private void loadPendingMissions(Context ctx) { File[] subs = mPendingMissionsDir.listFiles(); if (subs == null) { @@ -142,40 +142,63 @@ public class DownloadManager { continue; } - File dl = mis.getDownloadedFile(); - boolean exists = dl.exists(); + boolean exists; + try { + mis.storage = StoredFileHelper.deserialize(mis.storage, ctx); + exists = !mis.storage.isInvalid() && mis.storage.existsAsFile(); + + } catch (Exception ex) { + Log.e(TAG, "Failed to load the file source of " + mis.storage.toString()); + mis.storage.invalidate(); + exists = false; + } if (mis.isPsRunning()) { - if (mis.postprocessingThis) { + if (mis.psAlgorithm.worksOnSameFile) { // Incomplete post-processing results in a corrupted download file // because the selected algorithm works on the same file to save space. - if (exists && dl.isFile() && !dl.delete()) + if (exists && !mis.storage.delete()) Log.w(TAG, "Unable to delete incomplete download file: " + sub.getPath()); exists = true; } - mis.postprocessingState = 0; + mis.psState = 0; mis.errCode = DownloadMission.ERROR_POSTPROCESSING_STOPPED; mis.errObject = null; - } else if (exists && !dl.isFile()) { - // probably a folder, this should never happens - if (!sub.delete()) { - Log.w(TAG, "Unable to delete serialized file: " + sub.getPath()); + } else if (!exists) { + + StoredDirectoryHelper mainStorage = getMainStorage(mis.storage.getTag()); + + if (!mis.storage.isInvalid() && !mis.storage.create()) { + // using javaIO cannot recreate the file + // using SAF in older devices (no tree available) + // + // force the user to pick again the save path + mis.storage.invalidate(); + } else if (mainStorage != null) { + // if the user has changed the save path before this download, the original save path will be lost + StoredFileHelper newStorage = mainStorage.createFile(mis.storage.getName(), mis.storage.getType()); + if (newStorage == null) + mis.storage.invalidate(); + else + mis.storage = newStorage; + } + + if (mis.isInitialized()) { + // the progress is lost, reset mission state + DownloadMission m = new DownloadMission(mis.urls, mis.storage, mis.kind, mis.psAlgorithm); + m.timestamp = mis.timestamp; + m.threadCount = mis.threadCount; + m.source = mis.source; + m.nearLength = mis.nearLength; + m.enqueued = mis.enqueued; + m.errCode = DownloadMission.ERROR_PROGRESS_LOST; + mis = m; } - continue; } - if (!exists && mis.isInitialized()) { - // downloaded file deleted, reset mission state - DownloadMission m = new DownloadMission(mis.urls, mis.name, mis.location, mis.kind, mis.postprocessingName, mis.postprocessingArgs); - m.timestamp = mis.timestamp; - m.threadCount = mis.threadCount; - m.source = mis.source; - m.nearLength = mis.nearLength; - m.setEnqueued(mis.enqueued); - mis = m; - } + if (mis.psAlgorithm != null) mis.psAlgorithm.cacheDir = ctx.getCacheDir(); mis.running = false; mis.recovered = exists; @@ -196,51 +219,15 @@ public class DownloadManager { /** * Start a new download mission * - * @param urls the list of urls to download - * @param location the location - * @param name the name of the file to create - * @param kind type of file (a: audio v: video s: subtitle ?: file-extension defined) - * @param threads the number of threads maximal used to download chunks of the file. - * @param psName the name of the required post-processing algorithm, or {@code null} to ignore. - * @param source source url of the resource - * @param psArgs the arguments for the post-processing algorithm. + * @param mission the new download mission to add and run (if possible) */ - void startMission(String[] urls, String location, String name, char kind, int threads, - String source, String psName, String[] psArgs, long nearLength) { + void startMission(DownloadMission mission) { synchronized (this) { - // check for existing pending download - DownloadMission pendingMission = getPendingMission(location, name); - - if (pendingMission != null) { - if (pendingMission.running) { - // generate unique filename (?) - try { - name = generateUniqueName(location, name); - } catch (Exception e) { - Log.e(TAG, "Unable to generate unique name", e); - name = System.currentTimeMillis() + name; - Log.i(TAG, "Using " + name); - } - } else { - // dispose the mission - mMissionsPending.remove(pendingMission); - mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_DELETED); - pendingMission.delete(); - } - } else { - // check for existing finished download and dispose (if exists) - int index = getFinishedMissionIndex(location, name); - if (index >= 0) mDownloadDataSource.deleteMission(mMissionsFinished.remove(index)); - } - - DownloadMission mission = new DownloadMission(urls, name, location, kind, psName, psArgs); mission.timestamp = System.currentTimeMillis(); - mission.threadCount = threads; - mission.source = source; mission.mHandler = mHandler; mission.maxRetry = mPrefMaxRetry; - mission.nearLength = nearLength; + // create metadata file while (true) { mission.metadata = new File(mPendingMissionsDir, String.valueOf(mission.timestamp)); if (!mission.metadata.isFile() && !mission.metadata.exists()) { @@ -261,6 +248,14 @@ public class DownloadManager { // Before continue, save the metadata in case the internet connection is not available Utility.writeToFile(mission.metadata, mission); + if (mission.storage == null) { + // noting to do here + mission.errCode = DownloadMission.ERROR_FILE_CREATION; + if (mission.errObject != null) + mission.errObject = new IOException("DownloadMission.storage == NULL"); + return; + } + boolean start = !mPrefQueueLimit || getRunningMissionsCount() < 1; if (canDownloadInCurrentNetwork() && start) { @@ -292,7 +287,7 @@ public class DownloadManager { mMissionsPending.remove(mission); } else if (mission instanceof FinishedMission) { mMissionsFinished.remove(mission); - mDownloadDataSource.deleteMission(mission); + mFinishedMissionStore.deleteMission(mission); } mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_DELETED); @@ -300,18 +295,35 @@ public class DownloadManager { } } + public void forgetMission(StoredFileHelper storage) { + synchronized (this) { + Mission mission = getAnyMission(storage); + if (mission == null) return; + + if (mission instanceof DownloadMission) { + mMissionsPending.remove(mission); + } else if (mission instanceof FinishedMission) { + mMissionsFinished.remove(mission); + mFinishedMissionStore.deleteMission(mission); + } + + mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_DELETED); + mission.storage = null; + mission.delete(); + } + } + /** - * Get a pending mission by its location and name + * Get a pending mission by its path * - * @param location the location - * @param name the name + * @param storage where the file possible is stored * @return the mission or null if no such mission exists */ @Nullable - private DownloadMission getPendingMission(String location, String name) { + private DownloadMission getPendingMission(StoredFileHelper storage) { for (DownloadMission mission : mMissionsPending) { - if (location.equalsIgnoreCase(mission.location) && name.equalsIgnoreCase(mission.name)) { + if (mission.storage.equals(storage)) { return mission; } } @@ -319,16 +331,14 @@ public class DownloadManager { } /** - * Get a finished mission by its location and name + * Get a finished mission by its path * - * @param location the location - * @param name the name + * @param storage where the file possible is stored * @return the mission index or -1 if no such mission exists */ - private int getFinishedMissionIndex(String location, String name) { + private int getFinishedMissionIndex(StoredFileHelper storage) { for (int i = 0; i < mMissionsFinished.size(); i++) { - FinishedMission mission = mMissionsFinished.get(i); - if (location.equalsIgnoreCase(mission.location) && name.equalsIgnoreCase(mission.name)) { + if (mMissionsFinished.get(i).storage.equals(storage)) { return i; } } @@ -336,12 +346,12 @@ public class DownloadManager { return -1; } - public Mission getAnyMission(String location, String name) { + private Mission getAnyMission(StoredFileHelper storage) { synchronized (this) { - Mission mission = getPendingMission(location, name); + Mission mission = getPendingMission(storage); if (mission != null) return mission; - int idx = getFinishedMissionIndex(location, name); + int idx = getFinishedMissionIndex(storage); if (idx >= 0) return mMissionsFinished.get(idx); } @@ -382,7 +392,7 @@ public class DownloadManager { synchronized (this) { for (DownloadMission mission : mMissionsPending) { - if (mission.running || mission.isPsFailed() || mission.isFinished()) continue; + if (mission.running || !mission.canDownload()) continue; flag = true; mission.start(); @@ -392,58 +402,6 @@ public class DownloadManager { if (flag) mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PROGRESS); } - - /** - * Splits the filename into name and extension - *

- * Dots are ignored if they appear: not at all, at the beginning of the file, - * at the end of the file - * - * @param name the name to split - * @return a string array with a length of 2 containing the name and the extension - */ - private static String[] splitName(String name) { - int dotIndex = name.lastIndexOf('.'); - if (dotIndex <= 0 || (dotIndex == name.length() - 1)) { - return new String[]{name, ""}; - } else { - return new String[]{name.substring(0, dotIndex), name.substring(dotIndex + 1)}; - } - } - - /** - * Generates a unique file name. - *

- * e.g. "myName (1).txt" if the name "myName.txt" exists. - * - * @param location the location (to check for existing files) - * @param name the name of the file - * @return the unique file name - * @throws IllegalArgumentException if the location is not a directory - * @throws SecurityException if the location is not readable - */ - private static String generateUniqueName(String location, String name) { - if (location == null) throw new NullPointerException("location is null"); - if (name == null) throw new NullPointerException("name is null"); - File destination = new File(location); - if (!destination.isDirectory()) { - throw new IllegalArgumentException("location is not a directory: " + location); - } - final String[] nameParts = splitName(name); - String[] existingName = destination.list((dir, name1) -> name1.startsWith(nameParts[0])); - Arrays.sort(existingName); - String newName; - int downloadIndex = 0; - do { - newName = nameParts[0] + " (" + downloadIndex + ")." + nameParts[1]; - ++downloadIndex; - if (downloadIndex == 1000) { // Probably an error on our side - throw new RuntimeException("Too many existing files"); - } - } while (Arrays.binarySearch(existingName, newName) >= 0); - return newName; - } - /** * Set a pending download as finished * @@ -453,7 +411,7 @@ public class DownloadManager { synchronized (this) { mMissionsPending.remove(mission); mMissionsFinished.add(0, new FinishedMission(mission)); - mDownloadDataSource.addMission(mission); + mFinishedMissionStore.addFinishedMission(mission); } } @@ -474,7 +432,8 @@ public class DownloadManager { boolean flag = false; for (DownloadMission mission : mMissionsPending) { - if (mission.running || !mission.enqueued || mission.isFinished()) continue; + if (mission.running || !mission.enqueued || mission.isFinished() || mission.hasInvalidStorage()) + continue; resumeMission(mission); if (mPrefQueueLimit) return true; @@ -496,7 +455,7 @@ public class DownloadManager { public void forgetFinishedDownloads() { synchronized (this) { for (FinishedMission mission : mMissionsFinished) { - mDownloadDataSource.deleteMission(mission); + mFinishedMissionStore.deleteMission(mission); } mMissionsFinished.clear(); } @@ -523,7 +482,7 @@ public class DownloadManager { int paused = 0; synchronized (this) { for (DownloadMission mission : mMissionsPending) { - if (mission.isFinished() || mission.isPsRunning()) continue; + if (!mission.canDownload() || mission.isPsRunning()) continue; if (mission.running && isMetered) { paused++; @@ -565,24 +524,32 @@ public class DownloadManager { ), Toast.LENGTH_LONG).show(); } - void checkForRunningMission(String location, String name, DMChecker check) { - MissionCheck result = MissionCheck.None; - + public MissionState checkForExistingMission(StoredFileHelper storage) { synchronized (this) { - DownloadMission pending = getPendingMission(location, name); + DownloadMission pending = getPendingMission(storage); if (pending == null) { - if (getFinishedMissionIndex(location, name) >= 0) result = MissionCheck.Finished; + if (getFinishedMissionIndex(storage) >= 0) return MissionState.Finished; } else { if (pending.isFinished()) { - result = MissionCheck.Finished;// this never should happen (race-condition) + return MissionState.Finished;// this never should happen (race-condition) } else { - result = pending.running ? MissionCheck.PendingRunning : MissionCheck.Pending; + return pending.running ? MissionState.PendingRunning : MissionState.Pending; } } } - check.callback(result); + return MissionState.None; + } + + @Nullable + private StoredDirectoryHelper getMainStorage(@NonNull String tag) { + if (tag.equals(TAG_AUDIO)) return mMainStorageAudio; + if (tag.equals(TAG_VIDEO)) return mMainStorageVideo; + + Log.w(TAG, "Unknown download category, not [audio video]: " + String.valueOf(tag)); + + return null;// this never should happen } public class MissionIterator extends DiffUtil.Callback { @@ -689,7 +656,7 @@ public class DownloadManager { synchronized (DownloadManager.this) { for (DownloadMission mission : mMissionsPending) { - if (hidden.contains(mission) || mission.isPsFailed() || mission.isFinished()) + if (hidden.contains(mission) || mission.canDownload()) continue; if (mission.running) @@ -720,7 +687,14 @@ public class DownloadManager { @Override public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { - return areItemsTheSame(oldItemPosition, newItemPosition); + Object x = snapshot.get(oldItemPosition); + Object y = current.get(newItemPosition); + + if (x instanceof Mission && y instanceof Mission) { + return ((Mission) x).storage.equals(((Mission) y).storage); + } + + return false; } } diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java index be1e20dd6..deed9e8e3 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -6,11 +6,9 @@ import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.content.BroadcastReceiver; -import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.content.ServiceConnection; import android.content.SharedPreferences; import android.graphics.Bitmap; import android.graphics.BitmapFactory; @@ -21,12 +19,14 @@ import android.net.NetworkRequest; import android.net.Uri; import android.os.Binder; import android.os.Build; +import android.os.Environment; import android.os.Handler; import android.os.IBinder; import android.os.Looper; import android.os.Message; import android.preference.PreferenceManager; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.v4.app.NotificationCompat; import android.support.v4.app.NotificationCompat.Builder; import android.support.v4.content.PermissionChecker; @@ -39,9 +39,13 @@ import org.schabi.newpipe.download.DownloadActivity; import org.schabi.newpipe.player.helper.LockManager; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.io.StoredDirectoryHelper; +import us.shandian.giga.io.StoredFileHelper; +import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManager.NetworkState; import static org.schabi.newpipe.BuildConfig.APPLICATION_ID; @@ -61,19 +65,19 @@ public class DownloadManagerService extends Service { private static final int DOWNLOADS_NOTIFICATION_ID = 1001; private static final String EXTRA_URLS = "DownloadManagerService.extra.urls"; - private static final String EXTRA_NAME = "DownloadManagerService.extra.name"; - private static final String EXTRA_LOCATION = "DownloadManagerService.extra.location"; + private static final String EXTRA_PATH = "DownloadManagerService.extra.path"; private static final String EXTRA_KIND = "DownloadManagerService.extra.kind"; private static final String EXTRA_THREADS = "DownloadManagerService.extra.threads"; private static final String EXTRA_POSTPROCESSING_NAME = "DownloadManagerService.extra.postprocessingName"; private static final String EXTRA_POSTPROCESSING_ARGS = "DownloadManagerService.extra.postprocessingArgs"; private static final String EXTRA_SOURCE = "DownloadManagerService.extra.source"; private static final String EXTRA_NEAR_LENGTH = "DownloadManagerService.extra.nearLength"; + private static final String EXTRA_MAIN_STORAGE_TAG = "DownloadManagerService.extra.tag"; private static final String ACTION_RESET_DOWNLOAD_FINISHED = APPLICATION_ID + ".reset_download_finished"; private static final String ACTION_OPEN_DOWNLOADS_FINISHED = APPLICATION_ID + ".open_downloads_finished"; - private DMBinder mBinder; + private DownloadManagerBinder mBinder; private DownloadManager mManager; private Notification mNotification; private Handler mHandler; @@ -110,10 +114,10 @@ public class DownloadManagerService extends Service { /** * notify media scanner on downloaded media file ... * - * @param file the downloaded file + * @param file the downloaded file uri */ - private void notifyMediaScanner(File file) { - sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(file))); + private void notifyMediaScanner(Uri file) { + sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, file)); } @Override @@ -124,7 +128,7 @@ public class DownloadManagerService extends Service { Log.d(TAG, "onCreate"); } - mBinder = new DMBinder(); + mBinder = new DownloadManagerBinder(); mHandler = new Handler(Looper.myLooper()) { @Override public void handleMessage(Message msg) { @@ -186,10 +190,12 @@ public class DownloadManagerService extends Service { handlePreferenceChange(mPrefs, getString(R.string.downloads_queue_limit)); mLock = new LockManager(this); + + setupStorageAPI(true); } @Override - public int onStartCommand(Intent intent, int flags, int startId) { + public int onStartCommand(final Intent intent, int flags, int startId) { if (DEBUG) { Log.d(TAG, intent == null ? "Restarting" : "Starting"); } @@ -200,20 +206,7 @@ public class DownloadManagerService extends Service { String action = intent.getAction(); if (action != null) { if (action.equals(Intent.ACTION_RUN)) { - String[] urls = intent.getStringArrayExtra(EXTRA_URLS); - String name = intent.getStringExtra(EXTRA_NAME); - String location = intent.getStringExtra(EXTRA_LOCATION); - int threads = intent.getIntExtra(EXTRA_THREADS, 1); - char kind = intent.getCharExtra(EXTRA_KIND, '?'); - String psName = intent.getStringExtra(EXTRA_POSTPROCESSING_NAME); - String[] psArgs = intent.getStringArrayExtra(EXTRA_POSTPROCESSING_ARGS); - String source = intent.getStringExtra(EXTRA_SOURCE); - long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0); - - handleConnectivityState(true);// first check the actual network status - - mHandler.post(() -> mManager.startMission(urls, location, name, kind, threads, source, psName, psArgs, nearLength)); - + mHandler.post(() -> startMission(intent)); } else if (downloadDoneNotification != null) { if (action.equals(ACTION_RESET_DOWNLOAD_FINISHED) || action.equals(ACTION_OPEN_DOWNLOADS_FINISHED)) { downloadDoneCount = 0; @@ -264,12 +257,12 @@ public class DownloadManagerService extends Service { @Override public IBinder onBind(Intent intent) { int permissionCheck; - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { - permissionCheck = PermissionChecker.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE); - if (permissionCheck == PermissionChecker.PERMISSION_DENIED) { - Toast.makeText(this, "Permission denied (read)", Toast.LENGTH_SHORT).show(); - } - } +// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { +// permissionCheck = PermissionChecker.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE); +// if (permissionCheck == PermissionChecker.PERMISSION_DENIED) { +// Toast.makeText(this, "Permission denied (read)", Toast.LENGTH_SHORT).show(); +// } +// } permissionCheck = PermissionChecker.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE); if (permissionCheck == PermissionChecker.PERMISSION_DENIED) { @@ -284,8 +277,8 @@ public class DownloadManagerService extends Service { switch (msg.what) { case MESSAGE_FINISHED: - notifyMediaScanner(mission.getDownloadedFile()); - notifyFinishedDownload(mission.name); + notifyMediaScanner(mission.storage.getUri()); + notifyFinishedDownload(mission.storage.getName()); mManager.setFinished(mission); handleConnectivityState(false); updateForegroundState(mManager.runMissions()); @@ -344,7 +337,7 @@ public class DownloadManagerService extends Service { if (key.equals(getString(R.string.downloads_maximum_retry))) { try { String value = prefs.getString(key, getString(R.string.downloads_maximum_retry_default)); - mManager.mPrefMaxRetry = Integer.parseInt(value); + mManager.mPrefMaxRetry = value == null ? 0 : Integer.parseInt(value); } catch (Exception e) { mManager.mPrefMaxRetry = 0; } @@ -353,6 +346,12 @@ public class DownloadManagerService extends Service { mManager.mPrefMeteredDownloads = prefs.getBoolean(key, false); } else if (key.equals(getString(R.string.downloads_queue_limit))) { mManager.mPrefQueueLimit = prefs.getBoolean(key, true); + } else if (key.equals(getString(R.string.downloads_storage_api))) { + setupStorageAPI(false); + } else if (key.equals(getString(R.string.download_path_video_key))) { + loadMainStorage(key, DownloadManager.TAG_VIDEO, false); + } else if (key.equals(getString(R.string.download_path_audio_key))) { + loadMainStorage(key, DownloadManager.TAG_AUDIO, false); } } @@ -370,43 +369,61 @@ public class DownloadManagerService extends Service { mForeground = state; } - public static void startMission(Context context, String urls[], String location, String name, char kind, + /** + * Start a new download mission + * + * @param context the activity context + * @param urls the list of urls to download + * @param storage where the file is saved + * @param kind type of file (a: audio v: video s: subtitle ?: file-extension defined) + * @param threads the number of threads maximal used to download chunks of the file. + * @param psName the name of the required post-processing algorithm, or {@code null} to ignore. + * @param source source url of the resource + * @param psArgs the arguments for the post-processing algorithm. + * @param nearLength the approximated final length of the file + */ + public static void startMission(Context context, String urls[], StoredFileHelper storage, char kind, int threads, String source, String psName, String[] psArgs, long nearLength) { Intent intent = new Intent(context, DownloadManagerService.class); intent.setAction(Intent.ACTION_RUN); intent.putExtra(EXTRA_URLS, urls); - intent.putExtra(EXTRA_NAME, name); - intent.putExtra(EXTRA_LOCATION, location); + intent.putExtra(EXTRA_PATH, storage.getUri()); intent.putExtra(EXTRA_KIND, kind); intent.putExtra(EXTRA_THREADS, threads); intent.putExtra(EXTRA_SOURCE, source); intent.putExtra(EXTRA_POSTPROCESSING_NAME, psName); intent.putExtra(EXTRA_POSTPROCESSING_ARGS, psArgs); intent.putExtra(EXTRA_NEAR_LENGTH, nearLength); + intent.putExtra(EXTRA_MAIN_STORAGE_TAG, storage.getTag()); context.startService(intent); } - public static void checkForRunningMission(Context context, String location, String name, DMChecker checker) { - Intent intent = new Intent(); - intent.setClass(context, DownloadManagerService.class); - context.startService(intent); + public void startMission(Intent intent) { + String[] urls = intent.getStringArrayExtra(EXTRA_URLS); + Uri path = intent.getParcelableExtra(EXTRA_PATH); + int threads = intent.getIntExtra(EXTRA_THREADS, 1); + char kind = intent.getCharExtra(EXTRA_KIND, '?'); + String psName = intent.getStringExtra(EXTRA_POSTPROCESSING_NAME); + String[] psArgs = intent.getStringArrayExtra(EXTRA_POSTPROCESSING_ARGS); + String source = intent.getStringExtra(EXTRA_SOURCE); + long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0); + String tag = intent.getStringExtra(EXTRA_MAIN_STORAGE_TAG); - context.bindService(intent, new ServiceConnection() { - @Override - public void onServiceConnected(ComponentName cname, IBinder service) { - try { - ((DMBinder) service).getDownloadManager().checkForRunningMission(location, name, checker); - } catch (Exception err) { - Log.w(TAG, "checkForRunningMission() callback is defective", err); - } + StoredFileHelper storage; + try { + storage = new StoredFileHelper(this, path, tag); + } catch (IOException e) { + throw new RuntimeException(e);// this never should happen + } - context.unbindService(this); - } + final DownloadMission mission = new DownloadMission(urls, storage, kind, Postprocessing.getAlgorithm(psName, psArgs)); + mission.threadCount = threads; + mission.source = source; + mission.nearLength = nearLength; - @Override - public void onServiceDisconnected(ComponentName name) { - } - }, Context.BIND_AUTO_CREATE); + handleConnectivityState(true);// first check the actual network status + + mManager.startMission(mission); } public void notifyFinishedDownload(String name) { @@ -471,12 +488,12 @@ public class DownloadManagerService extends Service { if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { downloadFailedNotification.setContentTitle(getString(R.string.app_name)); downloadFailedNotification.setStyle(new NotificationCompat.BigTextStyle() - .bigText(getString(R.string.download_failed).concat(": ").concat(mission.name))); + .bigText(getString(R.string.download_failed).concat(": ").concat(mission.storage.getName()))); } else { downloadFailedNotification.setContentTitle(getString(R.string.download_failed)); - downloadFailedNotification.setContentText(mission.name); + downloadFailedNotification.setContentText(mission.storage.getName()); downloadFailedNotification.setStyle(new NotificationCompat.BigTextStyle() - .bigText(mission.name)); + .bigText(mission.storage.getName())); } mNotificationManager.notify(id, downloadFailedNotification.build()); @@ -508,16 +525,81 @@ public class DownloadManagerService extends Service { mLockAcquired = acquire; } + private void setupStorageAPI(boolean acquire) { + loadMainStorage(getString(R.string.download_path_audio_key), DownloadManager.TAG_VIDEO, acquire); + loadMainStorage(getString(R.string.download_path_video_key), DownloadManager.TAG_AUDIO, acquire); + } + + void loadMainStorage(String prefKey, String tag, boolean acquire) { + String path = mPrefs.getString(prefKey, null); + + final String JAVA_IO = getString(R.string.downloads_storage_api_default); + boolean useJavaIO = JAVA_IO.equals(mPrefs.getString(getString(R.string.downloads_storage_api), JAVA_IO)); + + final String defaultPath; + if (tag.equals(DownloadManager.TAG_VIDEO)) + defaultPath = Environment.DIRECTORY_MOVIES; + else// if (tag.equals(DownloadManager.TAG_AUDIO)) + defaultPath = Environment.DIRECTORY_MUSIC; + + StoredDirectoryHelper mainStorage; + if (path == null || path.isEmpty()) { + mainStorage = useJavaIO ? new StoredDirectoryHelper(defaultPath, tag) : null; + } else { + + if (path.charAt(0) == File.separatorChar) { + Log.i(TAG, "Migrating old save path: " + path); + + useJavaIO = true; + path = Uri.fromFile(new File(path)).toString(); + + mPrefs.edit().putString(prefKey, path).apply(); + } + + if (useJavaIO) { + mainStorage = new StoredDirectoryHelper(path, tag); + } else { + + // tree api is not available in older versions + if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + mainStorage = null; + } else { + try { + mainStorage = new StoredDirectoryHelper(this, Uri.parse(path), tag); + if (acquire) mainStorage.acquirePermissions(); + } catch (IOException e) { + Log.e(TAG, "Failed to load the storage of " + tag + " from path: " + path, e); + mainStorage = null; + } + } + } + } + + if (tag.equals(DownloadManager.TAG_VIDEO)) + mManager.mMainStorageVideo = mainStorage; + else// if (tag.equals(DownloadManager.TAG_AUDIO)) + mManager.mMainStorageAudio = mainStorage; + } //////////////////////////////////////////////////////////////////////////////////////////////// // Wrappers for DownloadManager //////////////////////////////////////////////////////////////////////////////////////////////// - public class DMBinder extends Binder { + public class DownloadManagerBinder extends Binder { public DownloadManager getDownloadManager() { return mManager; } + @Nullable + public StoredDirectoryHelper getMainStorageVideo() { + return mManager.mMainStorageVideo; + } + + @Nullable + public StoredDirectoryHelper getMainStorageAudio() { + return mManager.mMainStorageAudio; + } + public void addMissionEventListener(Handler handler) { manageObservers(handler, true); } @@ -548,10 +630,4 @@ public class DownloadManagerService extends Service { } - public interface DMChecker { - void callback(MissionCheck result); - } - - public enum MissionCheck {None, Pending, PendingRunning, Finished} - } diff --git a/app/src/main/java/us/shandian/giga/service/MissionState.java b/app/src/main/java/us/shandian/giga/service/MissionState.java new file mode 100644 index 000000000..2d7802ff5 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/service/MissionState.java @@ -0,0 +1,5 @@ +package us.shandian.giga.service; + +public enum MissionState { + None, Pending, PendingRunning, Finished +} diff --git a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java index cada3aeb8..4d80588e0 100644 --- a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java +++ b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java @@ -8,7 +8,6 @@ import android.content.Intent; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; -import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.os.Message; @@ -49,6 +48,7 @@ import java.util.Collections; import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.FinishedMission; import us.shandian.giga.get.Mission; +import us.shandian.giga.io.StoredFileHelper; import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.ui.common.Deleter; @@ -69,6 +69,7 @@ import static us.shandian.giga.get.DownloadMission.ERROR_PERMISSION_DENIED; import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING; import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_HOLD; import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_STOPPED; +import static us.shandian.giga.get.DownloadMission.ERROR_PROGRESS_LOST; import static us.shandian.giga.get.DownloadMission.ERROR_SSL_EXCEPTION; import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_EXCEPTION; import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_HOST; @@ -97,8 +98,9 @@ public class MissionAdapter extends Adapter { private MenuItem mStartButton; private MenuItem mPauseButton; private View mEmptyMessage; + private RecoverHelper mRecover; - public MissionAdapter(Context context, DownloadManager downloadManager, View emptyMessage) { + public MissionAdapter(Context context, @NonNull DownloadManager downloadManager, View emptyMessage) { mContext = context; mDownloadManager = downloadManager; mDeleter = null; @@ -156,7 +158,11 @@ public class MissionAdapter extends Adapter { if (h.item.mission instanceof DownloadMission) { mPendingDownloadsItems.remove(h); - if (mPendingDownloadsItems.size() < 1) setAutoRefresh(false); + if (mPendingDownloadsItems.size() < 1) { + setAutoRefresh(false); + if (mStartButton != null) mStartButton.setVisible(false); + if (mPauseButton != null) mPauseButton.setVisible(false); + } } h.popupMenu.dismiss(); @@ -189,10 +195,10 @@ public class MissionAdapter extends Adapter { ViewHolderItem h = (ViewHolderItem) view; h.item = item; - Utility.FileType type = Utility.getFileType(item.mission.kind, item.mission.name); + Utility.FileType type = Utility.getFileType(item.mission.kind, item.mission.storage.getName()); h.icon.setImageResource(Utility.getIconForFileType(type)); - h.name.setText(item.mission.name); + h.name.setText(item.mission.storage.getName()); h.progress.setColors(Utility.getBackgroundForFileType(mContext, type), Utility.getForegroundForFileType(mContext, type)); @@ -273,7 +279,7 @@ public class MissionAdapter extends Adapter { long length = mission.getLength(); int state; - if (mission.isPsFailed()) { + if (mission.isPsFailed() || mission.errCode == ERROR_POSTPROCESSING_HOLD) { state = 0; } else if (!mission.running) { state = mission.enqueued ? 1 : 2; @@ -334,11 +340,17 @@ public class MissionAdapter extends Adapter { if (BuildConfig.DEBUG) Log.v(TAG, "Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID + ".provider"); - Uri uri = FileProvider.getUriForFile( - mContext, - BuildConfig.APPLICATION_ID + ".provider", - mission.getDownloadedFile() - ); + Uri uri; + + if (mission.storage.isDirect()) { + uri = FileProvider.getUriForFile( + mContext, + BuildConfig.APPLICATION_ID + ".provider", + mission.storage.getIOFile() + ); + } else { + uri = mission.storage.getUri(); + } Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); @@ -366,13 +378,13 @@ public class MissionAdapter extends Adapter { Intent intent = new Intent(Intent.ACTION_SEND); intent.setType(resolveMimeType(mission)); - intent.putExtra(Intent.EXTRA_STREAM, mission.getDownloadedFile().toURI()); + intent.putExtra(Intent.EXTRA_STREAM, mission.storage.getUri()); mContext.startActivity(Intent.createChooser(intent, null)); } private static String resolveMimeType(@NonNull Mission mission) { - String ext = Utility.getFileExt(mission.getDownloadedFile().getName()); + String ext = Utility.getFileExt(mission.storage.getName()); if (ext == null) return DEFAULT_MIME_TYPE; String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.substring(1)); @@ -381,7 +393,7 @@ public class MissionAdapter extends Adapter { } private boolean checkInvalidFile(@NonNull Mission mission) { - if (mission.getDownloadedFile().exists()) return false; + if (mission.storage.existsAsFile()) return false; Toast.makeText(mContext, R.string.missing_file, Toast.LENGTH_SHORT).show(); return true; @@ -462,6 +474,8 @@ public class MissionAdapter extends Adapter { case ERROR_UNKNOWN_EXCEPTION: showError(mission.errObject, UserAction.DOWNLOAD_FAILED, R.string.general_error); return; + case ERROR_PROGRESS_LOST: + msg = R.string.error_progress_lost; default: if (mission.errCode >= 100 && mission.errCode < 600) { msgEx = "HTTP " + mission.errCode; @@ -490,7 +504,7 @@ public class MissionAdapter extends Adapter { } builder.setNegativeButton(android.R.string.ok, (dialog, which) -> dialog.cancel()) - .setTitle(mission.name) + .setTitle(mission.storage.getName()) .create() .show(); } @@ -539,6 +553,10 @@ public class MissionAdapter extends Adapter { updateProgress(h); return true; case R.id.retry: + if (mission.hasInvalidStorage()) { + mRecover.tryRecover(mission); + return true; + } mission.psContinue(true); return true; case R.id.cancel: @@ -561,7 +579,7 @@ public class MissionAdapter extends Adapter { return true; case R.id.md5: case R.id.sha1: - new ChecksumTask(mContext).execute(h.item.mission.getDownloadedFile().getAbsolutePath(), ALGORITHMS.get(id)); + new ChecksumTask(mContext).execute(h.item.mission.storage, ALGORITHMS.get(id)); return true; case R.id.source: /*Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(h.item.mission.source)); @@ -641,19 +659,38 @@ public class MissionAdapter extends Adapter { } - public void deleterDispose(Bundle bundle) { - if (mDeleter != null) mDeleter.dispose(bundle); + public void deleterDispose(boolean commitChanges) { + if (mDeleter != null) mDeleter.dispose(commitChanges); } - public void deleterLoad(Bundle bundle, View view) { + public void deleterLoad(View view) { if (mDeleter == null) - mDeleter = new Deleter(bundle, view, mContext, this, mDownloadManager, mIterator, mHandler); + mDeleter = new Deleter(view, mContext, this, mDownloadManager, mIterator, mHandler); } public void deleterResume() { if (mDeleter != null) mDeleter.resume(); } + public void recoverMission(DownloadMission mission, StoredFileHelper newStorage) { + for (ViewHolderItem h : mPendingDownloadsItems) { + if (mission != h.item.mission) continue; + + mission.changeStorage(newStorage); + mission.errCode = DownloadMission.ERROR_NOTHING; + mission.errObject = null; + + h.status.setText(UNDEFINED_PROGRESS); + h.state = -1; + h.size.setText(Utility.formatBytes(mission.getLength())); + h.progress.setMarquee(true); + + mDownloadManager.resumeMission(mission); + return; + } + + } + private boolean mUpdaterRunning = false; private final Runnable rUpdater = this::updater; @@ -695,6 +732,10 @@ public class MissionAdapter extends Adapter { return Float.isNaN(value) || Float.isInfinite(value); } + public void setRecover(@NonNull RecoverHelper callback) { + mRecover = callback; + } + class ViewHolderItem extends RecyclerView.ViewHolder { DownloadManager.MissionItem item; @@ -780,7 +821,11 @@ public class MissionAdapter extends Adapter { DownloadMission mission = item.mission instanceof DownloadMission ? (DownloadMission) item.mission : null; if (mission != null) { - if (mission.isPsRunning()) { + if (mission.hasInvalidStorage()) { + retry.setEnabled(true); + delete.setEnabled(true); + showError.setEnabled(true); + } else if (mission.isPsRunning()) { switch (mission.errCode) { case ERROR_INSUFFICIENT_STORAGE: case ERROR_POSTPROCESSING_HOLD: @@ -838,7 +883,7 @@ public class MissionAdapter extends Adapter { } - static class ChecksumTask extends AsyncTask { + static class ChecksumTask extends AsyncTask { ProgressDialog progressDialog; WeakReference weakReference; @@ -861,8 +906,8 @@ public class MissionAdapter extends Adapter { } @Override - protected String doInBackground(String... params) { - return Utility.checksum(params[0], params[1]); + protected String doInBackground(Object... params) { + return Utility.checksum((StoredFileHelper) params[0], (String) params[1]); } @Override @@ -889,4 +934,8 @@ public class MissionAdapter extends Adapter { } } + public interface RecoverHelper { + void tryRecover(DownloadMission mission); + } + } diff --git a/app/src/main/java/us/shandian/giga/ui/common/Deleter.java b/app/src/main/java/us/shandian/giga/ui/common/Deleter.java index 6407ab019..573bead94 100644 --- a/app/src/main/java/us/shandian/giga/ui/common/Deleter.java +++ b/app/src/main/java/us/shandian/giga/ui/common/Deleter.java @@ -3,8 +3,6 @@ package us.shandian.giga.ui.common; import android.content.Context; import android.content.Intent; import android.graphics.Color; -import android.net.Uri; -import android.os.Bundle; import android.os.Handler; import android.support.design.widget.Snackbar; import android.view.View; @@ -23,8 +21,6 @@ public class Deleter { private static final int TIMEOUT = 5000;// ms private static final int DELAY = 350;// ms private static final int DELAY_RESUME = 400;// ms - private static final String BUNDLE_NAMES = "us.shandian.giga.ui.common.deleter.names"; - private static final String BUNDLE_LOCATIONS = "us.shandian.giga.ui.common.deleter.locations"; private Snackbar snackbar; private ArrayList items; @@ -41,7 +37,7 @@ public class Deleter { private final Runnable rNext; private final Runnable rCommit; - public Deleter(Bundle b, View v, Context c, MissionAdapter a, DownloadManager d, MissionIterator i, Handler h) { + public Deleter(View v, Context c, MissionAdapter a, DownloadManager d, MissionIterator i, Handler h) { mView = v; mContext = c; mAdapter = a; @@ -55,27 +51,6 @@ public class Deleter { rCommit = this::commit; items = new ArrayList<>(2); - - if (b != null) { - String[] names = b.getStringArray(BUNDLE_NAMES); - String[] locations = b.getStringArray(BUNDLE_LOCATIONS); - - if (names == null || locations == null) return; - if (names.length < 1 || locations.length < 1) return; - if (names.length != locations.length) return; - - items.ensureCapacity(names.length); - - for (int j = 0; j < locations.length; j++) { - Mission mission = mDownloadManager.getAnyMission(locations[j], names[j]); - if (mission == null) continue; - - items.add(mission); - mIterator.hide(mission); - } - - if (items.size() > 0) resume(); - } } public void append(Mission item) { @@ -104,7 +79,7 @@ public class Deleter { private void next() { if (items.size() < 1) return; - String msg = mContext.getString(R.string.file_deleted).concat(":\n").concat(items.get(0).name); + String msg = mContext.getString(R.string.file_deleted).concat(":\n").concat(items.get(0).storage.getName()); snackbar = Snackbar.make(mView, msg, Snackbar.LENGTH_INDEFINITE); snackbar.setAction(R.string.undo, s -> forget()); @@ -125,7 +100,7 @@ public class Deleter { mDownloadManager.deleteMission(mission); if (mission instanceof FinishedMission) { - mContext.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(mission.getDownloadedFile()))); + mContext.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, mission.storage.getUri())); } break; } @@ -151,27 +126,14 @@ public class Deleter { mHandler.postDelayed(rShow, DELAY_RESUME); } - public void dispose(Bundle bundle) { + public void dispose(boolean commitChanges) { if (items.size() < 1) return; pause(); - if (bundle == null) { - for (Mission mission : items) mDownloadManager.deleteMission(mission); - items = null; - return; - } + if (!commitChanges) return; - String[] names = new String[items.size()]; - String[] locations = new String[items.size()]; - - for (int i = 0; i < items.size(); i++) { - Mission mission = items.get(i); - names[i] = mission.name; - locations[i] = mission.location; - } - - bundle.putStringArray(BUNDLE_NAMES, names); - bundle.putStringArray(BUNDLE_LOCATIONS, locations); + for (Mission mission : items) mDownloadManager.deleteMission(mission); + items = null; } } diff --git a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java index a3786a5e6..82ab777b0 100644 --- a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java +++ b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java @@ -1,7 +1,6 @@ package us.shandian.giga.ui.fragment; import android.app.Activity; -import android.app.Fragment; import android.content.ComponentName; import android.content.Context; import android.content.Intent; @@ -10,6 +9,7 @@ import android.content.SharedPreferences; import android.os.Bundle; import android.os.IBinder; import android.preference.PreferenceManager; +import android.support.v4.app.Fragment; import android.support.v7.widget.GridLayoutManager; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; @@ -18,18 +18,24 @@ import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; +import android.widget.Toast; import org.schabi.newpipe.R; import org.schabi.newpipe.util.ThemeHelper; +import java.io.IOException; + +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.io.StoredFileHelper; import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; -import us.shandian.giga.service.DownloadManagerService.DMBinder; +import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; import us.shandian.giga.ui.adapter.MissionAdapter; public class MissionsFragment extends Fragment { private static final int SPAN_SIZE = 2; + private static final int REQUEST_DOWNLOAD_PATH_SAF = 0x1230; private SharedPreferences mPrefs; private boolean mLinear; @@ -45,24 +51,32 @@ public class MissionsFragment extends Fragment { private LinearLayoutManager mLinearManager; private Context mContext; - private DMBinder mBinder; - private Bundle mBundle; + private DownloadManagerBinder mBinder; private boolean mForceUpdate; + private DownloadMission unsafeMissionTarget = null; + private ServiceConnection mConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder binder) { - mBinder = (DownloadManagerService.DMBinder) binder; + mBinder = (DownloadManagerBinder) binder; mBinder.clearDownloadNotifications(); mAdapter = new MissionAdapter(mContext, mBinder.getDownloadManager(), mEmpty); - mAdapter.deleterLoad(mBundle, getView()); + mAdapter.deleterLoad(getView()); + + mAdapter.setRecover(mission -> + StoredFileHelper.requestSafWithFileCreation( + MissionsFragment.this, + REQUEST_DOWNLOAD_PATH_SAF, + mission.storage.getName(), + mission.storage.getType() + ) + ); setAdapterButtons(); - mBundle = null; - mBinder.addMissionEventListener(mAdapter.getMessenger()); mBinder.enableNotifications(false); @@ -84,9 +98,6 @@ public class MissionsFragment extends Fragment { mPrefs = PreferenceManager.getDefaultSharedPreferences(getActivity()); mLinear = mPrefs.getBoolean("linear", false); - //mContext = getActivity().getApplicationContext(); - mBundle = savedInstanceState; - // Bind the service mContext.bindService(new Intent(mContext, DownloadManagerService.class), mConnection, Context.BIND_AUTO_CREATE); @@ -148,7 +159,7 @@ public class MissionsFragment extends Fragment { mBinder.removeMissionEventListener(mAdapter.getMessenger()); mBinder.enableNotifications(true); mContext.unbindService(mConnection); - mAdapter.deleterDispose(null); + mAdapter.deleterDispose(true); mBinder = null; mAdapter = null; @@ -178,10 +189,12 @@ public class MissionsFragment extends Fragment { return true; case R.id.start_downloads: item.setVisible(false); + mPause.setVisible(true); mBinder.getDownloadManager().startAllMissions(); return true; case R.id.pause_downloads: item.setVisible(false); + mStart.setVisible(true); mBinder.getDownloadManager().pauseAllMissions(false); mAdapter.ensurePausedMissions();// update items view default: @@ -231,7 +244,7 @@ public class MissionsFragment extends Fragment { super.onSaveInstanceState(outState); if (mAdapter != null) { - mAdapter.deleterDispose(outState); + mAdapter.deleterDispose(false); mForceUpdate = true; mBinder.removeMissionEventListener(mAdapter.getMessenger()); } @@ -260,4 +273,22 @@ public class MissionsFragment extends Fragment { if (mAdapter != null) mAdapter.onPaused(); if (mBinder != null) mBinder.enableNotifications(true); } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode != REQUEST_DOWNLOAD_PATH_SAF || resultCode != Activity.RESULT_OK) return; + + if (unsafeMissionTarget == null || data.getData() == null) { + return;// unsafeMissionTarget cannot be null + } + + try { + StoredFileHelper storage = new StoredFileHelper(mContext, data.getData(), unsafeMissionTarget.storage.getTag()); + mAdapter.recoverMission(unsafeMissionTarget, storage); + } catch (IOException e) { + Toast.makeText(mContext, R.string.general_error, Toast.LENGTH_LONG).show(); + } + } } diff --git a/app/src/main/java/us/shandian/giga/util/Utility.java b/app/src/main/java/us/shandian/giga/util/Utility.java index e5149cf9b..d77e598d8 100644 --- a/app/src/main/java/us/shandian/giga/util/Utility.java +++ b/app/src/main/java/us/shandian/giga/util/Utility.java @@ -12,11 +12,11 @@ import android.support.v4.content.ContextCompat; import android.widget.Toast; import org.schabi.newpipe.R; +import org.schabi.newpipe.streams.io.SharpStream; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileInputStream; -import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectInputStream; @@ -25,7 +25,8 @@ import java.io.Serializable; import java.net.HttpURLConnection; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.util.Locale; + +import us.shandian.giga.io.StoredFileHelper; public class Utility { @@ -206,7 +207,7 @@ public class Utility { Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); } - public static String checksum(String path, String algorithm) { + public static String checksum(StoredFileHelper source, String algorithm) { MessageDigest md; try { @@ -215,11 +216,11 @@ public class Utility { throw new RuntimeException(e); } - FileInputStream i; + SharpStream i; try { - i = new FileInputStream(path); - } catch (FileNotFoundException e) { + i = source.getStream(); + } catch (Exception e) { throw new RuntimeException(e); } @@ -247,15 +248,15 @@ public class Utility { } @SuppressWarnings("ResultOfMethodCallIgnored") - public static boolean mkdir(File path, boolean allDirs) { - if (path.exists()) return true; + public static boolean mkdir(File p, boolean allDirs) { + if (p.exists()) return true; if (allDirs) - path.mkdirs(); + p.mkdirs(); else - path.mkdir(); + p.mkdir(); - return path.exists(); + return p.exists(); } public static long getContentLength(HttpURLConnection connection) { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index b7a0ec7b0..dbf015c87 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -462,12 +462,12 @@ %s أنتهى التحميل إنشاء اسم فريد الكتابة فوق - يوجد ملف تحميل بهذا الاسم موجود مسبقاً + يوجد ملف تحميل بهذا الاسم موجود مسبقاً هنالك تحميل قيد التقدم بهذا الاسم إظهار خطأ كود - لا يمكن إنشاء الملف - لا يمكن إنشاء المجلد الوجهة + لا يمكن إنشاء الملف + لا يمكن إنشاء المجلد الوجهة تم رفضها من قبل النظام فشل اتصال الأمن تعذر العثور على الخادم diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 4285a73dc..f606281f4 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -432,8 +432,8 @@ Genera un nom únic Mostra l\'error Codi - No es pot crear el fitxer - No es pot crear la carpeta de destinació + No es pot crear el fitxer + No es pot crear la carpeta de destinació Atura Esdeveniments Notificacions de noves versions del NewPipe diff --git a/app/src/main/res/values-cmn/strings.xml b/app/src/main/res/values-cmn/strings.xml index f6c8e3e4a..73eb43c36 100644 --- a/app/src/main/res/values-cmn/strings.xml +++ b/app/src/main/res/values-cmn/strings.xml @@ -437,11 +437,11 @@ %s已下载完毕 生成独特的名字 覆写 - 同名的已下载文件已经存在 + 同名的已下载文件已经存在 同名下载进行中 显示错误 代码 - 无法创建该文件 + 无法创建该文件 系统拒绝此批准 安全连接失败 找不到服务器 @@ -464,7 +464,7 @@ 网格 切换视图 NewPipe 更新可用! - 无法创建目标文件夹 + 无法创建目标文件夹 服务器不接受多线程下载, 请重试使用 @string/msg_threads = 1 请求范围无法满足 继续进行%s个待下载转移 diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 4dc81f9c2..92535310e 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -372,8 +372,8 @@ Der er en download i gang med dette navn Vis fejl Kode - Filen kan ikke oprettes - Destinationsmappen kan ikke oprettes + Filen kan ikke oprettes + Destinationsmappen kan ikke oprettes Adgang nægtet af systemet Sikker forbindelse fejlede Kunne ikke finde serveren diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index dada4e80f..ef2789846 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -448,12 +448,12 @@ %s heruntergeladen Eindeutigen Namen erzeugen Überschreiben - Eine heruntergeladene Datei dieses Namens existiert bereits + Eine heruntergeladene Datei dieses Namens existiert bereits Eine Datei dieses Namens wird gerade heruntergeladen Fehler anzeigen Code - Die Datei kann nicht erstellt werden - Der Zielordner kann nicht erstellt werden + Die Datei kann nicht erstellt werden + Der Zielordner kann nicht erstellt werden System verweigert den Zugriff Sichere Verbindung fehlgeschlagen Der Server konnte nicht gefunden werden diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 818efc74c..eee110474 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -14,7 +14,7 @@ Compartir con Elegir navegador rotación - Ruta de descarga de vídeo + Carpeta de descarga de vídeo Ruta para almacenar los vídeos descargados Introducir directorio de descargas para vídeos Resolución por defecto de vídeo @@ -40,7 +40,7 @@ (Experimental) Forzar la descarga a través de Tor para una mayor privacidad (transmisión de vídeos aún no compatible). No se puede crear la carpeta de descarga \'%1$s\' Carpeta de descarga creada \'%1$s\' - Los audios descargados se almacenan aquí + Ruta para almacenar los audios descargados Introducir ruta de descarga para archivos de audio Bloqueado por GEMA Carpeta de descarga de audio @@ -418,7 +418,9 @@ abrir en modo popup Generar nombre único Sobrescribir - Ya existe un archivo descargado con este nombre + Ya existe un archivo con este nombre + Ya existe un archivo descargado con este nombre + No se puede sobrescribir el archivo Hay una descarga en curso con este nombre Hay una descarga pendiente con este nombre @@ -440,8 +442,8 @@ abrir en modo popup Mostrar error Codigo - No se puede crear la carpeta de destino - No se puede crear el archivo + No se puede crear la carpeta de destino + No se puede crear el archivo Permiso denegado por el sistema Fallo la conexión segura No se pudo encontrar el servidor @@ -453,6 +455,19 @@ abrir en modo popup Fallo el post-procesado NewPipe se cerro mientras se trabajaba en el archivo No hay suficiente espacio disponible en el dispositivo + Se perdió el progreso porque el archivo fue eliminado + + API de almacenamiento + Seleccione que API utilizar para almacenar las descargas + + Framework de acceso a almacenamiento + Java I/O + + Guardar como… + + No es posible descargar a una tarjeta SD externa. \¿Restablecer la ubicación de la carpeta de descarga\? + + Seleccione los directorios de descarga Desuscribirse Nueva pestaña diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 095bbfe5e..2868528e9 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -446,12 +446,12 @@ %s deskarga amaituta Sortu izen bakana Gainidatzi - Badago izen bera duen deskargatutako fitxategi bat + Badago izen bera duen deskargatutako fitxategi bat Badago izen bera duen deskarga bat abian Erakutsi errorea Kodea - Ezin da fitxategia sortu - Ezin da helburu karpeta sortu + Ezin da fitxategia sortu + Ezin da helburu karpeta sortu Sistemak baimena ukatu du Konexio seguruak huts egin du Ezin izan da zerbitzaria aurkitu diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index 9edee2411..41569ff0c 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -451,12 +451,12 @@ %s הורדות הסתיימו יצירת שם ייחודי שכתוב - כבר קיים קובץ בשם הזה + כבר קיים קובץ בשם הזה אחת ההורדות הפעילות כבר נושאת את השם הזה הצגת שגיאה קוד - לא ניתן ליצור את הקובץ - לא ניתן ליצור את תיקיית היעד + לא ניתן ליצור את הקובץ + לא ניתן ליצור את תיקיית היעד ההרשאה נדחתה על ידי המערכת החיבור המאובטח נכשל לא ניתן למצוא את השרת diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 56f31eac6..31801434b 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -447,12 +447,12 @@ %s unduhan selesai Hasilkan nama unik Timpa - File yang diunduh dengan nama ini sudah ada + File yang diunduh dengan nama ini sudah ada Ada unduhan yang sedang berlangsung dengan nama ini Tunjukkan kesalahan Kode - File tidak dapat dibuat - Folder tujuan tidak dapat dibuat + File tidak dapat dibuat + Folder tujuan tidak dapat dibuat Izin ditolak oleh sistem Koneksi aman gagal Tidak dapat menemukan server diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9bccc11df..4ff8de734 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -449,12 +449,12 @@ %s download finiti Genera un nome unico Sovrascrivi - Esiste già un file scaricato con lo stesso nome + Esiste già un file scaricato con lo stesso nome C\'è un download in progresso con questo nome Mostra errore Codice - Impossibile creare il file - Impossibile creare la cartella di destinazione + Impossibile creare il file + Impossibile creare la cartella di destinazione Permesso negato dal sistema Connessione sicura fallita Impossibile trovare il server diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 124c3580b..78a20b1ab 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -365,10 +365,10 @@ 自動生成 アプリの再起動後、設定した字幕設定が反映されます 何もありません - 保存したエクスポートファイルからYouTubeの購読をインポート: -\n -\n1. このURLを開きます: %1$s -\n2. ログインしていなければログインします + 保存したエクスポートファイルからYouTubeの購読をインポート: +\n +\n1. このURLを開きます: %1$s +\n2. ログインしていなければログインします \n3. ダウンロードが始まります (これがエクスポートファイルです) リセット 同意する @@ -391,8 +391,8 @@ \n3. 必要に応じてログインします \n4. リダイレクトされたプロファイル URL をコピーします。 あなたのID, soundcloud.com/あなたのid - この操作により通信料金が増えることがあります。ご注意ください。 -\n + この操作により通信料金が増えることがあります。ご注意ください。 +\n \n続行しますか\? 再生速度を変更 速度と音程を連動せずに変更 (歪むかもしれません) diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 7c8b56bec..ced8235f7 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -443,12 +443,12 @@ %s muat turun selesai Menjana nama yang unik Timpa - Fail yang dimuat turun dengan nama ini sudah wujud + Fail yang dimuat turun dengan nama ini sudah wujud Terdapat muat turun yang sedang berjalan dengan nama ini Tunjukkan kesilapan Kod - Fail tidak boleh dibuat - Folder destinasi tidak boleh dibuat + Fail tidak boleh dibuat + Folder destinasi tidak boleh dibuat Kebenaran ditolak oleh sistem Sambungan selamat gagal Tidak dapat mencari server diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index a806df31a..2f5d19c67 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -526,12 +526,12 @@ %s nedlastinger fullført Generer unikt navn Overskriv - Nedlastet fil ved dette navnet finnes allerede + Nedlastet fil ved dette navnet finnes allerede Nedlasting med dette navnet underveis allerede Vis feil Kode - Filen kan ikke opprettes - Målmappen kan ikke opprettes + Filen kan ikke opprettes + Målmappen kan ikke opprettes Tilgang nektet av systemet Sikker tilkobling mislyktes Fant ikke tjeneren diff --git a/app/src/main/res/values-nl-rBE/strings.xml b/app/src/main/res/values-nl-rBE/strings.xml index 51a30aa36..44b2ef6ab 100644 --- a/app/src/main/res/values-nl-rBE/strings.xml +++ b/app/src/main/res/values-nl-rBE/strings.xml @@ -445,12 +445,12 @@ %s downloads voltooid Unieke naam genereren Overschrijven - Der bestaat al een gedownload bestand met deze naam + Der bestaat al een gedownload bestand met deze naam Der is al een download met deze naam bezig Foutmelding weergeven Code - Het bestand kan niet aangemaakt worden - De doelmap kan niet aangemaakt worden + Het bestand kan niet aangemaakt worden + De doelmap kan niet aangemaakt worden Toelating geweigerd door het systeem Beveiligde verbinding is mislukt Kon de server niet vinden diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 04b47c4c8..96de68b57 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -449,12 +449,12 @@ %s downloads voltooid Genereer een unieke naam Overschrijven - Er bestaat al een gedownload bestand met deze naam + Er bestaat al een gedownload bestand met deze naam Er is een download aan de gang met deze naam Toon foutmelding Code - Het bestand kan niet worden gemaakt - De doelmap kan niet worden gemaakt + Het bestand kan niet worden gemaakt + De doelmap kan niet worden gemaakt Toestemming door het systeem geweigerd Beveiligde connectie is mislukt Kon de server niet vinden diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 4fedc93d9..29070990f 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -446,12 +446,12 @@ %s pobieranie zostało zakończone Wygeneruj unikalną nazwę Zastąp - Pobrany plik o tej nazwie już istnieje + Pobrany plik o tej nazwie już istnieje Trwa pobieranie z tą nazwą Pokaż błąd Kod - Nie można utworzyć pliku - Nie można utworzyć folderu docelowego + Nie można utworzyć pliku + Nie można utworzyć folderu docelowego Odmowa dostępu do systemu Bezpieczne połączenie nie powiodło się Nie można znaleźć serwera diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 798131f37..8a16b752d 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -446,12 +446,12 @@ abrir em modo popup %s downloads terminados Gerar nome único "Sobrescrever " - Um arquivo baixado com esse nome já existe + Um arquivo baixado com esse nome já existe Existe um download em progresso com esse nome Mostrar erro Código - O arquivo não pode ser criado - A pasta de destino não pode ser criada + O arquivo não pode ser criado + A pasta de destino não pode ser criada Permissão negada pelo sistema "Falha na conexão segura " Não foi possível encontrar o servidor diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 9db2e401a..a86c5b809 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -442,12 +442,12 @@ %s descargas terminadas Gerar nome único Sobrescrever - Um ficheiro descarregado com este nome já existe + Um ficheiro descarregado com este nome já existe Já existe uma descarga em curso com este nome Mostrar erro Código - O ficheiro não pode ser criado - A pasta de destino não pode ser criada + O ficheiro não pode ser criado + A pasta de destino não pode ser criada Permissão negada pelo sistema Ligação segura falhou Não foi possível encontrar o servidor diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d4bf5d803..620ca5619 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -442,12 +442,12 @@ Действие запрещено системой Ошибка загрузки Перезаписать - Файл с таким именем уже существует + Файл с таким именем уже существует Загрузка с таким именем уже выполняется Показать текст ошибки Код - Файл не может быть создан - Папка назначения не может быть создана + Папка назначения не может быть создана + Файл не может быть создан Доступ запрещен системой Сервер не найден "Сервер не поддерживает многопотоковую загрузку, попробуйте с @string/msg_threads = 1" diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 5209fe250..e518a1c0f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -449,12 +449,12 @@ %s indirme bitti Benzersiz ad oluştur Üzerine yaz - Bu ada sahip indirilen bir dosya zaten var + Bu ada sahip indirilen bir dosya zaten var Bu ad ile devam eden bir indirme var Hatayı göster Kod - Dosya oluşturulamıyor - Hedef klasör oluşturulamıyor + Dosya oluşturulamıyor + Hedef klasör oluşturulamıyor İzin sistem tarafından reddedildi Güvenli bağlantı başarısız Sunucu bulunamadı diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index b1899622f..ff247c579 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -440,11 +440,11 @@ %s tải về đã xong Tạo tên riêng biệt Ghi đè - Có một tệp đã tải về trùng tên + Có một tệp đã tải về trùng tên Có một tệp trùng tên đang tải về Hiện lỗi - Không thể tạo tệp - Không thể tạo thư mục đích + Không thể tạo tệp + Không thể tạo thư mục đích Quyền bị từ chối bởi hệ thống Không thể tạo kết nối an toàn Không thể tìm máy chủ diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 441061657..0194418cf 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -445,12 +445,12 @@ %s 個下載已結束 生成獨特的名稱 覆寫 - 已有此名稱的已下載檔案 + 已有此名稱的已下載檔案 已有此名稱的當案的下載正在進行 顯示錯誤 代碼 - 無法建立檔案 - 無法建立目的地資料夾 + 無法建立檔案 + 無法建立目的地資料夾 被系統拒絕的權限 安全連線失敗 找不到伺服器 diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index 214a074c4..b2be3135b 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -11,7 +11,7 @@ saved_tabs_key - download_path + download_path download_path_audio use_external_video_player @@ -160,6 +160,21 @@ clear_play_history clear_search_history + downloads_storage_api + + + javaIO + + + SAF + javaIO + + + + @string/storage_access_framework_description + @string/java_io_description + + file_rename file_replacement_character diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index afc6afeb3..7907d2974 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -176,7 +176,7 @@ Error External storage unavailable - Downloading to external SD card not yet possible. Reset download folder location\? + Downloading to external SD card not possible. Reset download folder location\? Network error Could not load all thumbnails Could not decrypt video URL signature @@ -512,15 +512,17 @@ Generate unique name Overwrite - A downloaded file with this name already exists + A file with this name already exists + A downloaded file with this name already exists + cannot overwrite the file There is a download in progress with this name There is a pending download with this name Show error Code - The file can not be created - The destination folder can not be created + The file can not be created + The destination folder can not be created Permission denied by the system Secure connection failed Could not find the server @@ -532,6 +534,7 @@ Post-processing failed NewPipe was closed while working on the file No space left on device + Progress lost, because the file was deleted Clear finished downloads Continue your %s pending transfers from Downloads @@ -546,4 +549,14 @@ Start downloads Pause downloads + Storage API + Select which API use to store the downloads + + Storage Access Framework + Java I/O + + Save as… + + Select the downloads save path + \ No newline at end of file diff --git a/app/src/main/res/xml/download_settings.xml b/app/src/main/res/xml/download_settings.xml index 9f32e7f2f..bbb91acac 100644 --- a/app/src/main/res/xml/download_settings.xml +++ b/app/src/main/res/xml/download_settings.xml @@ -4,10 +4,26 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:title="@string/settings_category_downloads_title"> + + + + +