From 5825843f68c0d44b0c65fbdc2c838d0c6a01e5f2 Mon Sep 17 00:00:00 2001 From: kapodamy Date: Sun, 23 Sep 2018 15:12:23 -0300 Subject: [PATCH] main commit Post-processing infrastructure * remove interfaces with one implementation * fix download resources with unknow length * marquee style for ProgressDrawable * "view details" option in mission context menu * notification for finished downloads * postprocessing infrastructure: sub-missions, circular file, layers for layers of abstractions for Java IO streams * Mp4 muxing (only DASH brand) * WebM muxing * Captions downloading * alert dialog for overwrite existing downloads finished or not. Misc changes * delete SQLiteDownloadDataSource.java * delete DownloadMissionSQLiteHelper.java * implement Localization from #114 Misc fixes (this branch) * restore old mission listeners variables. Prevents registered listeners get de-referenced on low-end devices * DownloadManagerService.checkForRunningMission() now return false if the mission has a error. * use Intent.FLAG_ACTIVITY_NEW_TASK when launching an activity from gigaget threads (apparently it is required in old versions of android) More changes * proper error handling "infrastructure" * queue instead of multiple downloads * move serialized pending downloads (.giga files) to app data * stop downloads when swicthing to mobile network (never works, see 2nd point) * save the thread count for next downloads * a lot of incoherences fixed * delete DownloadManagerTest.java (too many changes to keep this file updated) --- .../java/org/schabi/newpipe/Downloader.java | 9 +- .../download/DeleteDownloadManager.java | 33 +- .../newpipe/download/DownloadActivity.java | 48 +- .../newpipe/download/DownloadDialog.java | 258 +++++- .../fragments/detail/VideoDetailFragment.java | 19 +- .../newpipe/player/helper/PlayerHelper.java | 10 +- .../resolver/VideoPlaybackResolver.java | 6 +- .../newpipe/util/StreamItemAdapter.java | 46 +- .../giga/get/DownloadInitializer.java | 158 ++++ .../us/shandian/giga/get/DownloadManager.java | 53 -- .../us/shandian/giga/get/DownloadMission.java | 638 ++++++++++----- .../shandian/giga/get/DownloadRunnable.java | 143 ++-- .../giga/get/DownloadRunnableFallback.java | 133 +-- .../us/shandian/giga/get/FinishedMission.java | 16 + .../java/us/shandian/giga/get/Mission.java | 66 ++ .../giga/get/sqlite/DownloadDataSource.java | 73 ++ ...Helper.java => DownloadMissionHelper.java} | 67 +- .../get/sqlite/SQLiteDownloadDataSource.java | 79 -- .../giga/postprocessing/Mp4DashMuxer.java | 31 + .../giga/postprocessing/Postprocessing.java | 149 ++++ .../giga/postprocessing/TestAlgo.java | 54 ++ .../giga/postprocessing/TttmlConverter.java | 49 ++ .../giga/postprocessing/WebMMuxer.java | 44 + .../io/ChunkFileInputStream.java | 153 ++++ .../giga/postprocessing/io/CircularFile.java | 345 ++++++++ .../giga/postprocessing/io/FileStream.java | 126 +++ .../postprocessing/io/SharpInputStream.java | 59 ++ .../giga/service/DownloadManager.java | 670 ++++++++++++++++ .../giga/service/DownloadManagerService.java | 359 ++++++--- .../giga/ui/adapter/MissionAdapter.java | 759 ++++++++++++------ .../us/shandian/giga/ui/common/Deleter.java | 169 ++++ .../giga/ui/common/ProgressDrawable.java | 94 ++- .../giga/ui/fragment/MissionsFragment.java | 160 ++-- .../java/us/shandian/giga/util/Utility.java | 117 ++- app/src/main/res/drawable-hdpi/grid.png | Bin 3039 -> 3341 bytes app/src/main/res/drawable-hdpi/list.png | Bin 3129 -> 3156 bytes app/src/main/res/drawable-xhdpi/subtitle.png | Bin 0 -> 3733 bytes app/src/main/res/layout/download_dialog.xml | 7 + app/src/main/res/layout/mission_item.xml | 18 +- app/src/main/res/layout/missions.xml | 17 +- app/src/main/res/layout/missions_header.xml | 30 + app/src/main/res/menu/download_menu.xml | 28 +- app/src/main/res/menu/mission.xml | 79 +- app/src/main/res/values-es/strings.xml | 52 +- app/src/main/res/values/colors.xml | 2 + app/src/main/res/values/settings_keys.xml | 6 + app/src/main/res/values/strings.xml | 53 +- app/src/main/res/xml/download_settings.xml | 13 + 48 files changed, 4379 insertions(+), 1119 deletions(-) create mode 100644 app/src/main/java/us/shandian/giga/get/DownloadInitializer.java delete mode 100644 app/src/main/java/us/shandian/giga/get/DownloadManager.java create mode 100644 app/src/main/java/us/shandian/giga/get/FinishedMission.java create mode 100644 app/src/main/java/us/shandian/giga/get/Mission.java create mode 100644 app/src/main/java/us/shandian/giga/get/sqlite/DownloadDataSource.java rename app/src/main/java/us/shandian/giga/get/sqlite/{DownloadMissionSQLiteHelper.java => DownloadMissionHelper.java} (63%) delete mode 100644 app/src/main/java/us/shandian/giga/get/sqlite/SQLiteDownloadDataSource.java create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/Mp4DashMuxer.java create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/TestAlgo.java create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/TttmlConverter.java create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/io/ChunkFileInputStream.java create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/io/CircularFile.java create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/io/FileStream.java create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/io/SharpInputStream.java create mode 100644 app/src/main/java/us/shandian/giga/service/DownloadManager.java create mode 100644 app/src/main/java/us/shandian/giga/ui/common/Deleter.java create mode 100644 app/src/main/res/drawable-xhdpi/subtitle.png create mode 100644 app/src/main/res/layout/missions_header.xml diff --git a/app/src/main/java/org/schabi/newpipe/Downloader.java b/app/src/main/java/org/schabi/newpipe/Downloader.java index 62c7d1671..177f1f624 100644 --- a/app/src/main/java/org/schabi/newpipe/Downloader.java +++ b/app/src/main/java/org/schabi/newpipe/Downloader.java @@ -89,7 +89,8 @@ public class Downloader implements org.schabi.newpipe.extractor.Downloader { .build(); response = client.newCall(request).execute(); - return Long.parseLong(response.header("Content-Length")); + String contentLength = response.header("Content-Length"); + return contentLength == null ? -1 : Long.parseLong(contentLength); } catch (NumberFormatException e) { throw new IOException("Invalid content length", e); } finally { @@ -104,13 +105,13 @@ public class Downloader implements org.schabi.newpipe.extractor.Downloader { * but set the HTTP header field "Accept-Language" to the supplied string. * * @param siteUrl the URL of the text file to return the contents of - * @param localization the language and country (usually a 2-character code) to set + * @param localisation the language and country (usually a 2-character code) to set * @return the contents of the specified text file */ @Override - public String download(String siteUrl, Localization localization) throws IOException, ReCaptchaException { + public String download(String siteUrl, Localization localisation) throws IOException, ReCaptchaException { Map requestProperties = new HashMap<>(); - requestProperties.put("Accept-Language", localization.getLanguage()); + requestProperties.put("Accept-Language", localisation.getLanguage()); return download(siteUrl, requestProperties); } diff --git a/app/src/main/java/org/schabi/newpipe/download/DeleteDownloadManager.java b/app/src/main/java/org/schabi/newpipe/download/DeleteDownloadManager.java index 5a2d4a486..2f539e343 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DeleteDownloadManager.java +++ b/app/src/main/java/org/schabi/newpipe/download/DeleteDownloadManager.java @@ -28,14 +28,14 @@ public class DeleteDownloadManager { private static final String KEY_STATE = "delete_manager_state"; - private final View mView; - private final HashSet mPendingMap; - private final List mDisposableList; + private View mView; + private ArrayList mPendingMap; + private List mDisposableList; private DownloadManager mDownloadManager; private final PublishSubject publishSubject = PublishSubject.create(); DeleteDownloadManager(Activity activity) { - mPendingMap = new HashSet<>(); + mPendingMap = new ArrayList<>(); mDisposableList = new ArrayList<>(); mView = activity.findViewById(android.R.id.content); } @@ -45,11 +45,11 @@ public class DeleteDownloadManager { } public boolean contains(@NonNull DownloadMission mission) { - return mPendingMap.contains(mission.url); + return mPendingMap.contains(mission.timestamp); } public void add(@NonNull DownloadMission mission) { - mPendingMap.add(mission.url); + mPendingMap.add(mission.timestamp); if (mPendingMap.size() == 1) { showUndoDeleteSnackbar(mission); @@ -67,9 +67,10 @@ public class DeleteDownloadManager { public void restoreState(@Nullable Bundle savedInstanceState) { if (savedInstanceState == null) return; - List list = savedInstanceState.getStringArrayList(KEY_STATE); + long[] list = savedInstanceState.getLongArray(KEY_STATE); if (list != null) { - mPendingMap.addAll(list); + mPendingMap.ensureCapacity(mPendingMap.size() + list.length); + for (long timestamp : list) mPendingMap.add(timestamp); } } @@ -80,17 +81,20 @@ public class DeleteDownloadManager { disposable.dispose(); } - outState.putStringArrayList(KEY_STATE, new ArrayList<>(mPendingMap)); + long[] list = new long[mPendingMap.size()]; + for (int i = 0; i < mPendingMap.size(); i++) list[i] = mPendingMap.get(i); + + outState.putLongArray(KEY_STATE, list); } private void showUndoDeleteSnackbar() { if (mPendingMap.size() < 1) return; - String url = mPendingMap.iterator().next(); + long timestamp = mPendingMap.iterator().next(); for (int i = 0; i < mDownloadManager.getCount(); i++) { DownloadMission mission = mDownloadManager.getMission(i); - if (url.equals(mission.url)) { + if (timestamp == mission.timestamp) { showUndoDeleteSnackbar(mission); break; } @@ -106,7 +110,7 @@ public class DeleteDownloadManager { mDisposableList.add(disposable); snackbar.setAction(R.string.undo, v -> { - mPendingMap.remove(mission.url); + mPendingMap.remove(mission.timestamp); publishSubject.onNext(mission); disposable.dispose(); snackbar.dismiss(); @@ -115,12 +119,13 @@ public class DeleteDownloadManager { snackbar.addCallback(new BaseTransientBottomBar.BaseCallback() { @Override public void onDismissed(Snackbar transientBottomBar, int event) { + // TODO: disposable.isDisposed() is always true. fix this if (!disposable.isDisposed()) { Completable.fromAction(() -> deletePending(mission)) .subscribeOn(Schedulers.io()) .subscribe(); } - mPendingMap.remove(mission.url); + mPendingMap.remove(mission.timestamp); snackbar.removeCallback(this); mDisposableList.remove(disposable); showUndoDeleteSnackbar(); @@ -149,7 +154,7 @@ public class DeleteDownloadManager { private void deletePending(@NonNull DownloadMission mission) { for (int i = 0; i < mDownloadManager.getCount(); i++) { - if (mission.url.equals(mDownloadManager.getMission(i).url)) { + if (mission.timestamp == mDownloadManager.getMission(i).timestamp) { mDownloadManager.deleteMission(i); break; } 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 4a2c85149..29940f802 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java @@ -15,19 +15,16 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.settings.SettingsActivity; import org.schabi.newpipe.util.ThemeHelper; -import io.reactivex.Completable; -import io.reactivex.schedulers.Schedulers; import us.shandian.giga.service.DownloadManagerService; -import us.shandian.giga.ui.fragment.AllMissionsFragment; import us.shandian.giga.ui.fragment.MissionsFragment; public class DownloadActivity extends AppCompatActivity { private static final String MISSIONS_FRAGMENT_TAG = "fragment_tag"; - private DeleteDownloadManager mDeleteDownloadManager; @Override protected void onCreate(Bundle savedInstanceState) { + // Service Intent i = new Intent(); i.setClass(this, DownloadManagerService.class); @@ -47,32 +44,17 @@ public class DownloadActivity extends AppCompatActivity { actionBar.setDisplayShowTitleEnabled(true); } - mDeleteDownloadManager = new DeleteDownloadManager(this); - mDeleteDownloadManager.restoreState(savedInstanceState); - - MissionsFragment fragment = (MissionsFragment) getFragmentManager().findFragmentByTag(MISSIONS_FRAGMENT_TAG); - if (fragment != null) { - fragment.setDeleteManager(mDeleteDownloadManager); - } else { - getWindow().getDecorView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - updateFragments(); - getWindow().getDecorView().getViewTreeObserver().removeGlobalOnLayoutListener(this); - } - }); - } - } - - @Override - protected void onSaveInstanceState(Bundle outState) { - mDeleteDownloadManager.saveState(outState); - super.onSaveInstanceState(outState); + getWindow().getDecorView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + updateFragments(); + getWindow().getDecorView().getViewTreeObserver().removeGlobalOnLayoutListener(this); + } + }); } private void updateFragments() { - MissionsFragment fragment = new AllMissionsFragment(); - fragment.setDeleteManager(mDeleteDownloadManager); + MissionsFragment fragment = new MissionsFragment(); getFragmentManager().beginTransaction() .replace(R.id.frame, fragment, MISSIONS_FRAGMENT_TAG) @@ -99,7 +81,6 @@ public class DownloadActivity extends AppCompatActivity { case R.id.action_settings: { Intent intent = new Intent(this, SettingsActivity.class); startActivity(intent); - deletePending(); return true; } default: @@ -108,14 +89,7 @@ public class DownloadActivity extends AppCompatActivity { } @Override - public void onBackPressed() { - super.onBackPressed(); - deletePending(); - } - - private void deletePending() { - Completable.fromAction(mDeleteDownloadManager::deletePending) - .subscribeOn(Schedulers.io()) - .subscribe(); + public void onRestoreInstanceState(Bundle inState){ + super.onRestoreInstanceState(inState); } } 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 9bbda6032..aab6da1a4 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -1,11 +1,14 @@ package org.schabi.newpipe.download; import android.content.Context; +import android.content.SharedPreferences; import android.os.Bundle; +import android.preference.PreferenceManager; import android.support.annotation.IdRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.DialogFragment; +import android.support.v7.app.AlertDialog; import android.support.v7.widget.Toolbar; import android.util.Log; import android.view.LayoutInflater; @@ -22,10 +25,14 @@ import android.widget.Toast; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.Stream; 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.util.FilenameUtils; import org.schabi.newpipe.util.ListHelper; @@ -36,24 +43,36 @@ import org.schabi.newpipe.util.ThemeHelper; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import icepick.Icepick; import icepick.State; import io.reactivex.disposables.CompositeDisposable; +import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManagerService; public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheckedChangeListener, AdapterView.OnItemSelectedListener { private static final String TAG = "DialogFragment"; private static final boolean DEBUG = MainActivity.DEBUG; - @State protected StreamInfo currentInfo; - @State protected StreamSizeWrapper wrappedAudioStreams = StreamSizeWrapper.empty(); - @State protected StreamSizeWrapper wrappedVideoStreams = StreamSizeWrapper.empty(); - @State protected int selectedVideoIndex = 0; - @State protected int selectedAudioIndex = 0; + @State + protected StreamInfo currentInfo; + @State + protected StreamSizeWrapper wrappedAudioStreams = StreamSizeWrapper.empty(); + @State + protected StreamSizeWrapper wrappedVideoStreams = StreamSizeWrapper.empty(); + @State + protected StreamSizeWrapper wrappedSubtitleStreams = StreamSizeWrapper.empty(); + @State + protected int selectedVideoIndex = 0; + @State + protected int selectedAudioIndex = 0; + @State + protected int selectedSubtitleIndex = 0; private StreamItemAdapter audioStreamsAdapter; private StreamItemAdapter videoStreamsAdapter; + private StreamItemAdapter subtitleStreamsAdapter; private final CompositeDisposable disposables = new CompositeDisposable(); @@ -63,6 +82,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck private TextView threadsCountTextView; private SeekBar threadsSeekBar; + private SharedPreferences prefs; + public static DownloadDialog newInstance(StreamInfo info) { DownloadDialog dialog = new DownloadDialog(); dialog.setInfo(info); @@ -78,6 +99,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck instance.setVideoStreams(streamsList); instance.setSelectedVideoStream(selectedStreamIndex); instance.setAudioStreams(info.getAudioStreams()); + instance.setSubtitleStreams(info.getSubtitles()); + return instance; } @@ -86,7 +109,7 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck } public void setAudioStreams(List audioStreams) { - setAudioStreams(new StreamSizeWrapper<>(audioStreams)); + setAudioStreams(new StreamSizeWrapper<>(audioStreams, getContext())); } public void setAudioStreams(StreamSizeWrapper wrappedAudioStreams) { @@ -94,13 +117,21 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck } public void setVideoStreams(List videoStreams) { - setVideoStreams(new StreamSizeWrapper<>(videoStreams)); + setVideoStreams(new StreamSizeWrapper<>(videoStreams, getContext())); } public void setVideoStreams(StreamSizeWrapper wrappedVideoStreams) { this.wrappedVideoStreams = wrappedVideoStreams; } + public void setSubtitleStreams(List subtitleStreams) { + setSubtitleStreams(new StreamSizeWrapper<>(subtitleStreams, getContext())); + } + + public void setSubtitleStreams(StreamSizeWrapper wrappedSubtitleStreams) { + this.wrappedSubtitleStreams = wrappedSubtitleStreams; + } + public void setSelectedVideoStream(int selectedVideoIndex) { this.selectedVideoIndex = selectedVideoIndex; } @@ -109,6 +140,10 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck this.selectedAudioIndex = selectedAudioIndex; } + public void setSelectedSubtitleStream(int selectedSubtitleIndex) { + this.selectedSubtitleIndex = selectedSubtitleIndex; + } + /*////////////////////////////////////////////////////////////////////////// // LifeCycle //////////////////////////////////////////////////////////////////////////*/ @@ -116,7 +151,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (DEBUG) Log.d(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]"); + if (DEBUG) + Log.d(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]"); if (!PermissionHelper.checkStoragePermissions(getActivity(), PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { getDialog().dismiss(); return; @@ -127,11 +163,13 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck this.videoStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedVideoStreams, true); this.audioStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedAudioStreams); + this.subtitleStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedSubtitleStreams); } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - if (DEBUG) Log.d(TAG, "onCreateView() called with: inflater = [" + inflater + "], container = [" + container + "], savedInstanceState = [" + savedInstanceState + "]"); + if (DEBUG) + Log.d(TAG, "onCreateView() called with: inflater = [" + inflater + "], container = [" + container + "], savedInstanceState = [" + savedInstanceState + "]"); return inflater.inflate(R.layout.download_dialog, container); } @@ -142,6 +180,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck nameEditText.setText(FilenameUtils.createFilename(getContext(), currentInfo.getName())); selectedAudioIndex = ListHelper.getDefaultAudioFormat(getContext(), currentInfo.getAudioStreams()); + selectedSubtitleIndex = getSubtitleIndexBy(subtitleStreamsAdapter.getAll()); + streamsSpinner = view.findViewById(R.id.quality_spinner); streamsSpinner.setOnItemSelectedListener(this); @@ -154,14 +194,18 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck initToolbar(view.findViewById(R.id.toolbar)); setupDownloadOptions(); - int def = 3; - threadsCountTextView.setText(String.valueOf(def)); - threadsSeekBar.setProgress(def - 1); + prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); + + int threads = prefs.getInt(getString(R.string.default_download_threads), 3); + threadsCountTextView.setText(String.valueOf(threads)); + threadsSeekBar.setProgress(threads - 1); threadsSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekbar, int progress, boolean fromUser) { - threadsCountTextView.setText(String.valueOf(progress + 1)); + progress++; + prefs.edit().putInt(getString(R.string.default_download_threads), progress).apply(); + threadsCountTextView.setText(String.valueOf(progress)); } @Override @@ -189,6 +233,11 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck setupAudioSpinner(); } })); + disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedSubtitleStreams).subscribe(result -> { + if (radioVideoAudioGroup.getCheckedRadioButtonId() == R.id.subtitle_button) { + setupSubtitleSpinner(); + } + })); } @Override @@ -216,7 +265,7 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck toolbar.setOnMenuItemClickListener(item -> { if (item.getItemId() == R.id.okay) { - downloadSelected(); + prepareSelectedDownload(); return true; } return false; @@ -239,13 +288,24 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck setRadioButtonsState(true); } + private void setupSubtitleSpinner() { + if (getContext() == null) return; + + streamsSpinner.setAdapter(subtitleStreamsAdapter); + streamsSpinner.setSelection(selectedSubtitleIndex); + setRadioButtonsState(true); + } + /*////////////////////////////////////////////////////////////////////////// // Radio group Video&Audio options - Listener //////////////////////////////////////////////////////////////////////////*/ @Override public void onCheckedChanged(RadioGroup group, @IdRes int checkedId) { - if (DEBUG) Log.d(TAG, "onCheckedChanged() called with: group = [" + group + "], checkedId = [" + checkedId + "]"); + if (DEBUG) + Log.d(TAG, "onCheckedChanged() called with: group = [" + group + "], checkedId = [" + checkedId + "]"); + boolean flag = true; + switch (checkedId) { case R.id.audio_button: setupAudioSpinner(); @@ -253,7 +313,13 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck case R.id.video_button: setupVideoSpinner(); break; + case R.id.subtitle_button: + setupSubtitleSpinner(); + flag = false; + break; } + + threadsSeekBar.setEnabled(flag); } /*////////////////////////////////////////////////////////////////////////// @@ -262,7 +328,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck @Override 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 + "]"); + if (DEBUG) + Log.d(TAG, "onItemSelected() called with: parent = [" + parent + "], view = [" + view + "], position = [" + position + "], id = [" + id + "]"); switch (radioVideoAudioGroup.getCheckedRadioButtonId()) { case R.id.audio_button: selectedAudioIndex = position; @@ -270,6 +337,9 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck case R.id.video_button: selectedVideoIndex = position; break; + case R.id.subtitle_button: + selectedSubtitleIndex = position; + break; } } @@ -286,11 +356,14 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck 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 boolean isVideoStreamsAvailable = videoStreamsAdapter.getCount() > 0; final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0; + final boolean isSubtitleStreamsAvailable = subtitleStreamsAdapter.getCount() > 0; audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE : View.GONE); videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE : View.GONE); + subtitleButton.setVisibility(isSubtitleStreamsAvailable ? View.VISIBLE : View.GONE); if (isVideoStreamsAvailable) { videoButton.setChecked(true); @@ -298,6 +371,9 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck } else if (isAudioStreamsAvailable) { audioButton.setChecked(true); setupAudioSpinner(); + } else if (isSubtitleStreamsAvailable) { + subtitleButton.setChecked(true); + setupSubtitleSpinner(); } else { Toast.makeText(getContext(), R.string.no_streams_available_download, Toast.LENGTH_SHORT).show(); getDialog().dismiss(); @@ -307,28 +383,144 @@ 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); } - private void downloadSelected() { - Stream stream; - String location; - - String fileName = nameEditText.getText().toString().trim(); - if (fileName.isEmpty()) fileName = FilenameUtils.createFilename(getContext(), currentInfo.getName()); - - boolean isAudio = radioVideoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button; - if (isAudio) { - stream = audioStreamsAdapter.getItem(selectedAudioIndex); - location = NewPipeSettings.getAudioDownloadPath(getContext()); - } else { - stream = videoStreamsAdapter.getItem(selectedVideoIndex); - location = NewPipeSettings.getVideoDownloadPath(getContext()); + private int getSubtitleIndexBy(List streams) { + Localization loc = NewPipe.getLocalization(); + for (int j = 0; j < 2; j++) { + for (int i = 0; i < streams.size(); i++) { + Locale streamLocale = streams.get(i).getLocale(); + if (streamLocale.getLanguage().equals(loc.getLanguage())) { + if (j > 0 || streamLocale.getCountry().equals(loc.getCountry())) { + return i; + } + } + } } - String url = stream.getUrl(); - fileName += "." + stream.getFormat().getSuffix(); + return 0; + } + + private void prepareSelectedDownload() { + final Context context = getContext(); + Stream stream; + String location; + char kind; + + String fileName = nameEditText.getText().toString().trim(); + if (fileName.isEmpty()) + fileName = FilenameUtils.createFilename(context, currentInfo.getName()); + + switch (radioVideoAudioGroup.getCheckedRadioButtonId()) { + case R.id.audio_button: + stream = audioStreamsAdapter.getItem(selectedAudioIndex); + location = NewPipeSettings.getAudioDownloadPath(context); + kind = 'a'; + break; + case R.id.video_button: + stream = videoStreamsAdapter.getItem(selectedVideoIndex); + location = NewPipeSettings.getVideoDownloadPath(context); + kind = 'v'; + break; + case R.id.subtitle_button: + stream = subtitleStreamsAdapter.getItem(selectedSubtitleIndex); + location = NewPipeSettings.getVideoDownloadPath(context);// assume that subtitle & video go together + kind = 's'; + break; + default: + return; + } + + int threads; + + 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, (listed, finished) -> { + // should be safe run the following code without "getActivity().runOnUiThread()" + if (listed) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.download_dialog_title) + .setMessage(finished ? R.string.overwrite_warning : R.string.download_already_running) + .setPositiveButton( + finished ? R.string.overwrite : R.string.generate_unique_name, + (dialog, which) -> downloadSelected(context, stream, location, finalFileName, kind, threads) + ) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> { + dialog.cancel(); + }) + .create() + .show(); + } else { + downloadSelected(context, stream, location, finalFileName, kind, threads); + } + }); + } + + private void downloadSelected(Context context, Stream selectedStream, String location, String fileName, char kind, int threads) { + String[] urls; + String psName = null; + String[] psArgs = null; + String secondaryStream = null; + + if (selectedStream instanceof VideoStream) { + VideoStream videoStream = (VideoStream) selectedStream; + if (videoStream.isVideoOnly() && videoStream.getFormat() != MediaFormat.v3GPP) { + boolean m4v = videoStream.getFormat() == MediaFormat.MPEG_4; + + for (AudioStream audio : audioStreamsAdapter.getAll()) { + if (audio.getFormat() == (m4v ? MediaFormat.M4A : MediaFormat.WEBMA)) { + secondaryStream = audio.getUrl(); + break; + } + } + + if (secondaryStream == null) { + // retry, but this time in reverse order + List audioStreams = audioStreamsAdapter.getAll(); + for (int i = audioStreams.size() - 1; i >= 0; i--) { + AudioStream audio = audioStreams.get(i); + if (audio.getFormat() == (m4v ? MediaFormat.MP3 : MediaFormat.OPUS)) { + secondaryStream = audio.getUrl(); + break; + } + } + } + + if (secondaryStream == null) { + Log.w(TAG, "No audio stream candidates for video format " + videoStream.getFormat().name()); + psName = null; + psArgs = null; + } else { + psName = m4v ? Postprocessing.ALGORITHM_MP4_DASH_MUXER : Postprocessing.ALGORITHM_WEBM_MUXER; + psArgs = null; + } + } + } 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 duplicateLines + }; + } + + if (secondaryStream == null) { + urls = new String[]{selectedStream.getUrl()}; + } else { + urls = new String[]{selectedStream.getUrl(), secondaryStream}; + } + + DownloadManagerService.startMission(context, urls, location, fileName, kind, threads, currentInfo.getUrl(), psName, psArgs); - DownloadManagerService.startMission(getContext(), url, location, fileName, isAudio, threadsSeekBar.getProgress() + 1); getDialog().dismiss(); } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 9ab40e81c..ea5300a2e 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -63,6 +63,7 @@ import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.BaseStateFragment; @@ -371,14 +372,14 @@ public class VideoDetailFragment Log.w(TAG, "Can't open channel because we got no channel URL"); } else { try { - NavigationHelper.openChannelFragment( - getFragmentManager(), - currentInfo.getServiceId(), - currentInfo.getUploaderUrl(), - currentInfo.getUploaderName()); + NavigationHelper.openChannelFragment( + getFragmentManager(), + currentInfo.getServiceId(), + currentInfo.getUploaderUrl(), + currentInfo.getUploaderName()); } catch (Exception e) { ErrorActivity.reportUiError((AppCompatActivity) getActivity(), e); - } + } } break; case R.id.detail_thumbnail_root_layout: @@ -571,9 +572,6 @@ public class VideoDetailFragment .show(getFragmentManager(), TAG); } break; - case 3: - shareUrl(item.getName(), item.getUrl()); - break; default: break; } @@ -745,7 +743,7 @@ public class VideoDetailFragment sortedVideoStreams = ListHelper.getSortedStreamVideosList(activity, info.getVideoStreams(), info.getVideoOnlyStreams(), false); selectedVideoStreamIndex = ListHelper.getDefaultResolutionIndex(activity, sortedVideoStreams); - final StreamItemAdapter streamsAdapter = new StreamItemAdapter<>(activity, new StreamSizeWrapper<>(sortedVideoStreams), isExternalPlayerEnabled); + final StreamItemAdapter streamsAdapter = new StreamItemAdapter<>(activity, new StreamSizeWrapper<>(sortedVideoStreams, activity), isExternalPlayerEnabled); spinnerToolbar.setAdapter(streamsAdapter); spinnerToolbar.setSelection(selectedVideoStreamIndex); spinnerToolbar.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @@ -1276,6 +1274,7 @@ public class VideoDetailFragment downloadDialog.setVideoStreams(sortedVideoStreams); downloadDialog.setAudioStreams(currentInfo.getAudioStreams()); downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex); + downloadDialog.setSubtitleStreams(currentInfo.getSubtitles()); downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog"); } catch (Exception e) { diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java index 16dffc3de..5da262781 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java @@ -19,11 +19,11 @@ import com.google.android.exoplayer2.util.MimeTypes; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.Subtitles; +import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.SubtitlesFormat; +import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; @@ -87,7 +87,7 @@ public class PlayerHelper { return pitchFormatter.format(pitch); } - public static String mimeTypesOf(final SubtitlesFormat format) { + public static String subtitleMimeTypesOf(final MediaFormat format) { switch (format) { case VTT: return MimeTypes.TEXT_VTT; case TTML: return MimeTypes.APPLICATION_TTML; @@ -97,8 +97,8 @@ public class PlayerHelper { @NonNull public static String captionLanguageOf(@NonNull final Context context, - @NonNull final Subtitles subtitles) { - final String displayName = subtitles.getLocale().getDisplayName(subtitles.getLocale()); + @NonNull final SubtitlesStream subtitles) { + final String displayName = subtitles.getDisplayLanguageName(); return displayName + (subtitles.isAutoGenerated() ? " (" + context.getString(R.string.caption_auto_generated)+ ")" : ""); } diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java index 8f91f4886..84eeedead 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java @@ -10,10 +10,10 @@ import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MergingMediaSource; import org.schabi.newpipe.extractor.MediaFormat; -import org.schabi.newpipe.extractor.Subtitles; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.util.ListHelper; @@ -93,8 +93,8 @@ public class VideoPlaybackResolver implements PlaybackResolver { // Below are auxiliary media sources // Create subtitle sources - for (final Subtitles subtitle : info.getSubtitles()) { - final String mimeType = PlayerHelper.mimeTypesOf(subtitle.getFileType()); + for (final SubtitlesStream subtitle : info.getSubtitles()) { + final String mimeType = PlayerHelper.subtitleMimeTypesOf(subtitle.getFormat()); if (mimeType == null) continue; final Format textFormat = Format.createTextSampleFormat(null, mimeType, diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java index e100a447b..5ee04ef76 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java @@ -13,6 +13,7 @@ import org.schabi.newpipe.Downloader; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.Stream; +import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; import java.io.Serializable; @@ -94,12 +95,25 @@ public class StreamItemAdapter extends BaseAdapter { if (!showIconNoAudio) { woSoundIconVisibility = View.GONE; } else if (((VideoStream) stream).isVideoOnly()) { - woSoundIconVisibility = View.VISIBLE; + switch (stream.getFormat()) { + case WEBM:// fully supported + case MPEG_4:// ¿is DASH MPEG-4? + woSoundIconVisibility = View.INVISIBLE; + break; + default: + woSoundIconVisibility = View.VISIBLE; + break; + } } else if (isDropdownItem) { woSoundIconVisibility = View.INVISIBLE; } } else if (stream instanceof AudioStream) { qualityString = ((AudioStream) stream).getAverageBitrate() + "kbps"; + } else if (stream instanceof SubtitlesStream) { + qualityString = ((SubtitlesStream) stream).getDisplayLanguageName(); + if (((SubtitlesStream) stream).isAutoGenerated()) { + qualityString += " (" + context.getString(R.string.caption_auto_generated) + ")"; + } } else { qualityString = stream.getFormat().getSuffix(); } @@ -111,7 +125,12 @@ public class StreamItemAdapter extends BaseAdapter { sizeView.setVisibility(View.GONE); } - formatNameView.setText(stream.getFormat().getName()); + if (stream instanceof SubtitlesStream) { + formatNameView.setText(((SubtitlesStream) stream).getLanguageTag()); + } else { + formatNameView.setText(stream.getFormat().getName()); + } + qualityView.setText(qualityString); woSoundIconView.setVisibility(woSoundIconVisibility); @@ -122,15 +141,17 @@ public class StreamItemAdapter extends BaseAdapter { * A wrapper class that includes a way of storing the stream sizes. */ public static class StreamSizeWrapper implements Serializable { - private static final StreamSizeWrapper EMPTY = new StreamSizeWrapper<>(Collections.emptyList()); + private static final StreamSizeWrapper EMPTY = new StreamSizeWrapper<>(Collections.emptyList(), null); private final List streamsList; - private final long[] streamSizes; + private long[] streamSizes; + private final String unknownSize; - public StreamSizeWrapper(List streamsList) { + public StreamSizeWrapper(List streamsList, Context context) { this.streamsList = streamsList; this.streamSizes = new long[streamsList.size()]; + this.unknownSize = context == null ? "--.-" : context.getString(R.string.unknown_content); - for (int i = 0; i < streamSizes.length; i++) streamSizes[i] = -1; + for (int i = 0; i < streamSizes.length; i++) streamSizes[i] = -2; } /** @@ -143,7 +164,7 @@ public class StreamItemAdapter extends BaseAdapter { final Callable fetchAndSet = () -> { boolean hasChanged = false; for (X stream : streamsWrapper.getStreamsList()) { - if (streamsWrapper.getSizeInBytes(stream) > 0) { + if (streamsWrapper.getSizeInBytes(stream) > -2) { continue; } @@ -173,11 +194,18 @@ public class StreamItemAdapter extends BaseAdapter { } public String getFormattedSize(int streamIndex) { - return Utility.formatBytes(getSizeInBytes(streamIndex)); + return formatSize(getSizeInBytes(streamIndex)); } public String getFormattedSize(T stream) { - return Utility.formatBytes(getSizeInBytes(stream)); + return formatSize(getSizeInBytes(stream)); + } + + private String formatSize(long size) { + if (size > -1) { + return Utility.formatBytes(size); + } + return unknownSize; } public void setSize(int streamIndex, long sizeInBytes) { diff --git a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java new file mode 100644 index 000000000..190bac285 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java @@ -0,0 +1,158 @@ +package us.shandian.giga.get; + +import android.support.annotation.NonNull; +import android.util.Log; + +import java.io.File; +import java.io.IOException; +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; + +public class DownloadInitializer implements Runnable { + private final static String TAG = "DownloadInitializer"; + final static int mId = 0; + + private DownloadMission mMission; + + DownloadInitializer(@NonNull DownloadMission mission) { + mMission = mission; + } + + @Override + public void run() { + if (mMission.current > 0) mMission.resetState(); + + int retryCount = 0; + while (true) { + try { + mMission.currentThreadCount = mMission.threadCount; + + HttpURLConnection conn = mMission.openConnection(mId, -1, -1); + if (!mMission.running || Thread.interrupted()) return; + + mMission.length = conn.getContentLength(); + if (mMission.length == 0) { + mMission.notifyError(DownloadMission.ERROR_HTTP_NO_CONTENT, null); + return; + } + + // check for dynamic generated content + if (mMission.length == -1 && conn.getResponseCode() == 200) { + mMission.blocks = 0; + mMission.length = 0; + mMission.fallback = true; + mMission.unknownLength = true; + mMission.currentThreadCount = 1; + + if (DEBUG) { + Log.d(TAG, "falling back (unknown length)"); + } + } else { + // Open again + conn = mMission.openConnection(mId, mMission.length - 10, mMission.length); + + int code = conn.getResponseCode(); + if (!mMission.running || Thread.interrupted()) return; + + if (code == 206) { + if (mMission.currentThreadCount > 1) { + mMission.blocks = mMission.length / DownloadMission.BLOCK_SIZE; + + if (mMission.currentThreadCount > mMission.blocks) { + mMission.currentThreadCount = (int) mMission.blocks; + } + if (mMission.currentThreadCount <= 0) { + mMission.currentThreadCount = 1; + } + if (mMission.blocks * DownloadMission.BLOCK_SIZE < mMission.length) { + mMission.blocks++; + } + } else { + // if one thread is solicited don't calculate blocks, is useless + mMission.blocks = 0; + mMission.fallback = true; + mMission.unknownLength = false; + } + + if (DEBUG) { + Log.d(TAG, "http response code = " + code); + } + } else { + // Fallback to single thread + mMission.blocks = 0; + mMission.fallback = true; + mMission.unknownLength = false; + mMission.currentThreadCount = 1; + + if (DEBUG) { + Log.d(TAG, "falling back due http response code = " + code); + } + } + } + + for (long i = 0; i < mMission.currentThreadCount; i++) { + mMission.threadBlockPositions.add(i); + mMission.threadBytePositions.add(0); + } + + 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 "something", 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(); + + if (Thread.interrupted()) return; + + mMission.running = false; + break; + } catch (Exception e) { + if (e instanceof ClosedByInterruptException) { + return; + } else if (e instanceof IOException && e.getMessage().contains("Permission denied")) { + mMission.notifyError(DownloadMission.ERROR_PERMISSION_DENIED, e); + return; + } + + if (retryCount++ > mMission.maxRetry) { + Log.e(TAG, "initializer failed", e); + mMission.running = false; + mMission.notifyError(e); + return; + } + + //try again + Log.e(TAG, "initializer failed, retrying", e); + } + } + + mMission.start(); + } +} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadManager.java b/app/src/main/java/us/shandian/giga/get/DownloadManager.java deleted file mode 100644 index 45beb5563..000000000 --- a/app/src/main/java/us/shandian/giga/get/DownloadManager.java +++ /dev/null @@ -1,53 +0,0 @@ -package us.shandian.giga.get; - -public interface DownloadManager { - int BLOCK_SIZE = 512 * 1024; - - /** - * Start a new download mission - * - * @param url the url to download - * @param location the location - * @param name the name of the file to create - * @param isAudio true if the download is an audio file - * @param threads the number of threads maximal used to download chunks of the file. @return the identifier of the mission. - */ - int startMission(String url, String location, String name, boolean isAudio, int threads); - - /** - * Resume the execution of a download mission. - * - * @param id the identifier of the mission to resume. - */ - void resumeMission(int id); - - /** - * Pause the execution of a download mission. - * - * @param id the identifier of the mission to pause. - */ - void pauseMission(int id); - - /** - * Deletes the mission from the downloaded list but keeps the downloaded file. - * - * @param id The mission identifier - */ - void deleteMission(int id); - - /** - * Get the download mission by its identifier - * - * @param id the identifier of the download mission - * @return the download mission or null if the mission doesn't exist - */ - DownloadMission getMission(int id); - - /** - * Get the number of download missions. - * - * @return the number of download missions. - */ - int getCount(); - -} 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 79c4baf05..73df11ecb 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -1,102 +1,165 @@ package us.shandian.giga.get; import android.os.Handler; -import android.os.Looper; +import android.os.Message; import android.util.Log; import java.io.File; -import java.io.ObjectInputStream; -import java.io.Serializable; -import java.lang.ref.WeakReference; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.ConnectException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.UnknownHostException; import java.util.ArrayList; import java.util.HashMap; -import java.util.Iterator; import java.util.List; -import java.util.Map; +import javax.net.ssl.SSLException; + +import us.shandian.giga.postprocessing.Postprocessing; +import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; -public class DownloadMission implements Serializable { - private static final long serialVersionUID = 0L; +public class DownloadMission extends Mission { + private static final long serialVersionUID = 3L;// last bump: 16 october 2018 - private static final String TAG = DownloadMission.class.getSimpleName(); + static final int BUFFER_SIZE = 64 * 1024; + final static int BLOCK_SIZE = 512 * 1024; - public interface MissionListener { - HashMap handlerStore = new HashMap<>(); + private static final String TAG = "DownloadMission"; - void onProgressUpdate(DownloadMission downloadMission, long done, long total); - - void onFinish(DownloadMission downloadMission); - - void onError(DownloadMission downloadMission, int errCode); - } - - public static final int ERROR_SERVER_UNSUPPORTED = 206; - public static final int ERROR_UNKNOWN = 233; + public static final int ERROR_NOTHING = -1; + public static final int ERROR_PATH_CREATION = 1000; + public static final int ERROR_FILE_CREATION = 1001; + public static final int ERROR_UNKNOWN_EXCEPTION = 1002; + public static final int ERROR_PERMISSION_DENIED = 1003; + public static final int ERROR_SSL_EXCEPTION = 1004; + public static final int ERROR_UNKNOWN_HOST = 1005; + public static final int ERROR_CONNECT_HOST = 1006; + public static final int ERROR_POSTPROCESSING_FAILED = 1007; + public static final int ERROR_HTTP_NO_CONTENT = 204; + public static final int ERROR_HTTP_UNSUPPORTED_RANGE = 206; /** - * The filename + * The urls of the file to download */ - public String name; + public String[] urls; /** - * The url of the file to download + * Number of blocks the size of {@link DownloadMission#BLOCK_SIZE} */ - public String url; - - /** - * The directory to store the download - */ - public String location; - - /** - * Number of blocks the size of {@link DownloadManager#BLOCK_SIZE} - */ - public long blocks; - - /** - * Number of bytes - */ - public long length; + long blocks = -1; /** * Number of bytes downloaded */ public long done; + + /** + * Indicates a file generated dynamically on the web server + */ + public boolean unknownLength; + + /** + * offset in the file where the data should be written + */ + public long[] offsets; + + /** + * The post-processing algorithm arguments + */ + public String[] postprocessingArgs; + + /** + * The post-processing algorithm name + */ + public String postprocessingName; + + /** + * Indicates if the post-processing algorithm is actually running, used to detect corrupt downloads + */ + public boolean postprocessingRunning; + + /** + * Indicate if the post-processing algorithm works on the same file + */ + public boolean postprocessingThis; + + /** + * The current resource to download {@code urls[current]} + */ + public int current; + + /** + * Metadata where the mission state is saved + */ + public File metadata; + + /** + * maximum attempts + */ + public int maxRetry; + public int threadCount = 3; - public int finishCount; - private final List threadPositions = new ArrayList<>(); - public final Map blockState = new HashMap<>(); - public boolean running; - public boolean finished; - public boolean fallback; - public int errCode = -1; - public long timestamp; + boolean fallback; + private int finishCount; + public transient boolean running; + public transient boolean enqueued = true; + public int errCode = ERROR_NOTHING; + + public transient Exception errObject = null; public transient boolean recovered; - - private transient ArrayList> mListeners = new ArrayList<>(); + public transient Handler mHandler; private transient boolean mWritingToFile; - private static final int NO_IDENTIFIER = -1; + @SuppressWarnings("UseSparseArrays")// LongSparseArray is not serializable + private final HashMap blockState = new HashMap<>(); + final List threadBlockPositions = new ArrayList<>(); + final List threadBytePositions = new ArrayList<>(); + + private transient boolean deleted; + int currentThreadCount; + private transient Thread[] threads = null; + private transient Thread init = null; + + + protected DownloadMission() { - public DownloadMission() { } - public DownloadMission(String name, String url, String location) { + 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"); - if (url == null) throw new NullPointerException("url is null"); - if (url.isEmpty()) throw new IllegalArgumentException("url is empty"); + 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.url = url; + this.urls = urls; this.name = name; this.location = location; - } + this.kind = kind; + this.offsets = new long[urls.length]; + if (postprocessingName != null) { + Postprocessing algorithm = Postprocessing.getAlgorithm(postprocessingName, null); + this.postprocessingThis = algorithm.worksOnSameFile; + this.offsets[0] = algorithm.recommendedReserve; + this.postprocessingName = postprocessingName; + this.postprocessingArgs = postprocessingArgs; + } else { + if (DEBUG && urls.length > 1) { + Log.w(TAG, "mission created with multiple urls ¿missing post-processing algorithm?"); + } + } + } private void checkBlock(long block) { if (block < 0 || block >= blocks) { @@ -110,12 +173,12 @@ public class DownloadMission implements Serializable { * @param block the block identifier * @return true if the block is reserved and false if otherwise */ - public boolean isBlockPreserved(long block) { + boolean isBlockPreserved(long block) { checkBlock(block); return blockState.containsKey(block) ? blockState.get(block) : false; } - public void preserveBlock(long block) { + void preserveBlock(long block) { checkBlock(block); synchronized (blockState) { blockState.put(block, true); @@ -123,125 +186,192 @@ public class DownloadMission implements Serializable { } /** - * Set the download position of the file + * Set the block of the file * * @param threadId the identifier of the thread - * @param position the download position of the thread + * @param position the block of the thread */ - public void setPosition(int threadId, long position) { - threadPositions.set(threadId, position); + void setBlockPosition(int threadId, long position) { + threadBlockPositions.set(threadId, position); } /** - * Get the position of a thread + * Get the block of a file * * @param threadId the identifier of the thread - * @return the position for the thread + * @return the block for the thread */ - public long getPosition(int threadId) { - return threadPositions.get(threadId); + long getBlockPosition(int threadId) { + return threadBlockPositions.get(threadId); } - public synchronized void notifyProgress(long deltaLen) { + /** + * Save the position of the desired thread + * + * @param threadId the identifier of the thread + * @param position the relative position in bytes or zero + */ + void setThreadBytePosition(int threadId, int position) { + threadBytePositions.set(threadId, position); + } + + /** + * Get position inside of the block, where thread will be resumed + * + * @param threadId the identifier of the thread + * @return the relative position in bytes or zero + */ + int getBlockBytePosition(int threadId) { + return threadBytePositions.get(threadId); + } + + /** + * Open connection + * + * @param threadId id of the calling thread, used only for debug + * @param rangeStart range start + * @param rangeEnd range end + * @return a {@link java.net.URLConnection URLConnection} linking to the URL. + * @throws IOException if an I/O exception occurs. + * @throws HttpError if the the http response is not satisfiable + */ + HttpURLConnection openConnection(int threadId, long rangeStart, long rangeEnd) throws IOException, HttpError { + URL url = new URL(urls[current]); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setInstanceFollowRedirects(true); + + if (rangeStart >= 0) { + String req = "bytes=" + rangeStart + "-"; + if (rangeEnd > 0) req += rangeEnd; + + conn.setRequestProperty("Range", req); + if (DEBUG) { + Log.d(TAG, threadId + ":" + conn.getRequestProperty("Range")); + Log.d(TAG, threadId + ":Content-Length=" + conn.getContentLength() + " Code:" + conn.getResponseCode()); + } + } + + int statusCode = conn.getResponseCode(); + switch (statusCode) { + case 204: + case 205: + case 207: + throw new HttpError(conn.getResponseCode()); + default: + if (statusCode < 200 || statusCode > 299) { + throw new HttpError(statusCode); + } + } + + return conn; + } + + private void notify(int what) { + Message m = new Message(); + m.what = what; + m.obj = this; + + mHandler.sendMessage(m); + } + + synchronized void notifyProgress(long deltaLen) { if (!running) return; if (recovered) { recovered = false; } + if (unknownLength) { + length += deltaLen;// Update length before proceeding + } + done += deltaLen; if (done > length) { done = length; } - if (done != length) { - writeThisToFile(); + if (done != length && !deleted && !mWritingToFile) { + mWritingToFile = true; + runAsync(-2, this::writeThisToFile); } - for (WeakReference ref : mListeners) { - final MissionListener listener = ref.get(); - if (listener != null) { - MissionListener.handlerStore.get(listener).post(new Runnable() { - @Override - public void run() { - listener.onProgressUpdate(DownloadMission.this, done, length); - } - }); - } + notify(DownloadManagerService.MESSAGE_PROGRESS); + } + + synchronized void notifyError(Exception err) { + Log.e(TAG, "notifyError()", err); + + if (err instanceof FileNotFoundException) { + notifyError(ERROR_FILE_CREATION, null); + } else if (err instanceof SSLException) { + notifyError(ERROR_SSL_EXCEPTION, null); + } else if (err instanceof HttpError) { + notifyError(((HttpError) err).statusCode, null); + } else if (err instanceof ConnectException) { + notifyError(ERROR_CONNECT_HOST, null); + } else if (err instanceof UnknownHostException) { + notifyError(ERROR_UNKNOWN_HOST, null); + } else { + notifyError(ERROR_UNKNOWN_EXCEPTION, err); } } - /** - * Called by a download thread when it finished. - */ - public synchronized void notifyFinished() { - if (errCode > 0) return; + synchronized void notifyError(int code, Exception err) { + Log.e(TAG, "notifyError() code = " + code, err); + + errCode = code; + errObject = err; + + pause(); + + notify(DownloadManagerService.MESSAGE_ERROR); + } + + synchronized void notifyFinished() { + if (errCode > ERROR_NOTHING) return; finishCount++; - if (finishCount == threadCount) { - onFinish(); + if (finishCount == currentThreadCount) { + if ((current + 1) < urls.length) { + // prepare next sub-mission + long current_offset = offsets[current++]; + offsets[current] = current_offset + length; + initializer(); + return; + } + + current++; + unknownLength = false; + + if (!doPostprocessing()) return; + + if (errCode > ERROR_NOTHING) return; + if (DEBUG) { + Log.d(TAG, "onFinish"); + } + running = false; + deleteThisFromFile(); + + notify(DownloadManagerService.MESSAGE_FINISHED); } } - /** - * Called when all parts are downloaded - */ - private void onFinish() { - if (errCode > 0) return; - + private void notifyPostProcessing(boolean processing) { if (DEBUG) { - Log.d(TAG, "onFinish"); + Log.d(TAG, (processing ? "enter" : "exit") + " postprocessing on " + location + File.separator + name); } - running = false; - finished = true; - - deleteThisFromFile(); - - for (WeakReference ref : mListeners) { - final MissionListener listener = ref.get(); - if (listener != null) { - MissionListener.handlerStore.get(listener).post(new Runnable() { - @Override - public void run() { - listener.onFinish(DownloadMission.this); - } - }); + synchronized (blockState) { + if (!processing) { + postprocessingName = null; + postprocessingArgs = null; } - } - } - public synchronized void notifyError(int err) { - errCode = err; - - writeThisToFile(); - - for (WeakReference ref : mListeners) { - final MissionListener listener = ref.get(); - MissionListener.handlerStore.get(listener).post(new Runnable() { - @Override - public void run() { - listener.onError(DownloadMission.this, errCode); - } - }); - } - } - - public synchronized void addListener(MissionListener listener) { - Handler handler = new Handler(Looper.getMainLooper()); - MissionListener.handlerStore.put(listener, handler); - mListeners.add(new WeakReference<>(listener)); - } - - public synchronized void removeListener(MissionListener listener) { - for (Iterator> iterator = mListeners.iterator(); - iterator.hasNext(); ) { - WeakReference weakRef = iterator.next(); - if (listener != null && listener == weakRef.get()) { - iterator.remove(); - } + // don't return without fully write the current state + postprocessingRunning = processing; + Utility.writeToFile(metadata, DownloadMission.this); } } @@ -249,92 +379,206 @@ public class DownloadMission implements Serializable { * Start downloading with multiple threads. */ public void start() { - if (!running && !finished) { - running = true; + if (running || current >= urls.length) return; + enqueued = false; + running = true; + errCode = ERROR_NOTHING; - if (!fallback) { - for (int i = 0; i < threadCount; i++) { - if (threadPositions.size() <= i && !recovered) { - threadPositions.add((long) i); - } - new Thread(new DownloadRunnable(this, i)).start(); - } - } else { - // In fallback mode, resuming is not supported. - threadCount = 1; + if (blocks < 0) { + initializer(); + return; + } + + init = null; + + if (threads == null) { + threads = new Thread[currentThreadCount]; + } + + if (fallback) { + if (unknownLength) { done = 0; - blocks = 0; - new Thread(new DownloadRunnableFallback(this)).start(); + length = 0; + } + + threads[0] = runAsync(1, new DownloadRunnableFallback(this)); + } else { + for (int i = 0; i < currentThreadCount; i++) { + threads[i] = runAsync(i + 1, new DownloadRunnable(this, i)); } } } - public void pause() { - if (running) { - running = false; - recovered = true; + /** + * Pause the mission, does not affect the blocks that are being downloaded. + */ + public synchronized void pause() { + if (!running) return; - // TODO: Notify & Write state to info file - // if (err) + running = false; + recovered = true; + enqueued = false; + + if (init != null && init != Thread.currentThread() && init.isAlive()) { + init.interrupt(); + + try { + init.join(); + } catch (InterruptedException e) { + // nothing to do + } + + resetState(); + return; } + + if (DEBUG && blocks < 1) { + Log.w(TAG, "pausing a download that can not be resumed."); + } + + if (threads == null || Thread.interrupted()) { + writeThisToFile(); + return; + } + + // wait for all threads are suspended before save the state + runAsync(-1, () -> { + try { + for (Thread thread : threads) { + if (thread == Thread.currentThread()) continue; + + if (thread.isAlive()) { + thread.interrupt(); + thread.join(); + } + } + } catch (Exception e) { + // nothing to do + } finally { + writeThisToFile(); + } + }); } /** * Removes the file and the meta file */ - public void delete() { - deleteThisFromFile(); - new File(location, name).delete(); + @Override + public boolean delete() { + deleted = true; + boolean res = deleteThisFromFile(); + if (!super.delete()) res = false; + return res; + } + + void resetState() { + done = 0; + blocks = -1; + errCode = ERROR_NOTHING; + fallback = false; + unknownLength = false; + finishCount = 0; + threadBlockPositions.clear(); + threadBytePositions.clear(); + blockState.clear(); + threads = null; + + Utility.writeToFile(metadata, DownloadMission.this); + } + + private void initializer() { + init = runAsync(DownloadInitializer.mId, new DownloadInitializer(this)); + } /** * Write this {@link DownloadMission} to the meta file asynchronously * if no thread is already running. */ - public void writeThisToFile() { - if (!mWritingToFile) { - mWritingToFile = true; - new Thread() { - @Override - public void run() { - doWriteThisToFile(); - mWritingToFile = false; - } - }.start(); - } - } - - /** - * Write this {@link DownloadMission} to the meta file. - */ - private void doWriteThisToFile() { + private void writeThisToFile() { synchronized (blockState) { - Utility.writeToFile(getMetaFilename(), this); + if (deleted) return; + Utility.writeToFile(metadata, DownloadMission.this); + } + mWritingToFile = false; + } + + public boolean isFinished() { + return current >= urls.length && postprocessingName == null; + } + + private boolean doPostprocessing() { + if (postprocessingName == null) return true; + + try { + notifyPostProcessing(true); + notifyProgress(0); + + Thread.currentThread().setName("[" + TAG + "] post-processing = " + postprocessingName + " filename = " + name); + + Postprocessing algorithm = Postprocessing.getAlgorithm(postprocessingName, this); + algorithm.run(); + } 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); + + notifyError(ERROR_POSTPROCESSING_FAILED, err); + return false; + } finally { + notifyPostProcessing(false); + } + + if (errCode != ERROR_NOTHING) notify(DownloadManagerService.MESSAGE_ERROR); + + return errCode == ERROR_NOTHING; + } + + private boolean deleteThisFromFile() { + synchronized (blockState) { + return metadata.delete(); } } - private void readObject(ObjectInputStream inputStream) - throws java.io.IOException, ClassNotFoundException - { - inputStream.defaultReadObject(); - mListeners = new ArrayList<>(); - } - - private void deleteThisFromFile() { - new File(getMetaFilename()).delete(); - } - /** - * Get the path of the meta file + * run a method in a new thread * - * @return the path to the meta file + * @param id id of new thread (used for debugging only) + * @param who the object whose {@code run} method is invoked when this thread is started + * @return the created thread */ - private String getMetaFilename() { - return location + "/" + name + ".giga"; + private Thread runAsync(int id, Runnable who) { + // known thread ids: + // -2: state saving by notifyProgress() method + // -1: wait for saving the state by pause() method + // 0: initializer + // >=1: any download thread + + Thread thread = new Thread(who); + if (DEBUG) { + thread.setName(String.format("[%s] id = %s filename = %s", TAG, id, name)); + } + thread.start(); + + return thread; } - public File getDownloadedFile() { - return new File(location, name); - } + static class HttpError extends Exception { + int statusCode; + HttpError(int statusCode) { + this.statusCode = statusCode; + } + + @Override + public String getMessage() { + return "Http status code" + String.valueOf(statusCode); + } + } } 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 6ad8626c3..ad2fa7113 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,12 @@ package us.shandian.giga.get; import android.util.Log; +import java.io.BufferedInputStream; +import java.io.FileNotFoundException; import java.io.RandomAccessFile; import java.net.HttpURLConnection; import java.net.URL; +import java.nio.channels.ClosedByInterruptException; import static org.schabi.newpipe.BuildConfig.DEBUG; @@ -18,7 +21,7 @@ public class DownloadRunnable implements Runnable { private final DownloadMission mMission; private final int mId; - public DownloadRunnable(DownloadMission mission, int id) { + DownloadRunnable(DownloadMission mission, int id) { if (mission == null) throw new NullPointerException("mission is null"); mMission = mission; mId = id; @@ -27,14 +30,25 @@ public class DownloadRunnable implements Runnable { @Override public void run() { boolean retry = mMission.recovered; - long position = mMission.getPosition(mId); + long blockPosition = mMission.getBlockPosition(mId); + int retryCount = 0; if (DEBUG) { - Log.d(TAG, mId + ":default pos " + position); + Log.d(TAG, mId + ":default pos " + blockPosition); Log.d(TAG, mId + ":recovered: " + mMission.recovered); } - while (mMission.errCode == -1 && mMission.running && position < mMission.blocks) { + BufferedInputStream ipt = null; + RandomAccessFile f; + + try { + f = new RandomAccessFile(mMission.getDownloadedFile(), "rw"); + } catch (FileNotFoundException e) { + mMission.notifyError(e);// this never should happen + return; + } + + while (mMission.errCode == DownloadMission.ERROR_NOTHING && mMission.running && blockPosition < mMission.blocks) { if (Thread.currentThread().isInterrupted()) { mMission.pause(); @@ -42,57 +56,47 @@ public class DownloadRunnable implements Runnable { } if (DEBUG && retry) { - Log.d(TAG, mId + ":retry is true. Resuming at " + position); + Log.d(TAG, mId + ":retry is true. Resuming at " + blockPosition); } // Wait for an unblocked position - while (!retry && position < mMission.blocks && mMission.isBlockPreserved(position)) { + while (!retry && blockPosition < mMission.blocks && mMission.isBlockPreserved(blockPosition)) { if (DEBUG) { - Log.d(TAG, mId + ":position " + position + " preserved, passing"); + Log.d(TAG, mId + ":position " + blockPosition + " preserved, passing"); } - position++; + blockPosition++; } retry = false; - if (position >= mMission.blocks) { + if (blockPosition >= mMission.blocks) { break; } if (DEBUG) { - Log.d(TAG, mId + ":preserving position " + position); + Log.d(TAG, mId + ":preserving position " + blockPosition); } - mMission.preserveBlock(position); - mMission.setPosition(mId, position); + mMission.preserveBlock(blockPosition); + mMission.setBlockPosition(mId, blockPosition); - long start = position * DownloadManager.BLOCK_SIZE; - long end = start + DownloadManager.BLOCK_SIZE - 1; + long start = (blockPosition * DownloadMission.BLOCK_SIZE) + mMission.getBlockBytePosition(mId); + long end = start + DownloadMission.BLOCK_SIZE - 1; if (end >= mMission.length) { end = mMission.length - 1; } - HttpURLConnection conn = null; - int total = 0; try { - URL url = new URL(mMission.url); - conn = (HttpURLConnection) url.openConnection(); - conn.setRequestProperty("Range", "bytes=" + start + "-" + end); + HttpURLConnection conn = mMission.openConnection(mId, start, end); - if (DEBUG) { - Log.d(TAG, mId + ":" + conn.getRequestProperty("Range")); - Log.d(TAG, mId + ":Content-Length=" + conn.getContentLength() + " Code:" + conn.getResponseCode()); - } - - // A server may be ignoring the range request + // The server may be ignoring the range request if (conn.getResponseCode() != 206) { - mMission.errCode = DownloadMission.ERROR_SERVER_UNSUPPORTED; - notifyError(); + mMission.notifyError(new DownloadMission.HttpError(conn.getResponseCode())); if (DEBUG) { Log.e(TAG, mId + ":Unsupported " + conn.getResponseCode()); @@ -101,76 +105,67 @@ public class DownloadRunnable implements Runnable { break; } - RandomAccessFile f = new RandomAccessFile(mMission.location + "/" + mMission.name, "rw"); - f.seek(start); - java.io.InputStream ipt = conn.getInputStream(); - byte[] buf = new byte[64*1024]; + f.seek(mMission.offsets[mMission.current] + start); - while (start < end && mMission.running) { - int len = ipt.read(buf, 0, buf.length); + ipt = new BufferedInputStream(conn.getInputStream()); + byte[] buf = new byte[DownloadMission.BUFFER_SIZE]; + int len; - if (len == -1) { - break; - } else { - start += len; - total += len; - f.write(buf, 0, len); - notifyProgress(len); - } + while (start < end && mMission.running && (len = ipt.read(buf, 0, buf.length)) != -1) { + f.write(buf, 0, len); + start += len; + total += len; + mMission.notifyProgress(len); } if (DEBUG && mMission.running) { - Log.d(TAG, mId + ":position " + position + " finished, total length " + total); + Log.d(TAG, mId + ":position " + blockPosition + " finished, total length " + total); } - f.close(); - ipt.close(); - - // TODO We should save progress for each thread + // if the download is paused, save progress for this thread + if (!mMission.running) { + mMission.setThreadBytePosition(mId, total); + break; + } } catch (Exception e) { - // TODO Retry count limit & notify error - retry = true; + mMission.setThreadBytePosition(mId, total); - notifyProgress(-total); + if (e instanceof ClosedByInterruptException) break; + + if (retryCount++ > mMission.maxRetry) { + mMission.notifyError(e); + break; + } if (DEBUG) { - Log.d(TAG, mId + ":position " + position + " retrying", e); + Log.d(TAG, mId + ":position " + blockPosition + " retrying due exception", e); } } } + try { + f.close(); + } catch (Exception err) { + // ¿ejected media storage? ¿file deleted? ¿storage ran out of space? + } + + try { + if (ipt != null) ipt.close(); + } catch (Exception err) { + // nothing to do + } + if (DEBUG) { - Log.d(TAG, "thread " + mId + " exited main loop"); + Log.d(TAG, "thread " + mId + " exited from main download loop"); } - - if (mMission.errCode == -1 && mMission.running) { + if (mMission.errCode == DownloadMission.ERROR_NOTHING && mMission.running) { if (DEBUG) { Log.d(TAG, "no error has happened, notifying"); } - notifyFinished(); + mMission.notifyFinished(); } - if (DEBUG && !mMission.running) { Log.d(TAG, "The mission has been paused. Passing."); } } - - private void notifyProgress(final long len) { - synchronized (mMission) { - mMission.notifyProgress(len); - } - } - - private void notifyError() { - synchronized (mMission) { - mMission.notifyError(DownloadMission.ERROR_SERVER_UNSUPPORTED); - mMission.pause(); - } - } - - private void notifyFinished() { - synchronized (mMission) { - mMission.notifyFinished(); - } - } } 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 f24139910..a7c48c170 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java @@ -1,74 +1,109 @@ package us.shandian.giga.get; +import android.support.annotation.NonNull; +import android.util.Log; + import java.io.BufferedInputStream; +import java.io.IOException; import java.io.RandomAccessFile; import java.net.HttpURLConnection; -import java.net.URL; +import java.nio.channels.ClosedByInterruptException; + + +import static org.schabi.newpipe.BuildConfig.DEBUG; // Single-threaded fallback mode public class DownloadRunnableFallback implements Runnable { - private final DownloadMission mMission; - //private int mId; + private static final String TAG = "DownloadRunnableFallbac"; - public DownloadRunnableFallback(DownloadMission mission) { - if (mission == null) throw new NullPointerException("mission is null"); - //mId = id; + private final DownloadMission mMission; + private int retryCount = 0; + + private BufferedInputStream ipt; + private RandomAccessFile f; + + DownloadRunnableFallback(@NonNull DownloadMission mission) { mMission = mission; + ipt = null; + f = null; + } + + private void dispose() { + try { + if (ipt != null) ipt.close(); + } catch (IOException e) { + // nothing to do + } + + try { + if (f != null) f.close(); + } catch (IOException e) { + // ¿ejected media storage? ¿file deleted? ¿storage ran out of space? + } } @Override public void run() { - try { - URL url = new URL(mMission.url); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + boolean done; - if (conn.getResponseCode() != 200 && conn.getResponseCode() != 206) { - notifyError(DownloadMission.ERROR_SERVER_UNSUPPORTED); - } else { - RandomAccessFile f = new RandomAccessFile(mMission.location + "/" + mMission.name, "rw"); - f.seek(0); - BufferedInputStream ipt = new BufferedInputStream(conn.getInputStream()); - byte[] buf = new byte[512]; - int len = 0; + int start = 0; - while ((len = ipt.read(buf, 0, 512)) != -1 && mMission.running) { - f.write(buf, 0, len); - notifyProgress(len); - - if (Thread.interrupted()) { - break; - } - - } - - f.close(); - ipt.close(); + if (!mMission.unknownLength) { + start = mMission.getBlockBytePosition(0); + if (DEBUG && start > 0) { + Log.i(TAG, "Resuming a single-thread download at " + start); } + } + + try { + int rangeStart = (mMission.unknownLength || start < 1) ? -1 : start; + HttpURLConnection conn = mMission.openConnection(1, rangeStart, -1); + + // secondary check for the file length + if (!mMission.unknownLength) mMission.unknownLength = conn.getContentLength() == -1; + + f = new RandomAccessFile(mMission.getDownloadedFile(), "rw"); + f.seek(mMission.offsets[mMission.current] + start); + + ipt = new BufferedInputStream(conn.getInputStream()); + + byte[] buf = new byte[DownloadMission.BUFFER_SIZE]; + int len = 0; + + while (mMission.running && (len = ipt.read(buf, 0, buf.length)) != -1) { + f.write(buf, 0, len); + start += len; + + mMission.notifyProgress(len); + + if (Thread.interrupted()) break; + } + + // if thread goes interrupted check if the last part is written. This avoid re-download the whole file + done = len == -1; } catch (Exception e) { - notifyError(DownloadMission.ERROR_UNKNOWN); + dispose(); + + // save position + mMission.setThreadBytePosition(0, start); + + if (e instanceof ClosedByInterruptException) return; + + if (retryCount++ > mMission.maxRetry) { + mMission.notifyError(e); + return; + } + + run();// try again + return; } - if (mMission.errCode == -1 && mMission.running) { - notifyFinished(); - } - } + dispose(); - private void notifyProgress(final long len) { - synchronized (mMission) { - mMission.notifyProgress(len); - } - } - - private void notifyError(final int err) { - synchronized (mMission) { - mMission.notifyError(err); - mMission.pause(); - } - } - - private void notifyFinished() { - synchronized (mMission) { + if (done) { mMission.notifyFinished(); + } else { + mMission.setThreadBytePosition(0, start); } } } diff --git a/app/src/main/java/us/shandian/giga/get/FinishedMission.java b/app/src/main/java/us/shandian/giga/get/FinishedMission.java new file mode 100644 index 000000000..b7d6908a5 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/FinishedMission.java @@ -0,0 +1,16 @@ +package us.shandian.giga.get; + +public class FinishedMission extends Mission { + + public FinishedMission() { + } + + public FinishedMission(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 new file mode 100644 index 000000000..ec2ddaa26 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/Mission.java @@ -0,0 +1,66 @@ +package us.shandian.giga.get; + +import java.io.File; +import java.io.Serializable; +import java.text.SimpleDateFormat; +import java.util.Calendar; + +public abstract class Mission implements Serializable { + private static final long serialVersionUID = 0L;// last bump: 5 october 2018 + + /** + * Source url of the resource + */ + public String source; + + /** + * Length of the current resource + */ + public long length; + + /** + * creation timestamp (and maybe unique identifier) + */ + public long timestamp; + + /** + * The filename + */ + public String name; + + /** + * The directory to store the download + */ + public String location; + + /** + * pre-defined content type + */ + public char kind; + + /** + * get the target file on the storage + * + * @return File object + */ + public File getDownloadedFile() { + return new File(location, name); + } + + public boolean delete() { + deleted = true; + return getDownloadedFile().delete(); + } + + /** + * Indicate if this mission is deleted whatever is stored + */ + public transient boolean deleted = false; + + @Override + public String toString() { + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(timestamp); + return "[" + calendar.getTime().toString() + "] " + location + File.separator + name; + } +} 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 new file mode 100644 index 000000000..4b4d5d733 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/sqlite/DownloadDataSource.java @@ -0,0 +1,73 @@ +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/DownloadMissionSQLiteHelper.java b/app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionHelper.java similarity index 63% rename from app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionSQLiteHelper.java rename to app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionHelper.java index d5a83551b..6dadc98c8 100644 --- a/app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionSQLiteHelper.java +++ b/app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionHelper.java @@ -7,19 +7,19 @@ 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 {@link us.shandian.giga.get.DownloadMission} + * SQLiteHelper to store finished {@link us.shandian.giga.get.DownloadMission}'s */ -public class DownloadMissionSQLiteHelper extends SQLiteOpenHelper { - - +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 = 2; + private static final int DATABASE_VERSION = 3; + /** * The table name of download missions */ @@ -30,9 +30,9 @@ public class DownloadMissionSQLiteHelper extends SQLiteOpenHelper { */ static final String KEY_LOCATION = "location"; /** - * The key to the url of a mission + * The key to the urls of a mission */ - static final String KEY_URL = "url"; + static final String KEY_SOURCE_URL = "url"; /** * The key to the name of a mission */ @@ -45,6 +45,8 @@ public class DownloadMissionSQLiteHelper extends SQLiteOpenHelper { static final String KEY_TIMESTAMP = "timestamp"; + static final String KEY_KIND = "kind"; + /** * The statement to create the table */ @@ -52,16 +54,28 @@ public class DownloadMissionSQLiteHelper extends SQLiteOpenHelper { "CREATE TABLE " + MISSIONS_TABLE_NAME + " (" + KEY_LOCATION + " TEXT NOT NULL, " + KEY_NAME + " TEXT NOT NULL, " + - KEY_URL + " 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 + "));"; - - DownloadMissionSQLiteHelper(Context context) { + 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. * @@ -70,34 +84,29 @@ public class DownloadMissionSQLiteHelper extends SQLiteOpenHelper { */ public static ContentValues getValuesOfMission(DownloadMission downloadMission) { ContentValues values = new ContentValues(); - values.put(KEY_URL, downloadMission.url); + 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; } - @Override - public void onCreate(SQLiteDatabase db) { - db.execSQL(MISSIONS_CREATE_TABLE); - } - - @Override - public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - // Currently nothing to do - } - - public static DownloadMission getMissionFromCursor(Cursor cursor) { + public static FinishedMission getMissionFromCursor(Cursor cursor) { if (cursor == null) throw new NullPointerException("cursor is null"); - int pos; - String name = cursor.getString(cursor.getColumnIndexOrThrow(KEY_NAME)); - String location = cursor.getString(cursor.getColumnIndexOrThrow(KEY_LOCATION)); - String url = cursor.getString(cursor.getColumnIndexOrThrow(KEY_URL)); - DownloadMission mission = new DownloadMission(name, url, location); - mission.done = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE)); + + 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.finished = true; + mission.kind = kind.charAt(0); + return mission; } } diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/SQLiteDownloadDataSource.java b/app/src/main/java/us/shandian/giga/get/sqlite/SQLiteDownloadDataSource.java deleted file mode 100644 index e7b4caeb8..000000000 --- a/app/src/main/java/us/shandian/giga/get/sqlite/SQLiteDownloadDataSource.java +++ /dev/null @@ -1,79 +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.DownloadDataSource; -import us.shandian.giga.get.DownloadMission; - -import static us.shandian.giga.get.sqlite.DownloadMissionSQLiteHelper.KEY_LOCATION; -import static us.shandian.giga.get.sqlite.DownloadMissionSQLiteHelper.KEY_NAME; -import static us.shandian.giga.get.sqlite.DownloadMissionSQLiteHelper.MISSIONS_TABLE_NAME; - - -/** - * Non-thread-safe implementation of {@link DownloadDataSource} - */ -public class SQLiteDownloadDataSource implements DownloadDataSource { - - private static final String TAG = "DownloadDataSourceImpl"; - private final DownloadMissionSQLiteHelper downloadMissionSQLiteHelper; - - public SQLiteDownloadDataSource(Context context) { - downloadMissionSQLiteHelper = new DownloadMissionSQLiteHelper(context); - } - - @Override - public List loadMissions() { - ArrayList result; - SQLiteDatabase database = downloadMissionSQLiteHelper.getReadableDatabase(); - Cursor cursor = database.query(MISSIONS_TABLE_NAME, null, null, - null, null, null, DownloadMissionSQLiteHelper.KEY_TIMESTAMP); - - int count = cursor.getCount(); - if (count == 0) return new ArrayList<>(); - result = new ArrayList<>(count); - while (cursor.moveToNext()) { - result.add(DownloadMissionSQLiteHelper.getMissionFromCursor(cursor)); - } - return result; - } - - @Override - public void addMission(DownloadMission downloadMission) { - if (downloadMission == null) throw new NullPointerException("downloadMission is null"); - SQLiteDatabase database = downloadMissionSQLiteHelper.getWritableDatabase(); - ContentValues values = DownloadMissionSQLiteHelper.getValuesOfMission(downloadMission); - database.insert(MISSIONS_TABLE_NAME, null, values); - } - - @Override - public void updateMission(DownloadMission downloadMission) { - if (downloadMission == null) throw new NullPointerException("downloadMission is null"); - SQLiteDatabase database = downloadMissionSQLiteHelper.getWritableDatabase(); - ContentValues values = DownloadMissionSQLiteHelper.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); - } - } - - @Override - public void deleteMission(DownloadMission downloadMission) { - if (downloadMission == null) throw new NullPointerException("downloadMission is null"); - SQLiteDatabase database = downloadMissionSQLiteHelper.getWritableDatabase(); - database.delete(MISSIONS_TABLE_NAME, - KEY_LOCATION + " = ? AND " + - KEY_NAME + " = ?", - new String[]{downloadMission.location, downloadMission.name}); - } -} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp4DashMuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp4DashMuxer.java new file mode 100644 index 000000000..738135253 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/Mp4DashMuxer.java @@ -0,0 +1,31 @@ +package us.shandian.giga.postprocessing; + +import org.schabi.newpipe.extractor.utils.Mp4DashWriter; +import org.schabi.newpipe.extractor.utils.io.SharpStream; + +import java.io.IOException; + +import us.shandian.giga.get.DownloadMission; + +/** + * @author kapodamy + */ +class Mp4DashMuxer extends Postprocessing { + + Mp4DashMuxer(DownloadMission mission) { + super(mission); + recommendedReserve = 2048 * 1024;// 2 MiB + worksOnSameFile = true; + } + + @Override + int process(SharpStream out, SharpStream... sources) throws IOException { + Mp4DashWriter muxer = new Mp4DashWriter(sources); + muxer.parseSources(); + muxer.selectTracks(0, 0); + muxer.build(out); + + return OK_RESULT; + } + +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java new file mode 100644 index 000000000..811ec70d7 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java @@ -0,0 +1,149 @@ +package us.shandian.giga.postprocessing; + +import android.os.Message; + +import org.schabi.newpipe.extractor.utils.io.SharpStream; + +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.CircularFile; +import us.shandian.giga.service.DownloadManagerService; + +public abstract class Postprocessing { + + static final byte OK_RESULT = DownloadMission.ERROR_NOTHING; + + public static final String ALGORITHM_TTML_CONVERTER = "ttml"; + public static final String ALGORITHM_MP4_DASH_MUXER = "mp4D"; + public static final String ALGORITHM_WEBM_MUXER = "webm"; + private static final String ALGORITHM_TEST_ALGO = "test"; + + public static Postprocessing getAlgorithm(String algorithmName, DownloadMission mission) { + if (null == algorithmName) { + throw new NullPointerException("algorithmName"); + } else switch (algorithmName) { + case ALGORITHM_TTML_CONVERTER: + return new TttmlConverter(mission); + case ALGORITHM_MP4_DASH_MUXER: + return new Mp4DashMuxer(mission); + case ALGORITHM_WEBM_MUXER: + return new WebMMuxer(mission); + case ALGORITHM_TEST_ALGO: + return new TestAlgo(mission); + /*case "example-algorithm": + return new ExampleAlgorithm(mission);*/ + default: + throw new RuntimeException("Unimplemented post-processing algorithm: " + algorithmName); + } + } + + /** + * Get a boolean value that indicate if the given algorithm work on the same + * file + */ + public boolean worksOnSameFile; + + /** + * Get the recommended space to reserve for the given algorithm. The amount + * is in bytes + */ + public int recommendedReserve; + + protected DownloadMission mission; + + Postprocessing(DownloadMission mission) { + this.mission = mission; + } + + public void run() throws IOException { + File file = mission.getDownloadedFile(); + CircularFile out = null; + 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(file, mission.offsets[i], mission.getDownloadedFile().length(), "rw"); + + int[] idx = {0}; + CircularFile.OffsetChecker checker = () -> { + while (idx[0] < sources.length) { + /* + * WARNING: never use rewind() in any chunk after any writing (especially on first chunks) + * or the CircularFile can lead to unexpected results + */ + if (sources[idx[0]].isDisposed() || sources[idx[0]].available() < 1) { + idx[0]++; + continue;// the selected source is not used anymore + } + + return sources[idx[0]].getFilePointer() - 1; + } + + return -1; + }; + + out = new CircularFile(file, 0, this::progressReport, checker); + + mission.done = 0; + int result = process(out, sources); + + if (result == OK_RESULT) { + long finalLength = out.finalizeFile(); + mission.done = finalLength; + mission.length = finalLength; + } else { + mission.errCode = DownloadMission.ERROR_UNKNOWN_EXCEPTION; + mission.errObject = new RuntimeException("post-processing algorithm returned " + result); + } + + if (result != OK_RESULT && worksOnSameFile) { + //noinspection ResultOfMethodCallIgnored + new File(mission.location, mission.name).delete(); + } + } finally { + for (SharpStream source : sources) { + if (source != null && !source.isDisposed()) { + source.dispose(); + } + } + if (out != null) { + out.dispose(); + } + } + } + + /** + * Abstract method to execute the pos-processing algorithm + * + * @param out output stream + * @param sources files to be processed + * @return a error code, 0 means the operation was successful + * @throws IOException if an I/O error occurs. + */ + abstract int process(SharpStream out, SharpStream... sources) throws IOException; + + String getArgumentAt(int index, String defaultValue) { + if (mission.postprocessingArgs == null || index >= mission.postprocessingArgs.length) { + return defaultValue; + } + + return mission.postprocessingArgs[index]; + } + + private void progressReport(long done) { + mission.done = done; + if (mission.length < mission.done) mission.length = mission.done; + + Message m = new Message(); + m.what = DownloadManagerService.MESSAGE_PROGRESS; + m.obj = mission; + + mission.mHandler.sendMessage(m); + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/TestAlgo.java b/app/src/main/java/us/shandian/giga/postprocessing/TestAlgo.java new file mode 100644 index 000000000..996f02d97 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/TestAlgo.java @@ -0,0 +1,54 @@ +package us.shandian.giga.postprocessing; + +import android.util.Log; + +import org.schabi.newpipe.extractor.utils.io.SharpStream; + +import java.io.IOException; +import java.util.Random; + +import us.shandian.giga.get.DownloadMission; + +/** + * Algorithm for testing proposes + */ +class TestAlgo extends Postprocessing { + + public TestAlgo(DownloadMission mission) { + super(mission); + + worksOnSameFile = true; + recommendedReserve = 4096 * 1024;// 4 KiB + } + + @Override + int process(SharpStream out, SharpStream... sources) throws IOException { + + int written = 0; + int size = 5 * 1024 * 1024;// 5 MiB + byte[] buffer = new byte[8 * 1024];//8 KiB + mission.length = size; + + Random rnd = new Random(); + + // only write random data + sources[0].dispose(); + + while (written < size) { + rnd.nextBytes(buffer); + + int read = Math.min(buffer.length, size - written); + out.write(buffer, 0, read); + + try { + Thread.sleep((int) (Math.random() * 10)); + } catch (InterruptedException e) { + return -1; + } + + written += read; + } + + return Postprocessing.OK_RESULT; + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/TttmlConverter.java b/app/src/main/java/us/shandian/giga/postprocessing/TttmlConverter.java new file mode 100644 index 000000000..d05440d70 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/TttmlConverter.java @@ -0,0 +1,49 @@ +package us.shandian.giga.postprocessing; + +import org.schabi.newpipe.extractor.utils.io.SharpStream; +import org.schabi.newpipe.extractor.utils.SubtitleConverter; + +import java.io.IOException; + +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.postprocessing.io.SharpInputStream; +/** + * @author kapodamy + */ +class TttmlConverter extends Postprocessing { + + TttmlConverter(DownloadMission mission) { + super(mission); + recommendedReserve = 0;// due how XmlPullParser works, the xml is fully loaded on the ram + worksOnSameFile = true; + } + + @Override + int process(SharpStream out, SharpStream... sources) throws IOException { + // check if the subtitle is already in srt and copy, this should never happen + String format = getArgumentAt(0, null); + + if (format == null || format.equals("ttml")) { + SubtitleConverter ttmlDumper = new SubtitleConverter(); + + int res = ttmlDumper.dumpTTML( + sources[0], + out, + getArgumentAt(1, "true").equals("true"), + getArgumentAt(2, "true").equals("true") + ); + + return res == 0 ? OK_RESULT : res; + } else if (format.equals("srt")) { + byte[] buffer = new byte[8 * 1024]; + int read; + while ((read = sources[0].read(buffer)) > 0) { + out.write(buffer, 0, read); + } + return OK_RESULT; + } + + throw new UnsupportedOperationException("Can't convert this subtitle, unimplemented format: " + format); + } + +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java new file mode 100644 index 000000000..d73fdc3b7 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java @@ -0,0 +1,44 @@ +package us.shandian.giga.postprocessing; + +import org.schabi.newpipe.extractor.utils.WebMReader.TrackKind; +import org.schabi.newpipe.extractor.utils.WebMReader.WebMTrack; +import org.schabi.newpipe.extractor.utils.WebMWriter; +import org.schabi.newpipe.extractor.utils.io.SharpStream; + +import java.io.IOException; + +import us.shandian.giga.get.DownloadMission; + +/** + * @author kapodamy + */ +class WebMMuxer extends Postprocessing { + + WebMMuxer(DownloadMission mission) { + super(mission); + recommendedReserve = (1024 + 512) * 1024;// 1.50 MiB + worksOnSameFile = true; + } + + @Override + int process(SharpStream out, SharpStream... sources) throws IOException { + WebMWriter muxer = new WebMWriter(sources); + muxer.parseSources(); + + // youtube uses a webm with a fake video track that acts as a "cover image" + WebMTrack[] tracks = muxer.getTracksFromSource(1); + int audioTrackIndex = 0; + for (int i = 0; i < tracks.length; i++) { + if (tracks[i].kind == TrackKind.Audio) { + audioTrackIndex = i; + break; + } + } + + muxer.selectTracks(0, audioTrackIndex); + muxer.build(out); + + return OK_RESULT; + } + +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/io/ChunkFileInputStream.java b/app/src/main/java/us/shandian/giga/postprocessing/io/ChunkFileInputStream.java new file mode 100644 index 000000000..f3e3ccdda --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/io/ChunkFileInputStream.java @@ -0,0 +1,153 @@ +package us.shandian.giga.postprocessing.io; + +import org.schabi.newpipe.extractor.utils.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 int 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) { + } + + @Override + public void flush() { + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/io/CircularFile.java b/app/src/main/java/us/shandian/giga/postprocessing/io/CircularFile.java new file mode 100644 index 000000000..3d4f2931f --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/io/CircularFile.java @@ -0,0 +1,345 @@ +package us.shandian.giga.postprocessing.io; + +import org.schabi.newpipe.extractor.utils.io.SharpStream; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.ArrayList; + +public class CircularFile extends SharpStream { + + private final static int AUX_BUFFER_SIZE = 1024 * 1024;// 1 MiB + private final static int AUX2_BUFFER_SIZE = 256 * 1024;// 256 KiB + private final static int QUEUE_BUFFER_SIZE = 8 * 1024;// 8 KiB + + private RandomAccessFile out; + private long position; + private long maxLengthKnown = -1; + + private ArrayList auxiliaryBuffers; + private OffsetChecker callback; + private ManagedBuffer queue; + private long startOffset; + private ProgressReport onProgress; + private long reportPosition; + + public CircularFile(File file, long offset, ProgressReport progressReport, OffsetChecker checker) throws IOException { + if (checker == null) { + throw new NullPointerException("checker is null"); + } + + try { + queue = new ManagedBuffer(QUEUE_BUFFER_SIZE); + out = new RandomAccessFile(file, "rw"); + out.seek(offset); + position = offset; + } catch (IOException err) { + try { + if (out != null) { + out.close(); + } + } catch (IOException e) { + // nothing to do + } + throw err; + } + + auxiliaryBuffers = new ArrayList<>(1); + callback = checker; + startOffset = offset; + reportPosition = offset; + onProgress = progressReport; + + } + + /** + * Close the file without flushing any buffer + */ + @Override + public void dispose() { + try { + auxiliaryBuffers = null; + if (out != null) { + out.close(); + out = null; + } + } catch (IOException err) { + // nothing to do + } + } + + /** + * Flush any buffer and close the output file. Use this method if the + * operation is successful + * + * @return the final length of the file + * @throws IOException if an I/O error occurs + */ + public long finalizeFile() throws IOException { + flushEverything(); + + if (maxLengthKnown > -1) { + position = maxLengthKnown; + } + if (position < out.length()) { + out.setLength(position); + } + + dispose(); + + return position; + } + + @Override + public void write(byte b) throws IOException { + write(new byte[]{b}, 0, 1); + } + + @Override + public void write(byte b[]) throws IOException { + write(b, 0, b.length); + } + + @Override + public void write(byte b[], int off, int len) throws IOException { + if (len == 0) { + return; + } + + long end = callback.check(); + int available; + + if (end == -1) { + available = Integer.MAX_VALUE; + } else { + if (end < startOffset) { + throw new IOException("The reported offset is invalid. reported offset is " + String.valueOf(end)); + } + available = (int) (end - position); + } + + while (available > 0 && auxiliaryBuffers.size() > 0) { + ManagedBuffer aux = auxiliaryBuffers.get(0); + + if ((queue.size + aux.size) > available) { + available = 0;// wait for next check + break; + } + + writeQueue(aux.buffer, 0, aux.size); + available -= aux.size; + aux.dereference(); + auxiliaryBuffers.remove(0); + } + + if (available > (len + queue.size)) { + writeQueue(b, off, len); + } else { + int i = auxiliaryBuffers.size() - 1; + while (len > 0) { + if (i < 0) { + // allocate a new auxiliary buffer + auxiliaryBuffers.add(new ManagedBuffer(AUX_BUFFER_SIZE)); + i++; + } + + ManagedBuffer aux = auxiliaryBuffers.get(i); + available = aux.available(); + + if (available < 1) { + // secondary auxiliary buffer + available = len; + aux = new ManagedBuffer(Math.max(len, AUX2_BUFFER_SIZE)); + auxiliaryBuffers.add(aux); + i++; + } else { + available = Math.min(len, available); + } + + aux.write(b, off, available); + + len -= available; + if (len < 1) { + break; + } + off += available; + } + } + } + + private void writeOutside(byte buffer[], int offset, int length) throws IOException { + out.write(buffer, offset, length); + position += length; + + if (onProgress != null && position > reportPosition) { + reportPosition = position + AUX2_BUFFER_SIZE;// notify every 256 KiB (approx) + onProgress.report(position); + } + } + + private void writeQueue(byte[] buffer, int offset, int length) throws IOException { + while (length > 0) { + if (queue.available() < length) { + flushQueue(); + + if (length >= queue.buffer.length) { + writeOutside(buffer, offset, length); + return; + } + } + + int size = Math.min(queue.available(), length); + queue.write(buffer, offset, size); + + offset += size; + length -= size; + } + } + + private void flushQueue() throws IOException { + writeOutside(queue.buffer, 0, queue.size); + queue.size = 0; + } + + private void flushEverything() throws IOException { + flushQueue(); + + if (auxiliaryBuffers.size() > 0) { + for (ManagedBuffer aux : auxiliaryBuffers) { + writeOutside(aux.buffer, 0, aux.size); + aux.dereference(); + } + auxiliaryBuffers.clear(); + } + } + + /** + * Flush any buffer directly to the file. Warning: use this method ONLY if + * all read dependencies are disposed + * + * @throws IOException if the dependencies are not disposed + */ + @Override + public void flush() throws IOException { + if (callback.check() != -1) { + throw new IOException("All read dependencies of this file must be disposed first"); + } + flushEverything(); + + // Save the current file length in case the method {@code rewind()} is called + if (position > maxLengthKnown) { + maxLengthKnown = position; + } + } + + @Override + public void rewind() throws IOException { + flush(); + out.seek(startOffset); + + if (onProgress != null) onProgress.report(-position); + + position = startOffset; + reportPosition = startOffset; + + } + + @Override + public long skip(long amount) throws IOException { + flush(); + position += amount; + + out.seek(position); + + return amount; + } + + @Override + public boolean isDisposed() { + return out == null; + } + + @Override + public boolean canRewind() { + return true; + } + + @Override + public boolean canWrite() { + return true; + } + + // + @Override + public boolean canRead() { + return false; + } + + @Override + public int read() { + throw new UnsupportedOperationException("write-only"); + } + + @Override + public int read(byte[] buffer) { + throw new UnsupportedOperationException("write-only"); + } + + @Override + public int read(byte[] buffer, int offset, int count) { + throw new UnsupportedOperationException("write-only"); + } + + @Override + public int available() { + throw new UnsupportedOperationException("write-only"); + } +// + + public interface OffsetChecker { + + /** + * Checks the amount of available space ahead + * + * @return absolute offset in the file where no more data SHOULD NOT be + * written. If the value is -1 the whole file will be used + */ + long check(); + } + + public interface ProgressReport { + + void report(long progress); + } + + class ManagedBuffer { + + byte[] buffer; + int size; + + ManagedBuffer(int length) { + buffer = new byte[length]; + } + + void dereference() { + buffer = null; + size = 0; + } + + protected int available() { + return buffer.length - size; + } + + private void write(byte[] b, int off, int len) { + System.arraycopy(b, off, buffer, size, len); + size += len; + } + + @Override + public String toString() { + return "holding: " + String.valueOf(size) + " length: " + String.valueOf(buffer.length) + " available: " + String.valueOf(available()); + } + + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/io/FileStream.java b/app/src/main/java/us/shandian/giga/postprocessing/io/FileStream.java new file mode 100644 index 000000000..dd3f8c697 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/io/FileStream.java @@ -0,0 +1,126 @@ +package us.shandian.giga.postprocessing.io; + +import org.schabi.newpipe.extractor.utils.io.SharpStream; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.channels.FileChannel; + +/** + * @author kapodamy + */ +public class FileStream extends SharpStream { + + public enum Mode { + Read, + ReadWrite + } + + public RandomAccessFile source; + private final Mode mode; + + public FileStream(String path, Mode mode) throws IOException { + String flags; + + if (mode == Mode.Read) { + flags = "r"; + } else { + flags = "rw"; + } + + this.mode = mode; + source = new RandomAccessFile(path, flags); + } + + @Override + public int read() throws IOException { + return source.read(); + } + + @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 { + return source.read(b, off, len); + } + + @Override + public long skip(long pos) throws IOException { + FileChannel fc = source.getChannel(); + fc.position(fc.position() + pos); + return pos; + } + + @Override + public int available() { + try { + return (int) (source.length() - source.getFilePointer()); + } catch (IOException ex) { + return 0; + } + } + + @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 { + source.getChannel().position(0); + } + + @Override + public boolean canRewind() { + return true; + } + + @Override + public boolean canRead() { + return mode == Mode.Read || mode == Mode.ReadWrite; + } + + @Override + public boolean canWrite() { + return mode == Mode.ReadWrite; + } + + @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 flush() { + } + + @Override + public void setLength(long length) throws IOException { + source.setLength(length); + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/io/SharpInputStream.java b/app/src/main/java/us/shandian/giga/postprocessing/io/SharpInputStream.java new file mode 100644 index 000000000..831afbfc2 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/io/SharpInputStream.java @@ -0,0 +1,59 @@ +/* + * 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.extractor.utils.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() { + return base.available(); + } + + @Override + public void close() { + base.dispose(); + } +} diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManager.java b/app/src/main/java/us/shandian/giga/service/DownloadManager.java new file mode 100644 index 000000000..97a0da523 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/service/DownloadManager.java @@ -0,0 +1,670 @@ +package us.shandian.giga.service; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Handler; +import android.preference.PreferenceManager; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.util.DiffUtil; +import android.util.Log; +import android.widget.Toast; + +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.util.Utility; + +import static org.schabi.newpipe.BuildConfig.DEBUG; + +public class DownloadManager { + private static final String TAG = DownloadManager.class.getSimpleName(); + + enum NetworkState {Unavailable, WifiOperating, MobileOperating, OtherOperating} + + public final static int SPECIAL_NOTHING = 0; + public final static int SPECIAL_PENDING = 1; + public final static int SPECIAL_FINISHED = 2; + + private final DownloadDataSource mDownloadDataSource; + + private final ArrayList mMissionsPending = new ArrayList<>(); + private final ArrayList mMissionsFinished; + + private final Handler mHandler; + private final File mPendingMissionsDir; + + private NetworkState mLastNetworkStatus = NetworkState.Unavailable; + + private SharedPreferences mPrefs; + private String mPrefMaxRetry; + private String mPrefCrossNetwork; + + /** + * Create a new instance + * + * @param context Context for the data source for finished downloads + * @param handler Thread required for Messaging + */ + DownloadManager(@NonNull Context context, Handler handler) { + if (DEBUG) { + Log.d(TAG, "new DownloadManager instance. 0x" + Integer.toHexString(this.hashCode())); + } + + mDownloadDataSource = new DownloadDataSource(context); + mHandler = handler; + mMissionsFinished = loadFinishedMissions(); + mPendingMissionsDir = getPendingDir(context); + mPrefs = PreferenceManager.getDefaultSharedPreferences(context); + mPrefMaxRetry = context.getString(R.string.downloads_max_retry); + mPrefCrossNetwork = context.getString(R.string.cross_network_downloads); + + if (!Utility.mkdir(mPendingMissionsDir, false)) { + throw new RuntimeException("failed to create pending_downloads in data directory"); + } + + loadPendingMissions(); + } + + private static File getPendingDir(@NonNull Context context) { + //File dir = new File(ContextCompat.getDataDir(context), "pending_downloads"); + File dir = context.getExternalFilesDir("pending_downloads"); + + if (dir == null) { + // One of the following paths are not accessible ¿unmounted internal memory? + // /storage/emulated/0/Android/data/org.schabi.newpipe[.debug]/pending_downloads + // /sdcard/Android/data/org.schabi.newpipe[.debug]/pending_downloads + Log.w(TAG, "path to pending downloads are not accessible"); + } + + return dir; + } + + /** + * Loads finished missions from the data source + */ + private ArrayList loadFinishedMissions() { + ArrayList finishedMissions = mDownloadDataSource.loadFinishedMissions(); + + // missions always is stored by creation order, simply reverse the list + ArrayList result = new ArrayList<>(finishedMissions.size()); + 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; + } + + result.add(mission); + } + + return result; + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + private void loadPendingMissions() { + File[] subs = mPendingMissionsDir.listFiles(); + + if (subs == null) { + Log.e(TAG, "listFiles() returned null"); + return; + } + if (subs.length < 1) { + return; + } + if (DEBUG) { + Log.d(TAG, "Loading pending downloads from directory: " + mPendingMissionsDir.getAbsolutePath()); + } + + for (File sub : subs) { + if (sub.isFile()) { + DownloadMission mis = Utility.readFromFile(sub); + + if (mis == null) { + sub.delete(); + } else { + if (mis.isFinished()) { + sub.delete(); + continue; + } + + File dl = mis.getDownloadedFile(); + boolean exists = dl.exists(); + + if (mis.postprocessingRunning && mis.postprocessingThis) { + // Incomplete post-processing results in a corrupted download file + // because the selected algorithm works on the same file to save space. + if (!dl.delete()) { + Log.w(TAG, "Unable to delete incomplete download file: " + sub.getPath()); + } + exists = true; + mis.postprocessingRunning = false; + mis.errCode = DownloadMission.ERROR_POSTPROCESSING_FAILED; + mis.errObject = new RuntimeException("post-processing stopped unexpectedly"); + } + + if (exists && !dl.isFile()) { + // probably a folder, this should never happens + if (!sub.delete()) { + Log.w(TAG, "Unable to delete serialized file: " + sub.getPath()); + } + continue; + } + + if (!exists) { + // 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.maxRetry = mis.maxRetry; + mis = m; + } + + mis.running = false; + mis.recovered = exists; + mis.metadata = sub; + mis.mHandler = mHandler; + + mMissionsPending.add(mis); + } + } + } + + if (mMissionsPending.size() > 1) { + Collections.sort(mMissionsPending, (mission1, mission2) -> Long.compare(mission1.timestamp, mission2.timestamp)); + } + } + + /** + * 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 postprocessingName the name of the required post-processing algorithm, or {@code null} to ignore. + * @param source source url of the resource + * @param postProcessingArgs the arguments for the post-processing algorithm. + */ + void startMission(String[] urls, String location, String name, char kind, int threads, String source, + String postprocessingName, String[] postProcessingArgs) { + synchronized (this) { + // check for existing pending download + DownloadMission pendingMission = getPendingMission(location, name); + if (pendingMission != null) { + // 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 { + // check for existing finished download + int index = getFinishedMissionIndex(location, name); + if (index >= 0) mDownloadDataSource.deleteMission(mMissionsFinished.remove(index)); + } + + DownloadMission mission = new DownloadMission(urls, name, location, kind, postprocessingName, postProcessingArgs); + mission.timestamp = System.currentTimeMillis(); + mission.threadCount = threads; + mission.source = source; + mission.mHandler = mHandler; + mission.maxRetry = mPrefs.getInt(mPrefMaxRetry, 3); + + while (true) { + mission.metadata = new File(mPendingMissionsDir, String.valueOf(mission.timestamp)); + if (!mission.metadata.isFile() && !mission.metadata.exists()) { + try { + if (!mission.metadata.createNewFile()) + throw new RuntimeException("Cant create download metadata file"); + } catch (IOException e) { + throw new RuntimeException(e); + } + break; + } + mission.timestamp = System.currentTimeMillis(); + } + + mMissionsPending.add(mission); + + // Before starting, save the state in case the internet connection is not available + Utility.writeToFile(mission.metadata, mission); + + if (canDownloadInCurrentNetwork() && (getRunningMissionsCount() < 1)) { + mission.start(); + mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_RUNNING); + } + } + } + + + public void resumeMission(DownloadMission mission) { + if (!mission.running) { + mission.start(); + mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_RUNNING); + } + } + + public void pauseMission(DownloadMission mission) { + if (mission.running) { + mission.pause(); + mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PAUSED); + } + } + + public void deleteMission(Mission mission) { + synchronized (this) { + if (mission instanceof DownloadMission) { + mMissionsPending.remove(mission); + } else if (mission instanceof FinishedMission) { + mMissionsFinished.remove(mission); + mDownloadDataSource.deleteMission(mission); + } + + mission.delete(); + } + } + + + /** + * Get a pending mission by its location and name + * + * @param location the location + * @param name the name + * @return the mission or null if no such mission exists + */ + @Nullable + private DownloadMission getPendingMission(String location, String name) { + for (DownloadMission mission : mMissionsPending) { + if (location.equalsIgnoreCase(mission.location) && name.equalsIgnoreCase(mission.name)) { + return mission; + } + } + return null; + } + + /** + * Get a finished mission by its location and name + * + * @param location the location + * @param name the name + * @return the mission index or -1 if no such mission exists + */ + private int getFinishedMissionIndex(String location, String name) { + for (int i = 0; i < mMissionsFinished.size(); i++) { + FinishedMission mission = mMissionsFinished.get(i); + if (location.equalsIgnoreCase(mission.location) && name.equalsIgnoreCase(mission.name)) { + return i; + } + } + + return -1; + } + + public Mission getAnyMission(String location, String name) { + synchronized (this) { + Mission mission = getPendingMission(location, name); + if (mission != null) return mission; + + int idx = getFinishedMissionIndex(location, name); + if (idx >= 0) return mMissionsFinished.get(idx); + } + + return null; + } + + int getRunningMissionsCount() { + int count = 0; + synchronized (this) { + for (DownloadMission mission : mMissionsPending) { + if (mission.running && mission.errCode != DownloadMission.ERROR_POSTPROCESSING_FAILED && !mission.isFinished()) + count++; + } + } + + return count; + } + + void pauseAllMissions() { + synchronized (this) { + for (DownloadMission mission : mMissionsPending) mission.pause(); + } + } + + + /** + * 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 + * + * @param mission the desired mission + * @return true if exits pending missions running, otherwise, false + */ + boolean setFinished(DownloadMission mission) { + synchronized (this) { + int i = mMissionsPending.indexOf(mission); + mMissionsPending.remove(i); + + mMissionsFinished.add(0, new FinishedMission(mission)); + mDownloadDataSource.addMission(mission); + + if (mMissionsPending.size() < 1) return false; + + i = getRunningMissionsCount(); + if (i > 0) return true; + + // before returning, check the queue + if (!canDownloadInCurrentNetwork()) return false; + + for (DownloadMission mission1 : mMissionsPending) { + if (!mission1.running && mission.errCode != DownloadMission.ERROR_POSTPROCESSING_FAILED && mission1.enqueued) { + resumeMission(mMissionsPending.get(i)); + return true; + } + } + + return false; + } + } + + public MissionIterator getIterator() { + return new MissionIterator(); + } + + /** + * Forget all finished downloads, but, doesn't delete any file + */ + public void forgetFinishedDownloads() { + synchronized (this) { + for (FinishedMission mission : mMissionsFinished) { + mDownloadDataSource.deleteMission(mission); + } + mMissionsFinished.clear(); + } + } + + private boolean canDownloadInCurrentNetwork() { + if (mLastNetworkStatus == NetworkState.Unavailable) return false; + return !(mPrefs.getBoolean(mPrefCrossNetwork, false) && mLastNetworkStatus == NetworkState.MobileOperating); + } + + void handleConnectivityChange(NetworkState currentStatus) { + if (currentStatus == mLastNetworkStatus) return; + + mLastNetworkStatus = currentStatus; + boolean pauseOnMobile = mPrefs.getBoolean(mPrefCrossNetwork, false); + + if (currentStatus == NetworkState.Unavailable) { + return; + } else if (currentStatus != NetworkState.MobileOperating || !pauseOnMobile) { + return; + } + + boolean flag = false; + synchronized (this) { + for (DownloadMission mission : mMissionsPending) { + if (mission.running && mission.isFinished() && !mission.postprocessingRunning) { + flag = true; + mission.pause(); + } + } + } + + if (flag) mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PAUSED); + } + + /** + * Fast check for pending downloads. If exists, the user will be notified + * TODO: call this method in somewhere + * + * @param context the application context + */ + public static void notifyUserPendingDownloads(Context context) { + int pending = getPendingDir(context).list().length; + if (pending < 1) return; + + Toast.makeText(context, context.getString( + R.string.msg_pending_downloads, + String.valueOf(pending) + ), Toast.LENGTH_LONG).show(); + } + + void checkForRunningMission(String location, String name, DownloadManagerService.DMChecker check) { + boolean listed; + boolean finished = false; + + synchronized (this) { + DownloadMission mission = getPendingMission(location, name); + if (mission != null) { + listed = true; + } else { + listed = getFinishedMissionIndex(location, name) >= 0; + finished = listed; + } + } + + check.callback(listed, finished); + } + + public class MissionIterator extends DiffUtil.Callback { + final Object FINISHED = new Object(); + final Object PENDING = new Object(); + + ArrayList snapshot; + ArrayList current; + ArrayList hidden; + + private MissionIterator() { + hidden = new ArrayList<>(2); + current = null; + snapshot = getSpecialItems(); + } + + private ArrayList getSpecialItems() { + synchronized (DownloadManager.this) { + ArrayList pending = new ArrayList<>(mMissionsPending); + ArrayList finished = new ArrayList<>(mMissionsFinished); + ArrayList remove = new ArrayList<>(hidden); + + // hide missions (if required) + Iterator iterator = remove.iterator(); + while (iterator.hasNext()) { + Mission mission = iterator.next(); + if (pending.remove(mission) || finished.remove(mission)) iterator.remove(); + } + + int fakeTotal = pending.size(); + if (fakeTotal > 0) fakeTotal++; + + fakeTotal += finished.size(); + if (finished.size() > 0) fakeTotal++; + + ArrayList list = new ArrayList<>(fakeTotal); + if (pending.size() > 0) { + list.add(PENDING); + list.addAll(pending); + } + if (finished.size() > 0) { + list.add(FINISHED); + list.addAll(finished); + } + + + return list; + } + } + + public MissionItem getItem(int position) { + Object object = snapshot.get(position); + + if (object == PENDING) return new MissionItem(SPECIAL_PENDING); + if (object == FINISHED) return new MissionItem(SPECIAL_FINISHED); + + return new MissionItem(SPECIAL_NOTHING, (Mission) object); + } + + public int getSpecialAtItem(int position) { + Object object = snapshot.get(position); + + if (object == PENDING) return SPECIAL_PENDING; + if (object == FINISHED) return SPECIAL_FINISHED; + + return SPECIAL_NOTHING; + } + + public MissionItem getItemUnsafe(int position) { + synchronized (DownloadManager.this) { + int count = mMissionsPending.size(); + int count2 = mMissionsFinished.size(); + + if (count > 0) { + position--; + if (position == -1) + return new MissionItem(SPECIAL_PENDING); + else if (position < count) + return new MissionItem(SPECIAL_NOTHING, mMissionsPending.get(position)); + else if (position == count && count2 > 0) + return new MissionItem(SPECIAL_FINISHED); + else + position -= count; + } else { + if (count2 > 0 && position == 0) { + return new MissionItem(SPECIAL_FINISHED); + } + } + + position--; + + if (count2 < 1) { + throw new RuntimeException( + String.format("Out of range. pending_count=%s finished_count=%s position=%s", count, count2, position) + ); + } + + return new MissionItem(SPECIAL_NOTHING, mMissionsFinished.get(position)); + } + } + + + public void start() { + current = getSpecialItems(); + } + + public void end() { + snapshot = current; + current = null; + } + + public void hide(Mission mission) { + hidden.add(mission); + } + + public void unHide(Mission mission) { + hidden.remove(mission); + } + + + @Override + public int getOldListSize() { + return snapshot.size(); + } + + @Override + public int getNewListSize() { + return current.size(); + } + + @Override + public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { + return snapshot.get(oldItemPosition) == current.get(newItemPosition); + } + + @Override + public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { + return areItemsTheSame(oldItemPosition, newItemPosition); + } + } + + public class MissionItem { + public int special; + public Mission mission; + + MissionItem(int s, Mission m) { + special = s; + mission = m; + } + + MissionItem(int s) { + this(s, null); + } + } + +} 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 ff410a79a..797fb1c1d 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -2,17 +2,25 @@ package us.shandian.giga.service; import android.Manifest; import android.app.Notification; +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.graphics.Bitmap; import android.graphics.BitmapFactory; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; import android.net.Uri; import android.os.Binder; +import android.os.Build; import android.os.Handler; -import android.os.HandlerThread; import android.os.IBinder; +import android.os.Looper; import android.os.Message; import android.support.v4.app.NotificationCompat.Builder; import android.support.v4.content.PermissionChecker; @@ -21,48 +29,61 @@ import android.widget.Toast; import org.schabi.newpipe.R; import org.schabi.newpipe.download.DownloadActivity; -import org.schabi.newpipe.settings.NewPipeSettings; +import java.io.File; import java.util.ArrayList; +import java.util.Iterator; -import us.shandian.giga.get.DownloadDataSource; -import us.shandian.giga.get.DownloadManager; -import us.shandian.giga.get.DownloadManagerImpl; import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.get.sqlite.SQLiteDownloadDataSource; +import us.shandian.giga.service.DownloadManager.NetworkState; +import static org.schabi.newpipe.BuildConfig.APPLICATION_ID; import static org.schabi.newpipe.BuildConfig.DEBUG; public class DownloadManagerService extends Service { private static final String TAG = DownloadManagerService.class.getSimpleName(); - /** - * Message code of update messages stored as {@link Message#what}. - */ - private static final int UPDATE_MESSAGE = 0; - private static final int NOTIFICATION_ID = 1000; + public static final int MESSAGE_RUNNING = 1; + public static final int MESSAGE_PAUSED = 2; + public static final int MESSAGE_FINISHED = 3; + public static final int MESSAGE_PROGRESS = 4; + public static final int MESSAGE_ERROR = 5; + + private static final int FOREGROUND_NOTIFICATION_ID = 1000; + 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_IS_AUDIO = "DownloadManagerService.extra.is_audio"; + 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 ACTION_RESET_DOWNLOAD_COUNT = APPLICATION_ID + ".reset_download_count"; private DMBinder mBinder; private DownloadManager mManager; private Notification mNotification; private Handler mHandler; - private long mLastTimeStamp = System.currentTimeMillis(); - private DownloadDataSource mDataSource; + private int downloadDoneCount = 0; + private Builder downloadDoneNotification = null; + private StringBuilder downloadDoneList = null; + NotificationManager notificationManager = null; + private boolean mForeground = false; + private final ArrayList mEchoObservers = new ArrayList<>(1); + private BroadcastReceiver mNetworkStateListener; - private final MissionListener missionListener = new MissionListener(); - - - private void notifyMediaScanner(DownloadMission mission) { - Uri uri = Uri.parse("file://" + mission.location + "/" + mission.name); - // notify media scanner on downloaded media file ... - sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri)); + /** + * notify media scanner on downloaded media file ... + * + * @param file the downloaded file + */ + private void notifyMediaScanner(File file) { + sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(file))); } @Override @@ -74,19 +95,14 @@ public class DownloadManagerService extends Service { } mBinder = new DMBinder(); - if (mDataSource == null) { - mDataSource = new SQLiteDownloadDataSource(this); - } - if (mManager == null) { - ArrayList paths = new ArrayList<>(2); - paths.add(NewPipeSettings.getVideoDownloadPath(this)); - paths.add(NewPipeSettings.getAudioDownloadPath(this)); - mManager = new DownloadManagerImpl(paths, mDataSource, this); - if (DEBUG) { - Log.d(TAG, "mManager == null"); - Log.d(TAG, "Download directory: " + paths); + mHandler = new Handler(Looper.myLooper()) { + @Override + public void handleMessage(Message msg) { + DownloadManagerService.this.handleMessage(msg); } - } + }; + + mManager = new DownloadManager(this, mHandler); Intent openDownloadListIntent = new Intent(this, DownloadActivity.class) .setAction(Intent.ACTION_MAIN); @@ -105,56 +121,49 @@ public class DownloadManagerService extends Service { .setContentText(getString(R.string.msg_running_detail)); mNotification = builder.build(); + notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - HandlerThread thread = new HandlerThread("ServiceMessenger"); - thread.start(); - - mHandler = new Handler(thread.getLooper()) { + mNetworkStateListener = new BroadcastReceiver() { @Override - public void handleMessage(Message msg) { - switch (msg.what) { - case UPDATE_MESSAGE: { - int runningCount = 0; - - for (int i = 0; i < mManager.getCount(); i++) { - if (mManager.getMission(i).running) { - runningCount++; - } - } - updateState(runningCount); - break; - } + public void onReceive(Context context, Intent intent) { + if (intent.getBooleanExtra(ConnectivityManager.EXTRA_NO_CONNECTIVITY, false)) { + handleConnectivityChange(null); + return; } + handleConnectivityChange(intent.getParcelableExtra(ConnectivityManager.EXTRA_NETWORK_INFO)); } }; - - } - - private void startMissionAsync(final String url, final String location, final String name, - final boolean isAudio, final int threads) { - mHandler.post(new Runnable() { - @Override - public void run() { - int missionId = mManager.startMission(url, location, name, isAudio, threads); - mBinder.onMissionAdded(mManager.getMission(missionId)); - } - }); + registerReceiver(mNetworkStateListener, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); } @Override public int onStartCommand(Intent intent, int flags, int startId) { if (DEBUG) { + if (intent == null) { + Log.d(TAG, "Restarting"); + return START_NOT_STICKY; + } Log.d(TAG, "Starting"); } Log.i(TAG, "Got intent: " + intent); String action = intent.getAction(); - if (action != null && action.equals(Intent.ACTION_RUN)) { - String name = intent.getStringExtra(EXTRA_NAME); - String location = intent.getStringExtra(EXTRA_LOCATION); - int threads = intent.getIntExtra(EXTRA_THREADS, 1); - boolean isAudio = intent.getBooleanExtra(EXTRA_IS_AUDIO, false); - String url = intent.getDataString(); - startMissionAsync(url, location, name, isAudio, threads); + 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); + + mHandler.post(() -> mManager.startMission(urls, location, name, kind, threads, source, psName, psArgs)); + + } else if (downloadDoneNotification != null && action.equals(ACTION_RESET_DOWNLOAD_COUNT)) { + downloadDoneCount = 0; + downloadDoneList.setLength(0); + } } return START_NOT_STICKY; } @@ -167,11 +176,17 @@ public class DownloadManagerService extends Service { Log.d(TAG, "Destroying"); } - for (int i = 0; i < mManager.getCount(); i++) { - mManager.pauseMission(i); + stopForeground(true); + + if (notificationManager != null && downloadDoneNotification != null) { + downloadDoneNotification.setDeleteIntent(null);// prevent NewPipe running when is killed, cleared from recent, etc + notificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification.build()); } - stopForeground(true); + unregisterReceiver(mNetworkStateListener); + + mManager.pauseAllMissions(); + } @Override @@ -192,53 +207,171 @@ public class DownloadManagerService extends Service { return mBinder; } - private void postUpdateMessage() { - mHandler.sendEmptyMessage(UPDATE_MESSAGE); - } + public void handleMessage(Message msg) { + switch (msg.what) { + case MESSAGE_FINISHED: + DownloadMission mission = (DownloadMission) msg.obj; + notifyMediaScanner(mission.getDownloadedFile()); + notifyFinishedDownload(mission.name); + updateForegroundState(mManager.setFinished(mission)); + break; + case MESSAGE_RUNNING: + case MESSAGE_PROGRESS: + updateForegroundState(true); + break; + case MESSAGE_PAUSED: + case MESSAGE_ERROR: + updateForegroundState(mManager.getRunningMissionsCount() > 0); + break; + } - private void updateState(int runningCount) { - if (runningCount == 0) { - stopForeground(true); - } else { - startForeground(NOTIFICATION_ID, mNotification); + + synchronized (mEchoObservers) { + Iterator iterator = mEchoObservers.iterator(); + while (iterator.hasNext()) { + Handler handler = iterator.next(); + if (handler.getLooper().getThread().isAlive()) { + Message echo = new Message(); + echo.what = msg.what; + echo.obj = msg.obj; + handler.sendMessage(echo); + } else { + iterator.remove();// ¿missing call to removeMissionEventListener()? + } + } } } - public static void startMission(Context context, String url, String location, String name, boolean isAudio, int threads) { + private void handleConnectivityChange(NetworkInfo info) { + NetworkState status; + + if (info == null) { + status = NetworkState.Unavailable; + Log.i(TAG, "actual connectivity status is unavailable"); + } else if (!info.isAvailable() || !info.isConnected()) { + status = NetworkState.Unavailable; + Log.i(TAG, "actual connectivity status is not available and not connected"); + } else { + int type = info.getType(); + if (type == ConnectivityManager.TYPE_MOBILE || type == ConnectivityManager.TYPE_MOBILE_DUN) { + status = NetworkState.MobileOperating; + } else if (type == ConnectivityManager.TYPE_WIFI) { + status = NetworkState.WifiOperating; + } else if (type == ConnectivityManager.TYPE_WIMAX || + type == ConnectivityManager.TYPE_ETHERNET || + type == ConnectivityManager.TYPE_BLUETOOTH) { + status = NetworkState.OtherOperating; + } else { + status = NetworkState.Unavailable; + } + Log.i(TAG, "actual connectivity status is " + status.name()); + } + + if (mManager == null) return;// avoid race-conditions while the service is starting + mManager.handleConnectivityChange(status); + } + + public void updateForegroundState(boolean state) { + if (state == mForeground) return; + + if (state) { + startForeground(FOREGROUND_NOTIFICATION_ID, mNotification); + } else { + stopForeground(true); + } + + mForeground = state; + } + + public static void startMission(Context context, String urls[], String location, String name, + char kind, int threads, String source, String postprocessingName, + String[] postprocessingArgs) { Intent intent = new Intent(context, DownloadManagerService.class); intent.setAction(Intent.ACTION_RUN); - intent.setData(Uri.parse(url)); + intent.putExtra(EXTRA_URLS, urls); intent.putExtra(EXTRA_NAME, name); intent.putExtra(EXTRA_LOCATION, location); - intent.putExtra(EXTRA_IS_AUDIO, isAudio); + intent.putExtra(EXTRA_KIND, kind); intent.putExtra(EXTRA_THREADS, threads); + intent.putExtra(EXTRA_SOURCE, source); + intent.putExtra(EXTRA_POSTPROCESSING_NAME, postprocessingName); + intent.putExtra(EXTRA_POSTPROCESSING_ARGS, postprocessingArgs); context.startService(intent); } + public static void checkForRunningMission(Context context, String location, String name, DMChecker check) { + Intent intent = new Intent(); + intent.setClass(context, DownloadManagerService.class); + context.bindService(intent, new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName cname, IBinder service) { + try { + ((DMBinder) service).getDownloadManager().checkForRunningMission(location, name, check); + } catch (Exception err) { + Log.w(TAG, "checkForRunningMission() callback is defective", err); + } - private class MissionListener implements DownloadMission.MissionListener { - @Override - public void onProgressUpdate(DownloadMission downloadMission, long done, long total) { - long now = System.currentTimeMillis(); - long delta = now - mLastTimeStamp; - if (delta > 2000) { - postUpdateMessage(); - mLastTimeStamp = now; + // TODO: find a efficient way to unbind the service. This destroy the service due idle, but is started again when the user start a download. + context.unbindService(this); } - } - @Override - public void onFinish(DownloadMission downloadMission) { - postUpdateMessage(); - notifyMediaScanner(downloadMission); - } - - @Override - public void onError(DownloadMission downloadMission, int errCode) { - postUpdateMessage(); - } + @Override + public void onServiceDisconnected(ComponentName name) { + } + }, Context.BIND_AUTO_CREATE); } + public void notifyFinishedDownload(String name) { + if (notificationManager == null) { + return; + } + + if (downloadDoneNotification == null) { + downloadDoneList = new StringBuilder(name.length()); + + Bitmap icon = BitmapFactory.decodeResource(this.getResources(), android.R.drawable.stat_sys_download_done); + downloadDoneNotification = new Builder(this, getString(R.string.notification_channel_id)) + .setAutoCancel(true) + .setLargeIcon(icon) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setDeleteIntent(PendingIntent.getService(this, (int) System.currentTimeMillis(), + new Intent(this, DownloadManagerService.class) + .setAction(ACTION_RESET_DOWNLOAD_COUNT) + , PendingIntent.FLAG_UPDATE_CURRENT)) + .setContentIntent(mNotification.contentIntent); + } + + if (downloadDoneCount < 1) { + downloadDoneList.append(name); + + if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + downloadDoneNotification.setContentTitle(getString(R.string.app_name)); + downloadDoneNotification.setContentText(getString(R.string.download_finished, name)); + } else { + downloadDoneNotification.setContentTitle(getString(R.string.download_finished, name)); + downloadDoneNotification.setContentText(null); + } + } else { + downloadDoneList.append(", "); + downloadDoneList.append(name); + + downloadDoneNotification.setContentTitle(getString(R.string.download_finished_more, String.valueOf(downloadDoneCount + 1))); + downloadDoneNotification.setContentText(downloadDoneList.toString()); + } + + notificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification.build()); + downloadDoneCount++; + } + + private void manageObservers(Handler handler, boolean add) { + synchronized (mEchoObservers) { + if (add) { + mEchoObservers.add(handler); + } else { + mEchoObservers.remove(handler); + } + } + } // Wrapper of DownloadManager public class DMBinder extends Binder { @@ -246,14 +379,24 @@ public class DownloadManagerService extends Service { return mManager; } - public void onMissionAdded(DownloadMission mission) { - mission.addListener(missionListener); - postUpdateMessage(); + public void addMissionEventListener(Handler handler) { + manageObservers(handler, true); } - public void onMissionRemoved(DownloadMission mission) { - mission.removeListener(missionListener); - postUpdateMessage(); + public void removeMissionEventListener(Handler handler) { + manageObservers(handler, false); + } + + public void resetFinishedDownloadCount() { + if (notificationManager == null || downloadDoneNotification == null) return; + notificationManager.cancel(DOWNLOADS_NOTIFICATION_ID); + downloadDoneList.setLength(0); + downloadDoneCount = 0; } } + + public interface DMChecker { + void callback(boolean listed, boolean 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 d5555c2be..c40c215b8 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 @@ -1,5 +1,6 @@ package us.shandian.giga.ui.adapter; +import android.annotation.SuppressLint; import android.app.Activity; import android.app.ProgressDialog; import android.content.Context; @@ -7,12 +8,20 @@ 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; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.content.FileProvider; import android.support.v4.view.ViewCompat; +import android.support.v7.app.AlertDialog; +import android.support.v7.util.DiffUtil; import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.RecyclerView.ViewHolder; import android.util.Log; +import android.util.SparseArray; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; @@ -24,28 +33,28 @@ import android.widget.PopupMenu; import android.widget.TextView; import android.widget.Toast; +import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.R; -import org.schabi.newpipe.download.DeleteDownloadManager; +import org.schabi.newpipe.util.NavigationHelper; import java.io.File; import java.lang.ref.WeakReference; import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; import java.util.Locale; -import java.util.Map; -import us.shandian.giga.get.DownloadManager; import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.get.FinishedMission; +import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; +import us.shandian.giga.ui.common.Deleter; import us.shandian.giga.ui.common.ProgressDrawable; import us.shandian.giga.util.Utility; import static android.content.Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION; -public class MissionAdapter extends RecyclerView.Adapter { - private static final Map ALGORITHMS = new HashMap<>(); +public class MissionAdapter extends RecyclerView.Adapter { + private static final SparseArray ALGORITHMS = new SparseArray<>(); private static final String TAG = "MissionAdapter"; static { @@ -53,109 +62,131 @@ public class MissionAdapter extends RecyclerView.Adapter mItemList; - private DownloadManagerService.DMBinder mBinder; + private Deleter mDeleter; private int mLayout; + private DownloadManager.MissionIterator mIterator; + private Handler mHandler; + private ArrayList mPendingDownloadsItems = new ArrayList<>(); + private MenuItem mClear; + private View mEmptyMessage; - public MissionAdapter(Activity context, DownloadManagerService.DMBinder binder, DownloadManager downloadManager, DeleteDownloadManager deleteDownloadManager, boolean isLinear) { + public MissionAdapter(Context context, DownloadManager downloadManager, MenuItem clearButton, View emptyMessage) { mContext = context; mDownloadManager = downloadManager; - mDeleteDownloadManager = deleteDownloadManager; - mBinder = binder; + mDeleter = null; mInflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); - mLayout = isLinear ? R.layout.mission_item_linear : R.layout.mission_item; + mLayout = R.layout.mission_item; - mItemList = new ArrayList<>(); - updateItemList(); + mHandler = new Handler(Looper.myLooper()) { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case DownloadManagerService.MESSAGE_PROGRESS: + case DownloadManagerService.MESSAGE_ERROR: + case DownloadManagerService.MESSAGE_FINISHED: + onServiceMessage(msg); + } + } + }; + + mClear = clearButton; + mEmptyMessage = emptyMessage; + + mIterator = downloadManager.getIterator(); + + checkEmptyMessageVisibility(); } - public void updateItemList() { - mItemList.clear(); + @Override + @NonNull + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (viewType) { + case DownloadManager.SPECIAL_PENDING: + case DownloadManager.SPECIAL_FINISHED: + return new ViewHolderHeader(mInflater.inflate(R.layout.missions_header, parent, false)); + } - for (int i = 0; i < mDownloadManager.getCount(); i++) { - DownloadMission mission = mDownloadManager.getMission(i); - if (!mDeleteDownloadManager.contains(mission)) { - mItemList.add(mDownloadManager.getMission(i)); + return new ViewHolderItem(mInflater.inflate(mLayout, parent, false)); + } + + @Override + public void onViewRecycled(@NonNull ViewHolder view) { + super.onViewRecycled(view); + + if (view instanceof ViewHolderHeader) return; + ViewHolderItem h = (ViewHolderItem) view; + + if (h.item.mission instanceof DownloadMission) mPendingDownloadsItems.remove(h); + + h.popupMenu.dismiss(); + h.item = null; + h.lastTimeStamp = -1; + h.lastDone = -1; + h.lastCurrent = -1; + h.state = 0; + } + + @Override + @SuppressLint("SetTextI18n") + public void onBindViewHolder(@NonNull ViewHolder view, @SuppressLint("RecyclerView") int pos) { + DownloadManager.MissionItem item = mIterator.getItem(pos); + + if (view instanceof ViewHolderHeader) { + if (item.special == DownloadManager.SPECIAL_NOTHING) return; + int str; + if (item.special == DownloadManager.SPECIAL_PENDING) { + str = R.string.missions_header_pending; + } else { + str = R.string.missions_header_finished; + mClear.setVisible(true); } + + ((ViewHolderHeader) view).header.setText(str); + return; + } + + ViewHolderItem h = (ViewHolderItem) view; + h.item = item; + + Utility.FileType type = Utility.getFileType(item.mission.kind, item.mission.name); + + h.icon.setImageResource(Utility.getIconForFileType(type)); + h.name.setText(item.mission.name); + h.size.setText(Utility.formatBytes(item.mission.length)); + + h.progress.setColors(Utility.getBackgroundForFileType(mContext, type), Utility.getForegroundForFileType(mContext, type)); + + if (h.item.mission instanceof DownloadMission) { + DownloadMission mission = (DownloadMission) item.mission; + h.progress.setMarquee(mission.done < 1); + updateProgress(h); + h.pause.setTitle(mission.unknownLength ? R.string.stop : R.string.pause); + mPendingDownloadsItems.add(h); + } else { + h.progress.setMarquee(false); + h.status.setText("100%"); + h.progress.setProgress(1f); } } - @Override - public MissionAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - final ViewHolder h = new ViewHolder(mInflater.inflate(mLayout, parent, false)); - - h.menu.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - buildPopup(h); - } - }); - - /*h.itemView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - showDetail(h); - } - });*/ - - return h; - } - - @Override - public void onViewRecycled(MissionAdapter.ViewHolder h) { - super.onViewRecycled(h); - h.mission.removeListener(h.observer); - h.mission = null; - h.observer = null; - h.progress = null; - h.position = -1; - h.lastTimeStamp = -1; - h.lastDone = -1; - h.colorId = 0; - } - - @Override - public void onBindViewHolder(MissionAdapter.ViewHolder h, int pos) { - DownloadMission ms = mItemList.get(pos); - h.mission = ms; - h.position = pos; - - Utility.FileType type = Utility.getFileType(ms.name); - - h.icon.setImageResource(Utility.getIconForFileType(type)); - h.name.setText(ms.name); - h.size.setText(Utility.formatBytes(ms.length)); - - h.progress = new ProgressDrawable(mContext, Utility.getBackgroundForFileType(type), Utility.getForegroundForFileType(type)); - ViewCompat.setBackground(h.bkg, h.progress); - - h.observer = new MissionObserver(this, h); - ms.addListener(h.observer); - - updateProgress(h); - } - @Override public int getItemCount() { - return mItemList.size(); + return mIterator.getOldListSize(); } @Override - public long getItemId(int position) { - return position; + public int getItemViewType(int position) { + return mIterator.getSpecialAtItem(position); } - private void updateProgress(ViewHolder h) { - updateProgress(h, false); - } + private void updateProgress(ViewHolderItem h) { + if (h == null || h.item == null || h.item.mission instanceof FinishedMission) return; - private void updateProgress(ViewHolder h, boolean finished) { - if (h.mission == null) return; + DownloadMission mission = (DownloadMission) h.item.mission; long now = System.currentTimeMillis(); @@ -164,130 +195,110 @@ public class MissionAdapter extends RecyclerView.Adapter 1000 || finished) { - if (h.mission.errCode > 0) { - h.status.setText(R.string.msg_error); + if (hasError || deltaTime == 0 || deltaTime > 1000) { + // on error hide marquee or show if condition (mission.done < 1 || mission.unknownLength) is true + h.progress.setMarquee(!hasError && (mission.done < 1 || mission.unknownLength)); + + float progress; + if (mission.unknownLength) { + progress = Float.NaN; + h.progress.setProgress(0f); } else { - float progress = (float) h.mission.done / h.mission.length; - h.status.setText(String.format(Locale.US, "%.2f%%", progress * 100)); + progress = (float) mission.done / mission.length; + if (mission.urls.length > 1 && mission.current < mission.urls.length) { + progress = (progress / mission.urls.length) + ((float) mission.current / mission.urls.length); + } + } + + if (hasError) { + if (Float.isNaN(progress) || Float.isInfinite(progress)) h.progress.setProgress(1f); + h.status.setText(R.string.msg_error); + } else if (Float.isNaN(progress) || Float.isInfinite(progress)) { + h.status.setText("--.-%"); + } else { + h.status.setText(String.format("%.2f%%", progress * 100)); h.progress.setProgress(progress); } } + long length = mission.offsets[mission.current < mission.offsets.length ? mission.current : (mission.offsets.length - 1)]; + length += mission.length; + + int state = 0; + if (!mission.isFinished()) { + if (!mission.running) { + state = mission.enqueued ? 1 : 2; + } else if (mission.postprocessingRunning) { + state = 3; + } + } + + if (state != 0) { + if (h.state != state) { + String statusStr; + h.state = state; + + switch (state) { + case 1: + statusStr = mContext.getString(R.string.queued); + break; + case 2: + statusStr = mContext.getString(R.string.paused); + break; + case 3: + statusStr = mContext.getString(R.string.post_processing); + break; + default: + statusStr = "?"; + break; + } + + h.size.setText(Utility.formatBytes(length).concat(" (").concat(statusStr).concat(")")); + } else if (deltaTime > 1000 && deltaDone > 0) { + h.lastTimeStamp = now; + h.lastDone = mission.done; + } + + return; + } + if (deltaTime > 1000 && deltaDone > 0) { float speed = (float) deltaDone / deltaTime; String speedStr = Utility.formatSpeed(speed * 1000); - String sizeStr = Utility.formatBytes(h.mission.length); + String sizeStr = Utility.formatBytes(length); - h.size.setText(sizeStr + " " + speedStr); + h.size.setText(sizeStr.concat(" ").concat(speedStr)); h.lastTimeStamp = now; - h.lastDone = h.mission.done; + h.lastDone = mission.done; } } + private boolean viewWithFileProvider(@NonNull File file) { + if (!file.exists()) return true; - private void buildPopup(final ViewHolder h) { - PopupMenu popup = new PopupMenu(mContext, h.menu); - popup.inflate(R.menu.mission); + String ext = Utility.getFileExt(file.getName()); + if (ext == null) return false; - Menu menu = popup.getMenu(); - MenuItem start = menu.findItem(R.id.start); - MenuItem pause = menu.findItem(R.id.pause); - MenuItem view = menu.findItem(R.id.view); - MenuItem delete = menu.findItem(R.id.delete); - MenuItem checksum = menu.findItem(R.id.checksum); + String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.substring(1)); + Log.v(TAG, "Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID + ".provider"); - // Set to false first - start.setVisible(false); - pause.setVisible(false); - view.setVisible(false); - delete.setVisible(false); - checksum.setVisible(false); - - if (!h.mission.finished) { - if (!h.mission.running) { - if (h.mission.errCode == -1) { - start.setVisible(true); - } - - delete.setVisible(true); - } else { - pause.setVisible(true); - } - } else { - view.setVisible(true); - delete.setVisible(true); - checksum.setVisible(true); - } - - popup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { - @Override - public boolean onMenuItemClick(MenuItem item) { - int id = item.getItemId(); - switch (id) { - case R.id.start: - mDownloadManager.resumeMission(h.position); - mBinder.onMissionAdded(mItemList.get(h.position)); - return true; - case R.id.pause: - mDownloadManager.pauseMission(h.position); - mBinder.onMissionRemoved(mItemList.get(h.position)); - h.lastTimeStamp = -1; - h.lastDone = -1; - return true; - case R.id.view: - File f = new File(h.mission.location, h.mission.name); - String ext = Utility.getFileExt(h.mission.name); - - Log.d(TAG, "Viewing file: " + f.getAbsolutePath() + " ext: " + ext); - - if (ext == null) { - Log.w(TAG, "Can't view file because it has no extension: " + - h.mission.name); - return false; - } - - String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.substring(1)); - Log.v(TAG, "Mime: " + mime + " package: " + mContext.getApplicationContext().getPackageName() + ".provider"); - if (f.exists()) { - viewFileWithFileProvider(f, mime); - } else { - Log.w(TAG, "File doesn't exist"); - } - - return true; - case R.id.delete: - mDeleteDownloadManager.add(h.mission); - updateItemList(); - notifyDataSetChanged(); - return true; - case R.id.md5: - case R.id.sha1: - DownloadMission mission = mItemList.get(h.position); - new ChecksumTask(mContext).execute(mission.location + "/" + mission.name, ALGORITHMS.get(id)); - return true; - default: - return false; - } - } - }); - - popup.show(); - } - - private void viewFileWithFileProvider(File file, String mimetype) { - String ourPackage = mContext.getApplicationContext().getPackageName(); - Uri uri = FileProvider.getUriForFile(mContext, ourPackage + ".provider", file); + Uri uri = FileProvider.getUriForFile(mContext, BuildConfig.APPLICATION_ID + ".provider", file); Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); - intent.setDataAndType(uri, mimetype); + intent.setDataAndType(uri, mimeType); intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { intent.addFlags(FLAG_GRANT_PREFIX_URI_PERMISSION); @@ -300,75 +311,338 @@ public class MissionAdapter extends RecyclerView.Adapter= 100 && mission.errCode < 600) { + str.append("HTTP"); + } else if (mission.errObject == null) { + str.append("(not_decelerated_error_code)"); + } + break; } - @Override - public void onProgressUpdate(DownloadMission downloadMission, long done, long total) { - mAdapter.updateProgress(mHolder); + if (mission.errObject != null) { + str.append("\n\n"); + str.append(mission.errObject.toString()); } - @Override - public void onFinish(DownloadMission downloadMission) { - //mAdapter.mManager.deleteMission(mHolder.position); - // TODO Notification - //mAdapter.notifyDataSetChanged(); - if (mHolder.mission != null) { - mHolder.size.setText(Utility.formatBytes(mHolder.mission.length)); - mAdapter.updateProgress(mHolder, true); + AlertDialog.Builder builder = new AlertDialog.Builder(mContext); + builder.setTitle(mission.name) + .setMessage(str) + .setNegativeButton(android.R.string.ok, (dialog, which) -> dialog.cancel()) + .create() + .show(); + } + + public void clearFinishedDownloads() { + mDownloadManager.forgetFinishedDownloads(); + applyChanges(); + mClear.setVisible(false); + } + + private boolean handlePopupItem(@NonNull ViewHolderItem h, @NonNull MenuItem option) { + int id = option.getItemId(); + DownloadMission mission = h.item.mission instanceof DownloadMission ? (DownloadMission) h.item.mission : null; + + if (mission != null) { + switch (id) { + case R.id.start: + h.state = -1; + h.size.setText(Utility.formatBytes(mission.length)); + mDownloadManager.resumeMission(mission); + return true; + case R.id.pause: + h.state = -1; + mDownloadManager.pauseMission(mission); + notifyItemChanged(h.getAdapterPosition()); + h.lastTimeStamp = -1; + h.lastDone = -1; + return true; + case R.id.error_message_view: + showError(mission); + return true; + case R.id.queue: + h.queue.setChecked(!h.queue.isChecked()); + mission.enqueued = h.queue.isChecked(); + updateProgress(h); + return true; } } - @Override - public void onError(DownloadMission downloadMission, int errCode) { - mAdapter.updateProgress(mHolder); + switch (id) { + case R.id.open: + return viewWithFileProvider(h.item.mission.getDownloadedFile()); + case R.id.delete: + if (mDeleter == null) { + mDownloadManager.deleteMission(h.item.mission); + } else { + mDeleter.append(h.item.mission); + } + applyChanges(); + return true; + case R.id.md5: + case R.id.sha1: + new ChecksumTask(mContext).execute(h.item.mission.getDownloadedFile().getAbsolutePath(), ALGORITHMS.get(id)); + return true; + case R.id.source: + /*Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(h.item.mission.source)); + mContext.startActivity(intent);*/ + try { + Intent intent = NavigationHelper.getIntentByLink(mContext, h.item.mission.source); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mContext.startActivity(intent); + } catch (Exception e) { + Log.w(TAG, "Selected item has a invalid source", e); + } + return true; + default: + return false; } - } - private static class ChecksumTask extends AsyncTask { - ProgressDialog prog; - final WeakReference weakReference; + public void applyChanges() { + mIterator.start(); + DiffUtil.calculateDiff(mIterator, true).dispatchUpdatesTo(this); + mIterator.end(); - ChecksumTask(@NonNull Activity activity) { - weakReference = new WeakReference<>(activity); + checkEmptyMessageVisibility(); + + if (mIterator.getOldListSize() > 0) { + int lastItemType = mIterator.getSpecialAtItem(mIterator.getOldListSize() - 1); + mClear.setVisible(lastItemType == DownloadManager.SPECIAL_FINISHED); + } + } + + public void forceUpdate() { + mIterator.start(); + mIterator.end(); + + notifyDataSetChanged(); + } + + public void setLinear(boolean isLinear) { + mLayout = isLinear ? R.layout.mission_item_linear : R.layout.mission_item; + } + + private void checkEmptyMessageVisibility() { + int flag = mIterator.getOldListSize() > 0 ? View.GONE : View.VISIBLE; + if (mEmptyMessage.getVisibility() != flag) mEmptyMessage.setVisibility(flag); + } + + + public void deleterDispose(Bundle bundle) { + if (mDeleter != null) mDeleter.dispose(bundle); + } + + public void deleterLoad(Bundle bundle, View view) { + if (mDeleter == null) + mDeleter = new Deleter(bundle, view, mContext, this, mDownloadManager, mIterator, mHandler); + } + + public void deleterResume() { + if (mDeleter != null) mDeleter.resume(); + } + + + class ViewHolderItem extends RecyclerView.ViewHolder { + DownloadManager.MissionItem item; + + TextView status; + ImageView icon; + TextView name; + TextView size; + ProgressDrawable progress; + + PopupMenu popupMenu; + MenuItem start; + MenuItem pause; + MenuItem open; + MenuItem queue; + MenuItem showError; + MenuItem delete; + MenuItem source; + MenuItem checksum; + + long lastTimeStamp = -1; + long lastDone = -1; + int lastCurrent = -1; + int state = 0; + + ViewHolderItem(View view) { + super(view); + + progress = new ProgressDrawable(); + ViewCompat.setBackground(itemView.findViewById(R.id.item_bkg), progress); + + status = itemView.findViewById(R.id.item_status); + name = itemView.findViewById(R.id.item_name); + icon = itemView.findViewById(R.id.item_icon); + size = itemView.findViewById(R.id.item_size); + + name.setSelected(true); + + ImageView button = itemView.findViewById(R.id.item_more); + popupMenu = buildPopup(button); + button.setOnClickListener(v -> showPopupMenu()); + + Menu menu = popupMenu.getMenu(); + start = menu.findItem(R.id.start); + pause = menu.findItem(R.id.pause); + open = menu.findItem(R.id.open); + queue = menu.findItem(R.id.queue); + showError = menu.findItem(R.id.error_message_view); + delete = menu.findItem(R.id.delete); + source = menu.findItem(R.id.source); + checksum = menu.findItem(R.id.checksum); + + //h.itemView.setOnClickListener(v -> showDetail(h)); + } + + private void showPopupMenu() { + start.setVisible(false); + pause.setVisible(false); + open.setVisible(false); + queue.setVisible(false); + showError.setVisible(false); + delete.setVisible(false); + source.setVisible(false); + checksum.setVisible(false); + + DownloadMission mission = item.mission instanceof DownloadMission ? (DownloadMission) item.mission : null; + + if (mission != null) { + if (!mission.postprocessingRunning) { + if (mission.running) { + pause.setVisible(true); + } else { + if (mission.errCode != DownloadMission.ERROR_NOTHING) { + showError.setVisible(true); + } + + queue.setChecked(mission.enqueued); + + start.setVisible(mission.errCode != DownloadMission.ERROR_POSTPROCESSING_FAILED); + delete.setVisible(true); + queue.setVisible(true); + } + } + } else { + open.setVisible(true); + delete.setVisible(true); + checksum.setVisible(true); + } + + if (item.mission.source != null && !item.mission.source.isEmpty()) { + source.setVisible(true); + } + + popupMenu.show(); + } + + private PopupMenu buildPopup(final View button) { + PopupMenu popup = new PopupMenu(mContext, button); + popup.inflate(R.menu.mission); + popup.setOnMenuItemClickListener(option -> handlePopupItem(this, option)); + + return popup; + } + } + + class ViewHolderHeader extends RecyclerView.ViewHolder { + TextView header; + + ViewHolderHeader(View view) { + super(view); + header = itemView.findViewById(R.id.item_name); + } + } + + + static class ChecksumTask extends AsyncTask { + ProgressDialog progressDialog; + WeakReference weakReference; + + ChecksumTask(@NonNull Context context) { + weakReference = new WeakReference<>((Activity) context); } @Override @@ -378,10 +652,10 @@ public class MissionAdapter extends RecyclerView.Adapter items; + private boolean running = true; + + private Context mContext; + private MissionAdapter mAdapter; + private DownloadManager mDownloadManager; + private MissionIterator mIterator; + private Handler mHandler; + private View mView; + + private final Runnable rShow; + private final Runnable rNext; + private final Runnable rCommit; + + public Deleter(Bundle b, View v, Context c, MissionAdapter a, DownloadManager d, MissionIterator i, Handler h) { + mView = v; + mContext = c; + mAdapter = a; + mDownloadManager = d; + mIterator = i; + mHandler = h; + + // use variables to know the reference of the lambdas + rShow = this::show; + rNext = this::next; + 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) { + mIterator.hide(item); + items.add(0, item); + + show(); + } + + private void forget() { + mIterator.unHide(items.remove(0)); + mAdapter.applyChanges(); + + show(); + } + + private void show() { + if (items.size() < 1) return; + + pause(); + running = true; + + mHandler.postDelayed(rNext, DELAY); + } + + private void next() { + if (items.size() < 1) return; + + String msg = mContext.getString(R.string.file_deleted).concat(":\n").concat(items.get(0).name); + + snackbar = Snackbar.make(mView, msg, Snackbar.LENGTH_INDEFINITE); + snackbar.setAction(R.string.undo, s -> forget()); + snackbar.setActionTextColor(Color.YELLOW); + snackbar.show(); + + mHandler.postDelayed(rCommit, TIMEOUT); + } + + private void commit() { + if (items.size() < 1) return; + + while (items.size() > 0) { + Mission mission = items.remove(0); + if (mission.deleted) continue; + + mIterator.unHide(mission); + mDownloadManager.deleteMission(mission); + break; + } + + if (items.size() < 1) { + pause(); + return; + } + + show(); + } + + private void pause() { + running = false; + mHandler.removeCallbacks(rNext); + mHandler.removeCallbacks(rShow); + mHandler.removeCallbacks(rCommit); + if (snackbar != null) snackbar.dismiss(); + } + + public void resume() { + if (running) return; + mHandler.postDelayed(rShow, (int) (DELAY * 1.5f));// 150% of the delay + } + + public void dispose(Bundle bundle) { + if (items.size() < 1) return; + + pause(); + + if (bundle == null) { + for (Mission mission : items) mDownloadManager.deleteMission(mission); + items = null; + 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); + } +} \ No newline at end of file diff --git a/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java b/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java index 955ce4c65..6ecc843a4 100644 --- a/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java +++ b/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java @@ -1,25 +1,36 @@ -package us.shandian.giga.ui.common; +package us.shandian.giga.ui.common;// TODO: ¡git it! -import android.content.Context; import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.Paint; +import android.graphics.Path; import android.graphics.PixelFormat; +import android.graphics.Rect; import android.graphics.drawable.Drawable; -import android.support.annotation.ColorRes; +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.ColorInt; import android.support.annotation.NonNull; -import android.support.v4.content.ContextCompat; public class ProgressDrawable extends Drawable { - private float mProgress; - private final int mBackgroundColor; - private final int mForegroundColor; + private static final int MARQUEE_INTERVAL = 150; - public ProgressDrawable(Context context, @ColorRes int background, @ColorRes int foreground) { - this(ContextCompat.getColor(context, background), ContextCompat.getColor(context, foreground)); + private float mProgress; + private int mBackgroundColor, mForegroundColor; + private Handler mMarqueeHandler; + private float mMarqueeProgress; + private Path mMarqueeLine; + private int mMarqueeSize; + private long mMarqueeNext; + + public ProgressDrawable() { + mMarqueeLine = null;// marquee disabled + mMarqueeProgress = 0f; + mMarqueeSize = 0; + mMarqueeNext = 0; } - public ProgressDrawable(int background, int foreground) { + public void setColors(@ColorInt int background, @ColorInt int foreground) { mBackgroundColor = background; mForegroundColor = foreground; } @@ -29,10 +40,20 @@ public class ProgressDrawable extends Drawable { invalidateSelf(); } + public void setMarquee(boolean marquee) { + if (marquee == (mMarqueeLine != null)) { + return; + } + mMarqueeLine = marquee ? new Path() : null; + mMarqueeHandler = marquee ? new Handler(Looper.getMainLooper()) : null; + mMarqueeSize = 0; + mMarqueeNext = 0; + } + @Override public void draw(@NonNull Canvas canvas) { - int width = canvas.getWidth(); - int height = canvas.getHeight(); + int width = getBounds().width(); + int height = getBounds().height(); Paint paint = new Paint(); @@ -40,6 +61,42 @@ public class ProgressDrawable extends Drawable { canvas.drawRect(0, 0, width, height, paint); paint.setColor(mForegroundColor); + + if (mMarqueeLine != null) { + if (mMarqueeSize < 1) setupMarquee(width, height); + + int size = mMarqueeSize; + Paint paint2 = new Paint(); + paint2.setColor(mForegroundColor); + paint2.setStrokeWidth(size); + paint2.setStyle(Paint.Style.STROKE); + + size *= 2; + + if (mMarqueeProgress >= size) { + mMarqueeProgress = 1; + } else { + mMarqueeProgress++; + } + + // render marquee + width += size * 2; + Path marquee = new Path(); + for (float i = -size; i < width; i += size) { + marquee.addPath(mMarqueeLine, i + mMarqueeProgress, 0); + } + marquee.close(); + + canvas.drawPath(marquee, paint2);// draw marquee + + if (System.currentTimeMillis() >= mMarqueeNext) { + // program next update + mMarqueeNext = System.currentTimeMillis() + MARQUEE_INTERVAL; + mMarqueeHandler.postDelayed(this::invalidateSelf, MARQUEE_INTERVAL); + } + return; + } + canvas.drawRect(0, 0, (int) (mProgress * width), height, paint); } @@ -58,4 +115,17 @@ public class ProgressDrawable extends Drawable { return PixelFormat.OPAQUE; } + @Override + public void onBoundsChange(Rect rect) { + if (mMarqueeLine != null) setupMarquee(rect.width(), rect.height()); + } + + private void setupMarquee(int width, int height) { + mMarqueeSize = (int) ((width * 10f) / 100f);// the size is 10% of the width + + mMarqueeLine.rewind(); + mMarqueeLine.moveTo(-mMarqueeSize, -mMarqueeSize); + mMarqueeLine.lineTo(-mMarqueeSize * 4, height + mMarqueeSize); + mMarqueeLine.close(); + } } 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 5241415b2..c3a60f6d0 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 @@ -10,8 +10,6 @@ import android.content.SharedPreferences; import android.os.Bundle; import android.os.IBinder; import android.preference.PreferenceManager; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; import android.support.v7.widget.GridLayoutManager; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; @@ -23,39 +21,47 @@ import android.view.View; import android.view.ViewGroup; import org.schabi.newpipe.R; -import org.schabi.newpipe.download.DeleteDownloadManager; -import io.reactivex.disposables.Disposable; -import us.shandian.giga.get.DownloadManager; +import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; +import us.shandian.giga.service.DownloadManagerService.DMBinder; import us.shandian.giga.ui.adapter.MissionAdapter; -public abstract class MissionsFragment extends Fragment { - private DownloadManager mDownloadManager; - private DownloadManagerService.DMBinder mBinder; +public class MissionsFragment extends Fragment { + + private static final int SPAN_SIZE = 2; private SharedPreferences mPrefs; private boolean mLinear; private MenuItem mSwitch; + private MenuItem mClear; private RecyclerView mList; + private View mEmpty; private MissionAdapter mAdapter; private GridLayoutManager mGridManager; private LinearLayoutManager mLinearManager; private Context mActivity; - private DeleteDownloadManager mDeleteDownloadManager; - private Disposable mDeleteDisposable; + + private DMBinder mBinder; + private Bundle mBundle; + private boolean mForceUpdate; private final ServiceConnection mConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder binder) { mBinder = (DownloadManagerService.DMBinder) binder; - mDownloadManager = setupDownloadManager(mBinder); - if (mDeleteDownloadManager != null) { - mDeleteDownloadManager.setDownloadManager(mDownloadManager); - updateList(); - } + mBinder.resetFinishedDownloadCount(); + + mAdapter = new MissionAdapter(mActivity, mBinder.getDownloadManager(), mClear, mEmpty); + mAdapter.deleterLoad(mBundle, getView()); + + mBundle = null; + + mBinder.addMissionEventListener(mAdapter.getMessenger()); + + updateList(); } @Override @@ -66,14 +72,6 @@ public abstract class MissionsFragment extends Fragment { }; - public void setDeleteManager(@NonNull DeleteDownloadManager deleteDownloadManager) { - mDeleteDownloadManager = deleteDownloadManager; - if (mDownloadManager != null) { - mDeleteDownloadManager.setDownloadManager(mDownloadManager); - updateList(); - } - } - @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View v = inflater.inflate(R.layout.missions, container, false); @@ -81,24 +79,47 @@ public abstract class MissionsFragment extends Fragment { mPrefs = PreferenceManager.getDefaultSharedPreferences(getActivity()); mLinear = mPrefs.getBoolean("linear", false); + mActivity = getActivity(); + mBundle = savedInstanceState; + // Bind the service - Intent i = new Intent(); - i.setClass(getActivity(), DownloadManagerService.class); - getActivity().bindService(i, mConnection, Context.BIND_AUTO_CREATE); + mActivity.bindService(new Intent(mActivity, DownloadManagerService.class), mConnection, Context.BIND_AUTO_CREATE); // Views + mEmpty = v.findViewById(R.id.list_empty_view); mList = v.findViewById(R.id.mission_recycler); // Init - mGridManager = new GridLayoutManager(getActivity(), 2); + mGridManager = new GridLayoutManager(getActivity(), SPAN_SIZE); + mGridManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() { + @Override + public int getSpanSize(int position) { + switch (mAdapter.getItemViewType(position)) { + case DownloadManager.SPECIAL_PENDING: + case DownloadManager.SPECIAL_FINISHED: + return SPAN_SIZE; + default: + return 1; + } + } + }); + mLinearManager = new LinearLayoutManager(getActivity()); - mList.setLayoutManager(mGridManager); setHasOptionsMenu(true); return v; } + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + if (menu != null) { + mSwitch = menu.findItem(R.id.switch_mode); + mClear = menu.findItem(R.id.clear_list); + } + } + /** * Added in API level 23. */ @@ -108,7 +129,7 @@ public abstract class MissionsFragment extends Fragment { // Bug: in api< 23 this is never called // so mActivity=null - // so app crashes with nullpointer exception + // so app crashes with null-pointer exception mActivity = activity; } @@ -119,71 +140,78 @@ public abstract class MissionsFragment extends Fragment { @Override public void onAttach(Activity activity) { super.onAttach(activity); - mActivity = activity; } @Override - public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - if (mDeleteDownloadManager != null) { - mDeleteDisposable = mDeleteDownloadManager.getUndoObservable().subscribe(mission -> { - if (mAdapter != null) { - mAdapter.updateItemList(); - mAdapter.notifyDataSetChanged(); - } - }); + public void onDestroy() { + super.onDestroy(); + if (mBinder == null || mAdapter == null) return; + + mBinder.removeMissionEventListener(mAdapter.getMessenger()); + mActivity.unbindService(mConnection); + mAdapter.deleterDispose(null); + + mBinder = null; + mAdapter = null; + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (mAdapter != null) { + mAdapter.deleterDispose(outState); + mForceUpdate = true; + mBinder.removeMissionEventListener(mAdapter.getMessenger()); } } @Override - public void onDestroyView() { - super.onDestroyView(); - getActivity().unbindService(mConnection); - if (mDeleteDisposable != null) { - mDeleteDisposable.dispose(); - } - } + public void onResume() { + super.onResume(); + if (mAdapter != null) { + mAdapter.deleterResume(); - @Override - public void onPrepareOptionsMenu(Menu menu) { - mSwitch = menu.findItem(R.id.switch_mode); - super.onPrepareOptionsMenu(menu); + if (mForceUpdate) { + mForceUpdate = false; + mAdapter.forceUpdate(); + } + + mBinder.addMissionEventListener(mAdapter.getMessenger()); + } } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.switch_mode: - mLinear = !mLinear; - updateList(); - return true; - default: - return super.onOptionsItemSelected(item); - } - } - - public void notifyChange() { - mAdapter.notifyDataSetChanged(); + mLinear = !mLinear; + updateList(); + return true; + case R.id.clear_list: + mAdapter.clearFinishedDownloads(); + return true; + default: + return super.onOptionsItemSelected(item); + } } private void updateList() { - mAdapter = new MissionAdapter((Activity) mActivity, mBinder, mDownloadManager, mDeleteDownloadManager, mLinear); - if (mLinear) { mList.setLayoutManager(mLinearManager); } else { mList.setLayoutManager(mGridManager); } + mList.setAdapter(null); + mAdapter.notifyDataSetChanged(); + mAdapter.setLinear(mLinear); mList.setAdapter(mAdapter); if (mSwitch != null) { mSwitch.setIcon(mLinear ? R.drawable.grid : R.drawable.list); + mSwitch.setTitle(mLinear ? R.string.grid : R.string.list); + mPrefs.edit().putBoolean("linear", mLinear).apply(); } - - mPrefs.edit().putBoolean("linear", mLinear).apply(); } - - protected abstract DownloadManager setupDownloadManager(DownloadManagerService.DMBinder binder); } 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 163ac2b14..ac690be10 100644 --- a/app/src/main/java/us/shandian/giga/util/Utility.java +++ b/app/src/main/java/us/shandian/giga/util/Utility.java @@ -3,10 +3,11 @@ package us.shandian.giga.util; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; -import android.support.annotation.ColorRes; +import android.support.annotation.ColorInt; import android.support.annotation.DrawableRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.v4.content.ContextCompat; import android.widget.Toast; import org.schabi.newpipe.R; @@ -21,12 +22,14 @@ import java.io.ObjectOutputStream; import java.io.Serializable; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Locale; public class Utility { public enum FileType { VIDEO, MUSIC, + SUBTITLE, UNKNOWN } @@ -54,41 +57,32 @@ public class Utility { } } - public static void writeToFile(@NonNull String fileName, @NonNull Serializable serializable) { - ObjectOutputStream objectOutputStream = null; + public static void writeToFile(@NonNull File file, @NonNull Serializable serializable) { - try { - objectOutputStream = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(fileName))); + try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(file)))) { objectOutputStream.writeObject(serializable); } catch (Exception e) { //nothing to do - } finally { - if(objectOutputStream != null) { - try { - objectOutputStream.close(); - } catch (Exception e) { - //nothing to do - } - } } + //nothing to do } @Nullable @SuppressWarnings("unchecked") - public static T readFromFile(String file) { - T object = null; + public static T readFromFile(File file) { + T object; ObjectInputStream objectInputStream = null; try { objectInputStream = new ObjectInputStream(new FileInputStream(file)); object = (T) objectInputStream.readObject(); } catch (Exception e) { - //nothing to do + object = null; } - if(objectInputStream != null){ + if (objectInputStream != null) { try { - objectInputStream .close(); + objectInputStream.close(); } catch (Exception e) { //nothing to do } @@ -119,39 +113,68 @@ public class Utility { } } - public static FileType getFileType(String file) { - if (file.endsWith(".mp3") || file.endsWith(".wav") || file.endsWith(".flac") || file.endsWith(".m4a")) { + public static FileType getFileType(char kind, String file) { + switch (kind) { + case 'v': + return FileType.VIDEO; + case 'a': + return FileType.MUSIC; + case 's': + return FileType.SUBTITLE; + //default '?': + } + + if (file.endsWith(".srt") || file.endsWith(".vtt") || file.endsWith(".ssa")) { + return FileType.SUBTITLE; + } else if (file.endsWith(".mp3") || file.endsWith(".wav") || file.endsWith(".flac") || file.endsWith(".m4a") || file.endsWith(".opus")) { return FileType.MUSIC; } else if (file.endsWith(".mp4") || file.endsWith(".mpeg") || file.endsWith(".rm") || file.endsWith(".rmvb") || file.endsWith(".flv") || file.endsWith(".webp") || file.endsWith(".webm")) { return FileType.VIDEO; - } else { - return FileType.UNKNOWN; } + + return FileType.UNKNOWN; } - @ColorRes - public static int getBackgroundForFileType(FileType type) { + @ColorInt + public static int getBackgroundForFileType(Context ctx, FileType type) { + int colorRes; switch (type) { case MUSIC: - return R.color.audio_left_to_load_color; + colorRes = R.color.audio_left_to_load_color; + break; case VIDEO: - return R.color.video_left_to_load_color; + colorRes = R.color.video_left_to_load_color; + break; + case SUBTITLE: + colorRes = R.color.subtitle_left_to_load_color; + break; default: - return R.color.gray; + colorRes = R.color.gray; } + + return ContextCompat.getColor(ctx, colorRes); } - @ColorRes - public static int getForegroundForFileType(FileType type) { + @ColorInt + public static int getForegroundForFileType(Context ctx, FileType type) { + int colorRes; switch (type) { case MUSIC: - return R.color.audio_already_load_color; + colorRes = R.color.audio_already_load_color; + break; case VIDEO: - return R.color.video_already_load_color; + colorRes = R.color.video_already_load_color; + break; + case SUBTITLE: + colorRes = R.color.subtitle_already_load_color; + break; default: - return R.color.gray; + colorRes = R.color.gray; + break; } + + return ContextCompat.getColor(ctx, colorRes); } @DrawableRes @@ -161,6 +184,8 @@ public class Utility { return R.drawable.music; case VIDEO: return R.drawable.video; + case SUBTITLE: + return R.drawable.subtitle; default: return R.drawable.video; } @@ -168,12 +193,18 @@ public class Utility { public static void copyToClipboard(Context context, String str) { ClipboardManager cm = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + + if (cm == null) { + Toast.makeText(context, R.string.permission_denied, Toast.LENGTH_LONG).show(); + return; + } + cm.setPrimaryClip(ClipData.newPlainText("text", str)); Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); } public static String checksum(String path, String algorithm) { - MessageDigest md = null; + MessageDigest md; try { md = MessageDigest.getInstance(algorithm); @@ -181,7 +212,7 @@ public class Utility { throw new RuntimeException(e); } - FileInputStream i = null; + FileInputStream i; try { i = new FileInputStream(path); @@ -190,14 +221,14 @@ public class Utility { } byte[] buf = new byte[1024]; - int len = 0; + int len; try { while ((len = i.read(buf)) != -1) { md.update(buf, 0, len); } - } catch (IOException ignored) { - + } catch (IOException e) { + // nothing to do } byte[] digest = md.digest(); @@ -211,4 +242,16 @@ public class Utility { return sb.toString(); } + + @SuppressWarnings("ResultOfMethodCallIgnored") + public static boolean mkdir(File path, boolean allDirs) { + if (path.exists()) return true; + + if (allDirs) + path.mkdirs(); + else + path.mkdir(); + + return path.exists(); + } } diff --git a/app/src/main/res/drawable-hdpi/grid.png b/app/src/main/res/drawable-hdpi/grid.png index 254f1d300ab07382ea3bf99f51c74e616fe46487..26fa36c070f8a64dd45b446f92cd34cc5b159fbb 100644 GIT binary patch delta 3278 zcmV;<3^DWH7mXT_G=Dp3LqkwdXm50Hb7*gHAW1_*AaHVTW@&6?002mdotAf0Q`r`W z&%HOjP?8Xel+bHvQUXW~y-1NRDlrKVO2Ci+A~r-+a70m&rU)`9;DDn;k+C9*g#l5q z>jW7@)NybW8eS9UM8wF>;&Ay4=AVc79|!(*9u^V&B)*6* zlto0#rhiNzOqR7<$Ps@_@A2i55xYX*}0a9+V~OBmRJI%AsRq_9snpR5g-YB zWGm3`eGA4%1NqI1(V3W?`_F>@eOI_l{>T<2e~x2EL^8M%QO@j|{8|DuAOJ-`1L{B< z=mQhL1h&8txBw5}0|G%Phy-z9G2ns}kO4#>7k?ChwV)W3f~{aDs0MYQ5j2A~a2Rxf zW8gG62QGojU!?g~$*UQipUPL&zMmg;!4Do9IA%up=Rh?=qPj=x&RGBx1dpI68aT-2O}^EromdU5o`ssU{5#*j)WJ% z$$#)NI3F&8x4@Nf1AGwfgiphl;1O5~KY^zafDjQnqKhyQ7Q#kCk$5Bt5h1IP5~KoY zK-!QVq#wD8NRg+=TNDOGMKMrJlncrq6@}uWmZ4UmHlwOh2T+};KGapzC~6Az5lu#G zqRr9H=m2yqIvJgdE=E_No6sHTv*;1@IDh&T27{qvj4_TFe@q-E6|(}f8M7PHjyZ)H z#*AU6u~@7+)*S1K4aIV>Vr((C3VRTH5_<(Zj(vk8;&gDfIA2^mPKYbSRp451CvaDA z6Sx_?65bH+j1R^0@XPUK_(psWeh5E~pCKp{j0vuUNJ1)MEuoUoMmS5jOL##f5`XoG zPQ-AcfVhTONjyY6PaGw_B~eIbBsM95Bq41f?I)cg-6FjplgUP84{|(NOx{9nCZ8eS zC%;jkDp)E6DDV_kE7T}-DqK-`rifQGRPUdc#_t;A7UrBtJI zROyD&v@%uMMmbbDLwU1ui}D5KM}HJ7#faiV;Zcex`zU89V^oByPi0fN)OFNG>RIYI z4MQ`g1<+DyrL=EogS06Xii({|v`U^zjmmKqDIK93(F5q|^fLNk`gQs{RV`IdRle#b z)i%{Ds;|}NsClUI)k@Ub)kf6bsWa4l)YH_rsduU0(?DsMX@qO!YV6TCtAFuy4sDLh z9PXTwIfv)m)`T_9G$S-uXztS-(45xN*7DQJ(yG!rqxDpquI-_ns=Y(|g!ZHkRmV+7 zptD2gq|Rf83d56;&ZuPcF{X4ib^UcEy7jt)x-)vldQp0XdhL4m^a=Vb{UrSz`n~#7 z208{I1_cJK2Dc6IhAhJr!+%|d{f4iNjE!Q9HW+moJu+4^4lvF)ZZ*DZLN;+XS!U8; za?KQD$}&we-EDf=3^ubjOEIf48#0H@9n1yhyUm9!&=yV>LW>5A8%z?@lbOS8WsX|X zErTr!ExRnASs7TxTWz!IxB6&pZ=G)4Xnn_qViRanXwzf!tF4(W*MGLg_S#(XT;I8? z=XTG1Zf9=Cx7%ZP)1GP{V!y$@*ZzZpql3ty&0*5fz%kLW*6{|5#tLI?W}SCJJ9#;+ zb~@(t*4e>X?0ney7Z;{WnoEnzqj|>j`12a)jk)T%a$M_OrEUzjM7OZX~g?%562@eae34a)26HyS+zkh(bV9A1(1+OB#BFiH0 zM43cMqI#nhqZ6W=qhH5($CSrNW36IW#$Jlkh!ezh$7AE8xdr`1lgVC7dNk648kGzWRKONg3!bO?r`DyuP76)jpY|y|CcQlamywup zR7eq~3Hvg&GxIWsv&^%Kv!u(Mm+f3OB?=NXWkcDEvb)7J+0WE~#6+@QGMeL-QhTd=lZbbqDi%KBB%s->%XSL?4XT0OqT zZ_RsyNzibcgYHn?o4+lbmI*f_Xp?xw0u zA4_;87fY>6D@xyQ=5D_DmCaX`Uwzt=v}Lf&p={4q%vRyn>)YJ79Vk~W&o3X_9>BD~Ee(8aT1AWbo z&CM;EEoH56tE6@EV8X%6-*|u1-NtOIZ>P7H9)ALdB!?bmEKDc(k|2rKjV2%kTFe(>+#mT;+J#3Brk@6Q54zpPW9G zb?WKqB=X}qd>G$kEdEWK>u?x-@j$UM4?7HM|sdK=7 zpnu~B`yV<69R@px9EZ9uJ6-M>o;Q5riu;w*SG}*EyB2Wm(#ZUg;pqt>?FMZqM9Va~FNLGD$lUSLj#Xl?mT>H%A*>}Hq{4y{VJ2n1X^!(GW zn_sBE*#FY*W$$#@^!-;EuV!ACyitF1;4SNI|GSuX6EnH*vF|HCn11N_81(V(r;JaZ zegpS}^ZVdbsxOm@3NL>KVoOIv0RI600RN!9r;`8x010qNS#tmY3ljhU3ljkVnw%H_ z000McNliru;tC2ACJ+BqYj6Mn0vAa{K~z}7?U%7mR8bIyzqznT!4)bC5}`052K5EN z*l6t=sEsdx1&L2!!DH|Mc2-bfVGo7|6L%ADq!3xDSO{Y?n@oQu=Y)G*7F@EEoMiXh zf99Nf=AZw}g|Ut8noU(@nDHgE=o8=t&rzt~lIbCe~VN?IN?MUX6Wsf!~SwQ#-Z3{mJ;BHQ7-1 z@M`#Kz%{bse=C2QV!$Ghf5c>MGBaNR-$I%MXa^2-fOFu&yDb(=!|j-_kM&Dc-!vihG;r63xAT7>|MS58g#R7)dmdOwV9F+C zpA)u=OaAxg^b*YI3!~J)OUAz##DdidNS%n{peVR! zL5hf4i&b1W?jPKzwS9W;?d|*5`@H9z=lsrj&kukR5+;%6qJL@tIZ}l@I@lkNPe{bc zy?_E0NZAR{$W90N^4L z=L-RlQUJ&HumpsYY5E(E}?0f1SyGDiY{y#)Yvj#!WnKwtoXnL;eg z03bL507D)V%>y7z1E4U{zu>7~aD})?0RX_umCct+(lZpemCzb@^6=o|A>zVpu|i=N zDG+7}d$g zBmT#Qe}DasDbeCIv0N+_it$*9xKJWZ$9cI@kwhWMmEscVe=_kOmi-BncMSlnhEKpw zwg=onn6 zlow3K2mk;?pn)o|K?e-M6s*7woWUKuz!w5x27g3A3?#rj5JCpzfE)^;7?wd9RKPme z1hudO8lVxH;SjXJF*pt9;1XPc>u?taU>Kgl7`%oF1VP9M6Ja4bh!J9r*dopd7nzO( zB4J20l7OTj>4+3jBE`sZqynizYLQ(?Bl0bB6giDtK>Co|$RIL`{EECsF_eL_Q3KQh zb$>v)s2>`N#-MZ2bTkiLfR>_b(HgWKJ%F~Nr_oF3b#wrIijHG|(J>BYjM-sajE6;F ziC7vY#};GdST$CUHDeuEH+B^pz@B062qXfFfD`NpUW5?BY=V%GM_5c)L#QR}BeW8_ z2v-S%gfYS=B9o|3v?Y2H`NVi)I5gFH?u96Et<2CC!@_L(8Nsqkq-V znrUZg_h>Kabh-)MgC0ef(3jF{=m+WN>4Wrl3=M`2gU3i>C>d)Rdl{z~w;3;)Or{0X zmzl^^FxN60nP->}m~T~BD)uUT6_Lskl{%GHm421ys#H~TRX^2vstZ)BRS&CPR(+;M zRkKjzsR`5;tJSF;RlBJ+uFg_-Qh$$7&rx5azF+-<`eO~UhJ{9;hDf7SW4Fc`jUg7s zGG*~tLe_Ft1M4hnm`!3^via;xb_M$zb}xHOQ$usAW~^qBW}W77%>fR^vEcAI*_=ww zAx;DB| zx`n#ibhIR?(H}Q3Gzd138Ei2)WAMz7W9Vy` zX}HnwgyEPon!|7LN z8)u<&o%1yprc02^5|?(D7gKGgil=U$ddrpN8t%H%wbS*Zo4cFbt=VnV-ON43eXILT zE}I+4UBf-^LGTx1&sx}1}_Xg6+#RN4Ot&@lW)Km@*DYMGu&q^n$Z=?2%QyL z8~QNJCQKgI5srq>2;UHXZ>IT7>CCnWh~P(Th`1kV8JQRPeSZ`wDl)1r>QS^)ba8a| zEY_^#S^H zO&t^Rgqwv=MSs4cjiOPpx423?lIEROmG(H@JAFg?XogQlb;dIZPf{y+kr|S?BlAsG zMAqJ{&)IR=Ejg5&l$@hd4QZCNE7vf$D7Q~$D=U)?Nn} z(WA6du22pZOfRS_cv~1-c(_QtNLti0-)8>m`6CO07Jq!bkg!m=u(Q~>cvbfJ1`^^VQ7&C1OKHDNXFTgC{ zM|V%fo{xK_dk6MK@9S!GZ*1JJzrV5xZBjOk9)CzWaOInTZ`zyfns>EuS}G30LFK_G z-==(f<51|K&cocp&EJ`SxAh3?NO>#LI=^+SEu(FqJ)ynt=!~PC9bO$rzPJB=?=jt*-L?N>ambo5Q@JJIjcfBI^`)pOVQ z*DhV3dA;w(>>IakCfyvkCA#(acJ}QTcM9%I++BK)c(44v+WqPW`VZ=VwEnSWz-{38 zV8CF{!&wjS4he^z{*?dIhvCvk%tzHDMt>uXkDVVM8x0!0@?_4F;is~v6VJ+iR{weH zbF1gy{o?ye&shA}@C*5i&%dsDsq=F0tEsO#$0Nrdyv}(&@uvK(&f9(OxbM2($Gsn! zDEvVFQ1j9HW5=h^Pxn6OeE$3|_k{ENZr3j10000WV@Og>004R>004l5008;`0F#Xh z8h;0LNliru-V6~B91%_~FqQxS03vinSaefwW^{L9a%BKPWN%_+!mc1tXm50Hb7*gH z!mc1xZ*^m6;?CJX0002cNklZj}|1DQ%?hMG^vFv)i)DO^b5LW&H)b8d-{9KZq5hkOFfgqrif+409d&L{#P pSh002ovPDHLkV1ilPy>I{k diff --git a/app/src/main/res/drawable-hdpi/list.png b/app/src/main/res/drawable-hdpi/list.png index 0b3f54c20a192167c1908651dd9357e0266885ef..16da863e2e21606532a4a0880c30ee609e142fe8 100644 GIT binary patch delta 3092 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 + + - + + android:scaleType="center" + android:contentDescription="TODO"/> @@ -51,8 +51,8 @@ android:layout_centerHorizontal="true" android:scaleType="fitXY" android:gravity="center" - android:padding="10dp" - android:contentDescription="TODO" /> + android:contentDescription="TODO" + android:padding="10dp"/> + android:textColor="@color/white" + android:singleLine="true" + android:ellipsize="marquee" + android:marqueeRepeatLimit="marquee_forever" + android:scrollHorizontally="true"/> - + + + - - + android:layout_height="match_parent" + /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/missions_header.xml b/app/src/main/res/layout/missions_header.xml new file mode 100644 index 000000000..99b7c6b1a --- /dev/null +++ b/app/src/main/res/layout/missions_header.xml @@ -0,0 +1,30 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/download_menu.xml b/app/src/main/res/menu/download_menu.xml index e71eaf152..2d486d617 100644 --- a/app/src/main/res/menu/download_menu.xml +++ b/app/src/main/res/menu/download_menu.xml @@ -1,11 +1,25 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> + + + + + + + - - \ No newline at end of file diff --git a/app/src/main/res/menu/mission.xml b/app/src/main/res/menu/mission.xml index b76d1a923..4f7eba626 100644 --- a/app/src/main/res/menu/mission.xml +++ b/app/src/main/res/menu/mission.xml @@ -1,37 +1,50 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 9c06e228b..c13fbe54f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -492,7 +492,7 @@ abrir en modo popup Minimizar al reproductor de fondo Minimizar el reproductor emergente -Avance rápido durante el silencio + Avance rápido durante el silencio Paso Reiniciar @@ -500,4 +500,54 @@ abrir en modo popup Usuarios Listas de reproducción Pistas + + Finalizadas + En cola + + pausado + en cola + post-procesado + + Encolar + + Acción denegada por el sistema + + Archivo borrado + + + Descarga finalizada: %s + %s descargas finalizadas + + + Generar nombre único + Sobrescribir + Ya existe un archivo descargado con este nombre + Hay una descarga en curso con este nombre + + Mostrar como grilla + Mostrar como lista + Limpiar descargas finalizadas + Tienes %s descargas pendientes, ve a Descargas para continuarlas + Detener + Intentos maximos + Cantidad máxima de intentos antes de cancelar la descarga + Pausar al cambiar a datos moviles + No todas las descargas se pueden suspender, en esos casos, se reiniciaran + + + + Mostrar error + Codigo + 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 puede encontrar el servidor + No se puede conectar con el servidor + El servidor no devolvio datos + El servidor no acepta descargas multi-hilos, intente de nuevo con @string/msg_threads = 1 + Rango solicitado no satisfactorio + No encontrado + Fallo el post-procesado + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 515f1d46f..5741d1b4f 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -63,6 +63,8 @@ #000000 #CD5656 #BC211D + #008ea4 + #005a71 #FFFFFF diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index 133a679c9..7234a6639 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -174,6 +174,12 @@ @string/charset_most_special_characters_value + + downloads_max_retry + 3 + cross_network_downloads + + default_download_threads preferred_open_action_key diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ac95d98a4..855c2d092 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,7 +16,7 @@ Download stream file Search Settings - Did you mean: %1$s\? + Did you mean: %1$s? Share with Choose browser rotation @@ -143,6 +143,7 @@ Resizing Best resolution Undo + File deleted Play All Always Just Once @@ -520,9 +521,55 @@ None Minimize to background player Minimize to popup player - List view mode + List view mode List Grid - Auto + Auto Switch View + + + Finished + In queue + + paused + queued + post-processing + + Queue + + Action denied by the system + + + Download finished: %s + %s downloads finished + + + Generate unique name + Overwrite + A downloaded file with this name already exists + There is a download in progress with this name + + + Show error + Code + The file can not be created + The destination folder can not be created + Permission denied by the system + Secure connection failed + Can not found the server + Can not connect to the server + The server does not send data + The server does not accept multi-threaded downloads, retry with @string/msg_threads = 1 + Requested Range Not Satisfiable + Not found + Post-processing failed + + Clear finished downloads + You have %s pending downloads, goto Downloads to continue + Stop + Maximum retry + Maximum number of attempts before canceling the download + Pause on switching to mobile data + Not all downloads can be suspended, in those cases, will be restarted + diff --git a/app/src/main/res/xml/download_settings.xml b/app/src/main/res/xml/download_settings.xml index 0a8768e9e..ed38acbb7 100644 --- a/app/src/main/res/xml/download_settings.xml +++ b/app/src/main/res/xml/download_settings.xml @@ -29,4 +29,17 @@ android:summary="@string/settings_file_replacement_character_summary" android:title="@string/settings_file_replacement_character_title"/> + + + +