diff --git a/README.md b/README.md index 6c1aa3d4b..d4952b2b8 100644 --- a/README.md +++ b/README.md @@ -66,15 +66,22 @@ NewPipe does not use any Google framework libraries, nor the YouTube API. Websit * Enqueue videos * Local playlists * Subtitles -* Multi-service support (e.g. SoundCloud \[beta\]) * Livestream support +* Show comments ### Coming Features * Cast to UPnP and Cast -* Show comments * … and many more +### Supported Services + +NewPipe supports multiple services. Our [docs](https://teamnewpipe.github.io/documentation/) provide more info on how a new service can be added to the app and the extractor. Please get in touch with us if you intend to add a new one. Currently supported services are: + +* YouTube +* SoundCloud \[beta\] +* media.ccc.de \[beta\] + ## Updates When a change to the NewPipe code occurs (due to either adding features or bug fixing), eventually a release will occur. These are in the format x.xx.x . In order to get this new version, you can: * Build a debug APK yourself. This is the fastest way to get new features on your device, but is much more complicated, so we recommend using one of the other methods. diff --git a/app/build.gradle b/app/build.gradle index 4ce9a9611..a5153e1c7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -8,8 +8,8 @@ android { applicationId "org.schabi.newpipe" minSdkVersion 19 targetSdkVersion 28 - versionCode 720 - versionName "0.16.0" + versionCode 740 + versionName "0.16.2" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true @@ -57,7 +57,7 @@ dependencies { exclude module: 'support-annotations' }) - implementation 'com.github.TeamNewPipe:NewPipeExtractor:5f44031' + implementation 'com.github.TeamNewPipe:NewPipeExtractor:4488c217659d2' testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-core:2.23.0' diff --git a/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersionTask.java b/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersionTask.java index 6a6d1b9c2..87ffcb05d 100644 --- a/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersionTask.java +++ b/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersionTask.java @@ -14,6 +14,7 @@ import android.os.AsyncTask; import android.preference.PreferenceManager; import android.support.v4.app.NotificationCompat; import android.support.v4.app.NotificationManagerCompat; +import android.util.Log; import org.json.JSONException; import org.json.JSONObject; @@ -47,6 +48,8 @@ import okhttp3.Response; */ public class CheckForNewAppVersionTask extends AsyncTask { + private static final boolean DEBUG = MainActivity.DEBUG; + private static final String TAG = CheckForNewAppVersionTask.class.getSimpleName(); private static final Application app = App.getApp(); private static final String GITHUB_APK_SHA1 = "B0:2E:90:7C:1C:D6:FC:57:C3:35:F0:88:D0:8F:50:5F:94:E4:D2:15"; private static final String newPipeApiUrl = "https://newpipe.schabi.org/api/data.json"; @@ -90,9 +93,8 @@ public class CheckForNewAppVersionTask extends AsyncTask { Response response = client.newCall(request).execute(); return response.body().string(); } catch (IOException ex) { - ErrorActivity.reportError(app, ex, null, null, - ErrorActivity.ErrorInfo.make(UserAction.SOMETHING_ELSE, "none", - "app update API fail", R.string.app_ui_crash)); + // connectivity problems, do not alarm user and fail silently + if (DEBUG) Log.w(TAG, Log.getStackTraceString(ex)); } return null; @@ -117,9 +119,8 @@ public class CheckForNewAppVersionTask extends AsyncTask { compareAppVersionAndShowNotification(versionName, apkLocationUrl, versionCode); } catch (JSONException ex) { - ErrorActivity.reportError(app, ex, null, null, - ErrorActivity.ErrorInfo.make(UserAction.SOMETHING_ELSE, "none", - "could not parse app update JSON data", R.string.app_ui_crash)); + // connectivity problems, do not alarm user and fail silently + if (DEBUG) Log.w(TAG, Log.getStackTraceString(ex)); } } } diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index f040dc8b4..c7bf4c881 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -574,7 +574,7 @@ public class RouterActivity extends AppCompatActivity { playQueue = new SinglePlayQueue((StreamInfo) info); if (playerChoice.equals(videoPlayerKey)) { - NavigationHelper.playOnMainPlayer(this, playQueue); + NavigationHelper.playOnMainPlayer(this, playQueue, true); } else if (playerChoice.equals(backgroundPlayerKey)) { NavigationHelper.enqueueOnBackgroundPlayer(this, playQueue, true); } else if (playerChoice.equals(popupPlayerKey)) { @@ -587,11 +587,11 @@ public class RouterActivity extends AppCompatActivity { playQueue = info instanceof ChannelInfo ? new ChannelPlayQueue((ChannelInfo) info) : new PlaylistPlayQueue((PlaylistInfo) info); if (playerChoice.equals(videoPlayerKey)) { - NavigationHelper.playOnMainPlayer(this, playQueue); + NavigationHelper.playOnMainPlayer(this, playQueue, true); } else if (playerChoice.equals(backgroundPlayerKey)) { - NavigationHelper.playOnBackgroundPlayer(this, playQueue); + NavigationHelper.playOnBackgroundPlayer(this, playQueue, true); } else if (playerChoice.equals(popupPlayerKey)) { - NavigationHelper.playOnPopupPlayer(this, playQueue); + NavigationHelper.playOnPopupPlayer(this, playQueue, true); } } }; diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java index 847153e12..50d723f1f 100644 --- a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java +++ b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java @@ -50,6 +50,11 @@ public abstract class StreamHistoryDAO implements HistoryDAO> getHistory(); + @Query("SELECT * FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + + " = :streamId ORDER BY " + STREAM_ACCESS_DATE + " DESC LIMIT 1") + @Nullable + public abstract StreamHistoryEntity getLatestEntry(final long streamId); + @Query("DELETE FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") public abstract int deleteStreamHistory(final long streamId); diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java index 15940a964..d46d5cd74 100644 --- a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java +++ b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java @@ -4,6 +4,9 @@ package org.schabi.newpipe.database.stream.model; import android.arch.persistence.room.ColumnInfo; import android.arch.persistence.room.Entity; import android.arch.persistence.room.ForeignKey; +import android.support.annotation.Nullable; + +import java.util.concurrent.TimeUnit; import static android.arch.persistence.room.ForeignKey.CASCADE; import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID; @@ -22,6 +25,12 @@ public class StreamStateEntity { final public static String JOIN_STREAM_ID = "stream_id"; final public static String STREAM_PROGRESS_TIME = "progress_time"; + + /** Playback state will not be saved, if playback time less than this threshold */ + private static final int PLAYBACK_SAVE_THRESHOLD_START_SECONDS = 5; + /** Playback state will not be saved, if time left less than this threshold */ + private static final int PLAYBACK_SAVE_THRESHOLD_END_SECONDS = 10; + @ColumnInfo(name = JOIN_STREAM_ID) private long streamUid; @@ -48,4 +57,18 @@ public class StreamStateEntity { public void setProgressTime(long progressTime) { this.progressTime = progressTime; } + + public boolean isValid(int durationInSeconds) { + final int seconds = (int) TimeUnit.MILLISECONDS.toSeconds(progressTime); + return seconds > PLAYBACK_SAVE_THRESHOLD_START_SECONDS + && seconds < durationInSeconds - PLAYBACK_SAVE_THRESHOLD_END_SECONDS; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (obj instanceof StreamStateEntity) { + return ((StreamStateEntity) obj).streamUid == streamUid + && ((StreamStateEntity) obj).progressTime == progressTime; + } else return false; + } } diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java index 7ee686a66..41971dfd4 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java @@ -55,7 +55,7 @@ public class DownloadActivity extends AppCompatActivity { private void updateFragments() { MissionsFragment fragment = new MissionsFragment(); - getFragmentManager().beginTransaction() + getSupportFragmentManager().beginTransaction() .replace(R.id.frame, fragment, MISSIONS_FRAGMENT_TAG) .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) .commit(); diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java index ec6d42b29..56ea9366d 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -1,14 +1,23 @@ package org.schabi.newpipe.download; +import android.app.Activity; +import android.content.ComponentName; import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; import android.content.SharedPreferences; +import android.net.Uri; import android.os.Bundle; +import android.os.IBinder; import android.preference.PreferenceManager; import android.support.annotation.IdRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.annotation.StringRes; import android.support.v4.app.DialogFragment; +import android.support.v4.provider.DocumentFile; import android.support.v7.app.AlertDialog; +import android.support.v7.view.menu.ActionMenuItemView; import android.support.v7.widget.Toolbar; import android.util.Log; import android.util.SparseArray; @@ -34,7 +43,8 @@ import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.extractor.utils.Localization; -import org.schabi.newpipe.settings.NewPipeSettings; +import org.schabi.newpipe.report.ErrorActivity; +import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.util.FilenameUtils; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.PermissionHelper; @@ -43,19 +53,27 @@ import org.schabi.newpipe.util.StreamItemAdapter; import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper; import org.schabi.newpipe.util.ThemeHelper; +import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Locale; import icepick.Icepick; import icepick.State; import io.reactivex.disposables.CompositeDisposable; +import us.shandian.giga.io.StoredDirectoryHelper; +import us.shandian.giga.io.StoredFileHelper; import us.shandian.giga.postprocessing.Postprocessing; +import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; +import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; +import us.shandian.giga.service.MissionState; public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheckedChangeListener, AdapterView.OnItemSelectedListener { private static final String TAG = "DialogFragment"; private static final boolean DEBUG = MainActivity.DEBUG; + private static final int REQUEST_DOWNLOAD_PATH_SAF = 0x1230; @State protected StreamInfo currentInfo; @@ -80,7 +98,7 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck private EditText nameEditText; private Spinner streamsSpinner; - private RadioGroup radioVideoAudioGroup; + private RadioGroup radioStreamsGroup; private TextView threadsCountTextView; private SeekBar threadsSeekBar; @@ -160,7 +178,9 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck return; } - setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(getContext())); + context = getContext(); + + setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context)); Icepick.restoreInstanceState(this, savedInstanceState); SparseArray> secondaryStreams = new SparseArray<>(4); @@ -177,9 +197,59 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck } } - this.videoStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedVideoStreams, secondaryStreams); - this.audioStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedAudioStreams); - this.subtitleStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedSubtitleStreams); + this.videoStreamsAdapter = new StreamItemAdapter<>(context, wrappedVideoStreams, secondaryStreams); + this.audioStreamsAdapter = new StreamItemAdapter<>(context, wrappedAudioStreams); + this.subtitleStreamsAdapter = new StreamItemAdapter<>(context, wrappedSubtitleStreams); + + Intent intent = new Intent(context, DownloadManagerService.class); + context.startService(intent); + + context.bindService(intent, new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName cname, IBinder service) { + DownloadManagerBinder mgr = (DownloadManagerBinder) service; + + mainStorageAudio = mgr.getMainStorageAudio(); + mainStorageVideo = mgr.getMainStorageVideo(); + downloadManager = mgr.getDownloadManager(); + askForSavePath = mgr.askForSavePath(); + + okButton.setEnabled(true); + + context.unbindService(this); + + // check of download paths are defined + if (!askForSavePath) { + String msg = ""; + if (mainStorageVideo == null) msg += getString(R.string.download_path_title); + if (mainStorageAudio == null) + msg += getString(R.string.download_path_audio_title); + + if (!msg.isEmpty()) { + String title; + if (mainStorageVideo == null && mainStorageAudio == null) { + title = getString(R.string.general_error); + msg = getString(R.string.no_available_dir) + ":\n" + msg; + } else { + title = msg; + msg = getString(R.string.no_available_dir); + } + + new AlertDialog.Builder(context) + .setPositiveButton(android.R.string.ok, null) + .setTitle(title) + .setMessage(msg) + .create() + .show(); + } + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + // nothing to do + } + }, Context.BIND_AUTO_CREATE); } @Override @@ -204,8 +274,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck threadsCountTextView = view.findViewById(R.id.threads_count); threadsSeekBar = view.findViewById(R.id.threads); - radioVideoAudioGroup = view.findViewById(R.id.video_audio_group); - radioVideoAudioGroup.setOnCheckedChangeListener(this); + radioStreamsGroup = view.findViewById(R.id.video_audio_group); + radioStreamsGroup.setOnCheckedChangeListener(this); initToolbar(view.findViewById(R.id.toolbar)); setupDownloadOptions(); @@ -240,17 +310,17 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck disposables.clear(); disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedVideoStreams).subscribe(result -> { - if (radioVideoAudioGroup.getCheckedRadioButtonId() == R.id.video_button) { + if (radioStreamsGroup.getCheckedRadioButtonId() == R.id.video_button) { setupVideoSpinner(); } })); disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedAudioStreams).subscribe(result -> { - if (radioVideoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { + if (radioStreamsGroup.getCheckedRadioButtonId() == R.id.audio_button) { setupAudioSpinner(); } })); disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedSubtitleStreams).subscribe(result -> { - if (radioVideoAudioGroup.getCheckedRadioButtonId() == R.id.subtitle_button) { + if (radioStreamsGroup.getCheckedRadioButtonId() == R.id.subtitle_button) { setupSubtitleSpinner(); } })); @@ -263,22 +333,49 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck } @Override - public void onSaveInstanceState(Bundle outState) { + public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); Icepick.saveInstanceState(this, outState); } + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == REQUEST_DOWNLOAD_PATH_SAF && resultCode == Activity.RESULT_OK) { + if (data.getData() == null) { + showFailedDialog(R.string.general_error); + return; + } + + DocumentFile docFile = DocumentFile.fromSingleUri(context, data.getData()); + if (docFile == null) { + showFailedDialog(R.string.general_error); + return; + } + + // check if the selected file was previously used + checkSelectedDownload(null, data.getData(), docFile.getName(), docFile.getType()); + } + } + /*////////////////////////////////////////////////////////////////////////// // Inits //////////////////////////////////////////////////////////////////////////*/ private void initToolbar(Toolbar toolbar) { if (DEBUG) Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]"); + + boolean isLight = ThemeHelper.isLightThemeSelected(getActivity()); + toolbar.setTitle(R.string.download_dialog_title); - toolbar.setNavigationIcon(ThemeHelper.isLightThemeSelected(getActivity()) ? R.drawable.ic_arrow_back_black_24dp : R.drawable.ic_arrow_back_white_24dp); + toolbar.setNavigationIcon(isLight ? R.drawable.ic_arrow_back_black_24dp : R.drawable.ic_arrow_back_white_24dp); toolbar.inflateMenu(R.menu.dialog_url); toolbar.setNavigationOnClickListener(v -> getDialog().dismiss()); + okButton = toolbar.findViewById(R.id.okay); + okButton.setEnabled(false);// disable until the download service connection is done + toolbar.setOnMenuItemClickListener(item -> { if (item.getItemId() == R.id.okay) { prepareSelectedDownload(); @@ -346,7 +443,7 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck public void onItemSelected(AdapterView parent, View view, int position, long id) { if (DEBUG) Log.d(TAG, "onItemSelected() called with: parent = [" + parent + "], view = [" + view + "], position = [" + position + "], id = [" + id + "]"); - switch (radioVideoAudioGroup.getCheckedRadioButtonId()) { + switch (radioStreamsGroup.getCheckedRadioButtonId()) { case R.id.audio_button: selectedAudioIndex = position; break; @@ -370,9 +467,9 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck protected void setupDownloadOptions() { setRadioButtonsState(false); - final RadioButton audioButton = radioVideoAudioGroup.findViewById(R.id.audio_button); - final RadioButton videoButton = radioVideoAudioGroup.findViewById(R.id.video_button); - final RadioButton subtitleButton = radioVideoAudioGroup.findViewById(R.id.subtitle_button); + final RadioButton audioButton = radioStreamsGroup.findViewById(R.id.audio_button); + final RadioButton videoButton = radioStreamsGroup.findViewById(R.id.video_button); + final RadioButton subtitleButton = radioStreamsGroup.findViewById(R.id.subtitle_button); final boolean isVideoStreamsAvailable = videoStreamsAdapter.getCount() > 0; final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0; final boolean isSubtitleStreamsAvailable = subtitleStreamsAdapter.getCount() > 0; @@ -397,9 +494,9 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck } private void setRadioButtonsState(boolean enabled) { - radioVideoAudioGroup.findViewById(R.id.audio_button).setEnabled(enabled); - radioVideoAudioGroup.findViewById(R.id.video_button).setEnabled(enabled); - radioVideoAudioGroup.findViewById(R.id.subtitle_button).setEnabled(enabled); + radioStreamsGroup.findViewById(R.id.audio_button).setEnabled(enabled); + radioStreamsGroup.findViewById(R.id.video_button).setEnabled(enabled); + radioStreamsGroup.findViewById(R.id.subtitle_button).setEnabled(enabled); } private int getSubtitleIndexBy(List streams) { @@ -434,98 +531,297 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck return 0; } + StoredDirectoryHelper mainStorageAudio = null; + StoredDirectoryHelper mainStorageVideo = null; + DownloadManager downloadManager = null; + ActionMenuItemView okButton = null; + Context context; + boolean askForSavePath; + + private String getNameEditText() { + String str = nameEditText.getText().toString().trim(); + + return FilenameUtils.createFilename(context, str.isEmpty() ? currentInfo.getName() : str); + } + + private void showFailedDialog(@StringRes int msg) { + new AlertDialog.Builder(context) + .setTitle(R.string.general_error) + .setMessage(msg) + .setNegativeButton(android.R.string.ok, null) + .create() + .show(); + } + + private void showErrorActivity(Exception e) { + ErrorActivity.reportError( + context, + Collections.singletonList(e), + null, + null, + ErrorActivity.ErrorInfo.make(UserAction.SOMETHING_ELSE, "-", "-", R.string.general_error) + ); + } + private void prepareSelectedDownload() { - final Context context = getContext(); - Stream stream; - String location; - char kind; + StoredDirectoryHelper mainStorage; + MediaFormat format; + String mime; - String fileName = nameEditText.getText().toString().trim(); - if (fileName.isEmpty()) - fileName = FilenameUtils.createFilename(context, currentInfo.getName()); + // first, build the filename and get the output folder (if possible) + // later, run a very very very large file checking logic - switch (radioVideoAudioGroup.getCheckedRadioButtonId()) { + String filename = getNameEditText().concat("."); + + switch (radioStreamsGroup.getCheckedRadioButtonId()) { case R.id.audio_button: - stream = audioStreamsAdapter.getItem(selectedAudioIndex); - location = NewPipeSettings.getAudioDownloadPath(context); - kind = 'a'; + mainStorage = mainStorageAudio; + format = audioStreamsAdapter.getItem(selectedAudioIndex).getFormat(); + mime = format.mimeType; + filename += format.suffix; break; case R.id.video_button: - stream = videoStreamsAdapter.getItem(selectedVideoIndex); - location = NewPipeSettings.getVideoDownloadPath(context); - kind = 'v'; + mainStorage = mainStorageVideo; + format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); + mime = format.mimeType; + filename += format.suffix; break; case R.id.subtitle_button: - stream = subtitleStreamsAdapter.getItem(selectedSubtitleIndex); - location = NewPipeSettings.getVideoDownloadPath(context);// assume that subtitle & video files go together - kind = 's'; + mainStorage = mainStorageVideo;// subtitle & video files go together + format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat(); + mime = format.mimeType; + filename += format == MediaFormat.TTML ? MediaFormat.SRT.suffix : format.suffix; + break; + default: + throw new RuntimeException("No stream selected"); + } + + if (mainStorage == null || askForSavePath) { + // This part is called if with SAF preferred: + // * older android version running + // * save path not defined (via download settings) + // * the user as checked the "ask where to download" option + + StoredFileHelper.requestSafWithFileCreation(this, REQUEST_DOWNLOAD_PATH_SAF, filename, mime); + return; + } + + // check for existing file with the same name + checkSelectedDownload(mainStorage, mainStorage.findFile(filename), filename, mime); + } + + private void checkSelectedDownload(StoredDirectoryHelper mainStorage, Uri targetFile, String filename, String mime) { + StoredFileHelper storage; + + try { + if (mainStorage == null) { + // using SAF on older android version + storage = new StoredFileHelper(context, null, targetFile, ""); + } else if (targetFile == null) { + // the file does not exist, but it is probably used in a pending download + storage = new StoredFileHelper(mainStorage.getUri(), filename, mime, mainStorage.getTag()); + } else { + // the target filename is already use, attempt to use it + storage = new StoredFileHelper(context, mainStorage.getUri(), targetFile, mainStorage.getTag()); + } + } catch (Exception e) { + showErrorActivity(e); + return; + } + + // check if is our file + MissionState state = downloadManager.checkForExistingMission(storage); + @StringRes int msgBtn; + @StringRes int msgBody; + + switch (state) { + case Finished: + msgBtn = R.string.overwrite; + msgBody = R.string.overwrite_finished_warning; + break; + case Pending: + msgBtn = R.string.overwrite; + msgBody = R.string.download_already_pending; + break; + case PendingRunning: + msgBtn = R.string.generate_unique_name; + msgBody = R.string.download_already_running; + break; + case None: + if (mainStorage == null) { + // This part is called if: + // * using SAF on older android version + // * save path not defined + continueSelectedDownload(storage); + return; + } else if (targetFile == null) { + // This part is called if: + // * the filename is not used in a pending/finished download + // * the file does not exists, create + + if (!mainStorage.mkdirs()) { + showFailedDialog(R.string.error_path_creation); + return; + } + + storage = mainStorage.createFile(filename, mime); + if (storage == null || !storage.canWrite()) { + showFailedDialog(R.string.error_file_creation); + return; + } + + continueSelectedDownload(storage); + return; + } + msgBtn = R.string.overwrite; + msgBody = R.string.overwrite_unrelated_warning; 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(); + AlertDialog.Builder askDialog = new AlertDialog.Builder(context) + .setTitle(R.string.download_dialog_title) + .setMessage(msgBody) + .setNegativeButton(android.R.string.cancel, null); + final StoredFileHelper finalStorage = storage; + + + if (mainStorage == null) { + // This part is called if: + // * using SAF on older android version + // * save path not defined + switch (state) { + case Pending: + case Finished: + askDialog.setPositiveButton(msgBtn, (dialog, which) -> { + dialog.dismiss(); + downloadManager.forgetMission(finalStorage); + continueSelectedDownload(finalStorage); + }); + break; + } + + askDialog.create().show(); + return; } - final String finalFileName = fileName; + askDialog.setPositiveButton(msgBtn, (dialog, which) -> { + dialog.dismiss(); - DownloadManagerService.checkForRunningMission(context, location, fileName, (listed, finished) -> { - 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); + StoredFileHelper storageNew; + switch (state) { + case Finished: + case Pending: + downloadManager.forgetMission(finalStorage); + case None: + if (targetFile == null) { + storageNew = mainStorage.createFile(filename, mime); + } else { + try { + // try take (or steal) the file + storageNew = new StoredFileHelper(context, mainStorage.getUri(), targetFile, mainStorage.getTag()); + } catch (IOException e) { + Log.e(TAG, "Failed to take (or steal) the file in " + targetFile.toString()); + storageNew = null; + } + } + + if (storageNew != null && storageNew.canWrite()) + continueSelectedDownload(storageNew); + else + showFailedDialog(R.string.error_file_creation); + break; + case PendingRunning: + storageNew = mainStorage.createUniqueFile(filename, mime); + if (storageNew == null) + showFailedDialog(R.string.error_file_creation); + else + continueSelectedDownload(storageNew); + break; } }); + + askDialog.create().show(); } - private void downloadSelected(Context context, Stream selectedStream, String location, String fileName, char kind, int threads) { + private void continueSelectedDownload(@NonNull StoredFileHelper storage) { + if (!storage.canWrite()) { + showFailedDialog(R.string.permission_denied); + return; + } + + // check if the selected file has to be overwritten, by simply checking its length + try { + if (storage.length() > 0) storage.truncate(); + } catch (IOException e) { + Log.e(TAG, "failed to overwrite the file: " + storage.getUri().toString(), e); + showFailedDialog(R.string.overwrite_failed); + return; + } + + Stream selectedStream; + char kind; + int threads = threadsSeekBar.getProgress() + 1; String[] urls; String psName = null; String[] psArgs = null; String secondaryStreamUrl = null; long nearLength = 0; - if (selectedStream instanceof VideoStream) { - SecondaryStreamHelper secondaryStream = videoStreamsAdapter - .getAllSecondary() - .get(wrappedVideoStreams.getStreamsList().indexOf(selectedStream)); + // more download logic: select muxer, subtitle converter, etc. + switch (radioStreamsGroup.getCheckedRadioButtonId()) { + case R.id.audio_button: + threads = 1;// use unique thread for subtitles due small file size + kind = 'a'; + selectedStream = audioStreamsAdapter.getItem(selectedAudioIndex); - if (secondaryStream != null) { - secondaryStreamUrl = secondaryStream.getStream().getUrl(); - psName = selectedStream.getFormat() == MediaFormat.MPEG_4 ? Postprocessing.ALGORITHM_MP4_MUXER : Postprocessing.ALGORITHM_WEBM_MUXER; - psArgs = null; - long videoSize = wrappedVideoStreams.getSizeInBytes((VideoStream) selectedStream); - - // set nearLength, only, if both sizes are fetched or known. this probably does not work on slow networks - if (secondaryStream.getSizeInBytes() > 0 && videoSize > 0) { - nearLength = secondaryStream.getSizeInBytes() + videoSize; + if (selectedStream.getFormat() == MediaFormat.M4A) { + psName = Postprocessing.ALGORITHM_M4A_NO_DASH; } - } - } else if ((selectedStream instanceof SubtitlesStream) && selectedStream.getFormat() == MediaFormat.TTML) { - psName = Postprocessing.ALGORITHM_TTML_CONVERTER; - psArgs = new String[]{ - selectedStream.getFormat().getSuffix(), - "false",// ignore empty frames - "false",// detect youtube duplicate lines - }; + break; + case R.id.video_button: + kind = 'v'; + selectedStream = videoStreamsAdapter.getItem(selectedVideoIndex); + + SecondaryStreamHelper secondaryStream = videoStreamsAdapter + .getAllSecondary() + .get(wrappedVideoStreams.getStreamsList().indexOf(selectedStream)); + + if (secondaryStream != null) { + secondaryStreamUrl = secondaryStream.getStream().getUrl(); + + if (selectedStream.getFormat() == MediaFormat.MPEG_4) + psName = Postprocessing.ALGORITHM_MP4_FROM_DASH_MUXER; + else + psName = Postprocessing.ALGORITHM_WEBM_MUXER; + + psArgs = null; + long videoSize = wrappedVideoStreams.getSizeInBytes((VideoStream) selectedStream); + + // set nearLength, only, if both sizes are fetched or known. This probably + // does not work on slow networks but is later updated in the downloader + if (secondaryStream.getSizeInBytes() > 0 && videoSize > 0) { + nearLength = secondaryStream.getSizeInBytes() + videoSize; + } + } + break; + case R.id.subtitle_button: + kind = 's'; + selectedStream = subtitleStreamsAdapter.getItem(selectedSubtitleIndex); + + if (selectedStream.getFormat() == MediaFormat.TTML) { + psName = Postprocessing.ALGORITHM_TTML_CONVERTER; + psArgs = new String[]{ + selectedStream.getFormat().getSuffix(), + "false",// ignore empty frames + "false",// detect youtube duplicate lines + }; + } + break; + default: + return; } if (secondaryStreamUrl == null) { @@ -534,8 +830,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck urls = new String[]{selectedStream.getUrl(), secondaryStreamUrl}; } - DownloadManagerService.startMission(context, urls, location, fileName, kind, threads, currentInfo.getUrl(), psName, psArgs, nearLength); + DownloadManagerService.startMission(context, urls, storage, kind, threads, currentInfo.getUrl(), psName, psArgs, nearLength); - getDialog().dismiss(); + dismiss(); } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java index acee1f111..4546483d2 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java @@ -230,21 +230,4 @@ public abstract class BaseStateFragment extends BaseFragment implements ViewC ErrorActivity.reportError(getContext(), exception, MainActivity.class, rootView, ErrorActivity.ErrorInfo.make(userAction, serviceName, request, errorId)); } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - protected void openUrlInBrowser(String url) { - Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - startActivity(Intent.createChooser(intent, activity.getString(R.string.share_dialog_title))); - } - - protected void shareUrl(String subject, String url) { - Intent intent = new Intent(Intent.ACTION_SEND); - intent.setType("text/plain"); - intent.putExtra(Intent.EXTRA_SUBJECT, subject); - intent.putExtra(Intent.EXTRA_TEXT, url); - startActivity(Intent.createChooser(intent, getString(R.string.share_dialog_title))); - } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdaptor.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdaptor.java index 27cc3ec8a..8314f9539 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdaptor.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdaptor.java @@ -1,5 +1,6 @@ package org.schabi.newpipe.fragments.detail; +import android.support.annotation.Nullable; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentManager; import android.support.v4.app.FragmentPagerAdapter; @@ -61,6 +62,18 @@ public class TabAdaptor extends FragmentPagerAdapter { else return POSITION_NONE; } + public int getItemPositionByTitle(String title) { + return mFragmentTitleList.indexOf(title); + } + + @Nullable + public String getItemTitle(int position) { + if (position < 0 || position >= mFragmentTitleList.size()) { + return null; + } + return mFragmentTitleList.get(position); + } + public void notifyDataSetUpdate(){ notifyDataSetChanged(); } 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 8c87c1875..c80aa5658 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 @@ -86,14 +86,17 @@ import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; +import org.schabi.newpipe.util.ShareUtils; import org.schabi.newpipe.util.StreamItemAdapter; import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper; +import org.schabi.newpipe.views.AnimatedProgressBar; import java.io.Serializable; import java.util.Collection; import java.util.Collections; import java.util.LinkedList; import java.util.List; +import java.util.concurrent.TimeUnit; import icepick.State; import io.reactivex.Single; @@ -117,11 +120,12 @@ public class VideoDetailFragment private static final int RELATED_STREAMS_UPDATE_FLAG = 0x1; private static final int RESOLUTIONS_MENU_UPDATE_FLAG = 0x2; private static final int TOOLBAR_ITEMS_UPDATE_FLAG = 0x4; - private static final int COMMENTS_UPDATE_FLAG = 0x4; + private static final int COMMENTS_UPDATE_FLAG = 0x8; private boolean autoPlayEnabled; private boolean showRelatedStreams; private boolean showComments; + private String selectedTabTag; @State protected int serviceId = Constants.NO_SERVICE_ID; @@ -134,6 +138,8 @@ public class VideoDetailFragment private Disposable currentWorker; @NonNull private CompositeDisposable disposables = new CompositeDisposable(); + @Nullable + private Disposable positionSubscriber = null; private List sortedVideoStreams; private int selectedVideoStreamIndex = -1; @@ -151,6 +157,7 @@ public class VideoDetailFragment private View thumbnailBackgroundButton; private ImageView thumbnailImageView; private ImageView thumbnailPlayButton; + private AnimatedProgressBar positionView; private View videoTitleRoot; private TextView videoTitleTextView; @@ -163,6 +170,7 @@ public class VideoDetailFragment private TextView detailControlsDownload; private TextView appendControlsDetail; private TextView detailDurationView; + private TextView detailPositionView; private LinearLayout videoDescriptionRootLayout; private TextView videoUploadDateView; @@ -213,6 +221,9 @@ public class VideoDetailFragment showComments = PreferenceManager.getDefaultSharedPreferences(activity) .getBoolean(getString(R.string.show_comments_key), true); + selectedTabTag = PreferenceManager.getDefaultSharedPreferences(activity) + .getString(getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG); + PreferenceManager.getDefaultSharedPreferences(activity) .registerOnSharedPreferenceChangeListener(this); } @@ -226,6 +237,10 @@ public class VideoDetailFragment public void onPause() { super.onPause(); if (currentWorker != null) currentWorker.dispose(); + PreferenceManager.getDefaultSharedPreferences(getContext()) + .edit() + .putString(getString(R.string.stream_info_selected_tab_key), pageAdapter.getItemTitle(viewPager.getCurrentItem())) + .apply(); } @Override @@ -250,6 +265,8 @@ public class VideoDetailFragment // Check if it was loading when the fragment was stopped/paused, if (wasLoading.getAndSet(false)) { selectAndLoadVideo(serviceId, url, name); + } else if (currentInfo != null) { + updateProgressInfo(currentInfo); } } @@ -259,8 +276,10 @@ public class VideoDetailFragment PreferenceManager.getDefaultSharedPreferences(activity) .unregisterOnSharedPreferenceChangeListener(this); + if (positionSubscriber != null) positionSubscriber.dispose(); if (currentWorker != null) currentWorker.dispose(); if (disposables != null) disposables.clear(); + positionSubscriber = null; currentWorker = null; disposables = null; } @@ -453,6 +472,7 @@ public class VideoDetailFragment videoTitleTextView = rootView.findViewById(R.id.detail_video_title_view); videoTitleToggleArrow = rootView.findViewById(R.id.detail_toggle_description_view); videoCountView = rootView.findViewById(R.id.detail_view_count_view); + positionView = rootView.findViewById(R.id.position_view); detailControlsBackground = rootView.findViewById(R.id.detail_controls_background); detailControlsPopup = rootView.findViewById(R.id.detail_controls_popup); @@ -460,6 +480,7 @@ public class VideoDetailFragment detailControlsDownload = rootView.findViewById(R.id.detail_controls_download); appendControlsDetail = rootView.findViewById(R.id.touch_append_detail); detailDurationView = rootView.findViewById(R.id.detail_duration_view); + detailPositionView = rootView.findViewById(R.id.detail_position_view); videoDescriptionRootLayout = rootView.findViewById(R.id.detail_description_root_layout); videoUploadDateView = rootView.findViewById(R.id.detail_upload_date_view); @@ -527,10 +548,10 @@ public class VideoDetailFragment final DialogInterface.OnClickListener actions = (DialogInterface dialogInterface, int i) -> { switch (i) { case 0: - NavigationHelper.enqueueOnBackgroundPlayer(context, new SinglePlayQueue(item)); + NavigationHelper.enqueueOnBackgroundPlayer(context, new SinglePlayQueue(item), true); break; case 1: - NavigationHelper.enqueueOnPopupPlayer(getActivity(), new SinglePlayQueue(item)); + NavigationHelper.enqueueOnPopupPlayer(getActivity(), new SinglePlayQueue(item), true); break; case 2: if (getFragmentManager() != null) { @@ -539,7 +560,7 @@ public class VideoDetailFragment } break; case 3: - shareUrl(item.getName(), item.getUrl()); + ShareUtils.shareUrl(this.getContext(), item.getName(), item.getUrl()); break; default: break; @@ -628,13 +649,13 @@ public class VideoDetailFragment switch (id) { case R.id.menu_item_share: { if (currentInfo != null) { - shareUrl(currentInfo.getName(), currentInfo.getOriginalUrl()); + ShareUtils.shareUrl(this.getContext(), currentInfo.getName(), currentInfo.getOriginalUrl()); } return true; } case R.id.menu_item_openInBrowser: { if (currentInfo != null) { - openUrlInBrowser(currentInfo.getOriginalUrl()); + ShareUtils.openUrlInBrowser(this.getContext(), currentInfo.getOriginalUrl()); } return true; } @@ -815,6 +836,9 @@ public class VideoDetailFragment } private void initTabs() { + if (pageAdapter.getCount() != 0) { + selectedTabTag = pageAdapter.getItemTitle(viewPager.getCurrentItem()); + } pageAdapter.clearAllItems(); if(shouldShowComments()){ @@ -835,6 +859,8 @@ public class VideoDetailFragment if(pageAdapter.getCount() < 2){ tabLayout.setVisibility(View.GONE); }else{ + int position = pageAdapter.getItemPositionByTitle(selectedTabTag); + if(position != -1) viewPager.setCurrentItem(position); tabLayout.setVisibility(View.VISIBLE); } } @@ -876,11 +902,11 @@ public class VideoDetailFragment final PlayQueue itemQueue = new SinglePlayQueue(currentInfo); if (append) { - NavigationHelper.enqueueOnPopupPlayer(activity, itemQueue); + NavigationHelper.enqueueOnPopupPlayer(activity, itemQueue, false); } else { Toast.makeText(activity, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show(); final Intent intent = NavigationHelper.getPlayerIntent( - activity, PopupVideoPlayer.class, itemQueue, getSelectedVideoStream().resolution + activity, PopupVideoPlayer.class, itemQueue, getSelectedVideoStream().resolution, true ); activity.startService(intent); } @@ -900,9 +926,9 @@ public class VideoDetailFragment private void openNormalBackgroundPlayer(final boolean append) { final PlayQueue itemQueue = new SinglePlayQueue(currentInfo); if (append) { - NavigationHelper.enqueueOnBackgroundPlayer(activity, itemQueue); + NavigationHelper.enqueueOnBackgroundPlayer(activity, itemQueue, false); } else { - NavigationHelper.playOnBackgroundPlayer(activity, itemQueue); + NavigationHelper.playOnBackgroundPlayer(activity, itemQueue, true); } } @@ -912,7 +938,7 @@ public class VideoDetailFragment mIntent = NavigationHelper.getPlayerIntent(activity, MainVideoPlayer.class, playQueue, - getSelectedVideoStream().getResolution()); + getSelectedVideoStream().getResolution(), true); startActivity(mIntent); } @@ -1018,6 +1044,8 @@ public class VideoDetailFragment animateView(spinnerToolbar, false, 200); animateView(thumbnailPlayButton, false, 50); animateView(detailDurationView, false, 100); + animateView(detailPositionView, false, 100); + animateView(positionView, false, 50); videoTitleTextView.setText(name != null ? name : ""); videoTitleTextView.setMaxLines(1); @@ -1132,6 +1160,7 @@ public class VideoDetailFragment videoUploadDateView.setText(Localization.localizeDate(activity, info.getUploadDate())); } prepareDescription(info.getDescription()); + updateProgressInfo(info); animateView(spinnerToolbar, true, 500); setupActionBar(info); @@ -1181,7 +1210,7 @@ public class VideoDetailFragment downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex); downloadDialog.setSubtitleStreams(currentInfo.getSubtitles()); - downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog"); + downloadDialog.show(getActivity().getSupportFragmentManager(), "downloadDialog"); } catch (Exception e) { ErrorActivity.ErrorInfo info = ErrorActivity.ErrorInfo.make(UserAction.UI_ERROR, ServiceList.all() @@ -1206,9 +1235,7 @@ public class VideoDetailFragment protected boolean onError(Throwable exception) { if (super.onError(exception)) return true; - if (exception instanceof YoutubeStreamExtractor.GemaException) { - onBlockedByGemaError(); - } else if (exception instanceof ContentNotAvailableException) { + else if (exception instanceof ContentNotAvailableException) { showError(getString(R.string.content_not_available), false); } else { int errorId = exception instanceof YoutubeStreamExtractor.DecryptException @@ -1226,14 +1253,36 @@ public class VideoDetailFragment return true; } - public void onBlockedByGemaError() { - thumbnailBackgroundButton.setOnClickListener((View v) -> { - Intent intent = new Intent(); - intent.setAction(Intent.ACTION_VIEW); - intent.setData(Uri.parse(getString(R.string.c3s_url))); - startActivity(intent); - }); - - showError(getString(R.string.blocked_by_gema), false, R.drawable.gruese_die_gema); + private void updateProgressInfo(@NonNull final StreamInfo info) { + if (positionSubscriber != null) { + positionSubscriber.dispose(); + } + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); + final boolean playbackResumeEnabled = + prefs.getBoolean(activity.getString(R.string.enable_watch_history_key), true) + && prefs.getBoolean(activity.getString(R.string.enable_playback_resume_key), true); + if (!playbackResumeEnabled || info.getDuration() <= 0) { + positionView.setVisibility(View.INVISIBLE); + detailPositionView.setVisibility(View.GONE); + return; + } + final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext()); + positionSubscriber = recordManager.loadStreamState(info) + .subscribeOn(Schedulers.io()) + .onErrorComplete() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(state -> { + final int seconds = (int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()); + positionView.setMax((int) info.getDuration()); + positionView.setProgressAnimated(seconds); + detailPositionView.setText(Localization.getDurationString(seconds)); + animateView(positionView, true, 500); + animateView(detailPositionView, true, 500); + }, e -> { + if (DEBUG) e.printStackTrace(); + }, () -> { + animateView(positionView, false, 500); + animateView(detailPositionView, false, 500); + }); } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index b61fe0d02..d9c58fbf4 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java @@ -34,6 +34,7 @@ import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; +import org.schabi.newpipe.util.ShareUtils; import org.schabi.newpipe.util.StateSaver; import java.util.Collections; @@ -64,6 +65,12 @@ public abstract class BaseListFragment extends BaseStateFragment implem infoListAdapter = new InfoListAdapter(activity); } + @Override + public void onDetach() { + infoListAdapter.dispose(); + super.onDetach(); + } + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -93,6 +100,8 @@ public abstract class BaseListFragment extends BaseStateFragment implem } updateFlags = 0; } + + itemsList.post(infoListAdapter::updateStates); } /*////////////////////////////////////////////////////////////////////////// @@ -255,6 +264,7 @@ public abstract class BaseListFragment extends BaseStateFragment implem if (context == null || context.getResources() == null || getActivity() == null) return; final String[] commands = new String[]{ + context.getResources().getString(R.string.direct_on_background), context.getResources().getString(R.string.enqueue_on_background), context.getResources().getString(R.string.enqueue_on_popup), context.getResources().getString(R.string.append_playlist), @@ -264,19 +274,22 @@ public abstract class BaseListFragment extends BaseStateFragment implem final DialogInterface.OnClickListener actions = (dialogInterface, i) -> { switch (i) { case 0: - NavigationHelper.enqueueOnBackgroundPlayer(context, new SinglePlayQueue(item)); + NavigationHelper.playOnBackgroundPlayer(context, new SinglePlayQueue(item), true); break; case 1: - NavigationHelper.enqueueOnPopupPlayer(activity, new SinglePlayQueue(item)); + NavigationHelper.enqueueOnBackgroundPlayer(context, new SinglePlayQueue(item), true); break; case 2: + NavigationHelper.enqueueOnPopupPlayer(activity, new SinglePlayQueue(item), true); + break; + case 3: if (getFragmentManager() != null) { PlaylistAppendDialog.fromStreamInfoItems(Collections.singletonList(item)) .show(getFragmentManager(), TAG); } break; - case 3: - shareUrl(item.getName(), item.getUrl()); + case 4: + ShareUtils.shareUrl(this.getContext(), item.getName(), item.getUrl()); break; default: break; diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index b9489ffa7..934e934e9 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -46,6 +46,7 @@ import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.ShareUtils; import java.util.ArrayList; import java.util.Collections; @@ -169,19 +170,19 @@ public class ChannelFragment extends BaseListInfoFragment { final int index = Math.max(infoListAdapter.getItemsList().indexOf(item), 0); switch (i) { case 0: - NavigationHelper.enqueueOnBackgroundPlayer(context, new SinglePlayQueue(item)); + NavigationHelper.enqueueOnBackgroundPlayer(context, new SinglePlayQueue(item), false); break; case 1: - NavigationHelper.enqueueOnPopupPlayer(activity, new SinglePlayQueue(item)); + NavigationHelper.enqueueOnPopupPlayer(activity, new SinglePlayQueue(item), false); break; case 2: - NavigationHelper.playOnMainPlayer(context, getPlayQueue(index)); + NavigationHelper.playOnMainPlayer(context, getPlayQueue(index), true); break; case 3: - NavigationHelper.playOnBackgroundPlayer(context, getPlayQueue(index)); + NavigationHelper.playOnBackgroundPlayer(context, getPlayQueue(index), true); break; case 4: - NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(index)); + NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(index), true); break; case 5: if (getFragmentManager() != null) { @@ -190,7 +191,7 @@ public class ChannelFragment extends BaseListInfoFragment { } break; case 6: - shareUrl(item.getName(), item.getUrl()); + ShareUtils.shareUrl(this.getContext(), item.getName(), item.getUrl()); break; default: break; @@ -233,10 +234,10 @@ public class ChannelFragment extends BaseListInfoFragment { openRssFeed(); break; case R.id.menu_item_openInBrowser: - openUrlInBrowser(currentInfo.getOriginalUrl()); + ShareUtils.openUrlInBrowser(this.getContext(), currentInfo.getOriginalUrl()); break; case R.id.menu_item_share: - shareUrl(name, currentInfo.getOriginalUrl()); + ShareUtils.shareUrl(this.getContext(), name, currentInfo.getOriginalUrl()); break; default: return super.onOptionsItemSelected(item); @@ -439,11 +440,11 @@ public class ChannelFragment extends BaseListInfoFragment { monitorSubscription(result); headerPlayAllButton.setOnClickListener( - view -> NavigationHelper.playOnMainPlayer(activity, getPlayQueue())); + view -> NavigationHelper.playOnMainPlayer(activity, getPlayQueue(), false)); headerPopupButton.setOnClickListener( - view -> NavigationHelper.playOnPopupPlayer(activity, getPlayQueue())); + view -> NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false)); headerBackgroundButton.setOnClickListener( - view -> NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue())); + view -> NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false)); } private PlayQueue getPlayQueue() { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java index d552b4e66..77aa0a250 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java @@ -40,6 +40,7 @@ import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.ShareUtils; import org.schabi.newpipe.util.ThemeHelper; import java.util.ArrayList; @@ -153,22 +154,22 @@ public class PlaylistFragment extends BaseListInfoFragment { final int index = Math.max(infoListAdapter.getItemsList().indexOf(item), 0); switch (i) { case 0: - NavigationHelper.enqueueOnBackgroundPlayer(context, new SinglePlayQueue(item)); + NavigationHelper.enqueueOnBackgroundPlayer(context, new SinglePlayQueue(item), false); break; case 1: - NavigationHelper.enqueueOnPopupPlayer(activity, new SinglePlayQueue(item)); + NavigationHelper.enqueueOnPopupPlayer(activity, new SinglePlayQueue(item), false); break; case 2: - NavigationHelper.playOnMainPlayer(context, getPlayQueue(index)); + NavigationHelper.playOnMainPlayer(context, getPlayQueue(index), true); break; case 3: - NavigationHelper.playOnBackgroundPlayer(context, getPlayQueue(index)); + NavigationHelper.playOnBackgroundPlayer(context, getPlayQueue(index), true); break; case 4: - NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(index)); + NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(index), true); break; case 5: - shareUrl(item.getName(), item.getUrl()); + ShareUtils.shareUrl(requireContext(), item.getName(), item.getUrl()); break; default: break; @@ -230,10 +231,10 @@ public class PlaylistFragment extends BaseListInfoFragment { public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.menu_item_openInBrowser: - openUrlInBrowser(url); + ShareUtils.openUrlInBrowser(this.getContext(), url); break; case R.id.menu_item_share: - shareUrl(name, url); + ShareUtils.shareUrl(this.getContext(), name, url); break; case R.id.menu_item_bookmark: onBookmarkClicked(); @@ -300,19 +301,19 @@ public class PlaylistFragment extends BaseListInfoFragment { .subscribe(getPlaylistBookmarkSubscriber()); headerPlayAllButton.setOnClickListener(view -> - NavigationHelper.playOnMainPlayer(activity, getPlayQueue())); + NavigationHelper.playOnMainPlayer(activity, getPlayQueue(), false)); headerPopupButton.setOnClickListener(view -> - NavigationHelper.playOnPopupPlayer(activity, getPlayQueue())); + NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false)); headerBackgroundButton.setOnClickListener(view -> - NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue())); + NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false)); headerPopupButton.setOnLongClickListener(view -> { - NavigationHelper.enqueueOnPopupPlayer(activity, getPlayQueue()); + NavigationHelper.enqueueOnPopupPlayer(activity, getPlayQueue(), true); return true; }); headerBackgroundButton.setOnLongClickListener(view -> { - NavigationHelper.enqueueOnBackgroundPlayer(activity, getPlayQueue()); + NavigationHelper.enqueueOnBackgroundPlayer(activity, getPlayQueue(), true); return true; }); } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java index 0e9fd3277..39f7971dd 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java @@ -2,12 +2,14 @@ package org.schabi.newpipe.info_list; import android.content.Context; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.util.Log; import android.view.View; import android.view.ViewGroup; import com.nostra13.universalimageloader.core.ImageLoader; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; @@ -59,13 +61,14 @@ public class InfoItemBuilder { this.context = context; } - public View buildView(@NonNull ViewGroup parent, @NonNull final InfoItem infoItem) { - return buildView(parent, infoItem, false); + public View buildView(@NonNull ViewGroup parent, @NonNull final InfoItem infoItem, @Nullable StreamStateEntity state) { + return buildView(parent, infoItem, state, false); } - public View buildView(@NonNull ViewGroup parent, @NonNull final InfoItem infoItem, boolean useMiniVariant) { + public View buildView(@NonNull ViewGroup parent, @NonNull final InfoItem infoItem, + @Nullable StreamStateEntity state, boolean useMiniVariant) { InfoItemHolder holder = holderFromInfoType(parent, infoItem.getInfoType(), useMiniVariant); - holder.updateFromItem(infoItem); + holder.updateFromItem(infoItem, state); return holder.itemView; } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java index 5e7095c7d..7f5b07dbe 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java @@ -1,22 +1,25 @@ package org.schabi.newpipe.info_list; import android.app.Activity; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.v7.widget.GridLayoutManager; import android.support.v7.widget.RecyclerView; import android.util.Log; import android.view.View; import android.view.ViewGroup; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.info_list.holder.ChannelGridInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder; import org.schabi.newpipe.info_list.holder.CommentsInfoItemHolder; import org.schabi.newpipe.info_list.holder.CommentsMiniInfoItemHolder; -import org.schabi.newpipe.info_list.holder.ChannelGridInfoItemHolder; import org.schabi.newpipe.info_list.holder.InfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder; @@ -50,7 +53,7 @@ import java.util.List; * along with NewPipe. If not, see . */ -public class InfoListAdapter extends RecyclerView.Adapter { +public class InfoListAdapter extends StateObjectsListAdapter { private static final String TAG = InfoListAdapter.class.getSimpleName(); private static final boolean DEBUG = false; @@ -87,6 +90,7 @@ public class InfoListAdapter extends RecyclerView.Adapter(); } @@ -115,50 +119,64 @@ public class InfoListAdapter extends RecyclerView.Adapter data) { + public void addInfoItemList(@Nullable final List data) { if (data != null) { - if (DEBUG) { - Log.d(TAG, "addInfoItemList() before > infoItemList.size() = " + infoItemList.size() + ", data.size() = " + data.size()); - } - - int offsetStart = sizeConsideringHeaderOffset(); - infoItemList.addAll(data); - - if (DEBUG) { - Log.d(TAG, "addInfoItemList() after > offsetStart = " + offsetStart + ", infoItemList.size() = " + infoItemList.size() + ", header = " + header + ", footer = " + footer + ", showFooter = " + showFooter); - } - - notifyItemRangeInserted(offsetStart, data.size()); - - if (footer != null && showFooter) { - int footerNow = sizeConsideringHeaderOffset(); - notifyItemMoved(offsetStart, footerNow); - - if (DEBUG) Log.d(TAG, "addInfoItemList() footer from " + offsetStart + " to " + footerNow); - } + loadStates(data, infoItemList.size(), () -> addInfoItemListImpl(data)); } } - public void addInfoItem(InfoItem data) { + private void addInfoItemListImpl(@NonNull List data) { + if (DEBUG) { + Log.d(TAG, "addInfoItemList() before > infoItemList.size() = " + infoItemList.size() + ", data.size() = " + data.size()); + } + + int offsetStart = sizeConsideringHeaderOffset(); + infoItemList.addAll(data); + + if (DEBUG) { + Log.d(TAG, "addInfoItemList() after > offsetStart = " + offsetStart + ", infoItemList.size() = " + infoItemList.size() + ", header = " + header + ", footer = " + footer + ", showFooter = " + showFooter); + } + + notifyItemRangeInserted(offsetStart, data.size()); + + if (footer != null && showFooter) { + int footerNow = sizeConsideringHeaderOffset(); + notifyItemMoved(offsetStart, footerNow); + + if (DEBUG) Log.d(TAG, "addInfoItemList() footer from " + offsetStart + " to " + footerNow); + } + } + + public void addInfoItem(@Nullable InfoItem data) { if (data != null) { - if (DEBUG) { - Log.d(TAG, "addInfoItem() before > infoItemList.size() = " + infoItemList.size() + ", thread = " + Thread.currentThread()); - } + loadState(data, infoItemList.size(), () -> addInfoItemImpl(data)); + } + } - int positionInserted = sizeConsideringHeaderOffset(); - infoItemList.add(data); + private void addInfoItemImpl(@NonNull InfoItem data) { + if (DEBUG) { + Log.d(TAG, "addInfoItem() before > infoItemList.size() = " + infoItemList.size() + ", thread = " + Thread.currentThread()); + } - if (DEBUG) { - Log.d(TAG, "addInfoItem() after > position = " + positionInserted + ", infoItemList.size() = " + infoItemList.size() + ", header = " + header + ", footer = " + footer + ", showFooter = " + showFooter); - } - notifyItemInserted(positionInserted); + int positionInserted = sizeConsideringHeaderOffset(); + infoItemList.add(data); - if (footer != null && showFooter) { - int footerNow = sizeConsideringHeaderOffset(); - notifyItemMoved(positionInserted, footerNow); + if (DEBUG) { + Log.d(TAG, "addInfoItem() after > position = " + positionInserted + ", infoItemList.size() = " + infoItemList.size() + ", header = " + header + ", footer = " + footer + ", showFooter = " + showFooter); + } + notifyItemInserted(positionInserted); - if (DEBUG) Log.d(TAG, "addInfoItem() footer from " + positionInserted + " to " + footerNow); - } + if (footer != null && showFooter) { + int footerNow = sizeConsideringHeaderOffset(); + notifyItemMoved(positionInserted, footerNow); + + if (DEBUG) Log.d(TAG, "addInfoItem() footer from " + positionInserted + " to " + footerNow); + } + } + + public void updateStates() { + if (!infoItemList.isEmpty()) { + updateAllStates(infoItemList); } } @@ -167,6 +185,7 @@ public class InfoListAdapter extends RecyclerView.Adapter payloads) { + if (!payloads.isEmpty() && holder instanceof InfoItemHolder) { + for (Object payload : payloads) { + if (payload instanceof StreamStateEntity) { + ((InfoItemHolder) holder).updateState(infoItemList.get(header == null ? position : position - 1), + (StreamStateEntity) payload); + } else if (payload instanceof Boolean) { + ((InfoItemHolder) holder).updateState(infoItemList.get(header == null ? position : position - 1), + null); + } + } + } else { + onBindViewHolder(holder, position); + } + } + + @Override + protected void onItemStateChanged(int position, @Nullable StreamStateEntity state) { + notifyItemChanged(header == null ? position : position + 1, state != null ? state : false); + } + public GridLayoutManager.SpanSizeLookup getSpanSizeLookup(final int spanCount) { return new GridLayoutManager.SpanSizeLookup() { @Override diff --git a/app/src/main/java/org/schabi/newpipe/info_list/StateObjectsListAdapter.java b/app/src/main/java/org/schabi/newpipe/info_list/StateObjectsListAdapter.java new file mode 100644 index 000000000..23ae17bac --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/StateObjectsListAdapter.java @@ -0,0 +1,189 @@ +package org.schabi.newpipe.info_list; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.support.annotation.Nullable; +import android.support.v7.widget.RecyclerView; +import android.util.SparseArray; + +import org.schabi.newpipe.BuildConfig; +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.local.history.HistoryRecordManager; +import org.schabi.newpipe.util.SparseArrayUtils; + +import java.util.List; +import java.util.Objects; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; + +public abstract class StateObjectsListAdapter extends RecyclerView.Adapter { + + private final SparseArray states; + private final HistoryRecordManager recordManager; + private final CompositeDisposable stateLoaders; + private final Context context; + + public StateObjectsListAdapter(Context context) { + this.states = new SparseArray<>(); + this.recordManager = new HistoryRecordManager(context); + this.context = context; + this.stateLoaders = new CompositeDisposable(); + } + + @Nullable + public StreamStateEntity getState(int position) { + return states.get(position); + } + + protected void clearStates() { + states.clear(); + } + + private void appendStates(List statesEntities, int offset) { + for (int i = 0; i < statesEntities.size(); i++) { + final StreamStateEntity state = statesEntities.get(i); + if (state != null) { + states.append(offset + i, state); + } + } + } + + private void appendState(StreamStateEntity statesEntity, int offset) { + if (statesEntity != null) { + states.append(offset, statesEntity); + } + } + + protected void removeState(int index) { + states.remove(index); + } + + protected void moveState(int from, int to) { + final StreamStateEntity item = states.get(from); + if (from < to) { + SparseArrayUtils.shiftItemsDown(states, from, to); + } else { + SparseArrayUtils.shiftItemsUp(states, to, from); + } + states.put(to, item); + } + + protected void loadStates(List list, int offset, Runnable callback) { + if (isPlaybackStatesVisible()) { + stateLoaders.add( + recordManager.loadStreamStateBatch(list) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(streamStateEntities -> { + appendStates(streamStateEntities, offset); + callback.run(); + }, throwable -> { + if (BuildConfig.DEBUG) throwable.printStackTrace(); + callback.run(); + }) + ); + } else { + callback.run(); + } + } + + protected void loadState(InfoItem item, int offset, Runnable callback) { + if (isPlaybackStatesVisible()) { + stateLoaders.add( + recordManager.loadStreamState(item) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(streamStateEntities -> { + appendState(streamStateEntities[0], offset); + callback.run(); + }, throwable -> { + if (BuildConfig.DEBUG) throwable.printStackTrace(); + callback.run(); + }) + ); + } else { + callback.run(); + } + } + + protected void loadStatesForLocal(List list, int offset, Runnable callback) { + if (isPlaybackStatesVisible()) { + stateLoaders.add( + recordManager.loadLocalStreamStateBatch(list) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(streamStateEntities -> { + appendStates(streamStateEntities, offset); + callback.run(); + }, throwable -> { + if (BuildConfig.DEBUG) throwable.printStackTrace(); + callback.run(); + }) + ); + } else { + callback.run(); + } + } + + private void processStatesUpdates(List streamStateEntities) { + for (int i = 0; i < streamStateEntities.size(); i++) { + final StreamStateEntity newState = streamStateEntities.get(i); + if (!Objects.equals(states.get(i), newState)) { + if (newState == null) { + states.remove(i); + } else { + states.put(i, newState); + } + onItemStateChanged(i, newState); + } + } + } + + protected void updateAllStates(List list) { + if (isPlaybackStatesVisible()) { + stateLoaders.add( + recordManager.loadStreamStateBatch(list) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::processStatesUpdates, throwable -> { + if (BuildConfig.DEBUG) throwable.printStackTrace(); + }) + ); + } else { + final int[] positions = SparseArrayUtils.getKeys(states); + states.clear(); + for (int pos : positions) onItemStateChanged(pos, null); + } + } + + protected void updateAllLocalStates(List list) { + if (isPlaybackStatesVisible()) { + stateLoaders.add( + recordManager.loadLocalStreamStateBatch(list) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::processStatesUpdates, throwable -> { + if (BuildConfig.DEBUG) throwable.printStackTrace(); + }) + ); + } else { + final int[] positions = SparseArrayUtils.getKeys(states); + states.clear(); + for (int pos : positions) onItemStateChanged(pos, null); + } + } + + public void dispose() { + stateLoaders.dispose(); + } + + protected boolean isPlaybackStatesVisible() { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + return prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true) + && prefs.getBoolean(context.getString(R.string.enable_playback_resume_key), true) + && prefs.getBoolean(context.getString(R.string.enable_playback_state_lists_key), true); + } + + protected abstract void onItemStateChanged(int position, @Nullable StreamStateEntity state); + +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.java index 1e5c726a4..317934455 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.java @@ -1,9 +1,11 @@ package org.schabi.newpipe.info_list.holder; +import android.support.annotation.Nullable; import android.view.ViewGroup; import android.widget.TextView; import org.schabi.newpipe.R; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; @@ -38,8 +40,8 @@ public class ChannelInfoItemHolder extends ChannelMiniInfoItemHolder { } @Override - public void updateFromItem(final InfoItem infoItem) { - super.updateFromItem(infoItem); + public void updateFromItem(final InfoItem infoItem, @Nullable final StreamStateEntity state) { + super.updateFromItem(infoItem, state); if (!(infoItem instanceof ChannelInfoItem)) return; final ChannelInfoItem item = (ChannelInfoItem) infoItem; diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java index ca783833a..a191707c0 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java @@ -1,9 +1,11 @@ package org.schabi.newpipe.info_list.holder; +import android.support.annotation.Nullable; import android.view.ViewGroup; import android.widget.TextView; import org.schabi.newpipe.R; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; @@ -30,7 +32,7 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder { } @Override - public void updateFromItem(final InfoItem infoItem) { + public void updateFromItem(final InfoItem infoItem, @Nullable final StreamStateEntity state) { if (!(infoItem instanceof ChannelInfoItem)) return; final ChannelInfoItem item = (ChannelInfoItem) infoItem; diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsInfoItemHolder.java index 46e4b4563..4ecf86961 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsInfoItemHolder.java @@ -1,14 +1,14 @@ package org.schabi.newpipe.info_list.holder; +import android.support.annotation.Nullable; import android.view.ViewGroup; import android.widget.TextView; import org.schabi.newpipe.R; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; -import org.schabi.newpipe.util.Localization; /* * Created by Christian Schabesberger on 12.02.17. @@ -41,8 +41,8 @@ public class CommentsInfoItemHolder extends CommentsMiniInfoItemHolder { } @Override - public void updateFromItem(final InfoItem infoItem) { - super.updateFromItem(infoItem); + public void updateFromItem(final InfoItem infoItem, @Nullable final StreamStateEntity state) { + super.updateFromItem(infoItem, state); if (!(infoItem instanceof CommentsInfoItem)) return; final CommentsInfoItem item = (CommentsInfoItem) infoItem; diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java index 9be272198..3d3a9bb09 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java @@ -1,12 +1,15 @@ package org.schabi.newpipe.info_list.holder; +import android.support.annotation.Nullable; import android.support.v7.app.AppCompatActivity; import android.text.util.Linkify; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import org.jsoup.helper.StringUtil; import org.schabi.newpipe.R; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; @@ -45,7 +48,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { if(hours != null) timestamp += (Integer.parseInt(hours.replace(":", ""))*3600); if(minutes != null) timestamp += (Integer.parseInt(minutes.replace(":", ""))*60); if(seconds != null) timestamp += (Integer.parseInt(seconds)); - return streamUrl + url.replace(match.group(0), "&t=" + String.valueOf(timestamp)); + return streamUrl + url.replace(match.group(0), "#timestamp=" + String.valueOf(timestamp)); } }; @@ -64,7 +67,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { } @Override - public void updateFromItem(final InfoItem infoItem) { + public void updateFromItem(final InfoItem infoItem, @Nullable final StreamStateEntity state) { if (!(infoItem instanceof CommentsInfoItem)) return; final CommentsInfoItem item = (CommentsInfoItem) infoItem; @@ -76,6 +79,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { itemThumbnailView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { + if(StringUtil.isBlank(item.getAuthorEndpoint())) return; try { final AppCompatActivity activity = (AppCompatActivity) itemBuilder.getContext(); NavigationHelper.openChannelFragment( @@ -91,15 +95,14 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { streamUrl = item.getUrl(); - itemContentView.setMaxLines(commentDefaultLines); + itemContentView.setLines(commentDefaultLines); commentText = item.getCommentText(); itemContentView.setText(commentText); - linkify(); itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE); - if(itemContentView.getLineCount() == 0){ + if (itemContentView.getLineCount() == 0) { itemContentView.post(() -> ellipsize()); - }else{ + } else { ellipsize(); } @@ -119,15 +122,17 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { private void ellipsize() { if (itemContentView.getLineCount() > commentDefaultLines){ int endOfLastLine = itemContentView.getLayout().getLineEnd(commentDefaultLines - 1); - String newVal = itemContentView.getText().subSequence(0, endOfLastLine - 3) + "..."; + int end = itemContentView.getText().toString().lastIndexOf(' ', endOfLastLine -2); + if(end == -1) end = Math.max(endOfLastLine -2, 0); + String newVal = itemContentView.getText().subSequence(0, end) + " …"; itemContentView.setText(newVal); - linkify(); } + linkify(); } private void toggleEllipsize() { if (itemContentView.getText().toString().equals(commentText)) { - ellipsize(); + if (itemContentView.getLineCount() > commentDefaultLines) ellipsize(); } else { expand(); } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.java index ebb5b4114..3bc0d9e54 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.java @@ -1,9 +1,11 @@ package org.schabi.newpipe.info_list.holder; +import android.support.annotation.Nullable; import android.support.v7.widget.RecyclerView; import android.view.LayoutInflater; import android.view.ViewGroup; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; @@ -35,5 +37,8 @@ public abstract class InfoItemHolder extends RecyclerView.ViewHolder { this.itemBuilder = infoItemBuilder; } - public abstract void updateFromItem(final InfoItem infoItem); + public abstract void updateFromItem(final InfoItem infoItem, @Nullable final StreamStateEntity state); + + public void updateState(final InfoItem infoItem, @Nullable final StreamStateEntity state) { + } } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java index b6bd2f389..f9d617e66 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java @@ -1,10 +1,12 @@ package org.schabi.newpipe.info_list.holder; +import android.support.annotation.Nullable; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import org.schabi.newpipe.R; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; @@ -30,7 +32,7 @@ public class PlaylistMiniInfoItemHolder extends InfoItemHolder { } @Override - public void updateFromItem(final InfoItem infoItem) { + public void updateFromItem(final InfoItem infoItem, @Nullable final StreamStateEntity state) { if (!(infoItem instanceof PlaylistInfoItem)) return; final PlaylistInfoItem item = (PlaylistInfoItem) infoItem; diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java index 0a7705427..25502bc81 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java @@ -1,10 +1,12 @@ package org.schabi.newpipe.info_list.holder; +import android.support.annotation.Nullable; import android.text.TextUtils; import android.view.ViewGroup; import android.widget.TextView; import org.schabi.newpipe.R; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; @@ -40,8 +42,8 @@ public class StreamInfoItemHolder extends StreamMiniInfoItemHolder { } @Override - public void updateFromItem(final InfoItem infoItem) { - super.updateFromItem(infoItem); + public void updateFromItem(final InfoItem infoItem, @Nullable final StreamStateEntity state) { + super.updateFromItem(infoItem, state); if (!(infoItem instanceof StreamInfoItem)) return; final StreamInfoItem item = (StreamInfoItem) infoItem; diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java index 048b907af..aa2a3f878 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java @@ -1,5 +1,6 @@ package org.schabi.newpipe.info_list.holder; +import android.support.annotation.Nullable; import android.support.v4.content.ContextCompat; import android.view.View; import android.view.ViewGroup; @@ -7,12 +8,17 @@ import android.widget.ImageView; import android.widget.TextView; import org.schabi.newpipe.R; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.info_list.InfoItemBuilder; +import org.schabi.newpipe.util.AnimationUtils; import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.views.AnimatedProgressBar; + +import java.util.concurrent.TimeUnit; public class StreamMiniInfoItemHolder extends InfoItemHolder { @@ -20,6 +26,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { public final TextView itemVideoTitleView; public final TextView itemUploaderView; public final TextView itemDurationView; + public final AnimatedProgressBar itemProgressView; StreamMiniInfoItemHolder(InfoItemBuilder infoItemBuilder, int layoutId, ViewGroup parent) { super(infoItemBuilder, layoutId, parent); @@ -28,6 +35,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { itemVideoTitleView = itemView.findViewById(R.id.itemVideoTitleView); itemUploaderView = itemView.findViewById(R.id.itemUploaderView); itemDurationView = itemView.findViewById(R.id.itemDurationView); + itemProgressView = itemView.findViewById(R.id.itemProgressView); } public StreamMiniInfoItemHolder(InfoItemBuilder infoItemBuilder, ViewGroup parent) { @@ -35,7 +43,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { } @Override - public void updateFromItem(final InfoItem infoItem) { + public void updateFromItem(final InfoItem infoItem, @Nullable final StreamStateEntity state) { if (!(infoItem instanceof StreamInfoItem)) return; final StreamInfoItem item = (StreamInfoItem) infoItem; @@ -47,13 +55,22 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), R.color.duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); + if (state != null) { + itemProgressView.setVisibility(View.VISIBLE); + itemProgressView.setMax((int) item.getDuration()); + itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime())); + } else { + itemProgressView.setVisibility(View.GONE); + } } else if (item.getStreamType() == StreamType.LIVE_STREAM) { itemDurationView.setText(R.string.duration_live); itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), R.color.live_duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); + itemProgressView.setVisibility(View.GONE); } else { itemDurationView.setVisibility(View.GONE); + itemProgressView.setVisibility(View.GONE); } // Default thumbnail is shown on error, while loading and if the url is empty @@ -83,6 +100,22 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { } } + @Override + public void updateState(final InfoItem infoItem, @Nullable final StreamStateEntity state) { + final StreamInfoItem item = (StreamInfoItem) infoItem; + if (state != null && item.getDuration() > 0 && item.getStreamType() != StreamType.LIVE_STREAM) { + itemProgressView.setMax((int) item.getDuration()); + if (itemProgressView.getVisibility() == View.VISIBLE) { + itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime())); + } else { + itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime())); + AnimationUtils.animateView(itemProgressView, true, 500); + } + } else if (itemProgressView.getVisibility() == View.VISIBLE) { + AnimationUtils.animateView(itemProgressView, false, 500); + } + } + private void enableLongClick(final StreamInfoItem item) { itemView.setLongClickable(true); itemView.setOnLongClickListener(view -> { diff --git a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java b/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java index abdf82353..94672bd49 100644 --- a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java @@ -76,6 +76,8 @@ public abstract class BaseLocalListFragment extends BaseStateFragment } updateFlags = 0; } + + itemsList.post(itemListAdapter::updateStates); } /*////////////////////////////////////////////////////////////////////////// @@ -150,6 +152,7 @@ public abstract class BaseLocalListFragment extends BaseStateFragment public void onDestroyView() { super.onDestroyView(); itemsList = null; + itemListAdapter.dispose(); itemListAdapter = null; } diff --git a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java index e298dedd3..d29e85ee3 100644 --- a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java @@ -1,6 +1,8 @@ package org.schabi.newpipe.local; import android.app.Activity; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.v7.widget.GridLayoutManager; import android.support.v7.widget.RecyclerView; import android.util.Log; @@ -8,6 +10,8 @@ import android.view.View; import android.view.ViewGroup; import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; +import org.schabi.newpipe.info_list.StateObjectsListAdapter; import org.schabi.newpipe.local.holder.LocalItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistItemHolder; @@ -45,7 +49,7 @@ import java.util.List; * along with NewPipe. If not, see . */ -public class LocalItemListAdapter extends RecyclerView.Adapter { +public class LocalItemListAdapter extends StateObjectsListAdapter { private static final String TAG = LocalItemListAdapter.class.getSimpleName(); private static final boolean DEBUG = false; @@ -72,6 +76,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter(); dateFormat = DateFormat.getDateInstance(DateFormat.SHORT, @@ -86,39 +91,49 @@ public class LocalItemListAdapter extends RecyclerView.Adapter data) { + public void addItems(@Nullable List data) { if (data != null) { - if (DEBUG) { - Log.d(TAG, "addItems() before > localItems.size() = " + - localItems.size() + ", data.size() = " + data.size()); - } + loadStatesForLocal(data, localItems.size(), () -> addItemsImpl(data)); + } + } - int offsetStart = sizeConsideringHeader(); - localItems.addAll(data); + private void addItemsImpl(@NonNull List data) { + if (DEBUG) { + Log.d(TAG, "addItems() before > localItems.size() = " + + localItems.size() + ", data.size() = " + data.size()); + } - if (DEBUG) { - Log.d(TAG, "addItems() after > offsetStart = " + offsetStart + - ", localItems.size() = " + localItems.size() + - ", header = " + header + ", footer = " + footer + - ", showFooter = " + showFooter); - } + int offsetStart = sizeConsideringHeader(); + localItems.addAll(data); - notifyItemRangeInserted(offsetStart, data.size()); + if (DEBUG) { + Log.d(TAG, "addItems() after > offsetStart = " + offsetStart + + ", localItems.size() = " + localItems.size() + + ", header = " + header + ", footer = " + footer + + ", showFooter = " + showFooter); + } - if (footer != null && showFooter) { - int footerNow = sizeConsideringHeader(); - notifyItemMoved(offsetStart, footerNow); + notifyItemRangeInserted(offsetStart, data.size()); - if (DEBUG) Log.d(TAG, "addItems() footer from " + offsetStart + - " to " + footerNow); - } + if (footer != null && showFooter) { + int footerNow = sizeConsideringHeader(); + notifyItemMoved(offsetStart, footerNow); + + if (DEBUG) Log.d(TAG, "addItems() footer from " + offsetStart + + " to " + footerNow); + } + } + + public void updateStates() { + if (!localItems.isEmpty()) { + updateAllLocalStates(localItems); } } public void removeItem(final LocalItem data) { final int index = localItems.indexOf(data); - localItems.remove(index); + removeState(index); notifyItemRemoved(index + (header != null ? 1 : 0)); } @@ -130,6 +145,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter= localItems.size() || actualTo >= localItems.size()) return false; localItems.add(actualTo, localItems.remove(actualFrom)); + moveState(actualFrom, actualTo); notifyItemMoved(fromAdapterPosition, toAdapterPosition); return true; } @@ -139,6 +155,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter payloads) { + if (!payloads.isEmpty() && holder instanceof LocalItemHolder) { + for (Object payload : payloads) { + if (payload instanceof StreamStateEntity) { + ((LocalItemHolder) holder).updateState(localItems.get(header == null ? position : position - 1), + (StreamStateEntity) payload); + } else if (payload instanceof Boolean) { + ((LocalItemHolder) holder).updateState(localItems.get(header == null ? position : position - 1), + null); + } + } + } else { + onBindViewHolder(holder, position); + } + } + + @Override + protected void onItemStateChanged(int position, @Nullable StreamStateEntity state) { + notifyItemChanged(header == null ? position : position + 1, state != null ? state : false); + } + public GridLayoutManager.SpanSizeLookup getSpanSizeLookup(final int spanCount) { return new GridLayoutManager.SpanSizeLookup() { @Override diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java index e422b332a..5973ad920 100644 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java @@ -112,7 +112,10 @@ public final class PlaylistAppendDialog extends PlaylistDialog { public void onDestroyView() { super.onDestroyView(); if (playlistReactor != null) playlistReactor.dispose(); - if (playlistAdapter != null) playlistAdapter.unsetSelectedListener(); + if (playlistAdapter != null) { + playlistAdapter.dispose(); + playlistAdapter.unsetSelectedListener(); + } playlistReactor = null; playlistRecyclerView = null; diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.java b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.java index f1bb01734..475627c08 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.java @@ -21,8 +21,8 @@ import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.fragments.list.BaseListFragment; -import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.local.subscription.SubscriptionService; +import org.schabi.newpipe.report.UserAction; import java.util.Collections; import java.util.HashSet; @@ -262,7 +262,7 @@ public class FeedFragment extends BaseListFragment, Voi * If chosen feed already displayed, then we request another feed from another * subscription, until the subscription table runs out of new items. *

- * This Observer is self-contained and will dispose itself when complete. However, this + * This Observer is self-contained and will close itself when complete. However, this * does not obey the fragment lifecycle and may continue running in the background * until it is complete. This is done due to RxJava2 no longer propagate errors once * an observer is unsubscribed while the thread process is still running. diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java index 56453773a..dd2d67e43 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java @@ -26,23 +26,29 @@ import android.support.annotation.NonNull; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.database.AppDatabase; +import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.history.dao.SearchHistoryDAO; import org.schabi.newpipe.database.history.dao.StreamHistoryDAO; import org.schabi.newpipe.database.history.model.SearchHistoryEntry; import org.schabi.newpipe.database.history.model.StreamHistoryEntity; import org.schabi.newpipe.database.history.model.StreamHistoryEntry; +import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; +import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; import org.schabi.newpipe.database.stream.StreamStatisticsEntry; import org.schabi.newpipe.database.stream.dao.StreamDAO; import org.schabi.newpipe.database.stream.dao.StreamStateDAO; import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.database.stream.model.StreamStateEntity; +import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.player.playqueue.PlayQueueItem; import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.List; +import io.reactivex.Completable; import io.reactivex.Flowable; import io.reactivex.Maybe; import io.reactivex.Single; @@ -80,9 +86,9 @@ public class HistoryRecordManager { final Date currentTime = new Date(); return Maybe.fromCallable(() -> database.runInTransaction(() -> { final long streamId = streamTable.upsert(new StreamEntity(info)); - StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(); + StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId); - if (latestEntry != null && latestEntry.getStreamUid() == streamId) { + if (latestEntry != null) { streamHistoryTable.delete(latestEntry); latestEntry.setAccessDate(currentTime); latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1); @@ -99,7 +105,7 @@ public class HistoryRecordManager { } public Single deleteWholeStreamHistory() { - return Single.fromCallable(() -> streamHistoryTable.deleteAll()) + return Single.fromCallable(streamHistoryTable::deleteAll) .subscribeOn(Schedulers.io()); } @@ -160,7 +166,7 @@ public class HistoryRecordManager { } public Single deleteWholeSearchHistory() { - return Single.fromCallable(() -> searchHistoryTable.deleteAll()) + return Single.fromCallable(searchHistoryTable::deleteAll) .subscribeOn(Schedulers.io()); } @@ -180,21 +186,104 @@ public class HistoryRecordManager { // Stream State History /////////////////////////////////////////////////////// - @SuppressWarnings("unused") - public Maybe loadStreamState(final StreamInfo info) { - return Maybe.fromCallable(() -> streamTable.upsert(new StreamEntity(info))) - .flatMap(streamId -> streamStateTable.getState(streamId).firstElement()) - .flatMap(states -> states.isEmpty() ? Maybe.empty() : Maybe.just(states.get(0))) + public Maybe getStreamHistory(final StreamInfo info) { + return Maybe.fromCallable(() -> { + final long streamId = streamTable.upsert(new StreamEntity(info)); + return streamHistoryTable.getLatestEntry(streamId); + }).subscribeOn(Schedulers.io()); + } + + public Maybe loadStreamState(final PlayQueueItem queueItem) { + return queueItem.getStream() + .map((info) -> streamTable.upsert(new StreamEntity(info))) + .flatMapPublisher(streamStateTable::getState) + .firstElement() + .flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0))) + .filter(state -> state.isValid((int) queueItem.getDuration())) .subscribeOn(Schedulers.io()); } - public Maybe saveStreamState(@NonNull final StreamInfo info, final long progressTime) { - return Maybe.fromCallable(() -> database.runInTransaction(() -> { + public Maybe loadStreamState(final StreamInfo info) { + return Single.fromCallable(() -> streamTable.upsert(new StreamEntity(info))) + .flatMapPublisher(streamStateTable::getState) + .firstElement() + .flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0))) + .filter(state -> state.isValid((int) info.getDuration())) + .subscribeOn(Schedulers.io()); + } + + public Completable saveStreamState(@NonNull final StreamInfo info, final long progressTime) { + return Completable.fromAction(() -> database.runInTransaction(() -> { final long streamId = streamTable.upsert(new StreamEntity(info)); - return streamStateTable.upsert(new StreamStateEntity(streamId, progressTime)); + final StreamStateEntity state = new StreamStateEntity(streamId, progressTime); + if (state.isValid((int) info.getDuration())) { + streamStateTable.upsert(state); + } else { + streamStateTable.deleteState(streamId); + } })).subscribeOn(Schedulers.io()); } + public Single loadStreamState(final InfoItem info) { + return Single.fromCallable(() -> { + final List entities = streamTable.getStream(info.getServiceId(), info.getUrl()).blockingFirst(); + if (entities.isEmpty()) { + return new StreamStateEntity[]{null}; + } + final List states = streamStateTable.getState(entities.get(0).getUid()).blockingFirst(); + if (states.isEmpty()) { + return new StreamStateEntity[]{null}; + } + return new StreamStateEntity[]{states.get(0)}; + }).subscribeOn(Schedulers.io()); + } + + public Single> loadStreamStateBatch(final List infos) { + return Single.fromCallable(() -> { + final List result = new ArrayList<>(infos.size()); + for (InfoItem info : infos) { + final List entities = streamTable.getStream(info.getServiceId(), info.getUrl()).blockingFirst(); + if (entities.isEmpty()) { + result.add(null); + continue; + } + final List states = streamStateTable.getState(entities.get(0).getUid()).blockingFirst(); + if (states.isEmpty()) { + result.add(null); + continue; + } + result.add(states.get(0)); + } + return result; + }).subscribeOn(Schedulers.io()); + } + + public Single> loadLocalStreamStateBatch(final List items) { + return Single.fromCallable(() -> { + final List result = new ArrayList<>(items.size()); + for (LocalItem item : items) { + long streamId; + if (item instanceof StreamStatisticsEntry) { + streamId = ((StreamStatisticsEntry) item).streamId; + } else if (item instanceof PlaylistStreamEntity) { + streamId = ((PlaylistStreamEntity) item).getStreamUid(); + } else if (item instanceof PlaylistStreamEntry) { + streamId = ((PlaylistStreamEntry) item).streamId; + } else { + result.add(null); + continue; + } + final List states = streamStateTable.getState(streamId).blockingFirst(); + if (states.isEmpty()) { + result.add(null); + continue; + } + result.add(states.get(0)); + } + return result; + }).subscribeOn(Schedulers.io()); + } + /////////////////////////////////////////////////////// // Utility /////////////////////////////////////////////////////// diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java index 5436913dc..b97fdf8f2 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java @@ -34,6 +34,7 @@ import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.settings.SettingsActivity; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; +import org.schabi.newpipe.util.ShareUtils; import org.schabi.newpipe.util.ThemeHelper; import java.util.ArrayList; @@ -309,11 +310,11 @@ public class StatisticsPlaylistFragment } headerPlayAllButton.setOnClickListener(view -> - NavigationHelper.playOnMainPlayer(activity, getPlayQueue())); + NavigationHelper.playOnMainPlayer(activity, getPlayQueue(), false)); headerPopupButton.setOnClickListener(view -> - NavigationHelper.playOnPopupPlayer(activity, getPlayQueue())); + NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false)); headerBackgroundButton.setOnClickListener(view -> - NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue())); + NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false)); sortButton.setOnClickListener(view -> toggleSortMode()); hideLoading(); @@ -376,25 +377,25 @@ public class StatisticsPlaylistFragment final int index = Math.max(itemListAdapter.getItemsList().indexOf(item), 0); switch (i) { case 0: - NavigationHelper.enqueueOnBackgroundPlayer(context, new SinglePlayQueue(infoItem)); + NavigationHelper.enqueueOnBackgroundPlayer(context, new SinglePlayQueue(infoItem), false); break; case 1: - NavigationHelper.enqueueOnPopupPlayer(activity, new SinglePlayQueue(infoItem)); + NavigationHelper.enqueueOnPopupPlayer(activity, new SinglePlayQueue(infoItem), false); break; case 2: - NavigationHelper.playOnMainPlayer(context, getPlayQueue(index)); + NavigationHelper.playOnMainPlayer(context, getPlayQueue(index), true); break; case 3: - NavigationHelper.playOnBackgroundPlayer(context, getPlayQueue(index)); + NavigationHelper.playOnBackgroundPlayer(context, getPlayQueue(index), true); break; case 4: - NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(index)); + NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(index), true); break; case 5: deleteEntry(index); break; case 6: - shareUrl(item.toStreamInfoItem().getName(), item.toStreamInfoItem().getUrl()); + ShareUtils.shareUrl(this.getContext(), item.toStreamInfoItem().getName(), item.toStreamInfoItem().getUrl()); break; default: break; diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalItemHolder.java index 889751afa..c00fa1fb4 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalItemHolder.java @@ -1,10 +1,12 @@ package org.schabi.newpipe.local.holder; +import android.support.annotation.Nullable; import android.support.v7.widget.RecyclerView; import android.view.LayoutInflater; import android.view.ViewGroup; import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.local.LocalItemBuilder; import java.text.DateFormat; @@ -38,5 +40,8 @@ public abstract class LocalItemHolder extends RecyclerView.ViewHolder { this.itemBuilder = itemBuilder; } - public abstract void updateFromItem(final LocalItem item, final DateFormat dateFormat); + public abstract void updateFromItem(final LocalItem item, @Nullable final StreamStateEntity state, final DateFormat dateFormat); + + public void updateState(final LocalItem localItem, @Nullable final StreamStateEntity state) { + } } diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java index 8743684ee..0e6eca9ba 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java @@ -1,10 +1,12 @@ package org.schabi.newpipe.local.holder; +import android.support.annotation.Nullable; import android.view.View; import android.view.ViewGroup; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.util.ImageDisplayConstants; @@ -21,7 +23,7 @@ public class LocalPlaylistItemHolder extends PlaylistItemHolder { } @Override - public void updateFromItem(final LocalItem localItem, final DateFormat dateFormat) { + public void updateFromItem(final LocalItem localItem, @Nullable final StreamStateEntity state, final DateFormat dateFormat) { if (!(localItem instanceof PlaylistMetadataEntry)) return; final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem; @@ -32,6 +34,6 @@ public class LocalPlaylistItemHolder extends PlaylistItemHolder { itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView, ImageDisplayConstants.DISPLAY_PLAYLIST_OPTIONS); - super.updateFromItem(localItem, dateFormat); + super.updateFromItem(localItem, state, dateFormat); } } diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java index e591b73e5..0c4e66c9d 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java @@ -1,5 +1,6 @@ package org.schabi.newpipe.local.holder; +import android.support.annotation.Nullable; import android.support.v4.content.ContextCompat; import android.view.MotionEvent; import android.view.View; @@ -10,12 +11,16 @@ import android.widget.TextView; import org.schabi.newpipe.R; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.local.LocalItemBuilder; +import org.schabi.newpipe.util.AnimationUtils; import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.views.AnimatedProgressBar; import java.text.DateFormat; +import java.util.concurrent.TimeUnit; public class LocalPlaylistStreamItemHolder extends LocalItemHolder { @@ -24,6 +29,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder { public final TextView itemAdditionalDetailsView; public final TextView itemDurationView; public final View itemHandleView; + public final AnimatedProgressBar itemProgressView; LocalPlaylistStreamItemHolder(LocalItemBuilder infoItemBuilder, int layoutId, ViewGroup parent) { super(infoItemBuilder, layoutId, parent); @@ -33,6 +39,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder { itemAdditionalDetailsView = itemView.findViewById(R.id.itemAdditionalDetails); itemDurationView = itemView.findViewById(R.id.itemDurationView); itemHandleView = itemView.findViewById(R.id.itemHandle); + itemProgressView = itemView.findViewById(R.id.itemProgressView); } public LocalPlaylistStreamItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) { @@ -40,7 +47,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder { } @Override - public void updateFromItem(final LocalItem localItem, final DateFormat dateFormat) { + public void updateFromItem(final LocalItem localItem, @Nullable final StreamStateEntity state, final DateFormat dateFormat) { if (!(localItem instanceof PlaylistStreamEntry)) return; final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem; @@ -53,6 +60,13 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder { itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), R.color.duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); + if (state != null) { + itemProgressView.setVisibility(View.VISIBLE); + itemProgressView.setMax((int) item.duration); + itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime())); + } else { + itemProgressView.setVisibility(View.GONE); + } } else { itemDurationView.setVisibility(View.GONE); } @@ -79,6 +93,23 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder { itemHandleView.setOnTouchListener(getOnTouchListener(item)); } + @Override + public void updateState(LocalItem localItem, @Nullable StreamStateEntity state) { + if (!(localItem instanceof PlaylistStreamEntry)) return; + final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem; + if (state != null && item.duration > 0) { + itemProgressView.setMax((int) item.duration); + if (itemProgressView.getVisibility() == View.VISIBLE) { + itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime())); + } else { + itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime())); + AnimationUtils.animateView(itemProgressView, true, 500); + } + } else if (itemProgressView.getVisibility() == View.VISIBLE) { + AnimationUtils.animateView(itemProgressView, false, 500); + } + } + private View.OnTouchListener getOnTouchListener(final PlaylistStreamEntry item) { return (view, motionEvent) -> { view.performClick(); diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java index 57a5794e3..b24051a4f 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java @@ -10,12 +10,16 @@ import android.widget.TextView; import org.schabi.newpipe.R; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.stream.StreamStatisticsEntry; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.local.LocalItemBuilder; +import org.schabi.newpipe.util.AnimationUtils; import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.views.AnimatedProgressBar; import java.text.DateFormat; +import java.util.concurrent.TimeUnit; /* * Created by Christian Schabesberger on 01.08.16. @@ -45,6 +49,7 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder { public final TextView itemDurationView; @Nullable public final TextView itemAdditionalDetails; + public final AnimatedProgressBar itemProgressView; public LocalStatisticStreamItemHolder(LocalItemBuilder itemBuilder, ViewGroup parent) { this(itemBuilder, R.layout.list_stream_item, parent); @@ -58,6 +63,7 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder { itemUploaderView = itemView.findViewById(R.id.itemUploaderView); itemDurationView = itemView.findViewById(R.id.itemDurationView); itemAdditionalDetails = itemView.findViewById(R.id.itemAdditionalDetails); + itemProgressView = itemView.findViewById(R.id.itemProgressView); } private String getStreamInfoDetailLine(final StreamStatisticsEntry entry, @@ -70,7 +76,7 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder { } @Override - public void updateFromItem(final LocalItem localItem, final DateFormat dateFormat) { + public void updateFromItem(final LocalItem localItem, @Nullable final StreamStateEntity state, final DateFormat dateFormat) { if (!(localItem instanceof StreamStatisticsEntry)) return; final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem; @@ -82,8 +88,16 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder { itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), R.color.duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); + if (state != null) { + itemProgressView.setVisibility(View.VISIBLE); + itemProgressView.setMax((int) item.duration); + itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime())); + } else { + itemProgressView.setVisibility(View.GONE); + } } else { itemDurationView.setVisibility(View.GONE); + itemProgressView.setVisibility(View.GONE); } if (itemAdditionalDetails != null) { @@ -108,4 +122,21 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder { return true; }); } + + @Override + public void updateState(LocalItem localItem, @Nullable StreamStateEntity state) { + if (!(localItem instanceof StreamStatisticsEntry)) return; + final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem; + if (state != null && item.duration > 0) { + itemProgressView.setMax((int) item.duration); + if (itemProgressView.getVisibility() == View.VISIBLE) { + itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime())); + } else { + itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime())); + AnimationUtils.animateView(itemProgressView, true, 500); + } + } else if (itemProgressView.getVisibility() == View.VISIBLE) { + AnimationUtils.animateView(itemProgressView, false, 500); + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/PlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/PlaylistItemHolder.java index 5d6f192e1..2a81f9571 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/PlaylistItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/PlaylistItemHolder.java @@ -1,11 +1,13 @@ package org.schabi.newpipe.local.holder; +import android.support.annotation.Nullable; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import org.schabi.newpipe.R; import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.local.LocalItemBuilder; import java.text.DateFormat; @@ -31,7 +33,7 @@ public abstract class PlaylistItemHolder extends LocalItemHolder { } @Override - public void updateFromItem(final LocalItem localItem, final DateFormat dateFormat) { + public void updateFromItem(final LocalItem localItem, @Nullable final StreamStateEntity state, final DateFormat dateFormat) { itemView.setOnClickListener(view -> { if (itemBuilder.getOnItemSelectedListener() != null) { itemBuilder.getOnItemSelectedListener().selected(localItem); diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java index 5b2a88d38..bdcd42f67 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java @@ -1,9 +1,11 @@ package org.schabi.newpipe.local.holder; +import android.support.annotation.Nullable; import android.view.ViewGroup; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.util.ImageDisplayConstants; @@ -21,7 +23,7 @@ public class RemotePlaylistItemHolder extends PlaylistItemHolder { } @Override - public void updateFromItem(final LocalItem localItem, final DateFormat dateFormat) { + public void updateFromItem(final LocalItem localItem, @Nullable final StreamStateEntity state, final DateFormat dateFormat) { if (!(localItem instanceof PlaylistRemoteEntity)) return; final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem; @@ -33,6 +35,6 @@ public class RemotePlaylistItemHolder extends PlaylistItemHolder { itemBuilder.displayImage(item.getThumbnailUrl(), itemThumbnailView, ImageDisplayConstants.DISPLAY_PLAYLIST_OPTIONS); - super.updateFromItem(localItem, dateFormat); + super.updateFromItem(localItem, state, dateFormat); } } diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java index f400061e1..501016642 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java @@ -34,6 +34,7 @@ import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; +import org.schabi.newpipe.util.ShareUtils; import java.util.ArrayList; import java.util.Collections; @@ -318,11 +319,11 @@ public class LocalPlaylistFragment extends BaseLocalListFragment - NavigationHelper.playOnMainPlayer(activity, getPlayQueue())); + NavigationHelper.playOnMainPlayer(activity, getPlayQueue(), false)); headerPopupButton.setOnClickListener(view -> - NavigationHelper.playOnPopupPlayer(activity, getPlayQueue())); + NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false)); headerBackgroundButton.setOnClickListener(view -> - NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue())); + NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false)); hideLoading(); } @@ -533,20 +534,20 @@ public class LocalPlaylistFragment extends BaseLocalListFragment initPlayback(queue, repeatMode, playbackSpeed, playbackPitch, playbackSkipSilence, + /*playOnInit=*/true)) + .subscribe( + state -> queue.setRecovery(queue.getIndex(), state.getProgressTime()), + error -> { + if (DEBUG) error.printStackTrace(); + } + ); + databaseUpdateReactor.add(stateLoader); + return; + } + } // Good to go... initPlayback(queue, repeatMode, playbackSpeed, playbackPitch, playbackSkipSilence, /*playOnInit=*/true); @@ -603,6 +634,9 @@ public abstract class BasePlayer implements break; case Player.STATE_ENDED: // 4 changeState(STATE_COMPLETED); + if (currentMetadata != null) { + resetPlaybackState(currentMetadata.getMetadata()); + } isPrepared = false; break; } @@ -709,6 +743,7 @@ public abstract class BasePlayer implements case DISCONTINUITY_REASON_SEEK_ADJUSTMENT: case DISCONTINUITY_REASON_INTERNAL: if (playQueue.getIndex() != newWindowIndex) { + resetPlaybackState(playQueue.getItem()); playQueue.setIndex(newWindowIndex); } break; @@ -738,6 +773,9 @@ public abstract class BasePlayer implements @Override public void onSeekProcessed() { if (DEBUG) Log.d(TAG, "ExoPlayer - onSeekProcessed() called"); + if (isPrepared) { + savePlaybackState(); + } } /*////////////////////////////////////////////////////////////////////////// // Playback Listener @@ -1005,27 +1043,40 @@ public abstract class BasePlayer implements } } - protected void savePlaybackState(final StreamInfo info, final long progress) { + private void savePlaybackState(final StreamInfo info, final long progress) { if (info == null) return; + if (DEBUG) Log.d(TAG, "savePlaybackState() called"); final Disposable stateSaver = recordManager.saveStreamState(info, progress) .observeOn(AndroidSchedulers.mainThread()) + .doOnError((e) -> { + if (DEBUG) e.printStackTrace(); + }) .onErrorComplete() - .subscribe( - ignored -> {/* successful */}, - error -> Log.e(TAG, "savePlaybackState() failure: ", error) - ); + .subscribe(); databaseUpdateReactor.add(stateSaver); } - private void savePlaybackState() { + private void resetPlaybackState(final PlayQueueItem queueItem) { + if (queueItem == null) return; + final Disposable stateSaver = queueItem.getStream() + .flatMapCompletable(info -> recordManager.saveStreamState(info, 0)) + .observeOn(AndroidSchedulers.mainThread()) + .doOnError((e) -> { + if (DEBUG) e.printStackTrace(); + }) + .onErrorComplete() + .subscribe(); + databaseUpdateReactor.add(stateSaver); + } + + public void resetPlaybackState(final StreamInfo info) { + savePlaybackState(info, 0); + } + + public void savePlaybackState() { if (simpleExoPlayer == null || currentMetadata == null) return; final StreamInfo currentInfo = currentMetadata.getMetadata(); - - if (simpleExoPlayer.getCurrentPosition() > RECOVERY_SKIP_THRESHOLD_MILLIS && - simpleExoPlayer.getCurrentPosition() < - simpleExoPlayer.getDuration() - RECOVERY_SKIP_THRESHOLD_MILLIS) { - savePlaybackState(currentInfo, simpleExoPlayer.getCurrentPosition()); - } + savePlaybackState(currentInfo, simpleExoPlayer.getCurrentPosition()); } private void maybeUpdateCurrentMetadata() { @@ -1213,4 +1264,10 @@ public abstract class BasePlayer implements public boolean gotDestroyed() { return simpleExoPlayer == null; } + + private boolean isPlaybackResumeEnabled() { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + return prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true) + && prefs.getBoolean(context.getString(R.string.enable_playback_resume_key), true); + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java index 778a91c79..1491a66aa 100644 --- a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java @@ -24,11 +24,13 @@ import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.ActivityInfo; import android.content.res.Configuration; +import android.database.ContentObserver; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; import android.media.AudioManager; import android.os.Build; import android.os.Bundle; +import android.os.Handler; import android.preference.PreferenceManager; import android.provider.Settings; import android.support.annotation.ColorInt; @@ -46,6 +48,7 @@ import android.view.GestureDetector; import android.view.MotionEvent; import android.view.View; import android.view.WindowManager; +import android.widget.Button; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.PopupMenu; @@ -75,6 +78,7 @@ import org.schabi.newpipe.util.AnimationUtils; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; +import org.schabi.newpipe.util.ShareUtils; import org.schabi.newpipe.util.StateSaver; import org.schabi.newpipe.util.ThemeHelper; @@ -111,6 +115,8 @@ public final class MainVideoPlayer extends AppCompatActivity private boolean isInMultiWindow; private boolean isBackPressed; + private ContentObserver rotationObserver; + /*////////////////////////////////////////////////////////////////////////// // Activity LifeCycle //////////////////////////////////////////////////////////////////////////*/ @@ -145,6 +151,23 @@ public final class MainVideoPlayer extends AppCompatActivity Toast.makeText(this, R.string.general_error, Toast.LENGTH_SHORT).show(); finish(); } + + rotationObserver = new ContentObserver(new Handler()) { + @Override + public void onChange(boolean selfChange) { + super.onChange(selfChange); + if (globalScreenOrientationLocked()) { + final boolean lastOrientationWasLandscape = defaultPreferences.getBoolean( + getString(R.string.last_orientation_landscape_key), false); + setLandscape(lastOrientationWasLandscape); + } else { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); + } + } + }; + getContentResolver().registerContentObserver( + Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), + false, rotationObserver); } @Override @@ -237,6 +260,9 @@ public final class MainVideoPlayer extends AppCompatActivity playerState = createPlayerState(); playerImpl.destroy(); + if (rotationObserver != null) + getContentResolver().unregisterContentObserver(rotationObserver); + isInMultiWindow = false; isBackPressed = false; } @@ -246,6 +272,12 @@ public final class MainVideoPlayer extends AppCompatActivity super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(newBase)); } + @Override + protected void onPause() { + playerImpl.savePlaybackState(); + super.onPause(); + } + /*////////////////////////////////////////////////////////////////////////// // State Saving //////////////////////////////////////////////////////////////////////////*/ @@ -283,8 +315,8 @@ public final class MainVideoPlayer extends AppCompatActivity if (playerImpl != null && playerImpl.queueVisible) return; final int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION; + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { @ColorInt final int systenUiColor = @@ -393,6 +425,7 @@ public final class MainVideoPlayer extends AppCompatActivity private ImageButton playPauseButton; private ImageButton playPreviousButton; private ImageButton playNextButton; + private Button closeButton; private RelativeLayout queueLayout; private ImageButton itemsListCloseButton; @@ -402,6 +435,7 @@ public final class MainVideoPlayer extends AppCompatActivity private boolean queueVisible; private ImageButton moreOptionsButton; + private ImageButton shareButton; private ImageButton toggleOrientationButton; private ImageButton switchPopupButton; private ImageButton switchBackgroundButton; @@ -433,9 +467,11 @@ public final class MainVideoPlayer extends AppCompatActivity this.playPauseButton = rootView.findViewById(R.id.playPauseButton); this.playPreviousButton = rootView.findViewById(R.id.playPreviousButton); this.playNextButton = rootView.findViewById(R.id.playNextButton); + this.closeButton = rootView.findViewById(R.id.closeButton); this.moreOptionsButton = rootView.findViewById(R.id.moreOptionsButton); this.secondaryControls = rootView.findViewById(R.id.secondaryControls); + this.shareButton = rootView.findViewById(R.id.share); this.toggleOrientationButton = rootView.findViewById(R.id.toggleOrientation); this.switchBackgroundButton = rootView.findViewById(R.id.switchBackground); this.switchPopupButton = rootView.findViewById(R.id.switchPopup); @@ -479,8 +515,10 @@ public final class MainVideoPlayer extends AppCompatActivity playPauseButton.setOnClickListener(this); playPreviousButton.setOnClickListener(this); playNextButton.setOnClickListener(this); + closeButton.setOnClickListener(this); moreOptionsButton.setOnClickListener(this); + shareButton.setOnClickListener(this); toggleOrientationButton.setOnClickListener(this); switchBackgroundButton.setOnClickListener(this); switchPopupButton.setOnClickListener(this); @@ -575,7 +613,8 @@ public final class MainVideoPlayer extends AppCompatActivity this.getPlaybackSpeed(), this.getPlaybackPitch(), this.getPlaybackSkipSilence(), - this.getPlaybackQuality() + this.getPlaybackQuality(), + false ); context.startService(intent); @@ -597,7 +636,8 @@ public final class MainVideoPlayer extends AppCompatActivity this.getPlaybackSpeed(), this.getPlaybackPitch(), this.getPlaybackSkipSilence(), - this.getPlaybackQuality() + this.getPlaybackQuality(), + false ); context.startService(intent); @@ -631,6 +671,9 @@ public final class MainVideoPlayer extends AppCompatActivity } else if (v.getId() == moreOptionsButton.getId()) { onMoreOptionsClicked(); + } else if (v.getId() == shareButton.getId()) { + onShareClicked(); + } else if (v.getId() == toggleOrientationButton.getId()) { onScreenRotationClicked(); @@ -640,6 +683,9 @@ public final class MainVideoPlayer extends AppCompatActivity } else if (v.getId() == switchBackgroundButton.getId()) { onPlayBackgroundButtonClicked(); + } else if (v.getId() == closeButton.getId()) { + onPlaybackShutdown(); + return; } if (getCurrentState() != STATE_COMPLETED) { @@ -684,6 +730,13 @@ public final class MainVideoPlayer extends AppCompatActivity showControls(DEFAULT_CONTROLS_DURATION); } + private void onShareClicked() { + // share video at the current time (youtube.com/watch?v=ID&t=SECONDS) + ShareUtils.shareUrl(MainVideoPlayer.this, + playerImpl.getVideoTitle(), + playerImpl.getVideoUrl() + "&t=" + String.valueOf(playerImpl.getPlaybackSeekBar().getProgress()/1000)); + } + private void onScreenRotationClicked() { if (DEBUG) Log.d(TAG, "onScreenRotationClicked() called"); toggleOrientation(); @@ -766,6 +819,7 @@ public final class MainVideoPlayer extends AppCompatActivity super.onBlocked(); playPauseButton.setImageResource(R.drawable.ic_pause_white); animatePlayButtons(false, 100); + animateView(closeButton, false, DEFAULT_CONTROLS_DURATION); getRootView().setKeepScreenOn(true); } @@ -781,6 +835,7 @@ public final class MainVideoPlayer extends AppCompatActivity animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, false, 80, 0, () -> { playPauseButton.setImageResource(R.drawable.ic_pause_white); animatePlayButtons(true, 200); + animateView(closeButton, false, DEFAULT_CONTROLS_DURATION); }); getRootView().setKeepScreenOn(true); @@ -792,6 +847,7 @@ public final class MainVideoPlayer extends AppCompatActivity animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, false, 80, 0, () -> { playPauseButton.setImageResource(R.drawable.ic_play_arrow_white); animatePlayButtons(true, 200); + animateView(closeButton, false, DEFAULT_CONTROLS_DURATION); }); showSystemUi(); @@ -811,8 +867,8 @@ public final class MainVideoPlayer extends AppCompatActivity animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, false, 0, 0, () -> { playPauseButton.setImageResource(R.drawable.ic_replay_white); animatePlayButtons(true, DEFAULT_CONTROLS_DURATION); + animateView(closeButton, true, DEFAULT_CONTROLS_DURATION); }); - getRootView().setKeepScreenOn(false); super.onCompleted(); } @@ -847,8 +903,8 @@ public final class MainVideoPlayer extends AppCompatActivity if (DEBUG) Log.d(TAG, "hideControls() called with: delay = [" + delay + "]"); getControlsVisibilityHandler().removeCallbacksAndMessages(null); getControlsVisibilityHandler().postDelayed(() -> - animateView(getControlsRoot(), false, duration, 0, - MainVideoPlayer.this::hideSystemUi), + animateView(getControlsRoot(), false, duration, 0, + MainVideoPlayer.this::hideSystemUi), /*delayMillis=*/delay ); } @@ -1052,9 +1108,9 @@ public final class MainVideoPlayer extends AppCompatActivity final int resId = currentProgressPercent <= 0 ? R.drawable.ic_volume_off_white_72dp - : currentProgressPercent < 0.25 ? R.drawable.ic_volume_mute_white_72dp - : currentProgressPercent < 0.75 ? R.drawable.ic_volume_down_white_72dp - : R.drawable.ic_volume_up_white_72dp; + : currentProgressPercent < 0.25 ? R.drawable.ic_volume_mute_white_72dp + : currentProgressPercent < 0.75 ? R.drawable.ic_volume_down_white_72dp + : R.drawable.ic_volume_up_white_72dp; playerImpl.getVolumeImageView().setImageDrawable( AppCompatResources.getDrawable(getApplicationContext(), resId) @@ -1078,8 +1134,8 @@ public final class MainVideoPlayer extends AppCompatActivity final int resId = currentProgressPercent < 0.25 ? R.drawable.ic_brightness_low_white_72dp - : currentProgressPercent < 0.75 ? R.drawable.ic_brightness_medium_white_72dp - : R.drawable.ic_brightness_high_white_72dp; + : currentProgressPercent < 0.75 ? R.drawable.ic_brightness_medium_white_72dp + : R.drawable.ic_brightness_high_white_72dp; playerImpl.getBrightnessImageView().setImageDrawable( AppCompatResources.getDrawable(getApplicationContext(), resId) diff --git a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java index 7578c444c..3782d85c0 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java @@ -325,6 +325,7 @@ public final class PopupVideoPlayer extends Service { isPopupClosing = true; if (playerImpl != null) { + playerImpl.savePlaybackState(); if (playerImpl.getRootView() != null) { windowManager.removeView(playerImpl.getRootView()); } @@ -565,7 +566,8 @@ public final class PopupVideoPlayer extends Service { this.getPlaybackSpeed(), this.getPlaybackPitch(), this.getPlaybackSkipSilence(), - this.getPlaybackQuality() + this.getPlaybackQuality(), + false ); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); diff --git a/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java index 3e04f1e3a..bdd31f21b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java @@ -188,7 +188,8 @@ public abstract class ServicePlayerActivity extends AppCompatActivity this.player.getPlaybackSpeed(), this.player.getPlaybackPitch(), this.player.getPlaybackSkipSilence(), - null + null, + false ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); } diff --git a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java index fb60ac473..7ce778e6a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java @@ -305,9 +305,9 @@ public abstract class VideoPlayer extends BasePlayer captionItem.setOnMenuItemClickListener(menuItem -> { final int textRendererIndex = getRendererIndex(C.TRACK_TYPE_TEXT); if (textRendererIndex != RENDERER_UNAVAILABLE) { + trackSelector.setPreferredTextLanguage(captionLanguage); trackSelector.setParameters(trackSelector.buildUponParameters() - .setRendererDisabled(textRendererIndex, false) - .setPreferredTextLanguage(captionLanguage)); + .setRendererDisabled(textRendererIndex, false)); } return true; }); @@ -315,7 +315,6 @@ public abstract class VideoPlayer extends BasePlayer captionPopupMenu.setOnDismissListener(this); } - private void updateStreamRelatedViews() { if (getCurrentMetadata() == null) return; @@ -508,7 +507,7 @@ public abstract class VideoPlayer extends BasePlayer } // Normalize mismatching language strings - final String preferredLanguage = trackSelector.getParameters().preferredTextLanguage; + final String preferredLanguage = trackSelector.getPreferredTextLanguage(); // Build UI buildCaptionMenu(availableLanguages); if (trackSelector.getParameters().getRendererDisabled(textRenderer) || @@ -543,6 +542,11 @@ public abstract class VideoPlayer extends BasePlayer playbackSpeedTextView.setText(formatSpeed(getPlaybackSpeed())); super.onPrepared(playWhenReady); + + if (simpleExoPlayer.getCurrentPosition() != 0 && !isControlsVisible()) { + controlsVisibilityHandler.removeCallbacksAndMessages(null); + controlsVisibilityHandler.postDelayed(this::showControlsThenHide, DEFAULT_CONTROLS_DURATION); + } } @Override diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/BasePlayerMediaSession.java b/app/src/main/java/org/schabi/newpipe/player/playback/BasePlayerMediaSession.java index 657fe1327..7b55629b8 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/BasePlayerMediaSession.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/BasePlayerMediaSession.java @@ -58,8 +58,11 @@ public class BasePlayerMediaSession implements MediaSessionCallback { // set additional metadata for A2DP/AVRCP Bundle additionalMetadata = new Bundle(); + additionalMetadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, item.getTitle()); additionalMetadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, item.getUploader()); - additionalMetadata.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, item.getDuration()); + additionalMetadata.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, item.getDuration() * 1000); + additionalMetadata.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, index + 1); + additionalMetadata.putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, player.getPlayQueue().size()); descriptionBuilder.setExtras(additionalMetadata); final Uri thumbnailUri = Uri.parse(item.getThumbnailUrl()); diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java b/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java new file mode 100644 index 000000000..063d6b93e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java @@ -0,0 +1,115 @@ +package org.schabi.newpipe.player.playback; + +import android.support.annotation.NonNull; +import android.text.TextUtils; +import android.util.Pair; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.FixedTrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.util.Assertions; + +/** + * This class allows irregular text language labels for use when selecting text captions and + * is mostly a copy-paste from {@link DefaultTrackSelector}. + * + * This is a hack and should be removed once ExoPlayer fixes language normalization to accept + * a broader set of languages. + * */ +public class CustomTrackSelector extends DefaultTrackSelector { + private static final int WITHIN_RENDERER_CAPABILITIES_BONUS = 1000; + + private String preferredTextLanguage; + + public CustomTrackSelector(TrackSelection.Factory adaptiveTrackSelectionFactory) { + super(adaptiveTrackSelectionFactory); + } + + public String getPreferredTextLanguage() { + return preferredTextLanguage; + } + + public void setPreferredTextLanguage(@NonNull final String label) { + Assertions.checkNotNull(label); + if (!label.equals(preferredTextLanguage)) { + preferredTextLanguage = label; + invalidate(); + } + } + + /** @see DefaultTrackSelector#formatHasLanguage(Format, String)*/ + protected static boolean formatHasLanguage(Format format, String language) { + return language != null && TextUtils.equals(language, format.language); + } + + /** @see DefaultTrackSelector#formatHasNoLanguage(Format)*/ + protected static boolean formatHasNoLanguage(Format format) { + return TextUtils.isEmpty(format.language) || formatHasLanguage(format, C.LANGUAGE_UNDETERMINED); + } + + /** @see DefaultTrackSelector#selectTextTrack(TrackGroupArray, int[][], Parameters) */ + @Override + protected Pair selectTextTrack(TrackGroupArray groups, int[][] formatSupport, + Parameters params) { + TrackGroup selectedGroup = null; + int selectedTrackIndex = 0; + int selectedTrackScore = 0; + for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { + TrackGroup trackGroup = groups.get(groupIndex); + int[] trackFormatSupport = formatSupport[groupIndex]; + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + if (isSupported(trackFormatSupport[trackIndex], + params.exceedRendererCapabilitiesIfNecessary)) { + Format format = trackGroup.getFormat(trackIndex); + int maskedSelectionFlags = + format.selectionFlags & ~params.disabledTextTrackSelectionFlags; + boolean isDefault = (maskedSelectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; + boolean isForced = (maskedSelectionFlags & C.SELECTION_FLAG_FORCED) != 0; + int trackScore; + boolean preferredLanguageFound = formatHasLanguage(format, preferredTextLanguage); + if (preferredLanguageFound + || (params.selectUndeterminedTextLanguage && formatHasNoLanguage(format))) { + if (isDefault) { + trackScore = 8; + } else if (!isForced) { + // Prefer non-forced to forced if a preferred text language has been specified. Where + // both are provided the non-forced track will usually contain the forced subtitles as + // a subset. + trackScore = 6; + } else { + trackScore = 4; + } + trackScore += preferredLanguageFound ? 1 : 0; + } else if (isDefault) { + trackScore = 3; + } else if (isForced) { + if (formatHasLanguage(format, params.preferredAudioLanguage)) { + trackScore = 2; + } else { + trackScore = 1; + } + } else { + // Track should not be selected. + continue; + } + if (isSupported(trackFormatSupport[trackIndex], false)) { + trackScore += WITHIN_RENDERER_CAPABILITIES_BONUS; + } + if (trackScore > selectedTrackScore) { + selectedGroup = trackGroup; + selectedTrackIndex = trackIndex; + selectedTrackScore = trackScore; + } + } + } + } + return selectedGroup == null + ? null + : Pair.create( + new FixedTrackSelection(selectedGroup, selectedTrackIndex), selectedTrackScore); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java index db8cc797e..fb1a609cc 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java @@ -158,7 +158,7 @@ public class MediaSourceManager { * Dispose the manager and releases all message buses and loaders. * */ public void dispose() { - if (DEBUG) Log.d(TAG, "dispose() called."); + if (DEBUG) Log.d(TAG, "close() called."); debouncedSignal.onComplete(); debouncedLoader.dispose(); diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java index 5993481e2..40d1a11e7 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java @@ -16,6 +16,11 @@ public final class SinglePlayQueue extends PlayQueue { super(0, Collections.singletonList(new PlayQueueItem(info))); } + public SinglePlayQueue(final StreamInfo info, final long startPosition) { + super(0, Collections.singletonList(new PlayQueueItem(info))); + getItem().setRecoveryPosition(startPosition); + } + public SinglePlayQueue(final List items, final int index) { super(index, playQueueItemsOf(items)); } diff --git a/app/src/main/java/org/schabi/newpipe/report/UserAction.java b/app/src/main/java/org/schabi/newpipe/report/UserAction.java index 2b2369ad3..2cca9305a 100644 --- a/app/src/main/java/org/schabi/newpipe/report/UserAction.java +++ b/app/src/main/java/org/schabi/newpipe/report/UserAction.java @@ -17,7 +17,9 @@ public enum UserAction { REQUESTED_KIOSK("requested kiosk"), REQUESTED_COMMENTS("requested comments"), DELETE_FROM_HISTORY("delete from history"), - PLAY_STREAM("Play stream"); + PLAY_STREAM("Play stream"), + DOWNLOAD_POSTPROCESSING("download post-processing"), + DOWNLOAD_FAILED("download failed"); private final String message; diff --git a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java index 8214d7b4b..e671e4d3a 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java @@ -1,30 +1,77 @@ package org.schabi.newpipe.settings; import android.app.Activity; +import android.app.AlertDialog; +import android.content.ContentResolver; +import android.content.Context; import android.content.Intent; +import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.support.annotation.Nullable; +import android.support.annotation.StringRes; import android.support.v7.preference.Preference; import android.util.Log; +import android.widget.Toast; import com.nononsenseapps.filepicker.Utils; import org.schabi.newpipe.R; import org.schabi.newpipe.util.FilePickerActivityHelper; -public class DownloadSettingsFragment extends BasePreferenceFragment { - private static final int REQUEST_DOWNLOAD_PATH = 0x1235; - private static final int REQUEST_DOWNLOAD_AUDIO_PATH = 0x1236; +import java.io.File; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; - private String DOWNLOAD_PATH_PREFERENCE; +import us.shandian.giga.io.StoredDirectoryHelper; + +public class DownloadSettingsFragment extends BasePreferenceFragment { + private static final int REQUEST_DOWNLOAD_VIDEO_PATH = 0x1235; + private static final int REQUEST_DOWNLOAD_AUDIO_PATH = 0x1236; + public static final boolean IGNORE_RELEASE_ON_OLD_PATH = true; + + private String DOWNLOAD_PATH_VIDEO_PREFERENCE; private String DOWNLOAD_PATH_AUDIO_PREFERENCE; + private String DOWNLOAD_STORAGE_ASK; + + private Preference prefPathVideo; + private Preference prefPathAudio; + private Preference prefStorageAsk; + + private Context ctx; + @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - initKeys(); + DOWNLOAD_PATH_VIDEO_PREFERENCE = getString(R.string.download_path_video_key); + DOWNLOAD_PATH_AUDIO_PREFERENCE = getString(R.string.download_path_audio_key); + DOWNLOAD_STORAGE_ASK = getString(R.string.downloads_storage_ask); + + prefPathVideo = findPreference(DOWNLOAD_PATH_VIDEO_PREFERENCE); + prefPathAudio = findPreference(DOWNLOAD_PATH_AUDIO_PREFERENCE); + prefStorageAsk = findPreference(DOWNLOAD_STORAGE_ASK); + updatePreferencesSummary(); + updatePathPickers(!defaultPreferences.getBoolean(DOWNLOAD_STORAGE_ASK, false)); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + prefStorageAsk.setSummary(R.string.downloads_storage_ask_summary); + } + + if (hasInvalidPath(DOWNLOAD_PATH_VIDEO_PREFERENCE) || hasInvalidPath(DOWNLOAD_PATH_AUDIO_PREFERENCE)) { + Toast.makeText(ctx, R.string.download_pick_path, Toast.LENGTH_SHORT).show(); + updatePreferencesSummary(); + } + + prefStorageAsk.setOnPreferenceChangeListener((preference, value) -> { + updatePathPickers(!(boolean) value); + return true; + }); } @Override @@ -32,52 +79,183 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { addPreferencesFromResource(R.xml.download_settings); } - private void initKeys() { - DOWNLOAD_PATH_PREFERENCE = getString(R.string.download_path_key); - DOWNLOAD_PATH_AUDIO_PREFERENCE = getString(R.string.download_path_audio_key); + @Override + public void onAttach(Context context) { + super.onAttach(context); + ctx = context; + } + + @Override + public void onDetach() { + super.onDetach(); + ctx = null; + prefStorageAsk.setOnPreferenceChangeListener(null); } private void updatePreferencesSummary() { - findPreference(DOWNLOAD_PATH_PREFERENCE).setSummary(defaultPreferences.getString(DOWNLOAD_PATH_PREFERENCE, getString(R.string.download_path_summary))); - findPreference(DOWNLOAD_PATH_AUDIO_PREFERENCE).setSummary(defaultPreferences.getString(DOWNLOAD_PATH_AUDIO_PREFERENCE, getString(R.string.download_path_audio_summary))); + showPathInSummary(DOWNLOAD_PATH_VIDEO_PREFERENCE, R.string.download_path_summary, prefPathVideo); + showPathInSummary(DOWNLOAD_PATH_AUDIO_PREFERENCE, R.string.download_path_audio_summary, prefPathAudio); + } + + private void showPathInSummary(String prefKey, @StringRes int defaultString, Preference target) { + String rawUri = defaultPreferences.getString(prefKey, null); + if (rawUri == null || rawUri.isEmpty()) { + target.setSummary(getString(defaultString)); + return; + } + + if (rawUri.charAt(0) == File.separatorChar) { + target.setSummary(rawUri); + return; + } + if (rawUri.startsWith(ContentResolver.SCHEME_FILE)) { + target.setSummary(new File(URI.create(rawUri)).getPath()); + return; + } + + try { + rawUri = URLDecoder.decode(rawUri, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + // nothing to do + } + + target.setSummary(rawUri); + } + + private boolean isFileUri(String path) { + return path.charAt(0) == File.separatorChar || path.startsWith(ContentResolver.SCHEME_FILE); + } + + private boolean hasInvalidPath(String prefKey) { + String value = defaultPreferences.getString(prefKey, null); + return value == null || value.isEmpty(); + } + + private void updatePathPickers(boolean enabled) { + prefPathVideo.setEnabled(enabled); + prefPathAudio.setEnabled(enabled); + } + + // FIXME: after releasing the old path, all downloads created on the folder becomes inaccessible + private void forgetSAFTree(Context ctx, String oldPath) { + if (IGNORE_RELEASE_ON_OLD_PATH) { + return; + } + + if (oldPath == null || oldPath.isEmpty() || isFileUri(oldPath)) return; + + try { + Uri uri = Uri.parse(oldPath); + + ctx.getContentResolver().releasePersistableUriPermission(uri, StoredDirectoryHelper.PERMISSION_FLAGS); + ctx.revokeUriPermission(uri, StoredDirectoryHelper.PERMISSION_FLAGS); + + Log.i(TAG, "Revoke old path permissions success on " + oldPath); + } catch (Exception err) { + Log.e(TAG, "Error revoking old path permissions on " + oldPath, err); + } + } + + private void showMessageDialog(@StringRes int title, @StringRes int message) { + AlertDialog.Builder msg = new AlertDialog.Builder(ctx); + msg.setTitle(title); + msg.setMessage(message); + msg.setPositiveButton(android.R.string.ok, null); + msg.show(); } @Override public boolean onPreferenceTreeClick(Preference preference) { if (DEBUG) { - Log.d(TAG, "onPreferenceTreeClick() called with: preference = [" + preference + "]"); + Log.d(TAG, "onPreferenceTreeClick() called with: preference = [" + preference + "]"); } - if (preference.getKey().equals(DOWNLOAD_PATH_PREFERENCE) - || preference.getKey().equals(DOWNLOAD_PATH_AUDIO_PREFERENCE)) { - Intent i = new Intent(getActivity(), FilePickerActivityHelper.class) + String key = preference.getKey(); + int request; + + if (key.equals(DOWNLOAD_PATH_VIDEO_PREFERENCE)) { + request = REQUEST_DOWNLOAD_VIDEO_PATH; + } else if (key.equals(DOWNLOAD_PATH_AUDIO_PREFERENCE)) { + request = REQUEST_DOWNLOAD_AUDIO_PATH; + } else { + return super.onPreferenceTreeClick(preference); + } + + Intent i; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + .putExtra("android.content.extra.SHOW_ADVANCED", true) + .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | StoredDirectoryHelper.PERMISSION_FLAGS); + } else { + i = new Intent(getActivity(), FilePickerActivityHelper.class) .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) .putExtra(FilePickerActivityHelper.EXTRA_MODE, FilePickerActivityHelper.MODE_DIR); - if (preference.getKey().equals(DOWNLOAD_PATH_PREFERENCE)) { - startActivityForResult(i, REQUEST_DOWNLOAD_PATH); - } else if (preference.getKey().equals(DOWNLOAD_PATH_AUDIO_PREFERENCE)) { - startActivityForResult(i, REQUEST_DOWNLOAD_AUDIO_PATH); - } } - return super.onPreferenceTreeClick(preference); + startActivityForResult(i, request); + + return true; } @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (DEBUG) { - Log.d(TAG, "onActivityResult() called with: requestCode = [" + requestCode + "], resultCode = [" + resultCode + "], data = [" + data + "]"); + Log.d(TAG, "onActivityResult() called with: requestCode = [" + requestCode + "], " + + "resultCode = [" + resultCode + "], data = [" + data + "]" + ); } - if ((requestCode == REQUEST_DOWNLOAD_PATH || requestCode == REQUEST_DOWNLOAD_AUDIO_PATH) - && resultCode == Activity.RESULT_OK && data.getData() != null) { - String key = getString(requestCode == REQUEST_DOWNLOAD_PATH ? R.string.download_path_key : R.string.download_path_audio_key); - String path = Utils.getFileForUri(data.getData()).getAbsolutePath(); + if (resultCode != Activity.RESULT_OK) return; - defaultPreferences.edit().putString(key, path).apply(); - updatePreferencesSummary(); + String key; + if (requestCode == REQUEST_DOWNLOAD_VIDEO_PATH) + key = DOWNLOAD_PATH_VIDEO_PREFERENCE; + else if (requestCode == REQUEST_DOWNLOAD_AUDIO_PATH) + key = DOWNLOAD_PATH_AUDIO_PREFERENCE; + else + return; + + Uri uri = data.getData(); + if (uri == null) { + showMessageDialog(R.string.general_error, R.string.invalid_directory); + return; } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + // steps: + // 1. revoke permissions on the old save path + // 2. acquire permissions on the new save path + // 3. save the new path, if step(2) was successful + final Context ctx = getContext(); + if (ctx == null) throw new NullPointerException("getContext()"); + + forgetSAFTree(ctx, defaultPreferences.getString(key, "")); + + try { + ctx.grantUriPermission(ctx.getPackageName(), uri, StoredDirectoryHelper.PERMISSION_FLAGS); + + StoredDirectoryHelper mainStorage = new StoredDirectoryHelper(ctx, uri, null); + Log.i(TAG, "Acquiring tree success from " + uri.toString()); + + if (!mainStorage.canWrite()) + throw new IOException("No write permissions on " + uri.toString()); + } catch (IOException err) { + Log.e(TAG, "Error acquiring tree from " + uri.toString(), err); + showMessageDialog(R.string.general_error, R.string.no_available_dir); + return; + } + } else { + File target = Utils.getFileForUri(data.getData()); + if (!target.canWrite()) { + showMessageDialog(R.string.download_to_sdcard_error_title, R.string.download_to_sdcard_error_message); + return; + } + uri = Uri.fromFile(target); + } + + defaultPreferences.edit().putString(key, uri.toString()).apply(); + updatePreferencesSummary(); } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java index 2a0e2645b..f18d90a95 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java +++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java @@ -70,37 +70,23 @@ public class NewPipeSettings { getAudioDownloadFolder(context); } - public static File getVideoDownloadFolder(Context context) { - return getDir(context, R.string.download_path_key, Environment.DIRECTORY_MOVIES); + private static void getVideoDownloadFolder(Context context) { + getDir(context, R.string.download_path_video_key, Environment.DIRECTORY_MOVIES); } - public static String getVideoDownloadPath(Context context) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - final String key = context.getString(R.string.download_path_key); - return prefs.getString(key, Environment.DIRECTORY_MOVIES); + private static void getAudioDownloadFolder(Context context) { + getDir(context, R.string.download_path_audio_key, Environment.DIRECTORY_MUSIC); } - public static File getAudioDownloadFolder(Context context) { - return getDir(context, R.string.download_path_audio_key, Environment.DIRECTORY_MUSIC); - } - - public static String getAudioDownloadPath(Context context) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - final String key = context.getString(R.string.download_path_audio_key); - return prefs.getString(key, Environment.DIRECTORY_MUSIC); - } - - private static File getDir(Context context, int keyID, String defaultDirectoryName) { + private static void getDir(Context context, int keyID, String defaultDirectoryName) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); final String key = context.getString(keyID); String downloadPath = prefs.getString(key, null); - if ((downloadPath != null) && (!downloadPath.isEmpty())) return new File(downloadPath.trim()); + if ((downloadPath != null) && (!downloadPath.isEmpty())) return; - final File dir = getDir(defaultDirectoryName); SharedPreferences.Editor spEditor = prefs.edit(); - spEditor.putString(key, getNewPipeChildFolderPathForDir(dir)); + spEditor.putString(key, getNewPipeChildFolderPathForDir(getDir(defaultDirectoryName))); spEditor.apply(); - return dir; } @NonNull @@ -108,19 +94,7 @@ public class NewPipeSettings { return new File(Environment.getExternalStorageDirectory(), defaultDirectoryName); } - public static void resetDownloadFolders(Context context) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - resetDownloadFolder(prefs, context.getString(R.string.download_path_audio_key), Environment.DIRECTORY_MUSIC); - resetDownloadFolder(prefs, context.getString(R.string.download_path_key), Environment.DIRECTORY_MOVIES); - } - - private static void resetDownloadFolder(SharedPreferences prefs, String key, String defaultDirectoryName) { - SharedPreferences.Editor spEditor = prefs.edit(); - spEditor.putString(key, getNewPipeChildFolderPathForDir(getDir(defaultDirectoryName))); - spEditor.apply(); - } - private static String getNewPipeChildFolderPathForDir(File dir) { - return new File(dir, "NewPipe").getAbsolutePath(); + return new File(dir, "NewPipe").toURI().toString(); } } diff --git a/app/src/main/java/org/schabi/newpipe/streams/DataReader.java b/app/src/main/java/org/schabi/newpipe/streams/DataReader.java index d0e946eb7..0e62810c5 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/DataReader.java +++ b/app/src/main/java/org/schabi/newpipe/streams/DataReader.java @@ -1,9 +1,10 @@ package org.schabi.newpipe.streams; +import org.schabi.newpipe.streams.io.SharpStream; + import java.io.EOFException; import java.io.IOException; - -import org.schabi.newpipe.streams.io.SharpStream; +import java.io.InputStream; /** * @author kapodamy @@ -15,89 +16,239 @@ public class DataReader { public final static int INTEGER_SIZE = 4; public final static int FLOAT_SIZE = 4; - private long pos; - public final SharpStream stream; - private final boolean rewind; + private final static int BUFFER_SIZE = 128 * 1024;// 128 KiB + + private long position = 0; + private final SharpStream stream; + + private InputStream view; + private int viewSize; public DataReader(SharpStream stream) { - this.rewind = stream.canRewind(); this.stream = stream; - this.pos = 0L; + this.readOffset = this.readBuffer.length; } public long position() { - return pos; + return position; } - public final int readInt() throws IOException { + public int read() throws IOException { + if (fillBuffer()) { + return -1; + } + + position++; + readCount--; + + return readBuffer[readOffset++] & 0xFF; + } + + public long skipBytes(long amount) throws IOException { + if (readCount < 0) { + return 0; + } else if (readCount == 0) { + amount = stream.skip(amount); + } else { + if (readCount > amount) { + readCount -= (int) amount; + readOffset += (int) amount; + } else { + amount = readCount + stream.skip(amount - readCount); + readCount = 0; + readOffset = readBuffer.length; + } + } + + position += amount; + return amount; + } + + public int readInt() throws IOException { primitiveRead(INTEGER_SIZE); return primitive[0] << 24 | primitive[1] << 16 | primitive[2] << 8 | primitive[3]; } - public final int read() throws IOException { - int value = stream.read(); - if (value == -1) { - throw new EOFException(); - } - - pos++; - return value; + public short readShort() throws IOException { + primitiveRead(SHORT_SIZE); + return (short) (primitive[0] << 8 | primitive[1]); } - public final long skipBytes(long amount) throws IOException { - amount = stream.skip(amount); - pos += amount; - return amount; - } - - public final long readLong() throws IOException { + public long readLong() throws IOException { primitiveRead(LONG_SIZE); long high = primitive[0] << 24 | primitive[1] << 16 | primitive[2] << 8 | primitive[3]; long low = primitive[4] << 24 | primitive[5] << 16 | primitive[6] << 8 | primitive[7]; return high << 32 | low; } - public final short readShort() throws IOException { - primitiveRead(SHORT_SIZE); - return (short) (primitive[0] << 8 | primitive[1]); - } - - public final int read(byte[] buffer) throws IOException { + public int read(byte[] buffer) throws IOException { return read(buffer, 0, buffer.length); } - public final int read(byte[] buffer, int offset, int count) throws IOException { - int res = stream.read(buffer, offset, count); - pos += res; + public int read(byte[] buffer, int offset, int count) throws IOException { + if (readCount < 0) { + return -1; + } + int total = 0; - return res; + if (count >= readBuffer.length) { + if (readCount > 0) { + System.arraycopy(readBuffer, readOffset, buffer, offset, readCount); + readOffset += readCount; + + offset += readCount; + count -= readCount; + + total = readCount; + readCount = 0; + } + total += Math.max(stream.read(buffer, offset, count), 0); + } else { + while (count > 0 && !fillBuffer()) { + int read = Math.min(readCount, count); + System.arraycopy(readBuffer, readOffset, buffer, offset, read); + + readOffset += read; + readCount -= read; + + offset += read; + count -= read; + + total += read; + } + } + + position += total; + return total; } - public final boolean available() { - return stream.available() > 0; + public boolean available() { + return readCount > 0 || stream.available() > 0; } public void rewind() throws IOException { stream.rewind(); - pos = 0; + + if ((position - viewSize) > 0) { + viewSize = 0;// drop view + } else { + viewSize += position; + } + + position = 0; + readOffset = readBuffer.length; } public boolean canRewind() { - return rewind; + return stream.canRewind(); } - private short[] primitive = new short[LONG_SIZE]; + /** + * Wraps this instance of {@code DataReader} into {@code InputStream} + * object. Note: Any read in the {@code DataReader} will not modify + * (decrease) the view size + * + * @param size the size of the view + * @return the view + */ + public InputStream getView(int size) { + if (view == null) { + view = new InputStream() { + @Override + public int read() throws IOException { + if (viewSize < 1) { + return -1; + } + int res = DataReader.this.read(); + if (res > 0) { + viewSize--; + } + return res; + } + + @Override + public int read(byte[] buffer) throws IOException { + return read(buffer, 0, buffer.length); + } + + @Override + public int read(byte[] buffer, int offset, int count) throws IOException { + if (viewSize < 1) { + return -1; + } + + int res = DataReader.this.read(buffer, offset, Math.min(viewSize, count)); + viewSize -= res; + + return res; + } + + @Override + public long skip(long amount) throws IOException { + if (viewSize < 1) { + return 0; + } + int res = (int) DataReader.this.skipBytes(Math.min(amount, viewSize)); + viewSize -= res; + + return res; + } + + @Override + public int available() { + return viewSize; + } + + @Override + public void close() { + viewSize = 0; + } + + @Override + public boolean markSupported() { + return false; + } + + }; + } + viewSize = size; + + return view; + } + + private final short[] primitive = new short[LONG_SIZE]; private void primitiveRead(int amount) throws IOException { byte[] buffer = new byte[amount]; - int read = stream.read(buffer, 0, amount); - pos += read; + int read = read(buffer, 0, amount); + if (read != amount) { - throw new EOFException("Truncated data, missing " + String.valueOf(amount - read) + " bytes"); + throw new EOFException("Truncated stream, missing " + String.valueOf(amount - read) + " bytes"); } - for (int i = 0; i < buffer.length; i++) { - primitive[i] = (short) (buffer[i] & 0xFF);// the "byte" datatype is signed and is very annoying + for (int i = 0; i < amount; i++) { + primitive[i] = (short) (buffer[i] & 0xFF);// the "byte" data type in java is signed and is very annoying } } + + private final byte[] readBuffer = new byte[BUFFER_SIZE]; + private int readOffset; + private int readCount; + + private boolean fillBuffer() throws IOException { + if (readCount < 0) { + return true; + } + if (readOffset >= readBuffer.length) { + readCount = stream.read(readBuffer); + if (readCount < 1) { + readCount = -1; + return true; + } + readOffset = 0; + } + + return readCount < 1; + } + } diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java index 271929d47..c52ebf3aa 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java @@ -1,17 +1,15 @@ package org.schabi.newpipe.streams; +import org.schabi.newpipe.streams.io.SharpStream; + import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; - import java.nio.ByteBuffer; - import java.util.ArrayList; import java.util.NoSuchElementException; -import org.schabi.newpipe.streams.io.SharpStream; - /** * @author kapodamy */ @@ -35,14 +33,29 @@ public class Mp4DashReader { private static final int ATOM_TREX = 0x74726578; private static final int ATOM_TKHD = 0x746B6864; private static final int ATOM_MFRA = 0x6D667261; - private static final int ATOM_TFRA = 0x74667261; private static final int ATOM_MDHD = 0x6D646864; + private static final int ATOM_EDTS = 0x65647473; + private static final int ATOM_ELST = 0x656C7374; + private static final int ATOM_HDLR = 0x68646C72; + private static final int ATOM_MINF = 0x6D696E66; + private static final int ATOM_DINF = 0x64696E66; + private static final int ATOM_STBL = 0x7374626C; + private static final int ATOM_STSD = 0x73747364; + private static final int ATOM_VMHD = 0x766D6864; + private static final int ATOM_SMHD = 0x736D6864; + private static final int BRAND_DASH = 0x64617368; + private static final int BRAND_ISO5 = 0x69736F35; + + private static final int HANDLER_VIDE = 0x76696465; + private static final int HANDLER_SOUN = 0x736F756E; + private static final int HANDLER_SUBT = 0x73756274; // private final DataReader stream; private Mp4Track[] tracks = null; + private int[] brands = null; private Box box; private Moof moof; @@ -50,9 +63,10 @@ public class Mp4DashReader { private boolean chunkZero = false; private int selectedTrack = -1; + private Box backupBox = null; public enum TrackKind { - Audio, Video, Other + Audio, Video, Subtitles, Other } public Mp4DashReader(SharpStream source) { @@ -65,8 +79,15 @@ public class Mp4DashReader { } box = readBox(ATOM_FTYP); - if (parse_ftyp() != BRAND_DASH) { - throw new NoSuchElementException("Main Brand is not dash"); + brands = parse_ftyp(box); + switch (brands[0]) { + case BRAND_DASH: + case BRAND_ISO5:// ¿why not? + break; + default: + throw new NoSuchElementException( + "Not a MPEG-4 DASH container, major brand is not 'dash' or 'iso5' is " + boxName(brands[0]) + ); } Moov moov = null; @@ -84,8 +105,6 @@ public class Mp4DashReader { break; case ATOM_MFRA: break; - case ATOM_MDAT: - throw new IOException("Expected moof, found mdat"); } } @@ -107,15 +126,26 @@ public class Mp4DashReader { } } - if (moov.trak[i].tkhd.bHeight == 0 && moov.trak[i].tkhd.bWidth == 0) { - tracks[i].kind = moov.trak[i].tkhd.bVolume == 0 ? TrackKind.Other : TrackKind.Audio; - } else { - tracks[i].kind = TrackKind.Video; + switch (moov.trak[i].mdia.hdlr.subType) { + case HANDLER_VIDE: + tracks[i].kind = TrackKind.Video; + break; + case HANDLER_SOUN: + tracks[i].kind = TrackKind.Audio; + break; + case HANDLER_SUBT: + tracks[i].kind = TrackKind.Subtitles; + break; + default: + tracks[i].kind = TrackKind.Other; + break; } } + + backupBox = box; } - public Mp4Track selectTrack(int index) { + Mp4Track selectTrack(int index) { selectedTrack = index; return tracks[index]; } @@ -126,7 +156,7 @@ public class Mp4DashReader { * @return list with a basic info * @throws IOException if the source stream is not seekeable */ - public int getFragmentsCount() throws IOException { + int getFragmentsCount() throws IOException { if (selectedTrack < 0) { throw new IllegalStateException("track no selected"); } @@ -136,7 +166,6 @@ public class Mp4DashReader { Box tmp; int count = 0; - long orig_offset = stream.position(); if (box.type == ATOM_MOOF) { tmp = box; @@ -162,17 +191,36 @@ public class Mp4DashReader { ensure(tmp); } while (stream.available() && (tmp = readBox()) != null); - stream.rewind(); - stream.skipBytes((int) orig_offset); + rewind(); return count; } + public int[] getBrands() { + if (brands == null) throw new IllegalStateException("Not parsed"); + return brands; + } + + public void rewind() throws IOException { + if (!stream.canRewind()) { + throw new IOException("The provided stream doesn't allow seek"); + } + if (box == null) { + return; + } + + box = backupBox; + chunkZero = false; + + stream.rewind(); + stream.skipBytes(backupBox.offset + (DataReader.INTEGER_SIZE * 2)); + } + public Mp4Track[] getAvailableTracks() { return tracks; } - public Mp4TrackChunk getNextChunk() throws IOException { + public Mp4DashChunk getNextChunk(boolean infoOnly) throws IOException { Mp4Track track = tracks[selectedTrack]; while (stream.available()) { @@ -208,7 +256,7 @@ public class Mp4DashReader { if (hasFlag(moof.traf.tfhd.bFlags, 0x10)) { moof.traf.trun.chunkSize = moof.traf.tfhd.defaultSampleSize * moof.traf.trun.entryCount; } else { - moof.traf.trun.chunkSize = box.size - 8; + moof.traf.trun.chunkSize = (int) (box.size - 8); } } if (!hasFlag(moof.traf.trun.bFlags, 0x900) && moof.traf.trun.chunkDuration == 0) { @@ -228,9 +276,12 @@ public class Mp4DashReader { continue;// find another chunk } - Mp4TrackChunk chunk = new Mp4TrackChunk(); + Mp4DashChunk chunk = new Mp4DashChunk(); chunk.moof = moof; - chunk.data = new TrackDataChunk(stream, moof.traf.trun.chunkSize); + if (!infoOnly) { + chunk.data = stream.getView(moof.traf.trun.chunkSize); + } + moof = null; stream.skipBytes(chunk.moof.traf.trun.dataOffset); @@ -269,6 +320,10 @@ public class Mp4DashReader { b.size = stream.readInt(); b.type = stream.readInt(); + if (b.size == 1) { + b.size = stream.readLong(); + } + return b; } @@ -280,6 +335,25 @@ public class Mp4DashReader { return b; } + private byte[] readFullBox(Box ref) throws IOException { + // full box reading is limited to 2 GiB, and should be enough + int size = (int) ref.size; + + ByteBuffer buffer = ByteBuffer.allocate(size); + buffer.putInt(size); + buffer.putInt(ref.type); + + int read = size - 8; + + if (stream.read(buffer.array(), 8, read) != read) { + throw new EOFException( + String.format("EOF reached in box: type=%s offset=%s size=%s", boxName(ref.type), ref.offset, ref.size) + ); + } + + return buffer.array(); + } + private void ensure(Box ref) throws IOException { long skip = ref.offset + ref.size - stream.position(); @@ -310,6 +384,14 @@ public class Mp4DashReader { return null; } + private Box untilAnyBox(Box ref) throws IOException { + if (stream.position() >= (ref.offset + ref.size)) { + return null; + } + + return readBox(); + } + // // @@ -329,7 +411,7 @@ public class Mp4DashReader { return obj; } } - + return obj; } @@ -397,14 +479,14 @@ public class Mp4DashReader { private long parse_tfdt() throws IOException { int version = stream.read(); - stream.skipBytes(3);// flags + stream.skipBytes(3);// flags return version == 0 ? readUint() : stream.readLong(); } private Trun parse_trun() throws IOException { Trun obj = new Trun(); obj.bFlags = stream.readInt(); - obj.entryCount = stream.readInt();// unsigned int + obj.entryCount = stream.readInt();// unsigned int obj.entries_rowSize = 0; if (hasFlag(obj.bFlags, 0x0100)) { @@ -448,11 +530,18 @@ public class Mp4DashReader { return obj; } - private int parse_ftyp() throws IOException { - int brand = stream.readInt(); + private int[] parse_ftyp(Box ref) throws IOException { + int i = 0; + int[] list = new int[(int) ((ref.offset + ref.size - stream.position() - 4) / 4)]; + + list[i++] = stream.readInt();// major brand + stream.skipBytes(4);// minor version - return brand; + for (; i < list.length; i++) + list[i] = stream.readInt();// compatible brands + + return list; } private Mvhd parse_mvhd() throws IOException { @@ -521,32 +610,66 @@ public class Mp4DashReader { trak.tkhd = parse_tkhd(); ensure(b); - b = untilBox(ref, ATOM_MDIA); - trak.mdia = new byte[b.size]; + while ((b = untilBox(ref, ATOM_MDIA, ATOM_EDTS)) != null) { + switch (b.type) { + case ATOM_MDIA: + trak.mdia = parse_mdia(b); + break; + case ATOM_EDTS: + trak.edst_elst = parse_edts(b); + break; + } - ByteBuffer buffer = ByteBuffer.wrap(trak.mdia); - buffer.putInt(b.size); - buffer.putInt(ATOM_MDIA); - stream.read(trak.mdia, 8, b.size - 8); - - trak.mdia_mdhd_timeScale = parse_mdia(buffer); + ensure(b); + } return trak; } - private int parse_mdia(ByteBuffer data) { - while (data.hasRemaining()) { - int end = data.position() + data.getInt(); - if (data.getInt() == ATOM_MDHD) { - byte version = data.get(); - data.position(data.position() + 3 + ((version == 0 ? 4 : 8) * 2)); - return data.getInt(); - } + private Mdia parse_mdia(Box ref) throws IOException { + Mdia obj = new Mdia(); - data.position(end); + Box b; + while ((b = untilBox(ref, ATOM_MDHD, ATOM_HDLR, ATOM_MINF)) != null) { + switch (b.type) { + case ATOM_MDHD: + obj.mdhd = readFullBox(b); + + // read time scale + ByteBuffer buffer = ByteBuffer.wrap(obj.mdhd); + byte version = buffer.get(8); + buffer.position(12 + ((version == 0 ? 4 : 8) * 2)); + obj.mdhd_timeScale = buffer.getInt(); + break; + case ATOM_HDLR: + obj.hdlr = parse_hdlr(b); + break; + case ATOM_MINF: + obj.minf = parse_minf(b); + break; + } + ensure(b); } - return 0;// this NEVER should happen + return obj; + } + + private Hdlr parse_hdlr(Box ref) throws IOException { + // version + // flags + stream.skipBytes(4); + + Hdlr obj = new Hdlr(); + obj.bReserved = new byte[12]; + + obj.type = stream.readInt(); + obj.subType = stream.readInt(); + stream.read(obj.bReserved); + + // component name (is a ansi/ascii string) + stream.skipBytes((ref.offset + ref.size) - stream.position()); + + return obj; } private Moov parse_moov(Box ref) throws IOException { @@ -570,7 +693,7 @@ public class Mp4DashReader { ensure(b); } - moov.trak = tmp.toArray(new Trak[tmp.size()]); + moov.trak = tmp.toArray(new Trak[0]); return moov; } @@ -584,7 +707,7 @@ public class Mp4DashReader { ensure(b); } - return tmp.toArray(new Trex[tmp.size()]); + return tmp.toArray(new Trex[0]); } private Trex parse_trex() throws IOException { @@ -602,74 +725,74 @@ public class Mp4DashReader { return obj; } - private Tfra parse_tfra() throws IOException { - int version = stream.read(); - - stream.skipBytes(3);// flags - - Tfra tfra = new Tfra(); - tfra.trackId = stream.readInt(); - - stream.skipBytes(3);// reserved - int bFlags = stream.read(); - int size_tts = ((bFlags >> 4) & 3) + ((bFlags >> 2) & 3) + (bFlags & 3); - - tfra.entries_time = new int[stream.readInt()]; - - for (int i = 0; i < tfra.entries_time.length; i++) { - tfra.entries_time[i] = version == 0 ? stream.readInt() : (int) stream.readLong(); - stream.skipBytes(size_tts + (version == 0 ? 4 : 8)); + private Elst parse_edts(Box ref) throws IOException { + Box b = untilBox(ref, ATOM_ELST); + if (b == null) { + return null; } - return tfra; - } - - private Sidx parse_sidx() throws IOException { - int version = stream.read(); + Elst obj = new Elst(); + boolean v1 = stream.read() == 1; stream.skipBytes(3);// flags - Sidx obj = new Sidx(); - obj.referenceId = stream.readInt(); - obj.timescale = stream.readInt(); + int entryCount = stream.readInt(); + if (entryCount < 1) { + obj.bMediaRate = 0x00010000;// default media rate (1.0) + return obj; + } - // earliest presentation entries_time - // first offset - // reserved - stream.skipBytes((2 * (version == 0 ? 4 : 8)) + 2); + if (v1) { + stream.skipBytes(DataReader.LONG_SIZE);// segment duration + obj.MediaTime = stream.readLong(); + // ignore all remain entries + stream.skipBytes((entryCount - 1) * (DataReader.LONG_SIZE * 2)); + } else { + stream.skipBytes(DataReader.INTEGER_SIZE);// segment duration + obj.MediaTime = stream.readInt(); + } - obj.entries_subsegmentDuration = new int[stream.readShort()]; + obj.bMediaRate = stream.readInt(); - for (int i = 0; i < obj.entries_subsegmentDuration.length; i++) { - // reference type - // referenced size - stream.skipBytes(4); - obj.entries_subsegmentDuration[i] = stream.readInt();// unsigned int + return obj; + } - // starts with SAP - // SAP type - // SAP delta entries_time - stream.skipBytes(4); + private Minf parse_minf(Box ref) throws IOException { + Minf obj = new Minf(); + + Box b; + while ((b = untilAnyBox(ref)) != null) { + + switch (b.type) { + case ATOM_DINF: + obj.dinf = readFullBox(b); + break; + case ATOM_STBL: + obj.stbl_stsd = parse_stbl(b); + break; + case ATOM_VMHD: + case ATOM_SMHD: + obj.$mhd = readFullBox(b); + break; + + } + ensure(b); } return obj; } - private Tfra[] parse_mfra(Box ref, int trackCount) throws IOException { - ArrayList tmp = new ArrayList<>(trackCount); - long limit = ref.offset + ref.size; + /** + * this only read the "stsd" box inside + */ + private byte[] parse_stbl(Box ref) throws IOException { + Box b = untilBox(ref, ATOM_STSD); - while (stream.position() < limit) { - box = readBox(); - - if (box.type == ATOM_TFRA) { - tmp.add(parse_tfra()); - } - - ensure(box); + if (b == null) { + return new byte[0];// this never should happens (missing codec startup data) } - return tmp.toArray(new Tfra[tmp.size()]); + return readFullBox(b); } // @@ -679,14 +802,7 @@ public class Mp4DashReader { int type; long offset; - int size; - } - - class Sidx { - - int timescale; - int referenceId; - int[] entries_subsegmentDuration; + long size; } public class Moof { @@ -711,12 +827,16 @@ public class Mp4DashReader { int defaultSampleFlags; } - public class TrunEntry { + class TrunEntry { + + int sampleDuration; + int sampleSize; + int sampleFlags; + int sampleCompositionTimeOffset; + + boolean hasCompositionTimeOffset; + boolean isKeyframe; - public int sampleDuration; - public int sampleSize; - public int sampleFlags; - public int sampleCompositionTimeOffset; } public class Trun { @@ -749,6 +869,31 @@ public class Mp4DashReader { entry.sampleCompositionTimeOffset = buffer.getInt(); } + entry.hasCompositionTimeOffset = hasFlag(bFlags, 0x0800); + entry.isKeyframe = !hasFlag(entry.sampleFlags, 0x10000); + + return entry; + } + + public TrunEntry getAbsoluteEntry(int i, Tfhd header) { + TrunEntry entry = getEntry(i); + + if (!hasFlag(bFlags, 0x0100) && hasFlag(header.bFlags, 0x20)) { + entry.sampleFlags = header.defaultSampleFlags; + } + + if (!hasFlag(bFlags, 0x0200) && hasFlag(header.bFlags, 0x10)) { + entry.sampleSize = header.defaultSampleSize; + } + + if (!hasFlag(bFlags, 0x0100) && hasFlag(header.bFlags, 0x08)) { + entry.sampleDuration = header.defaultSampleDuration; + } + + if (i == 0 && hasFlag(bFlags, 0x0004)) { + entry.sampleFlags = bFirstSampleFlags; + } + return entry; } } @@ -768,9 +913,9 @@ public class Mp4DashReader { public class Trak { public Tkhd tkhd; - public int mdia_mdhd_timeScale; + public Elst edst_elst; + public Mdia mdia; - byte[] mdia; } class Mvhd { @@ -786,12 +931,6 @@ public class Mp4DashReader { Trex[] mvex_trex; } - class Tfra { - - int trackId; - int[] entries_time; - } - public class Trex { private int trackId; @@ -801,6 +940,34 @@ public class Mp4DashReader { int defaultSampleFlags; } + public class Elst { + + public long MediaTime; + public int bMediaRate; + } + + public class Mdia { + + public int mdhd_timeScale; + public byte[] mdhd; + public Hdlr hdlr; + public Minf minf; + } + + public class Hdlr { + + public int type; + public int subType; + public byte[] bReserved; + } + + public class Minf { + + public byte[] dinf; + public byte[] stbl_stsd; + public byte[] $mhd; + } + public class Mp4Track { public TrackKind kind; @@ -808,10 +975,43 @@ public class Mp4DashReader { public Trex trex; } - public class Mp4TrackChunk { + public class Mp4DashChunk { public InputStream data; public Moof moof; + private int i = 0; + + public TrunEntry getNextSampleInfo() { + if (i >= moof.traf.trun.entryCount) { + return null; + } + return moof.traf.trun.getAbsoluteEntry(i++, moof.traf.tfhd); + } + + public Mp4DashSample getNextSample() throws IOException { + if (data == null) { + throw new IllegalStateException("This chunk has info only"); + } + if (i >= moof.traf.trun.entryCount) { + return null; + } + + Mp4DashSample sample = new Mp4DashSample(); + sample.info = moof.traf.trun.getAbsoluteEntry(i++, moof.traf.tfhd); + sample.data = new byte[sample.info.sampleSize]; + + if (data.read(sample.data) != sample.info.sampleSize) { + throw new EOFException("EOF reached while reading a sample"); + } + + return sample; + } + } + + public class Mp4DashSample { + + public TrunEntry info; + public byte[] data; } // } diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4DashWriter.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4DashWriter.java deleted file mode 100644 index babb2e24c..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4DashWriter.java +++ /dev/null @@ -1,623 +0,0 @@ -package org.schabi.newpipe.streams; - -import org.schabi.newpipe.streams.io.SharpStream; - -import org.schabi.newpipe.streams.Mp4DashReader.Mp4Track; -import org.schabi.newpipe.streams.Mp4DashReader.Mp4TrackChunk; -import org.schabi.newpipe.streams.Mp4DashReader.Trak; -import org.schabi.newpipe.streams.Mp4DashReader.Trex; - - -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.List; - -import static org.schabi.newpipe.streams.Mp4DashReader.hasFlag; - -/** - * - * @author kapodamy - */ -public class Mp4DashWriter { - - private final static byte DIMENSIONAL_FIVE = 5; - private final static byte DIMENSIONAL_TWO = 2; - private final static short DEFAULT_TIMESCALE = 1000; - private final static int BUFFER_SIZE = 8 * 1024; - private final static byte DEFAULT_TREX_SIZE = 32; - private final static byte[] TFRA_TTS_DEFAULT = new byte[]{0x01, 0x01, 0x01}; - private final static int EPOCH_OFFSET = 2082844800; - - private Mp4Track[] infoTracks; - private SharpStream[] sourceTracks; - - private Mp4DashReader[] readers; - private final long time; - - private boolean done = false; - private boolean parsed = false; - - private long written = 0; - private ArrayList> chunkTimes; - private ArrayList moofOffsets; - private ArrayList fragSizes; - - public Mp4DashWriter(SharpStream... source) { - sourceTracks = source; - readers = new Mp4DashReader[sourceTracks.length]; - infoTracks = new Mp4Track[sourceTracks.length]; - time = (System.currentTimeMillis() / 1000L) + EPOCH_OFFSET; - } - - public Mp4Track[] getTracksFromSource(int sourceIndex) throws IllegalStateException { - if (!parsed) { - throw new IllegalStateException("All sources must be parsed first"); - } - - return readers[sourceIndex].getAvailableTracks(); - } - - public void parseSources() throws IOException, IllegalStateException { - if (done) { - throw new IllegalStateException("already done"); - } - if (parsed) { - throw new IllegalStateException("already parsed"); - } - - try { - for (int i = 0; i < readers.length; i++) { - readers[i] = new Mp4DashReader(sourceTracks[i]); - readers[i].parse(); - } - - } finally { - parsed = true; - } - } - - public void selectTracks(int... trackIndex) throws IOException { - if (done) { - throw new IOException("already done"); - } - if (chunkTimes != null) { - throw new IOException("tracks already selected"); - } - - try { - chunkTimes = new ArrayList<>(readers.length); - moofOffsets = new ArrayList<>(32); - fragSizes = new ArrayList<>(32); - - for (int i = 0; i < readers.length; i++) { - infoTracks[i] = readers[i].selectTrack(trackIndex[i]); - - chunkTimes.add(new ArrayList(32)); - } - - } finally { - parsed = true; - } - } - - public long getBytesWritten() { - return written; - } - - public void build(SharpStream out) throws IOException, RuntimeException { - if (done) { - throw new RuntimeException("already done"); - } - if (!out.canWrite()) { - throw new IOException("the provided output is not writable"); - } - - long sidxOffsets = -1; - int maxFrags = 0; - - for (SharpStream stream : sourceTracks) { - if (!stream.canRewind()) { - sidxOffsets = -2;// sidx not available - } - } - - try { - dump(make_ftyp(), out); - dump(make_moov(), out); - - if (sidxOffsets == -1 && out.canRewind()) { - // - int reserved = 0; - for (Mp4DashReader reader : readers) { - int count = reader.getFragmentsCount(); - if (count > maxFrags) { - maxFrags = count; - } - reserved += 12 + calcSidxBodySize(count); - } - if (maxFrags > 0xFFFF) { - sidxOffsets = -3;// TODO: to many fragments, needs a multi-sidx implementation - } else { - sidxOffsets = written; - dump(make_free(reserved), out); - } - // - } - ArrayList chunks = new ArrayList<>(readers.length); - chunks.add(null); - - int read; - byte[] buffer = new byte[BUFFER_SIZE]; - int sequenceNumber = 1; - - while (true) { - chunks.clear(); - - for (int i = 0; i < readers.length; i++) { - Mp4TrackChunk chunk = readers[i].getNextChunk(); - if (chunk == null || chunk.moof.traf.trun.chunkSize < 1) { - continue; - } - chunk.moof.traf.tfhd.trackId = i + 1; - chunks.add(chunk); - - if (sequenceNumber == 1) { - if (chunk.moof.traf.trun.entryCount > 0 && hasFlag(chunk.moof.traf.trun.bFlags, 0x0800)) { - chunkTimes.get(i).add(chunk.moof.traf.trun.getEntry(0).sampleCompositionTimeOffset); - } else { - chunkTimes.get(i).add(0); - } - } - - chunkTimes.get(i).add(chunk.moof.traf.trun.chunkDuration); - } - - if (chunks.size() < 1) { - break; - } - - long offset = written; - moofOffsets.add(offset); - - dump(make_moof(sequenceNumber++, chunks, offset), out); - dump(make_mdat(chunks), out); - - for (Mp4TrackChunk chunk : chunks) { - while ((read = chunk.data.read(buffer)) > 0) { - out.write(buffer, 0, read); - written += read; - } - } - - fragSizes.add((int) (written - offset)); - } - - dump(make_mfra(), out); - - if (sidxOffsets > 0 && moofOffsets.size() == maxFrags) { - long len = written; - - out.rewind(); - out.skip(sidxOffsets); - - written = sidxOffsets; - sidxOffsets = moofOffsets.get(0); - - for (int i = 0; i < readers.length; i++) { - dump(make_sidx(i, sidxOffsets - written), out); - } - - written = len; - } - } finally { - done = true; - } - } - - public boolean isDone() { - return done; - } - - public boolean isParsed() { - return parsed; - } - - public void close() { - done = true; - parsed = true; - - for (SharpStream src : sourceTracks) { - src.dispose(); - } - - sourceTracks = null; - readers = null; - infoTracks = null; - moofOffsets = null; - chunkTimes = null; - } - - // - private void dump(byte[][] buffer, SharpStream stream) throws IOException { - for (byte[] buff : buffer) { - stream.write(buff); - written += buff.length; - } - } - - private byte[][] lengthFor(byte[][] buffer) { - int length = 0; - for (byte[] buff : buffer) { - length += buff.length; - } - - ByteBuffer.wrap(buffer[0]).putInt(length); - - return buffer; - } - - private int calcSidxBodySize(int entryCount) { - return 4 + 4 + 8 + 8 + 4 + (entryCount * 12); - } - // - - // - private byte[][] make_moof(int sequence, ArrayList chunks, long referenceOffset) { - int pos = 2; - TrunExtra[] extra = new TrunExtra[chunks.size()]; - - byte[][] buffer = new byte[pos + (extra.length * DIMENSIONAL_FIVE)][]; - buffer[0] = new byte[]{ - 0x00, 0x00, 0x00, 0x00, 0x6D, 0x6F, 0x6F, 0x66,// info header - 0x00, 0x00, 0x00, 0x10, 0x6D, 0x66, 0x68, 0x64, 0x00, 0x00, 0x00, 0x00//mfhd - }; - buffer[1] = new byte[4]; - ByteBuffer.wrap(buffer[1]).putInt(sequence); - - for (int i = 0; i < extra.length; i++) { - extra[i] = new TrunExtra(); - for (byte[] buff : make_traf(chunks.get(i), extra[i], referenceOffset)) { - buffer[pos++] = buff; - } - } - - lengthFor(buffer); - - int offset = 8 + ByteBuffer.wrap(buffer[0]).getInt(); - - for (int i = 0; i < extra.length; i++) { - extra[i].byteBuffer.putInt(offset); - offset += chunks.get(i).moof.traf.trun.chunkSize; - } - - return buffer; - } - - private byte[][] make_traf(Mp4TrackChunk chunk, TrunExtra extra, long moofOffset) { - byte[][] buffer = new byte[DIMENSIONAL_FIVE][]; - buffer[0] = new byte[]{ - 0x00, 0x00, 0x00, 0x00, 0x74, 0x72, 0x61, 0x66, - 0x00, 0x00, 0x00, 0x00, 0x74, 0x66, 0x68, 0x64 - }; - - int flags = (chunk.moof.traf.tfhd.bFlags & 0x38) | 0x01; - byte tfhdBodySize = 8 + 8; - if (hasFlag(flags, 0x08)) { - tfhdBodySize += 4; - } - if (hasFlag(flags, 0x10)) { - tfhdBodySize += 4; - } - if (hasFlag(flags, 0x20)) { - tfhdBodySize += 4; - } - buffer[1] = new byte[tfhdBodySize]; - ByteBuffer set = ByteBuffer.wrap(buffer[1]); - set.position(4); - set.putInt(chunk.moof.traf.tfhd.trackId); - set.putLong(moofOffset); - if (hasFlag(flags, 0x08)) { - set.putInt(chunk.moof.traf.tfhd.defaultSampleDuration); - } - if (hasFlag(flags, 0x10)) { - set.putInt(chunk.moof.traf.tfhd.defaultSampleSize); - } - if (hasFlag(flags, 0x20)) { - set.putInt(chunk.moof.traf.tfhd.defaultSampleFlags); - } - set.putInt(0, flags); - ByteBuffer.wrap(buffer[0]).putInt(8, 8 + tfhdBodySize); - - buffer[2] = new byte[]{ - 0x00, 0x00, 0x00, 0x14, - 0x74, 0x66, 0x64, 0x74, - 0x01, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00 - }; - - ByteBuffer.wrap(buffer[2]).putLong(12, chunk.moof.traf.tfdt); - - buffer[3] = new byte[]{ - 0x00, 0x00, 0x00, 0x00, 0x74, 0x72, 0x75, 0x6E, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00 - }; - - buffer[4] = chunk.moof.traf.trun.bEntries; - - lengthFor(buffer); - - set = ByteBuffer.wrap(buffer[3]); - set.putInt(buffer[3].length + buffer[4].length); - set.position(8); - set.putInt((chunk.moof.traf.trun.bFlags | 0x01) & 0x0F01); - set.putInt(chunk.moof.traf.trun.entryCount); - extra.byteBuffer = set; - - return buffer; - } - - private byte[][] make_mdat(ArrayList chunks) { - byte[][] buffer = new byte[][]{ - { - 0x00, 0x00, 0x00, 0x00, 0x6D, 0x64, 0x61, 0x74 - } - }; - - int length = 0; - - for (Mp4TrackChunk chunk : chunks) { - length += chunk.moof.traf.trun.chunkSize; - } - - ByteBuffer.wrap(buffer[0]).putInt(length + 8); - - return buffer; - } - - private byte[][] make_ftyp() { - return new byte[][]{ - { - 0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x64, 0x61, 0x73, 0x68, 0x00, 0x00, 0x00, 0x00, - 0x6D, 0x70, 0x34, 0x31, 0x69, 0x73, 0x6F, 0x6D, 0x69, 0x73, 0x6F, 0x36, 0x69, 0x73, 0x6F, 0x32 - } - }; - } - - private byte[][] make_mvhd() { - byte[][] buffer = new byte[DIMENSIONAL_FIVE][]; - - buffer[0] = new byte[]{ - 0x00, 0x00, 0x00, 0x78, 0x6D, 0x76, 0x68, 0x64, 0x01, 0x00, 0x00, 0x00 - }; - buffer[1] = new byte[28]; - buffer[2] = new byte[]{ - 0x00, 0x01, 0x00, 0x00, 0x01, 0x00,// default volume and rate - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,// reserved values - // default matrix - 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x40, 0x00, 0x00, 0x00 - }; - buffer[3] = new byte[24];// predefined - buffer[4] = ByteBuffer.allocate(4).putInt(infoTracks.length + 1).array(); - - long longestTrack = 0; - - for (Mp4Track track : infoTracks) { - long tmp = (long) ((track.trak.tkhd.duration / (double) track.trak.mdia_mdhd_timeScale) * DEFAULT_TIMESCALE); - if (tmp > longestTrack) { - longestTrack = tmp; - } - } - - ByteBuffer.wrap(buffer[1]) - .putLong(time) - .putLong(time) - .putInt(DEFAULT_TIMESCALE) - .putLong(longestTrack); - - return buffer; - } - - private byte[][] make_trak(int trackId, Trak trak) throws RuntimeException { - if (trak.tkhd.matrix.length != 36) { - throw new RuntimeException("bad track matrix length (expected 36)"); - } - - byte[][] buffer = new byte[DIMENSIONAL_FIVE][]; - - buffer[0] = new byte[]{ - 0x00, 0x00, 0x00, 0x00, 0x74, 0x72, 0x61, 0x6B,// trak header - 0x00, 0x00, 0x00, 0x68, 0x74, 0x6B, 0x68, 0x64, 0x01, 0x00, 0x00, 0x03 // tkhd header - }; - buffer[1] = new byte[48]; - buffer[2] = trak.tkhd.matrix; - buffer[3] = new byte[8]; - buffer[4] = trak.mdia; - - ByteBuffer set = ByteBuffer.wrap(buffer[1]); - set.putLong(time); - set.putLong(time); - set.putInt(trackId); - set.position(24); - set.putLong(trak.tkhd.duration); - set.position(40); - set.putShort(trak.tkhd.bLayer); - set.putShort(trak.tkhd.bAlternateGroup); - set.putShort(trak.tkhd.bVolume); - - ByteBuffer.wrap(buffer[3]) - .putInt(trak.tkhd.bWidth) - .putInt(trak.tkhd.bHeight); - - return lengthFor(buffer); - } - - private byte[][] make_moov() throws RuntimeException { - int pos = 1; - byte[][] buffer = new byte[2 + (DIMENSIONAL_TWO * infoTracks.length) + (DIMENSIONAL_FIVE * infoTracks.length) + DIMENSIONAL_FIVE + 1][]; - - buffer[0] = new byte[]{ - 0x00, 0x00, 0x00, 0x00, 0x6D, 0x6F, 0x6F, 0x76 - }; - - for (byte[] buff : make_mvhd()) { - buffer[pos++] = buff; - } - - for (int i = 0; i < infoTracks.length; i++) { - for (byte[] buff : make_trak(i + 1, infoTracks[i].trak)) { - buffer[pos++] = buff; - } - } - - buffer[pos] = new byte[]{ - 0x00, 0x00, 0x00, 0x00, 0x6D, 0x76, 0x65, 0x78 - }; - - ByteBuffer.wrap(buffer[pos++]).putInt((infoTracks.length * DEFAULT_TREX_SIZE) + 8); - - for (int i = 0; i < infoTracks.length; i++) { - for (byte[] buff : make_trex(i + 1, infoTracks[i].trex)) { - buffer[pos++] = buff; - } - } - - // default udta - buffer[pos] = new byte[]{ - 0x00, 0x00, 0x00, 0x5C, 0x75, 0x64, 0x74, 0x61, 0x00, 0x00, 0x00, 0x54, 0x6D, 0x65, 0x74, 0x61, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x68, 0x64, 0x6C, 0x72, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x6D, 0x64, 0x69, 0x72, 0x61, 0x70, 0x70, 0x6C, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x27, 0x69, 0x6C, 0x73, 0x74, 0x00, 0x00, 0x00, - 0x1F, (byte) 0xA9, 0x63, 0x6D, 0x74, 0x00, 0x00, 0x00, 0x17, 0x64, 0x61, 0x74, 0x61, 0x00, 0x00, 0x00, - 0x01, 0x00, 0x00, 0x00, 0x00, - 0x4E, 0x65, 0x77, 0x50, 0x69, 0x70, 0x65// "NewPipe" binary string - }; - - return lengthFor(buffer); - } - - private byte[][] make_trex(int trackId, Trex trex) { - byte[][] buffer = new byte[][]{ - { - 0x00, 0x00, 0x00, 0x20, 0x74, 0x72, 0x65, 0x78, 0x00, 0x00, 0x00, 0x00 - }, - new byte[20] - }; - - ByteBuffer.wrap(buffer[1]) - .putInt(trackId) - .putInt(trex.defaultSampleDescriptionIndex) - .putInt(trex.defaultSampleDuration) - .putInt(trex.defaultSampleSize) - .putInt(trex.defaultSampleFlags); - - return buffer; - } - - private byte[][] make_tfra(int trackId, List times, List moofOffsets) { - int entryCount = times.size() - 1; - byte[][] buffer = new byte[DIMENSIONAL_TWO][]; - buffer[0] = new byte[]{ - 0x00, 0x00, 0x00, 0x00, 0x74, 0x66, 0x72, 0x61, 0x01, 0x00, 0x00, 0x00 - }; - buffer[1] = new byte[12 + ((16 + TFRA_TTS_DEFAULT.length) * entryCount)]; - - ByteBuffer set = ByteBuffer.wrap(buffer[1]); - set.putInt(trackId); - set.position(8); - set.putInt(entryCount); - - long decodeTime = 0; - - for (int i = 0; i < entryCount; i++) { - decodeTime += times.get(i); - set.putLong(decodeTime); - set.putLong(moofOffsets.get(i)); - set.put(TFRA_TTS_DEFAULT);// default values: traf number/trun number/sample number - } - - return lengthFor(buffer); - } - - private byte[][] make_mfra() { - byte[][] buffer = new byte[2 + (DIMENSIONAL_TWO * infoTracks.length)][]; - buffer[0] = new byte[]{ - 0x00, 0x00, 0x00, 0x00, 0x6D, 0x66, 0x72, 0x61 - }; - int pos = 1; - - for (int i = 0; i < infoTracks.length; i++) { - for (byte[] buff : make_tfra(i + 1, chunkTimes.get(i), moofOffsets)) { - buffer[pos++] = buff; - } - } - - buffer[pos] = new byte[]{// mfro - 0x00, 0x00, 0x00, 0x10, 0x6D, 0x66, 0x72, 0x6F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 - }; - - lengthFor(buffer); - - ByteBuffer set = ByteBuffer.wrap(buffer[pos]); - set.position(12); - set.put(buffer[0], 0, 4); - - return buffer; - - } - - private byte[][] make_sidx(int internalTrackId, long firstOffset) { - List times = chunkTimes.get(internalTrackId); - int count = times.size() - 1;// the first item is ignored (composition time) - - if (count > 65535) { - throw new OutOfMemoryError("to many fragments. sidx limit is 65535, found " + String.valueOf(count)); - } - - byte[][] buffer = new byte[][]{ - new byte[]{ - 0x00, 0x00, 0x00, 0x00, 0x73, 0x69, 0x64, 0x78, 0x01, 0x00, 0x00, 0x00 - }, - new byte[calcSidxBodySize(count)] - }; - - lengthFor(buffer); - - ByteBuffer set = ByteBuffer.wrap(buffer[1]); - set.putInt(internalTrackId + 1); - set.putInt(infoTracks[internalTrackId].trak.mdia_mdhd_timeScale); - set.putLong(0); - set.putLong(firstOffset - ByteBuffer.wrap(buffer[0]).getInt()); - set.putInt(0xFFFF & count);// unsigned - - int i = 0; - while (i < count) { - set.putInt(fragSizes.get(i) & 0x7fffffff);// default reference type is 0 - set.putInt(times.get(i + 1)); - set.putInt(0x90000000);// default SAP settings - i++; - } - - return buffer; - } - - private byte[][] make_free(int totalSize) { - return lengthFor(new byte[][]{ - new byte[]{0x00, 0x00, 0x00, 0x00, 0x66, 0x72, 0x65, 0x65}, - new byte[totalSize - 8]// this is waste of RAM - }); - - } - -// - - class TrunExtra { - - ByteBuffer byteBuffer; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java new file mode 100644 index 000000000..03aab447c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java @@ -0,0 +1,810 @@ +package org.schabi.newpipe.streams; + +import org.schabi.newpipe.streams.Mp4DashReader.Hdlr; +import org.schabi.newpipe.streams.Mp4DashReader.Mdia; +import org.schabi.newpipe.streams.Mp4DashReader.Mp4DashChunk; +import org.schabi.newpipe.streams.Mp4DashReader.Mp4DashSample; +import org.schabi.newpipe.streams.Mp4DashReader.Mp4Track; +import org.schabi.newpipe.streams.Mp4DashReader.TrunEntry; +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * @author kapodamy + */ +public class Mp4FromDashWriter { + + private final static int EPOCH_OFFSET = 2082844800; + private final static short DEFAULT_TIMESCALE = 1000; + private final static byte SAMPLES_PER_CHUNK_INIT = 2; + private final static byte SAMPLES_PER_CHUNK = 6;// ffmpeg uses 2, basic uses 1 (with 60fps uses 21 or 22). NewPipe will use 6 + private final static long THRESHOLD_FOR_CO64 = 0xFFFEFFFFL;// near 3.999 GiB + private final static int THRESHOLD_MOOV_LENGTH = (256 * 1024) + (2048 * 1024); // 2.2 MiB enough for: 1080p 60fps 00h35m00s + + private final long time; + + private ByteBuffer auxBuffer; + private SharpStream outStream; + + private long lastWriteOffset = -1; + private long writeOffset; + + private boolean moovSimulation = true; + + private boolean done = false; + private boolean parsed = false; + + private Mp4Track[] tracks; + private SharpStream[] sourceTracks; + + private Mp4DashReader[] readers; + private Mp4DashChunk[] readersChunks; + + private int overrideMainBrand = 0x00; + + public Mp4FromDashWriter(SharpStream... sources) throws IOException { + for (SharpStream src : sources) { + if (!src.canRewind() && !src.canRead()) { + throw new IOException("All sources must be readable and allow rewind"); + } + } + + sourceTracks = sources; + readers = new Mp4DashReader[sourceTracks.length]; + readersChunks = new Mp4DashChunk[readers.length]; + time = (System.currentTimeMillis() / 1000L) + EPOCH_OFFSET; + } + + public Mp4Track[] getTracksFromSource(int sourceIndex) throws IllegalStateException { + if (!parsed) { + throw new IllegalStateException("All sources must be parsed first"); + } + + return readers[sourceIndex].getAvailableTracks(); + } + + public void parseSources() throws IOException, IllegalStateException { + if (done) { + throw new IllegalStateException("already done"); + } + if (parsed) { + throw new IllegalStateException("already parsed"); + } + + try { + for (int i = 0; i < readers.length; i++) { + readers[i] = new Mp4DashReader(sourceTracks[i]); + readers[i].parse(); + } + + } finally { + parsed = true; + } + } + + public void selectTracks(int... trackIndex) throws IOException { + if (done) { + throw new IOException("already done"); + } + if (tracks != null) { + throw new IOException("tracks already selected"); + } + + try { + tracks = new Mp4Track[readers.length]; + for (int i = 0; i < readers.length; i++) { + tracks[i] = readers[i].selectTrack(trackIndex[i]); + } + } finally { + parsed = true; + } + } + + public void setMainBrand(int brandId) { + overrideMainBrand = brandId; + } + + public boolean isDone() { + return done; + } + + public boolean isParsed() { + return parsed; + } + + public void close() throws IOException { + done = true; + parsed = true; + + for (SharpStream src : sourceTracks) { + src.close(); + } + + tracks = null; + sourceTracks = null; + + readers = null; + readersChunks = null; + + auxBuffer = null; + outStream = null; + } + + public void build(SharpStream output) throws IOException { + if (done) { + throw new RuntimeException("already done"); + } + if (!output.canWrite()) { + throw new IOException("the provided output is not writable"); + } + + // + // WARNING: the muxer requires at least 8 samples of every track + // not allowed for very short tracks (less than 0.5 seconds) + // + outStream = output; + int read = 8;// mdat box header size + long totalSampleSize = 0; + int[] sampleExtra = new int[readers.length]; + int[] defaultMediaTime = new int[readers.length]; + int[] defaultSampleDuration = new int[readers.length]; + int[] sampleCount = new int[readers.length]; + + TablesInfo[] tablesInfo = new TablesInfo[tracks.length]; + for (int i = 0; i < tablesInfo.length; i++) { + tablesInfo[i] = new TablesInfo(); + } + + // + for (int i = 0; i < readers.length; i++) { + int samplesSize = 0; + int sampleSizeChanges = 0; + int compositionOffsetLast = -1; + + Mp4DashChunk chunk; + while ((chunk = readers[i].getNextChunk(true)) != null) { + + if (defaultMediaTime[i] < 1 && chunk.moof.traf.tfhd.defaultSampleDuration > 0) { + defaultMediaTime[i] = chunk.moof.traf.tfhd.defaultSampleDuration; + } + + read += chunk.moof.traf.trun.chunkSize; + sampleExtra[i] += chunk.moof.traf.trun.chunkDuration;// calculate track duration + + TrunEntry info; + while ((info = chunk.getNextSampleInfo()) != null) { + if (info.isKeyframe) { + tablesInfo[i].stss++; + } + + if (info.sampleDuration > defaultSampleDuration[i]) { + defaultSampleDuration[i] = info.sampleDuration; + } + + tablesInfo[i].stsz++; + if (samplesSize != info.sampleSize) { + samplesSize = info.sampleSize; + sampleSizeChanges++; + } + + if (info.hasCompositionTimeOffset) { + if (info.sampleCompositionTimeOffset != compositionOffsetLast) { + tablesInfo[i].ctts++; + compositionOffsetLast = info.sampleCompositionTimeOffset; + } + } + + totalSampleSize += info.sampleSize; + } + } + + if (defaultMediaTime[i] < 1) { + defaultMediaTime[i] = defaultSampleDuration[i]; + } + + readers[i].rewind(); + + int tmp = tablesInfo[i].stsz - SAMPLES_PER_CHUNK_INIT; + tablesInfo[i].stco = (tmp / SAMPLES_PER_CHUNK) + 1;// +1 for samples in first chunk + + tmp = tmp % SAMPLES_PER_CHUNK; + if (tmp == 0) { + tablesInfo[i].stsc = 2;// first chunk (init) and succesive chunks + tablesInfo[i].stsc_bEntries = new int[]{ + 1, SAMPLES_PER_CHUNK_INIT, 1, + 2, SAMPLES_PER_CHUNK, 1 + }; + } else { + tablesInfo[i].stsc = 3;// first chunk (init) and succesive chunks and remain chunk + tablesInfo[i].stsc_bEntries = new int[]{ + 1, SAMPLES_PER_CHUNK_INIT, 1, + 2, SAMPLES_PER_CHUNK, 1, + tablesInfo[i].stco + 1, tmp, 1 + }; + tablesInfo[i].stco++; + } + + sampleCount[i] = tablesInfo[i].stsz; + + if (sampleSizeChanges == 1) { + tablesInfo[i].stsz = 0; + tablesInfo[i].stsz_default = samplesSize; + } else { + tablesInfo[i].stsz_default = 0; + } + + if (tablesInfo[i].stss == tablesInfo[i].stsz) { + tablesInfo[i].stss = -1;// for audio tracks (all samples are keyframes) + } + + // ensure track duration + if (tracks[i].trak.tkhd.duration < 1) { + tracks[i].trak.tkhd.duration = sampleExtra[i];// this never should happen + } + } + // + + boolean is64 = read > THRESHOLD_FOR_CO64; + + // calculate the moov size; + int auxSize = make_moov(defaultMediaTime, tablesInfo, is64); + + if (auxSize < THRESHOLD_MOOV_LENGTH) { + auxBuffer = ByteBuffer.allocate(auxSize);// cache moov in the memory + } + + moovSimulation = false; + writeOffset = 0; + + final int ftyp_size = make_ftyp(); + + // reserve moov space in the output stream + /*if (outStream.canSetLength()) { + long length = writeOffset + auxSize; + outStream.setLength(length); + outSeek(length); + } else {*/ + if (auxSize > 0) { + int length = auxSize; + byte[] buffer = new byte[8 * 1024];// 8 KiB + while (length > 0) { + int count = Math.min(length, buffer.length); + outWrite(buffer, 0, count); + length -= count; + } + } + + if (auxBuffer == null) { + outSeek(ftyp_size); + } + + // tablesInfo contais row counts + // and after returning from make_moov() will contain table offsets + make_moov(defaultMediaTime, tablesInfo, is64); + + // write tables: stts stsc + // reset for ctts table: sampleCount sampleExtra + for (int i = 0; i < readers.length; i++) { + writeEntryArray(tablesInfo[i].stts, 2, sampleCount[i], defaultSampleDuration[i]); + writeEntryArray(tablesInfo[i].stsc, tablesInfo[i].stsc_bEntries.length, tablesInfo[i].stsc_bEntries); + tablesInfo[i].stsc_bEntries = null; + if (tablesInfo[i].ctts > 0) { + sampleCount[i] = 1;// index is not base zero + sampleExtra[i] = -1; + } + } + + if (auxBuffer == null) { + outRestore(); + } + + outWrite(make_mdat(totalSampleSize, is64)); + + int[] sampleIndex = new int[readers.length]; + int[] sizes = new int[SAMPLES_PER_CHUNK]; + int[] sync = new int[SAMPLES_PER_CHUNK]; + + int written = readers.length; + while (written > 0) { + written = 0; + + for (int i = 0; i < readers.length; i++) { + if (sampleIndex[i] < 0) { + continue;// track is done + } + + long chunkOffset = writeOffset; + int syncCount = 0; + int limit = sampleIndex[i] == 0 ? SAMPLES_PER_CHUNK_INIT : SAMPLES_PER_CHUNK; + + int j = 0; + for (; j < limit; j++) { + Mp4DashSample sample = getNextSample(i); + + if (sample == null) { + if (tablesInfo[i].ctts > 0 && sampleExtra[i] >= 0) { + writeEntryArray(tablesInfo[i].ctts, 1, sampleCount[i], sampleExtra[i]);// flush last entries + } + sampleIndex[i] = -1; + break; + } + + sampleIndex[i]++; + + if (tablesInfo[i].ctts > 0) { + if (sample.info.sampleCompositionTimeOffset == sampleExtra[i]) { + sampleCount[i]++; + } else { + if (sampleExtra[i] >= 0) { + tablesInfo[i].ctts = writeEntryArray(tablesInfo[i].ctts, 2, sampleCount[i], sampleExtra[i]); + outRestore(); + } + sampleCount[i] = 1; + sampleExtra[i] = sample.info.sampleCompositionTimeOffset; + } + } + + if (tablesInfo[i].stss > 0 && sample.info.isKeyframe) { + sync[syncCount++] = sampleIndex[i]; + } + + if (tablesInfo[i].stsz > 0) { + sizes[j] = sample.data.length; + } + + outWrite(sample.data, 0, sample.data.length); + } + + if (j > 0) { + written++; + + if (tablesInfo[i].stsz > 0) { + tablesInfo[i].stsz = writeEntryArray(tablesInfo[i].stsz, j, sizes); + } + + if (syncCount > 0) { + tablesInfo[i].stss = writeEntryArray(tablesInfo[i].stss, syncCount, sync); + } + + if (is64) { + tablesInfo[i].stco = writeEntry64(tablesInfo[i].stco, chunkOffset); + } else { + tablesInfo[i].stco = writeEntryArray(tablesInfo[i].stco, 1, (int) chunkOffset); + } + + outRestore(); + } + } + } + + if (auxBuffer != null) { + // dump moov + outSeek(ftyp_size); + outStream.write(auxBuffer.array(), 0, auxBuffer.capacity()); + auxBuffer = null; + } + } + + private Mp4DashSample getNextSample(int track) throws IOException { + if (readersChunks[track] == null) { + readersChunks[track] = readers[track].getNextChunk(false); + if (readersChunks[track] == null) { + return null;// EOF reached + } + } + + Mp4DashSample sample = readersChunks[track].getNextSample(); + if (sample == null) { + readersChunks[track] = null; + return getNextSample(track); + } else { + return sample; + } + } + + // + private int writeEntry64(int offset, long value) throws IOException { + outBackup(); + + auxSeek(offset); + auxWrite(ByteBuffer.allocate(8).putLong(value).array()); + + return offset + 8; + } + + private int writeEntryArray(int offset, int count, int... values) throws IOException { + outBackup(); + + auxSeek(offset); + + int size = count * 4; + ByteBuffer buffer = ByteBuffer.allocate(size); + + for (int i = 0; i < count; i++) { + buffer.putInt(values[i]); + } + + auxWrite(buffer.array()); + + return offset + size; + } + + private void outBackup() { + if (auxBuffer == null && lastWriteOffset < 0) { + lastWriteOffset = writeOffset; + } + } + + /** + * Restore to the previous position before the first call to writeEntry64() + * or writeEntryArray() methods. + */ + private void outRestore() throws IOException { + if (lastWriteOffset > 0) { + outSeek(lastWriteOffset); + lastWriteOffset = -1; + } + } + // + + // + private void outWrite(byte[] buffer) throws IOException { + outWrite(buffer, 0, buffer.length); + } + + private void outWrite(byte[] buffer, int offset, int count) throws IOException { + writeOffset += count; + outStream.write(buffer, offset, count); + } + + private void outSeek(long offset) throws IOException { + if (outStream.canSeek()) { + outStream.seek(offset); + writeOffset = offset; + } else if (outStream.canRewind()) { + outStream.rewind(); + writeOffset = 0; + outSkip(offset); + } else { + throw new IOException("cannot seek or rewind the output stream"); + } + } + + private void outSkip(long amount) throws IOException { + outStream.skip(amount); + writeOffset += amount; + } + + private int lengthFor(int offset) throws IOException { + int size = auxOffset() - offset; + + if (moovSimulation) { + return size; + } + + auxSeek(offset); + auxWrite(size); + auxSkip(size - 4); + + return size; + } + + private int make(int type, int extra, int columns, int rows) throws IOException { + final byte base = 16; + int size = columns * rows * 4; + int total = size + base; + int offset = auxOffset(); + + if (extra >= 0) { + total += 4; + } + + auxWrite(ByteBuffer.allocate(12) + .putInt(total) + .putInt(type) + .putInt(0x00)// default version & flags + .array() + ); + + if (extra >= 0) { + //size += 4;// commented for auxiliar buffer !!! + offset += 4; + auxWrite(extra); + } + + auxWrite(rows); + auxSkip(size); + + return offset + base; + } + + private void auxWrite(int value) throws IOException { + auxWrite(ByteBuffer.allocate(4) + .putInt(value) + .array() + ); + } + + private void auxWrite(byte[] buffer) throws IOException { + if (moovSimulation) { + writeOffset += buffer.length; + } else if (auxBuffer == null) { + outWrite(buffer, 0, buffer.length); + } else { + auxBuffer.put(buffer); + } + } + + private void auxSeek(int offset) throws IOException { + if (moovSimulation) { + writeOffset = offset; + } else if (auxBuffer == null) { + outSeek(offset); + } else { + auxBuffer.position(offset); + } + } + + private void auxSkip(int amount) throws IOException { + if (moovSimulation) { + writeOffset += amount; + } else if (auxBuffer == null) { + outSkip(amount); + } else { + auxBuffer.position(auxBuffer.position() + amount); + } + } + + private int auxOffset() { + return auxBuffer == null ? (int) writeOffset : auxBuffer.position(); + } + // + + // + private int make_ftyp() throws IOException { + byte[] buffer = new byte[]{ + 0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70,// ftyp + 0x6D, 0x70, 0x34, 0x32,// mayor brand (mp42) + 0x00, 0x00, 0x02, 0x00,// default minor version (512) + 0x6D, 0x70, 0x34, 0x31, 0x69, 0x73, 0x6F, 0x6D, 0x69, 0x73, 0x6F, 0x32// compatible brands: mp41 isom iso2 + }; + + if (overrideMainBrand != 0) + ByteBuffer.wrap(buffer).putInt(8, overrideMainBrand); + + outWrite(buffer); + + return buffer.length; + } + + private byte[] make_mdat(long refSize, boolean is64) { + if (is64) { + refSize += 16; + } else { + refSize += 8; + } + + ByteBuffer buffer = ByteBuffer.allocate(is64 ? 16 : 8) + .putInt(is64 ? 0x01 : (int) refSize) + .putInt(0x6D646174);// mdat + + if (is64) { + buffer.putLong(refSize); + } + + return buffer.array(); + } + + private void make_mvhd(long longestTrack) throws IOException { + auxWrite(new byte[]{ + 0x00, 0x00, 0x00, 0x78, 0x6D, 0x76, 0x68, 0x64, 0x01, 0x00, 0x00, 0x00 + }); + auxWrite(ByteBuffer.allocate(28) + .putLong(time) + .putLong(time) + .putInt(DEFAULT_TIMESCALE) + .putLong(longestTrack) + .array() + ); + + auxWrite(new byte[]{ + 0x00, 0x01, 0x00, 0x00, 0x01, 0x00,// default volume and rate + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,// reserved values + // default matrix + 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x40, 0x00, 0x00, 0x00 + }); + auxWrite(new byte[24]);// predefined + auxWrite(ByteBuffer.allocate(4) + .putInt(tracks.length + 1) + .array() + ); + } + + private int make_moov(int[] defaultMediaTime, TablesInfo[] tablesInfo, boolean is64) throws RuntimeException, IOException { + int start = auxOffset(); + + auxWrite(new byte[]{ + 0x00, 0x00, 0x00, 0x00, 0x6D, 0x6F, 0x6F, 0x76 + }); + + long longestTrack = 0; + long[] durations = new long[tracks.length]; + + for (int i = 0; i < durations.length; i++) { + durations[i] = (long) Math.ceil( + ((double) tracks[i].trak.tkhd.duration / tracks[i].trak.mdia.mdhd_timeScale) * DEFAULT_TIMESCALE + ); + + if (durations[i] > longestTrack) { + longestTrack = durations[i]; + } + } + + make_mvhd(longestTrack); + + for (int i = 0; i < tracks.length; i++) { + if (tracks[i].trak.tkhd.matrix.length != 36) { + throw new RuntimeException("bad track matrix length (expected 36) in track n°" + i); + } + make_trak(i, durations[i], defaultMediaTime[i], tablesInfo[i], is64); + } + + // udta/meta/ilst/©too + auxWrite(new byte[]{ + 0x00, 0x00, 0x00, 0x5C, 0x75, 0x64, 0x74, 0x61, 0x00, 0x00, 0x00, 0x54, 0x6D, 0x65, 0x74, 0x61, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x68, 0x64, 0x6C, 0x72, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x6D, 0x64, 0x69, 0x72, 0x61, 0x70, 0x70, 0x6C, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x27, 0x69, 0x6C, 0x73, 0x74, 0x00, 0x00, 0x00, + 0x1F, (byte) 0xA9, 0x74, 0x6F, 0x6F, 0x00, 0x00, 0x00, 0x17, 0x64, 0x61, 0x74, 0x61, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x4E, 0x65, 0x77, 0x50, 0x69, 0x70, 0x65// "NewPipe" binary string + }); + + return lengthFor(start); + } + + private void make_trak(int index, long duration, int defaultMediaTime, TablesInfo tables, boolean is64) throws IOException { + int start = auxOffset(); + + auxWrite(new byte[]{ + 0x00, 0x00, 0x00, 0x00, 0x74, 0x72, 0x61, 0x6B,// trak header + 0x00, 0x00, 0x00, 0x68, 0x74, 0x6B, 0x68, 0x64, 0x01, 0x00, 0x00, 0x03 // tkhd header + }); + + ByteBuffer buffer = ByteBuffer.allocate(48); + buffer.putLong(time); + buffer.putLong(time); + buffer.putInt(index + 1); + buffer.position(24); + buffer.putLong(duration); + buffer.position(40); + buffer.putShort(tracks[index].trak.tkhd.bLayer); + buffer.putShort(tracks[index].trak.tkhd.bAlternateGroup); + buffer.putShort(tracks[index].trak.tkhd.bVolume); + auxWrite(buffer.array()); + + auxWrite(tracks[index].trak.tkhd.matrix); + auxWrite(ByteBuffer.allocate(8) + .putInt(tracks[index].trak.tkhd.bWidth) + .putInt(tracks[index].trak.tkhd.bHeight) + .array() + ); + + auxWrite(new byte[]{ + 0x00, 0x00, 0x00, 0x24, 0x65, 0x64, 0x74, 0x73,// edts header + 0x00, 0x00, 0x00, 0x1C, 0x65, 0x6C, 0x73, 0x74, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01// elst header + }); + + int bMediaRate; + int mediaTime; + + if (tracks[index].trak.edst_elst == null) { + // is a audio track ¿is edst/elst opcional for audio tracks? + mediaTime = 0x00;// ffmpeg set this value as zero, instead of defaultMediaTime + bMediaRate = 0x00010000; + } else { + mediaTime = (int) tracks[index].trak.edst_elst.MediaTime; + bMediaRate = tracks[index].trak.edst_elst.bMediaRate; + } + + auxWrite(ByteBuffer + .allocate(12) + .putInt((int) duration) + .putInt(mediaTime) + .putInt(bMediaRate) + .array() + ); + + make_mdia(tracks[index].trak.mdia, tables, is64); + + lengthFor(start); + } + + private void make_mdia(Mdia mdia, TablesInfo tablesInfo, boolean is64) throws IOException { + + int start_mdia = auxOffset(); + auxWrite(new byte[]{0x00, 0x00, 0x00, 0x00, 0x6D, 0x64, 0x69, 0x61});// mdia + auxWrite(mdia.mdhd); + auxWrite(make_hdlr(mdia.hdlr)); + + int start_minf = auxOffset(); + auxWrite(new byte[]{0x00, 0x00, 0x00, 0x00, 0x6D, 0x69, 0x6E, 0x66});// minf + auxWrite(mdia.minf.$mhd); + auxWrite(mdia.minf.dinf); + + int start_stbl = auxOffset(); + auxWrite(new byte[]{0x00, 0x00, 0x00, 0x00, 0x73, 0x74, 0x62, 0x6C});// stbl + auxWrite(mdia.minf.stbl_stsd); + + // + // In audio tracks the following tables is not required: ssts ctts + // And stsz can be empty if has a default sample size + // + if (moovSimulation) { + make(0x73747473, -1, 2, 1); + if (tablesInfo.stss > 0) { + make(0x73747373, -1, 1, tablesInfo.stss); + } + if (tablesInfo.ctts > 0) { + make(0x63747473, -1, 2, tablesInfo.ctts); + } + make(0x73747363, -1, 3, tablesInfo.stsc); + make(0x7374737A, tablesInfo.stsz_default, 1, tablesInfo.stsz); + make(is64 ? 0x636F3634 : 0x7374636F, -1, is64 ? 2 : 1, tablesInfo.stco); + } else { + tablesInfo.stts = make(0x73747473, -1, 2, 1); + if (tablesInfo.stss > 0) { + tablesInfo.stss = make(0x73747373, -1, 1, tablesInfo.stss); + } + if (tablesInfo.ctts > 0) { + tablesInfo.ctts = make(0x63747473, -1, 2, tablesInfo.ctts); + } + tablesInfo.stsc = make(0x73747363, -1, 3, tablesInfo.stsc); + tablesInfo.stsz = make(0x7374737A, tablesInfo.stsz_default, 1, tablesInfo.stsz); + tablesInfo.stco = make(is64 ? 0x636F3634 : 0x7374636F, -1, is64 ? 2 : 1, tablesInfo.stco); + } + + lengthFor(start_stbl); + lengthFor(start_minf); + lengthFor(start_mdia); + } + + private byte[] make_hdlr(Hdlr hdlr) { + ByteBuffer buffer = ByteBuffer.wrap(new byte[]{ + 0x00, 0x00, 0x00, 0x77, 0x68, 0x64, 0x6C, 0x72,// hdlr + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // binary string "ISO Media file created in NewPipe (A libre lightweight streaming frontend for Android)." + 0x49, 0x53, 0x4F, 0x20, 0x4D, 0x65, 0x64, 0x69, 0x61, 0x20, 0x66, 0x69, 0x6C, 0x65, 0x20, 0x63, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x20, 0x69, 0x6E, 0x20, 0x4E, 0x65, 0x77, 0x50, 0x69, 0x70, + 0x65, 0x20, 0x28, 0x41, 0x20, 0x6C, 0x69, 0x62, 0x72, 0x65, 0x20, 0x6C, 0x69, 0x67, 0x68, 0x74, + 0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x20, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6D, 0x69, 0x6E, 0x67, + 0x20, 0x66, 0x72, 0x6F, 0x6E, 0x74, 0x65, 0x6E, 0x64, 0x20, 0x66, 0x6F, 0x72, 0x20, 0x41, 0x6E, + 0x64, 0x72, 0x6F, 0x69, 0x64, 0x29, 0x2E + }); + + buffer.position(12); + buffer.putInt(hdlr.type); + buffer.putInt(hdlr.subType); + buffer.put(hdlr.bReserved);// always is a zero array + + return buffer.array(); + } + // + + class TablesInfo { + + public int stts; + public int stsc; + public int[] stsc_bEntries; + public int ctts; + public int stsz; + public int stsz_default; + public int stss; + public int stco; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/SubtitleConverter.java b/app/src/main/java/org/schabi/newpipe/streams/SubtitleConverter.java index 26aaf49a5..c41db4373 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/SubtitleConverter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/SubtitleConverter.java @@ -1,5 +1,6 @@ package org.schabi.newpipe.streams; +import org.schabi.newpipe.streams.io.SharpStream; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; @@ -12,8 +13,6 @@ import java.nio.charset.Charset; import java.text.ParseException; import java.util.Locale; -import org.schabi.newpipe.streams.io.SharpStream; - import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; @@ -27,11 +26,11 @@ public class SubtitleConverter { public void dumpTTML(SharpStream in, final SharpStream out, final boolean ignoreEmptyFrames, final boolean detectYoutubeDuplicateLines ) throws IOException, ParseException, SAXException, ParserConfigurationException, XPathExpressionException { - + final FrameWriter callback = new FrameWriter() { int frameIndex = 0; final Charset charset = Charset.forName("utf-8"); - + @Override public void yield(SubtitleFrame frame) throws IOException { if (ignoreEmptyFrames && frame.isEmptyText()) { @@ -48,13 +47,13 @@ public class SubtitleConverter { out.write(NEW_LINE.getBytes(charset)); } }; - + read_xml_based(in, callback, detectYoutubeDuplicateLines, "tt", "xmlns", "http://www.w3.org/ns/ttml", new String[]{"timedtext", "head", "wp"}, new String[]{"body", "div", "p"}, "begin", "end", true - ); + ); } private void read_xml_based(SharpStream source, FrameWriter callback, boolean detectYoutubeDuplicateLines, @@ -70,7 +69,7 @@ public class SubtitleConverter { * Language parsing is not supported */ - byte[] buffer = new byte[source.available()]; + byte[] buffer = new byte[(int) source.available()]; source.read(buffer); DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); @@ -206,7 +205,7 @@ public class SubtitleConverter { } } - private static NodeList selectNodes(Document xml, String[] path, String namespaceUri) throws XPathExpressionException { + private static NodeList selectNodes(Document xml, String[] path, String namespaceUri) { Element ref = xml.getDocumentElement(); for (int i = 0; i < path.length - 1; i++) { diff --git a/app/src/main/java/org/schabi/newpipe/streams/TrackDataChunk.java b/app/src/main/java/org/schabi/newpipe/streams/TrackDataChunk.java deleted file mode 100644 index 86eb5ff4f..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/TrackDataChunk.java +++ /dev/null @@ -1,65 +0,0 @@ -package org.schabi.newpipe.streams; - -import java.io.InputStream; -import java.io.IOException; - -public class TrackDataChunk extends InputStream { - - private final DataReader base; - private int size; - - public TrackDataChunk(DataReader base, int size) { - this.base = base; - this.size = size; - } - - @Override - public int read() throws IOException { - if (size < 1) { - return -1; - } - - int res = base.read(); - - if (res >= 0) { - size--; - } - - return res; - } - - @Override - public int read(byte[] buffer) throws IOException { - return read(buffer, 0, buffer.length); - } - - @Override - public int read(byte[] buffer, int offset, int count) throws IOException { - count = Math.min(size, count); - int read = base.read(buffer, offset, count); - size -= count; - return read; - } - - @Override - public long skip(long amount) throws IOException { - long res = base.skipBytes(Math.min(amount, size)); - size -= res; - return res; - } - - @Override - public int available() { - return size; - } - - @Override - public void close() { - size = 0; - } - - @Override - public boolean markSupported() { - return false; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java b/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java index f61ef14c5..0c635ebe3 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java +++ b/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java @@ -1,12 +1,13 @@ package org.schabi.newpipe.streams; +import org.schabi.newpipe.streams.io.SharpStream; + import java.io.EOFException; import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.NoSuchElementException; -import java.util.Objects; - -import org.schabi.newpipe.streams.io.SharpStream; /** * @@ -121,7 +122,7 @@ public class WebMReader { } private String readString(Element parent) throws IOException { - return new String(readBlob(parent), "utf-8"); + return new String(readBlob(parent), StandardCharsets.UTF_8);// or use "utf-8" } private byte[] readBlob(Element parent) throws IOException { @@ -193,6 +194,7 @@ public class WebMReader { return elem; } } + ensure(elem); } @@ -306,7 +308,7 @@ public class WebMReader { entry.trackNumber = readNumber(elem); break; case ID_TrackType: - entry.trackType = (int)readNumber(elem); + entry.trackType = (int) readNumber(elem); break; case ID_CodecID: entry.codecId = readString(elem); @@ -445,7 +447,7 @@ public class WebMReader { public class SimpleBlock { - public TrackDataChunk data; + public InputStream data; SimpleBlock(Element ref) { this.ref = ref; @@ -492,7 +494,7 @@ public class WebMReader { currentSimpleBlock = readSimpleBlock(elem); if (currentSimpleBlock.trackNumber == tracks[selectedTrack].trackNumber) { - currentSimpleBlock.data = new TrackDataChunk(stream, (int) currentSimpleBlock.dataSize); + currentSimpleBlock.data = stream.getView((int) currentSimpleBlock.dataSize); return currentSimpleBlock; } diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java index ea038c607..98261b0c9 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java @@ -1,20 +1,20 @@ package org.schabi.newpipe.streams; +import android.support.annotation.NonNull; + import org.schabi.newpipe.streams.WebMReader.Cluster; import org.schabi.newpipe.streams.WebMReader.Segment; import org.schabi.newpipe.streams.WebMReader.SimpleBlock; import org.schabi.newpipe.streams.WebMReader.WebMTrack; +import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; import java.io.InputStream; -import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import org.schabi.newpipe.streams.io.SharpStream; - /** - * * @author kapodamy */ public class WebMWriter { @@ -94,10 +94,6 @@ public class WebMWriter { } } - public long getBytesWritten() { - return written; - } - public boolean isDone() { return done; } @@ -111,7 +107,7 @@ public class WebMWriter { parsed = true; for (SharpStream src : sourceTracks) { - src.dispose(); + src.close(); } sourceTracks = null; @@ -138,42 +134,42 @@ public class WebMWriter { /* segment */ listBuffer.add(new byte[]{ - 0x18, 0x53, (byte) 0x80, 0x67, 0x01, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00// segment content size + 0x18, 0x53, (byte) 0x80, 0x67, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00// segment content size }); long baseSegmentOffset = written + listBuffer.get(0).length; /* seek head */ listBuffer.add(new byte[]{ - 0x11, 0x4d, (byte) 0x9b, 0x74, (byte) 0xbe, - 0x4d, (byte) 0xbb, (byte) 0x8b, - 0x53, (byte) 0xab, (byte) 0x84, 0x15, 0x49, (byte) 0xa9, 0x66, 0x53, - (byte) 0xac, (byte) 0x81, /*info offset*/ 0x43, - 0x4d, (byte) 0xbb, (byte) 0x8b, 0x53, (byte) 0xab, - (byte) 0x84, 0x16, 0x54, (byte) 0xae, 0x6b, 0x53, (byte) 0xac, (byte) 0x81, - /*tracks offset*/ 0x6a, - 0x4d, (byte) 0xbb, (byte) 0x8e, 0x53, (byte) 0xab, (byte) 0x84, 0x1f, - 0x43, (byte) 0xb6, 0x75, 0x53, (byte) 0xac, (byte) 0x84, /*cluster offset [2]*/ 0x00, 0x00, 0x00, 0x00, - 0x4d, (byte) 0xbb, (byte) 0x8e, 0x53, (byte) 0xab, (byte) 0x84, 0x1c, 0x53, - (byte) 0xbb, 0x6b, 0x53, (byte) 0xac, (byte) 0x84, /*cues offset [7]*/ 0x00, 0x00, 0x00, 0x00 + 0x11, 0x4d, (byte) 0x9b, 0x74, (byte) 0xbe, + 0x4d, (byte) 0xbb, (byte) 0x8b, + 0x53, (byte) 0xab, (byte) 0x84, 0x15, 0x49, (byte) 0xa9, 0x66, 0x53, + (byte) 0xac, (byte) 0x81, /*info offset*/ 0x43, + 0x4d, (byte) 0xbb, (byte) 0x8b, 0x53, (byte) 0xab, + (byte) 0x84, 0x16, 0x54, (byte) 0xae, 0x6b, 0x53, (byte) 0xac, (byte) 0x81, + /*tracks offset*/ 0x6a, + 0x4d, (byte) 0xbb, (byte) 0x8e, 0x53, (byte) 0xab, (byte) 0x84, 0x1f, + 0x43, (byte) 0xb6, 0x75, 0x53, (byte) 0xac, (byte) 0x84, /*cluster offset [2]*/ 0x00, 0x00, 0x00, 0x00, + 0x4d, (byte) 0xbb, (byte) 0x8e, 0x53, (byte) 0xab, (byte) 0x84, 0x1c, 0x53, + (byte) 0xbb, 0x6b, 0x53, (byte) 0xac, (byte) 0x84, /*cues offset [7]*/ 0x00, 0x00, 0x00, 0x00 }); /* info */ listBuffer.add(new byte[]{ - 0x15, 0x49, (byte) 0xa9, 0x66, (byte) 0xa2, 0x2a, (byte) 0xd7, (byte) 0xb1 + 0x15, 0x49, (byte) 0xa9, 0x66, (byte) 0xa2, 0x2a, (byte) 0xd7, (byte) 0xb1 }); listBuffer.add(encode(DEFAULT_TIMECODE_SCALE, true));// this value MUST NOT exceed 4 bytes listBuffer.add(new byte[]{0x44, (byte) 0x89, (byte) 0x84, - 0x00, 0x00, 0x00, 0x00,// info.duration - - /* MuxingApp */ - 0x4d, (byte) 0x80, (byte) 0x87, 0x4E, - 0x65, 0x77, 0x50, 0x69, 0x70, 0x65, // "NewPipe" binary string - - /* WritingApp */ - 0x57, 0x41, (byte) 0x87, 0x4E, - 0x65, 0x77, 0x50, 0x69, 0x70, 0x65// "NewPipe" binary string + 0x00, 0x00, 0x00, 0x00,// info.duration + + /* MuxingApp */ + 0x4d, (byte) 0x80, (byte) 0x87, 0x4E, + 0x65, 0x77, 0x50, 0x69, 0x70, 0x65, // "NewPipe" binary string + + /* WritingApp */ + 0x57, 0x41, (byte) 0x87, 0x4E, + 0x65, 0x77, 0x50, 0x69, 0x70, 0x65// "NewPipe" binary string }); /* tracks */ @@ -200,7 +196,6 @@ public class WebMWriter { long nextCueTime = infoTracks[cuesForTrackId].trackType == 1 ? -1 : 0; ArrayList keyFrames = new ArrayList<>(32); - //ArrayList chunks = new ArrayList<>(readers.length); ArrayList clusterOffsets = new ArrayList<>(32); ArrayList clusterSizes = new ArrayList<>(32); @@ -283,24 +278,21 @@ public class WebMWriter { long segmentSize = written - offsetSegmentSizeSet - 7; - // final step write offsets and sizes - out.rewind(); - written = 0; - - skipTo(out, offsetSegmentSizeSet); + /* ---- final step write offsets and sizes ---- */ + seekTo(out, offsetSegmentSizeSet); writeLong(out, segmentSize); if (predefinedDurations[durationFromTrackId] > -1) { duration += predefinedDurations[durationFromTrackId];// this value is full-filled in makeTrackEntry() method } - skipTo(out, offsetInfoDurationSet); + seekTo(out, offsetInfoDurationSet); writeFloat(out, duration); firstClusterOffset -= baseSegmentOffset; - skipTo(out, offsetClusterSet); + seekTo(out, offsetClusterSet); writeInt(out, firstClusterOffset); - skipTo(out, cueReservedOffset); + seekTo(out, cueReservedOffset); /* Cue */ dump(new byte[]{0x1c, 0x53, (byte) 0xbb, 0x6b, 0x20, 0x00, 0x00}, out); @@ -321,20 +313,16 @@ public class WebMWriter { voidBuffer.putShort((short) (firstClusterOffset - written - 4)); dump(voidBuffer.array(), out); - out.rewind(); - written = 0; - - skipTo(out, offsetCuesSet); + seekTo(out, offsetCuesSet); writeInt(out, (int) (cueReservedOffset - baseSegmentOffset)); - skipTo(out, cueReservedOffset + 5); + seekTo(out, cueReservedOffset + 5); writeShort(out, cueSize); for (int i = 0; i < clusterSizes.size(); i++) { - skipTo(out, clusterOffsets.get(i)); - byte[] size = ByteBuffer.allocate(4).putInt(clusterSizes.get(i) | 0x200000).array(); - out.write(size, 1, 3); - written += 3; + seekTo(out, clusterOffsets.get(i)); + byte[] buffer = ByteBuffer.allocate(4).putInt(clusterSizes.get(i) | 0x10000000).array(); + dump(buffer, out); } } @@ -365,20 +353,29 @@ public class WebMWriter { bloq.dataSize = (int) res.dataSize; bloq.trackNumber = internalTrackId; bloq.flags = res.flags; - bloq.absoluteTimecode = convertTimecode(res.relativeTimeCode, readersSegment[internalTrackId].info.timecodeScale, DEFAULT_TIMECODE_SCALE); + bloq.absoluteTimecode = convertTimecode(res.relativeTimeCode, readersSegment[internalTrackId].info.timecodeScale); bloq.absoluteTimecode += readersCluter[internalTrackId].timecode; return bloq; } - private short convertTimecode(int time, long oldTimeScale, int newTimeScale) { - return (short) (time * (newTimeScale / oldTimeScale)); + private short convertTimecode(int time, long oldTimeScale) { + return (short) (time * (DEFAULT_TIMECODE_SCALE / oldTimeScale)); } - private void skipTo(SharpStream stream, long absoluteOffset) throws IOException { - absoluteOffset -= written; - written += absoluteOffset; - stream.skip(absoluteOffset); + private void seekTo(SharpStream stream, long offset) throws IOException { + if (stream.canSeek()) { + stream.seek(offset); + } else { + if (offset > written) { + stream.skip(offset - written); + } else { + stream.rewind(); + stream.skip(offset); + } + } + + written = offset; } private void writeLong(SharpStream stream, long number) throws IOException { @@ -453,7 +450,7 @@ public class WebMWriter { /* cluster */ dump(new byte[]{0x1f, 0x43, (byte) 0xb6, 0x75}, stream); clusterOffsets.add(written);// warning: max cluster size is 256 MiB - dump(new byte[]{0x20, 0x00, 0x00}, stream); + dump(new byte[]{0x10, 0x00, 0x00, 0x00}, stream); startOffset = written;// size for the this cluster @@ -468,12 +465,12 @@ public class WebMWriter { private void makeEBML(SharpStream stream) throws IOException { // deafult values dump(new byte[]{ - 0x1A, 0x45, (byte) 0xDF, (byte) 0xA3, 0x01, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x1F, 0x42, (byte) 0x86, (byte) 0x81, 0x01, - 0x42, (byte) 0xF7, (byte) 0x81, 0x01, 0x42, (byte) 0xF2, (byte) 0x81, 0x04, - 0x42, (byte) 0xF3, (byte) 0x81, 0x08, 0x42, (byte) 0x82, (byte) 0x84, 0x77, - 0x65, 0x62, 0x6D, 0x42, (byte) 0x87, (byte) 0x81, 0x02, - 0x42, (byte) 0x85, (byte) 0x81, 0x02 + 0x1A, 0x45, (byte) 0xDF, (byte) 0xA3, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x1F, 0x42, (byte) 0x86, (byte) 0x81, 0x01, + 0x42, (byte) 0xF7, (byte) 0x81, 0x01, 0x42, (byte) 0xF2, (byte) 0x81, 0x04, + 0x42, (byte) 0xF3, (byte) 0x81, 0x08, 0x42, (byte) 0x82, (byte) 0x84, 0x77, + 0x65, 0x62, 0x6D, 0x42, (byte) 0x87, (byte) 0x81, 0x02, + 0x42, (byte) 0x85, (byte) 0x81, 0x02 }, stream); } @@ -618,9 +615,10 @@ public class WebMWriter { int offset = withLength ? 1 : 0; byte[] buffer = new byte[offset + length]; - long marker = (long) Math.floor((length - 1) / 8); + long marker = (long) Math.floor((length - 1f) / 8f); - for (int i = length - 1, mul = 1; i >= 0; i--, mul *= 0x100) { + float mul = 1; + for (int i = length - 1; i >= 0; i--, mul *= 0x100) { long b = (long) Math.floor(number / mul); if (!withLength && i == marker) { b = b | (0x80 >> (length - 1)); @@ -637,11 +635,7 @@ public class WebMWriter { private ArrayList encode(String value) { byte[] str; - try { - str = value.getBytes("utf-8"); - } catch (UnsupportedEncodingException err) { - str = value.getBytes(); - } + str = value.getBytes(StandardCharsets.UTF_8);// or use "utf-8" ArrayList buffer = new ArrayList<>(2); buffer.add(encode(str.length, false)); @@ -720,9 +714,10 @@ public class WebMWriter { return (flags & 0x80) == 0x80; } + @NonNull @Override public String toString() { - return String.format("trackNumber=%s isKeyFrame=%S absoluteTimecode=%s", trackNumber, (flags & 0x80) == 0x80, absoluteTimecode); + return String.format("trackNumber=%s isKeyFrame=%S absoluteTimecode=%s", trackNumber, isKeyframe(), absoluteTimecode); } } } diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java b/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java index 48bea06f6..5950ba3dd 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java +++ b/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java @@ -1,11 +1,12 @@ package org.schabi.newpipe.streams.io; +import java.io.Closeable; import java.io.IOException; /** - * based c# + * based on c# */ -public abstract class SharpStream { +public abstract class SharpStream implements Closeable { public abstract int read() throws IOException; @@ -15,16 +16,14 @@ public abstract class SharpStream { public abstract long skip(long amount) throws IOException; - - public abstract int available(); + public abstract long available(); public abstract void rewind() throws IOException; + public abstract boolean isClosed(); - public abstract void dispose(); - - public abstract boolean isDisposed(); - + @Override + public abstract void close(); public abstract boolean canRewind(); @@ -32,6 +31,13 @@ public abstract class SharpStream { public abstract boolean canWrite(); + public boolean canSetLength() { + return false; + } + + public boolean canSeek() { + return false; + } public abstract void write(byte value) throws IOException; @@ -39,9 +45,19 @@ public abstract class SharpStream { public abstract void write(byte[] buffer, int offset, int count) throws IOException; - public abstract void flush() throws IOException; + public void flush() throws IOException { + // STUB + } public void setLength(long length) throws IOException { throw new IOException("Not implemented"); } + + public void seek(long offset) throws IOException { + throw new IOException("Not implemented"); + } + + public long length() throws IOException { + throw new UnsupportedOperationException("Unsupported operation"); + } } diff --git a/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java b/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java index 570b5f8b2..ac79fee23 100644 --- a/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java +++ b/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java @@ -31,7 +31,7 @@ public class CommentTextOnTouchListener implements View.OnTouchListener { public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener(); - private static final Pattern timestampPattern = Pattern.compile(".*&t=(\\d+)"); + private static final Pattern timestampPattern = Pattern.compile("(.*)#timestamp=(\\d+)"); @Override public boolean onTouch(View v, MotionEvent event) { @@ -86,6 +86,12 @@ public class CommentTextOnTouchListener implements View.OnTouchListener { private boolean handleUrl(Context context, URLSpan urlSpan) { String url = urlSpan.getURL(); + int seconds = -1; + Matcher matcher = timestampPattern.matcher(url); + if(matcher.matches()){ + url = matcher.group(1); + seconds = Integer.parseInt(matcher.group(2)); + } StreamingService service; StreamingService.LinkType linkType; try { @@ -97,9 +103,7 @@ public class CommentTextOnTouchListener implements View.OnTouchListener { if(linkType == StreamingService.LinkType.NONE){ return false; } - Matcher matcher = timestampPattern.matcher(url); - if(linkType == StreamingService.LinkType.STREAM && matcher.matches()){ - int seconds = Integer.parseInt(matcher.group(1)); + if(linkType == StreamingService.LinkType.STREAM && seconds != -1){ return playOnPopup(context, url, service, seconds); }else{ NavigationHelper.openRouterActivity(context, url); @@ -119,9 +123,8 @@ public class CommentTextOnTouchListener implements View.OnTouchListener { single.subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(info -> { - PlayQueue playQueue = new SinglePlayQueue((StreamInfo) info); - ((StreamInfo) info).setStartPosition(seconds); - NavigationHelper.enqueueOnPopupPlayer(context, playQueue, true); + PlayQueue playQueue = new SinglePlayQueue((StreamInfo) info, seconds*1000); + NavigationHelper.playOnPopupPlayer(context, playQueue, false); }); return true; } diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java index 0f1c39473..b70e5fdef 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -243,8 +243,6 @@ public final class ExtractorHelper { context.startActivity(intent); } else if (exception instanceof IOException) { Toast.makeText(context, R.string.network_error, Toast.LENGTH_LONG).show(); - } else if (exception instanceof YoutubeStreamExtractor.GemaException) { - Toast.makeText(context, R.string.blocked_by_gema, Toast.LENGTH_LONG).show(); } else if (exception instanceof ContentNotAvailableException) { Toast.makeText(context, R.string.content_not_available, Toast.LENGTH_LONG).show(); } else { diff --git a/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java b/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java index b874a9eca..37d94cd16 100644 --- a/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java @@ -10,6 +10,9 @@ import java.util.regex.Pattern; public class FilenameUtils { + private static final String CHARSET_MOST_SPECIAL = "[\\n\\r|?*<\":\\\\>/']+"; + private static final String CHARSET_ONLY_LETTERS_AND_DIGITS = "[^\\w\\d]+"; + /** * #143 #44 #42 #22: make sure that the filename does not contain illegal chars. * @param context the context to retrieve strings and preferences from @@ -18,11 +21,28 @@ public class FilenameUtils { */ public static String createFilename(Context context, String title) { SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); - final String key = context.getString(R.string.settings_file_charset_key); - final String value = sharedPreferences.getString(key, context.getString(R.string.default_file_charset_value)); - Pattern pattern = Pattern.compile(value); + + final String charset_ld = context.getString(R.string.charset_letters_and_digits_value); + final String charset_ms = context.getString(R.string.charset_most_special_value); + final String defaultCharset = context.getString(R.string.default_file_charset_value); final String replacementChar = sharedPreferences.getString(context.getString(R.string.settings_file_replacement_character_key), "_"); + String selectedCharset = sharedPreferences.getString(context.getString(R.string.settings_file_charset_key), null); + + final String charset; + + if (selectedCharset == null || selectedCharset.isEmpty()) selectedCharset = defaultCharset; + + if (selectedCharset.equals(charset_ld)) { + charset = CHARSET_ONLY_LETTERS_AND_DIGITS; + } else if (selectedCharset.equals(charset_ms)) { + charset = CHARSET_MOST_SPECIAL; + } else { + charset = selectedCharset;// ¿is the user using a custom charset? + } + + Pattern pattern = Pattern.compile(charset); + return createFilename(title, pattern, replacementChar); } diff --git a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java index 8fc423837..fa5530f12 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java @@ -430,24 +430,26 @@ public final class ListHelper { */ private static String getResolutionLimit(Context context) { String resolutionLimit = null; - if (!isWifiActive(context)) { + if (isMeteredNetwork(context)) { SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); String defValue = context.getString(R.string.limit_data_usage_none_key); String value = preferences.getString( context.getString(R.string.limit_mobile_data_usage_key), defValue); - resolutionLimit = value.equals(defValue) ? null : value; + resolutionLimit = defValue.equals(value) ? null : value; } return resolutionLimit; } /** - * Are we connected to wifi? + * The current network is metered (like mobile data)? * @param context App context - * @return {@code true} if connected to wifi + * @return {@code true} if connected to a metered network */ - private static boolean isWifiActive(Context context) + private static boolean isMeteredNetwork(Context context) { ConnectivityManager manager = (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE); - return manager != null && manager.getActiveNetworkInfo() != null && manager.getActiveNetworkInfo().getType() == ConnectivityManager.TYPE_WIFI; + if (manager == null || manager.getActiveNetworkInfo() == null) return false; + + return manager.isActiveNetworkMetered(); } } diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index 98ae3a88a..89c4b33fe 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -69,12 +69,14 @@ public class NavigationHelper { public static Intent getPlayerIntent(@NonNull final Context context, @NonNull final Class targetClazz, @NonNull final PlayQueue playQueue, - @Nullable final String quality) { + @Nullable final String quality, + final boolean resumePlayback) { Intent intent = new Intent(context, targetClazz); final String cacheKey = SerializedCache.getInstance().put(playQueue, PlayQueue.class); if (cacheKey != null) intent.putExtra(VideoPlayer.PLAY_QUEUE_KEY, cacheKey); if (quality != null) intent.putExtra(VideoPlayer.PLAYBACK_QUALITY, quality); + intent.putExtra(VideoPlayer.RESUME_PLAYBACK, resumePlayback); return intent; } @@ -82,16 +84,18 @@ public class NavigationHelper { @NonNull public static Intent getPlayerIntent(@NonNull final Context context, @NonNull final Class targetClazz, - @NonNull final PlayQueue playQueue) { - return getPlayerIntent(context, targetClazz, playQueue, null); + @NonNull final PlayQueue playQueue, + final boolean resumePlayback) { + return getPlayerIntent(context, targetClazz, playQueue, null, resumePlayback); } @NonNull public static Intent getPlayerEnqueueIntent(@NonNull final Context context, @NonNull final Class targetClazz, @NonNull final PlayQueue playQueue, - final boolean selectOnAppend) { - return getPlayerIntent(context, targetClazz, playQueue) + final boolean selectOnAppend, + final boolean resumePlayback) { + return getPlayerIntent(context, targetClazz, playQueue, resumePlayback) .putExtra(BasePlayer.APPEND_ONLY, true) .putExtra(BasePlayer.SELECT_ON_APPEND, selectOnAppend); } @@ -104,40 +108,41 @@ public class NavigationHelper { final float playbackSpeed, final float playbackPitch, final boolean playbackSkipSilence, - @Nullable final String playbackQuality) { - return getPlayerIntent(context, targetClazz, playQueue, playbackQuality) + @Nullable final String playbackQuality, + final boolean resumePlayback) { + return getPlayerIntent(context, targetClazz, playQueue, playbackQuality, resumePlayback) .putExtra(BasePlayer.REPEAT_MODE, repeatMode) .putExtra(BasePlayer.PLAYBACK_SPEED, playbackSpeed) .putExtra(BasePlayer.PLAYBACK_PITCH, playbackPitch) .putExtra(BasePlayer.PLAYBACK_SKIP_SILENCE, playbackSkipSilence); } - public static void playOnMainPlayer(final Context context, final PlayQueue queue) { - final Intent playerIntent = getPlayerIntent(context, MainVideoPlayer.class, queue); + public static void playOnMainPlayer(final Context context, final PlayQueue queue, final boolean resumePlayback) { + final Intent playerIntent = getPlayerIntent(context, MainVideoPlayer.class, queue, resumePlayback); playerIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(playerIntent); } - public static void playOnPopupPlayer(final Context context, final PlayQueue queue) { + public static void playOnPopupPlayer(final Context context, final PlayQueue queue, final boolean resumePlayback) { if (!PermissionHelper.isPopupEnabled(context)) { PermissionHelper.showPopupEnablementToast(context); return; } Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show(); - startService(context, getPlayerIntent(context, PopupVideoPlayer.class, queue)); + startService(context, getPlayerIntent(context, PopupVideoPlayer.class, queue, resumePlayback)); } - public static void playOnBackgroundPlayer(final Context context, final PlayQueue queue) { + public static void playOnBackgroundPlayer(final Context context, final PlayQueue queue, final boolean resumePlayback) { Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT).show(); - startService(context, getPlayerIntent(context, BackgroundPlayer.class, queue)); + startService(context, getPlayerIntent(context, BackgroundPlayer.class, queue, resumePlayback)); } - public static void enqueueOnPopupPlayer(final Context context, final PlayQueue queue) { - enqueueOnPopupPlayer(context, queue, false); + public static void enqueueOnPopupPlayer(final Context context, final PlayQueue queue, final boolean resumePlayback) { + enqueueOnPopupPlayer(context, queue, false, resumePlayback); } - public static void enqueueOnPopupPlayer(final Context context, final PlayQueue queue, boolean selectOnAppend) { + public static void enqueueOnPopupPlayer(final Context context, final PlayQueue queue, boolean selectOnAppend, final boolean resumePlayback) { if (!PermissionHelper.isPopupEnabled(context)) { PermissionHelper.showPopupEnablementToast(context); return; @@ -145,17 +150,17 @@ public class NavigationHelper { Toast.makeText(context, R.string.popup_playing_append, Toast.LENGTH_SHORT).show(); startService(context, - getPlayerEnqueueIntent(context, PopupVideoPlayer.class, queue, selectOnAppend)); + getPlayerEnqueueIntent(context, PopupVideoPlayer.class, queue, selectOnAppend, resumePlayback)); } - public static void enqueueOnBackgroundPlayer(final Context context, final PlayQueue queue) { - enqueueOnBackgroundPlayer(context, queue, false); + public static void enqueueOnBackgroundPlayer(final Context context, final PlayQueue queue, final boolean resumePlayback) { + enqueueOnBackgroundPlayer(context, queue, false, resumePlayback); } - public static void enqueueOnBackgroundPlayer(final Context context, final PlayQueue queue, boolean selectOnAppend) { + public static void enqueueOnBackgroundPlayer(final Context context, final PlayQueue queue, boolean selectOnAppend, final boolean resumePlayback) { Toast.makeText(context, R.string.background_player_append, Toast.LENGTH_SHORT).show(); startService(context, - getPlayerEnqueueIntent(context, BackgroundPlayer.class, queue, selectOnAppend)); + getPlayerEnqueueIntent(context, BackgroundPlayer.class, queue, selectOnAppend, resumePlayback)); } public static void startService(@NonNull final Context context, @NonNull final Intent intent) { diff --git a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java index b3522aea0..7febfa053 100644 --- a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java @@ -38,7 +38,7 @@ public class SecondaryStreamHelper { public static AudioStream getAudioStreamFor(@NonNull List audioStreams, @NonNull VideoStream videoStream) { switch (videoStream.getFormat()) { case WEBM: - case MPEG_4: + case MPEG_4:// ¿is mpeg-4 DASH? break; default: return null; diff --git a/app/src/main/java/org/schabi/newpipe/util/ShareUtils.java b/app/src/main/java/org/schabi/newpipe/util/ShareUtils.java new file mode 100644 index 000000000..c5c78a726 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/ShareUtils.java @@ -0,0 +1,22 @@ +package org.schabi.newpipe.util; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import org.schabi.newpipe.R; + +public class ShareUtils { + public static void openUrlInBrowser(Context context, String url) { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + context.startActivity(Intent.createChooser(intent, context.getString(R.string.share_dialog_title))); + } + + public static void shareUrl(Context context, String subject, String url) { + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_SUBJECT, subject); + intent.putExtra(Intent.EXTRA_TEXT, url); + context.startActivity(Intent.createChooser(intent, context.getString(R.string.share_dialog_title))); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/SparseArrayUtils.java b/app/src/main/java/org/schabi/newpipe/util/SparseArrayUtils.java new file mode 100644 index 000000000..d17c9aa42 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/SparseArrayUtils.java @@ -0,0 +1,30 @@ +package org.schabi.newpipe.util; + +import android.util.SparseArray; + +public abstract class SparseArrayUtils { + + public static void shiftItemsDown(SparseArray sparseArray, int lower, int upper) { + for (int i = lower + 1; i <= upper; i++) { + final T o = sparseArray.get(i); + sparseArray.put(i - 1, o); + sparseArray.remove(i); + } + } + + public static void shiftItemsUp(SparseArray sparseArray, int lower, int upper) { + for (int i = upper - 1; i >= lower; i--) { + final T o = sparseArray.get(i); + sparseArray.put(i + 1, o); + sparseArray.remove(i); + } + } + + public static int[] getKeys(SparseArray sparseArray) { + final int[] result = new int[sparseArray.size()]; + for (int i = 0; i < result.length; i++) { + result[i] = sparseArray.keyAt(i); + } + return result; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/AnimatedProgressBar.java b/app/src/main/java/org/schabi/newpipe/views/AnimatedProgressBar.java new file mode 100644 index 000000000..fe3e0d7bc --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/AnimatedProgressBar.java @@ -0,0 +1,64 @@ +package org.schabi.newpipe.views; + +import android.content.Context; +import android.support.annotation.Nullable; +import android.util.AttributeSet; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.Animation; +import android.view.animation.Transformation; +import android.widget.ProgressBar; + +public final class AnimatedProgressBar extends ProgressBar { + + @Nullable + private ProgressBarAnimation animation = null; + + public AnimatedProgressBar(Context context) { + super(context); + } + + public AnimatedProgressBar(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public AnimatedProgressBar(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public synchronized void setProgressAnimated(int progress) { + cancelAnimation(); + animation = new ProgressBarAnimation(this, getProgress(), progress); + startAnimation(animation); + } + + private void cancelAnimation() { + if (animation != null) { + animation.cancel(); + animation = null; + } + clearAnimation(); + } + + private static class ProgressBarAnimation extends Animation { + + private final AnimatedProgressBar progressBar; + private final float from; + private final float to; + + ProgressBarAnimation(AnimatedProgressBar progressBar, float from, float to) { + super(); + this.progressBar = progressBar; + this.from = from; + this.to = to; + setDuration(500); + setInterpolator(new AccelerateDecelerateInterpolator()); + } + + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + super.applyTransformation(interpolatedTime, t); + float value = from + (to - from) * interpolatedTime; + progressBar.setProgress((int) value); + } + } +} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java index b864cf4fb..8f1b9ba4e 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java @@ -1,185 +1,173 @@ -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.InterruptedIOException; -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 extends Thread { - private final static String TAG = "DownloadInitializer"; - final static int mId = 0; - - private DownloadMission mMission; - private HttpURLConnection mConn; - - DownloadInitializer(@NonNull DownloadMission mission) { - mMission = mission; - mConn = null; - } - - @Override - public void run() { - if (mMission.current > 0) mMission.resetState(); - - int retryCount = 0; - while (true) { - try { - mMission.currentThreadCount = mMission.threadCount; - - mConn = mMission.openConnection(mId, -1, -1); - mMission.establishConnection(mId, mConn); - - if (!mMission.running || Thread.interrupted()) return; - - mMission.length = Utility.getContentLength(mConn); - - - if (mMission.length == 0) { - mMission.notifyError(DownloadMission.ERROR_HTTP_NO_CONTENT, null); - return; - } - - // check for dynamic generated content - if (mMission.length == -1 && mConn.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 - mConn = mMission.openConnection(mId, mMission.length - 10, mMission.length); - mMission.establishConnection(mId, mConn); - - if (!mMission.running || Thread.interrupted()) return; - - synchronized (mMission.blockState) { - if (mConn.getResponseCode() == 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 = 1; - mMission.fallback = true; - mMission.unknownLength = false; - } - - if (DEBUG) { - Log.d(TAG, "http response code = " + mConn.getResponseCode()); - } - } 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 = " + mConn.getResponseCode()); - } - } - - for (long i = 0; i < mMission.currentThreadCount; i++) { - mMission.threadBlockPositions.add(i); - mMission.threadBytePositions.add(0L); - } - } - - if (!mMission.running || Thread.interrupted()) return; - } - - File file; - if (mMission.current == 0) { - file = new File(mMission.location); - if (!Utility.mkdir(file, true)) { - mMission.notifyError(DownloadMission.ERROR_PATH_CREATION, null); - return; - } - - file = new File(file, mMission.name); - - // if the name is used by another process, delete it - if (file.exists() && !file.isFile() && !file.delete()) { - mMission.notifyError(DownloadMission.ERROR_FILE_CREATION, null); - return; - } - - if (!file.exists() && !file.createNewFile()) { - mMission.notifyError(DownloadMission.ERROR_FILE_CREATION, null); - return; - } - } else { - file = new File(mMission.location, mMission.name); - } - - RandomAccessFile af = new RandomAccessFile(file, "rw"); - af.setLength(mMission.offsets[mMission.current] + mMission.length); - af.seek(mMission.offsets[mMission.current]); - af.close(); - - if (!mMission.running || Thread.interrupted()) return; - - mMission.running = false; - break; - } catch (InterruptedIOException | ClosedByInterruptException e) { - return; - } catch (Exception e) { - if (!mMission.running) return; - - 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.notifyError(e); - return; - } - - Log.e(TAG, "initializer failed, retrying", e); - } - } - - // hide marquee in the progress bar - mMission.done++; - - mMission.start(); - } - - @Override - public void interrupt() { - super.interrupt(); - - if (mConn != null) { - try { - mConn.disconnect(); - } catch (Exception e) { - // nothing to do - } - } - } -} +package us.shandian.giga.get; + +import android.support.annotation.NonNull; +import android.util.Log; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; +import java.io.InterruptedIOException; +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 extends Thread { + private final static String TAG = "DownloadInitializer"; + final static int mId = 0; + private final static int RESERVE_SPACE_DEFAULT = 5 * 1024 * 1024;// 5 MiB + private final static int RESERVE_SPACE_MAXIMUM = 150 * 1024 * 1024;// 150 MiB + + private DownloadMission mMission; + private HttpURLConnection mConn; + + DownloadInitializer(@NonNull DownloadMission mission) { + mMission = mission; + mConn = null; + } + + @Override + public void run() { + if (mMission.current > 0) mMission.resetState(false, true, DownloadMission.ERROR_NOTHING); + + int retryCount = 0; + while (true) { + try { + if (mMission.blocks == null && mMission.current == 0) { + // calculate the whole size of the mission + long finalLength = 0; + long lowestSize = Long.MAX_VALUE; + + for (int i = 0; i < mMission.urls.length && mMission.running; i++) { + mConn = mMission.openConnection(mMission.urls[i], mId, -1, -1); + mMission.establishConnection(mId, mConn); + + if (Thread.interrupted()) return; + long length = Utility.getContentLength(mConn); + + if (i == 0) mMission.length = length; + if (length > 0) finalLength += length; + if (length < lowestSize) lowestSize = length; + } + + mMission.nearLength = finalLength; + + // reserve space at the start of the file + if (mMission.psAlgorithm != null && mMission.psAlgorithm.reserveSpace) { + if (lowestSize < 1) { + // the length is unknown use the default size + mMission.offsets[0] = RESERVE_SPACE_DEFAULT; + } else { + // use the smallest resource size to download, otherwise, use the maximum + mMission.offsets[0] = lowestSize < RESERVE_SPACE_MAXIMUM ? lowestSize : RESERVE_SPACE_MAXIMUM; + } + } + } else { + // ask for the current resource length + mConn = mMission.openConnection(mId, -1, -1); + mMission.establishConnection(mId, mConn); + + if (!mMission.running || Thread.interrupted()) return; + + mMission.length = Utility.getContentLength(mConn); + } + + if (mMission.length == 0 || mConn.getResponseCode() == 204) { + mMission.notifyError(DownloadMission.ERROR_HTTP_NO_CONTENT, null); + return; + } + + // check for dynamic generated content + if (mMission.length == -1 && mConn.getResponseCode() == 200) { + mMission.blocks = new int[0]; + mMission.length = 0; + mMission.unknownLength = true; + + if (DEBUG) { + Log.d(TAG, "falling back (unknown length)"); + } + } else { + // Open again + mConn = mMission.openConnection(mId, mMission.length - 10, mMission.length); + mMission.establishConnection(mId, mConn); + + if (!mMission.running || Thread.interrupted()) return; + + synchronized (mMission.LOCK) { + if (mConn.getResponseCode() == 206) { + + if (mMission.threadCount > 1) { + int count = (int) (mMission.length / DownloadMission.BLOCK_SIZE); + if ((count * DownloadMission.BLOCK_SIZE) < mMission.length) count++; + + mMission.blocks = new int[count]; + } else { + // if one thread is required don't calculate blocks, is useless + mMission.blocks = new int[0]; + mMission.unknownLength = false; + } + + if (DEBUG) { + Log.d(TAG, "http response code = " + mConn.getResponseCode()); + } + } else { + // Fallback to single thread + mMission.blocks = new int[0]; + mMission.unknownLength = false; + + if (DEBUG) { + Log.d(TAG, "falling back due http response code = " + mConn.getResponseCode()); + } + } + } + + if (!mMission.running || Thread.interrupted()) return; + } + + SharpStream fs = mMission.storage.getStream(); + fs.setLength(mMission.offsets[mMission.current] + mMission.length); + fs.seek(mMission.offsets[mMission.current]); + fs.close(); + + if (!mMission.running || Thread.interrupted()) return; + + mMission.running = false; + break; + } catch (InterruptedIOException | ClosedByInterruptException e) { + return; + } catch (Exception e) { + if (!mMission.running) return; + + 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.notifyError(e); + return; + } + + Log.e(TAG, "initializer failed, retrying", e); + } + } + + mMission.start(); + } + + @Override + public void interrupt() { + super.interrupt(); + + if (mConn != null) { + try { + mConn.disconnect(); + } catch (Exception e) { + // nothing to do + } + } + } +} 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 243a8585a..eb96d53f4 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -4,19 +4,22 @@ import android.os.Handler; import android.os.Message; import android.util.Log; +import org.schabi.newpipe.Downloader; + import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.Serializable; import java.net.ConnectException; import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; import java.net.URL; import java.net.UnknownHostException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; +import javax.annotation.Nullable; import javax.net.ssl.SSLException; +import us.shandian.giga.io.StoredFileHelper; import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.util.Utility; @@ -24,10 +27,13 @@ import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; public class DownloadMission extends Mission { - private static final long serialVersionUID = 3L;// last bump: 8 november 2018 + private static final long serialVersionUID = 5L;// last bump: 30 june 2019 static final int BUFFER_SIZE = 64 * 1024; - final static int BLOCK_SIZE = 512 * 1024; + static final int BLOCK_SIZE = 512 * 1024; + + @SuppressWarnings("SpellCheckingInspection") + private static final String INSUFFICIENT_STORAGE = "ENOSPC"; private static final String TAG = "DownloadMission"; @@ -40,6 +46,11 @@ public class DownloadMission extends Mission { public static final int ERROR_UNKNOWN_HOST = 1005; public static final int ERROR_CONNECT_HOST = 1006; public static final int ERROR_POSTPROCESSING = 1007; + public static final int ERROR_POSTPROCESSING_STOPPED = 1008; + public static final int ERROR_POSTPROCESSING_HOLD = 1009; + public static final int ERROR_INSUFFICIENT_STORAGE = 1010; + public static final int ERROR_PROGRESS_LOST = 1011; + public static final int ERROR_TIMEOUT = 1012; public static final int ERROR_HTTP_NO_CONTENT = 204; public static final int ERROR_HTTP_UNSUPPORTED_RANGE = 206; @@ -48,11 +59,6 @@ public class DownloadMission extends Mission { */ public String[] urls; - /** - * Number of blocks the size of {@link DownloadMission#BLOCK_SIZE} - */ - long blocks = -1; - /** * Number of bytes downloaded */ @@ -68,171 +74,128 @@ public class DownloadMission extends Mission { */ public long[] offsets; - /** - * The post-processing algorithm arguments - */ - public String[] postprocessingArgs; - - /** - * The post-processing algorithm name - */ - public String postprocessingName; - /** * Indicates if the post-processing state: * 0: ready * 1: running * 2: completed + * 3: hold */ - public int postprocessingState; + public volatile int psState; /** - * Indicate if the post-processing algorithm works on the same file + * the post-processing algorithm instance */ - public boolean postprocessingThis; + public Postprocessing psAlgorithm; /** - * The current resource to download {@code urls[current]} + * The current resource to download, {@code urls[current]} and {@code offsets[current]} */ public int current; /** * Metadata where the mission state is saved */ - public File metadata; + public transient File metadata; /** * maximum attempts */ - public int maxRetry; + public transient int maxRetry; /** * Approximated final length, this represent the sum of all resources sizes */ public long nearLength; + /** + * Download blocks, the size is multiple of {@link DownloadMission#BLOCK_SIZE}. + * Every entry (block) in this array holds an offset, used to resume the download. + * An block offset can be -1 if the block was downloaded successfully. + */ + int[] blocks; + + /** + * Download/File resume offset in fallback mode (if applicable) {@link DownloadRunnableFallback} + */ + long fallbackResumeOffset; + + /** + * Maximum of download threads running, chosen by the user + */ public int threadCount = 3; - boolean fallback; - private int finishCount; + + private transient int finishCount; public transient boolean running; - public transient boolean enqueued = true; + public boolean enqueued; public int errCode = ERROR_NOTHING; + public Exception errObject = null; - public transient Exception errObject = null; public transient boolean recovered; public transient Handler mHandler; private transient boolean mWritingToFile; + private transient boolean[] blockAcquired; - @SuppressWarnings("UseSparseArrays")// LongSparseArray is not serializable - final HashMap blockState = new HashMap<>(); - final List threadBlockPositions = new ArrayList<>(); - final List threadBytePositions = new ArrayList<>(); + final Object LOCK = new Lock(); private transient boolean deleted; - int currentThreadCount; - private transient Thread[] threads = new Thread[0]; + + public transient volatile Thread[] threads = new Thread[0]; private transient Thread init = null; - - protected DownloadMission() { - - } - - public DownloadMission(String url, String name, String location, char kind) { - this(new String[]{url}, name, location, kind, null, null); - } - - public DownloadMission(String[] urls, String name, String location, char kind, String postprocessingName, String[] postprocessingArgs) { - if (name == null) throw new NullPointerException("name is null"); - if (name.isEmpty()) throw new IllegalArgumentException("name is empty"); + public DownloadMission(String[] urls, StoredFileHelper storage, char kind, Postprocessing psInstance) { if (urls == null) throw new NullPointerException("urls is null"); if (urls.length < 1) throw new IllegalArgumentException("urls is empty"); - if (location == null) throw new NullPointerException("location is null"); - if (location.isEmpty()) throw new IllegalArgumentException("location is empty"); this.urls = urls; - this.name = name; - this.location = location; this.kind = kind; this.offsets = new long[urls.length]; + this.enqueued = true; + this.maxRetry = 3; + this.storage = storage; + this.psAlgorithm = psInstance; - 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?"); + if (DEBUG && psInstance == null && urls.length > 1) { + Log.w(TAG, "mission created with multiple urls ¿missing post-processing algorithm?"); + } + } + + /** + * Acquire a block + * + * @return the block or {@code null} if no more blocks left + */ + @Nullable + Block acquireBlock() { + synchronized (LOCK) { + for (int i = 0; i < blockAcquired.length; i++) { + if (!blockAcquired[i] && blocks[i] >= 0) { + Block block = new Block(); + block.position = i; + block.done = blocks[i]; + + blockAcquired[i] = true; + return block; + } } } + + return null; } - private void checkBlock(long block) { - if (block < 0 || block >= blocks) { - throw new IllegalArgumentException("illegal block identifier"); + /** + * Release an block + * + * @param position the index of the block + * @param done amount of bytes downloaded + */ + void releaseBlock(int position, int done) { + synchronized (LOCK) { + blockAcquired[position] = false; + blocks[position] = done; } } - /** - * Check if a block is reserved - * - * @param block the block identifier - * @return true if the block is reserved and false if otherwise - */ - boolean isBlockPreserved(long block) { - checkBlock(block); - return blockState.containsKey(block) ? blockState.get(block) : false; - } - - void preserveBlock(long block) { - checkBlock(block); - synchronized (blockState) { - blockState.put(block, true); - } - } - - /** - * Set the block of the file - * - * @param threadId the identifier of the thread - * @param position the block of the thread - */ - void setBlockPosition(int threadId, long position) { - threadBlockPositions.set(threadId, position); - } - - /** - * Get the block of a file - * - * @param threadId the identifier of the thread - * @return the block for the thread - */ - long getBlockPosition(int threadId) { - return threadBlockPositions.get(threadId); - } - - /** - * 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, long position) { - threadBytePositions.set(threadId, position); - } - - /** - * Get position inside of the thread, where thread will be resumed - * - * @param threadId the identifier of the thread - * @return the relative position in bytes or zero - */ - long getThreadBytePosition(int threadId) { - return threadBytePositions.get(threadId); - } - /** * Open connection * @@ -243,9 +206,18 @@ public class DownloadMission extends Mission { * @throws IOException if an I/O exception occurs. */ HttpURLConnection openConnection(int threadId, long rangeStart, long rangeEnd) throws IOException { - URL url = new URL(urls[current]); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + return openConnection(urls[current], threadId, rangeStart, rangeEnd); + } + + HttpURLConnection openConnection(String url, int threadId, long rangeStart, long rangeEnd) throws IOException { + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); conn.setInstanceFollowRedirects(true); + conn.setRequestProperty("User-Agent", Downloader.USER_AGENT); + conn.setRequestProperty("Accept", "*/*"); + + // BUG workaround: switching between networks can freeze the download forever + conn.setConnectTimeout(30000); + conn.setReadTimeout(10000); if (rangeStart >= 0) { String req = "bytes=" + rangeStart + "-"; @@ -337,20 +309,48 @@ public class DownloadMission extends Mission { notifyError(ERROR_CONNECT_HOST, null); } else if (err instanceof UnknownHostException) { notifyError(ERROR_UNKNOWN_HOST, null); + } else if (err instanceof SocketTimeoutException) { + notifyError(ERROR_TIMEOUT, null); } else { notifyError(ERROR_UNKNOWN_EXCEPTION, err); } } - synchronized void notifyError(int code, Exception err) { + public synchronized void notifyError(int code, Exception err) { Log.e(TAG, "notifyError() code = " + code, err); + if (err instanceof IOException) { + if (!storage.canWrite() || err.getMessage().contains("Permission denied")) { + code = ERROR_PERMISSION_DENIED; + err = null; + } else if (err.getMessage().contains(INSUFFICIENT_STORAGE)) { + code = ERROR_INSUFFICIENT_STORAGE; + err = null; + } + } errCode = code; errObject = err; - pause(); + switch (code) { + case ERROR_SSL_EXCEPTION: + case ERROR_UNKNOWN_HOST: + case ERROR_CONNECT_HOST: + case ERROR_TIMEOUT: + // do not change the queue flag for network errors, can be + // recovered silently without the user interaction + break; + default: + // also checks for server errors + if (code < 500 || code > 599) enqueued = false; + } notify(DownloadManagerService.MESSAGE_ERROR); + + if (running) { + running = false; + recovered = true; + if (threads != null) selfPause(); + } } synchronized void notifyFinished() { @@ -358,11 +358,11 @@ public class DownloadMission extends Mission { finishCount++; - if (finishCount == currentThreadCount) { + if (blocks.length < 1 || threads == null || finishCount == threads.length) { if (errCode != ERROR_NOTHING) return; if (DEBUG) { - Log.d(TAG, "onFinish" + (current + 1) + "/" + urls.length); + Log.d(TAG, "onFinish: " + (current + 1) + "/" + urls.length); } if ((current + 1) < urls.length) { @@ -378,6 +378,7 @@ public class DownloadMission extends Mission { if (!doPostprocessing()) return; + enqueued = false; running = false; deleteThisFromFile(); @@ -386,25 +387,23 @@ public class DownloadMission extends Mission { } private void notifyPostProcessing(int state) { - if (DEBUG) { - String action; - switch (state) { - case 1: - action = "Running"; - break; - case 2: - action = "Completed"; - break; - default: - action = "Failed"; - } - - Log.d(TAG, action + " postprocessing on " + location + File.separator + name); + String action; + switch (state) { + case 1: + action = "Running"; + break; + case 2: + action = "Completed"; + break; + default: + action = "Failed"; } - synchronized (blockState) { + Log.d(TAG, action + " postprocessing on " + storage.getName()); + + synchronized (LOCK) { // don't return without fully write the current state - postprocessingState = state; + psState = state; Utility.writeToFile(metadata, DownloadMission.this); } } @@ -420,52 +419,52 @@ public class DownloadMission extends Mission { if (threads != null) for (Thread thread : threads) joinForThread(thread); - enqueued = false; running = true; errCode = ERROR_NOTHING; - if (current >= urls.length && postprocessingName != null) { - runAsync(1, () -> { - if (doPostprocessing()) { - running = false; - deleteThisFromFile(); - - notify(DownloadManagerService.MESSAGE_FINISHED); - } - }); - + if (current >= urls.length) { + threads = null; + runAsync(1, this::notifyFinished); return; } - if (blocks < 0) { + if (blocks == null) { initializer(); return; } init = null; + finishCount = 0; + blockAcquired = new boolean[blocks.length]; - if (threads == null || threads.length < 1) { - threads = new Thread[currentThreadCount]; - } - - if (fallback) { + if (blocks.length < 1) { if (unknownLength) { done = 0; length = 0; } - threads[0] = runAsync(1, new DownloadRunnableFallback(this)); + threads = new Thread[]{runAsync(1, new DownloadRunnableFallback(this))}; } else { - for (int i = 0; i < currentThreadCount; i++) { + int remainingBlocks = 0; + for (int block : blocks) if (block >= 0) remainingBlocks++; + + if (remainingBlocks < 1) { + runAsync(1, this::notifyFinished); + return; + } + + threads = new Thread[Math.min(threadCount, remainingBlocks)]; + + for (int i = 0; i < threads.length; i++) { threads[i] = runAsync(i + 1, new DownloadRunnable(this, i)); } } } /** - * Pause the mission, does not affect the blocks that are being downloaded. + * Pause the mission */ - public synchronized void pause() { + public void pause() { if (!running) return; if (isPsRunning()) { @@ -477,71 +476,84 @@ public class DownloadMission extends Mission { running = false; recovered = true; - enqueued = false; - if (init != null && Thread.currentThread() != init && init.isAlive()) { + if (init != null && init.isAlive()) { + // NOTE: if start() method is running ¡will no have effect! init.interrupt(); - synchronized (blockState) { - resetState(); + synchronized (LOCK) { + resetState(false, true, ERROR_NOTHING); } return; } - if (DEBUG && blocks == 0) { + if (DEBUG && unknownLength) { Log.w(TAG, "pausing a download that can not be resumed (range requests not allowed by the server)."); } - if (threads == null || Thread.currentThread().isInterrupted()) { + // check if the calling thread (alias UI thread) is interrupted + if (Thread.currentThread().isInterrupted()) { writeThisToFile(); return; } // wait for all threads are suspended before save the state - runAsync(-1, () -> { - try { - for (Thread thread : threads) { - if (thread.isAlive()) { - thread.interrupt(); - thread.join(5000); - } + if (threads != null) runAsync(-1, this::selfPause); + } + + private void selfPause() { + try { + for (Thread thread : threads) { + if (thread.isAlive()) { + thread.interrupt(); + thread.join(5000); } - } catch (Exception e) { - // nothing to do - } finally { - writeThisToFile(); } - }); + } catch (Exception e) { + // nothing to do + } finally { + writeThisToFile(); + } } /** - * Removes the file and the meta file + * Removes the downloaded file and the meta file */ @Override public boolean delete() { deleted = true; + if (psAlgorithm != null) psAlgorithm.cleanupTemporalDir(); + boolean res = deleteThisFromFile(); - if (!super.delete()) res = false; + + if (!super.delete()) return 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 = new Thread[0]; - Utility.writeToFile(metadata, DownloadMission.this); + /** + * Resets the mission state + * + * @param rollback {@code true} true to forget all progress, otherwise, {@code false} + * @param persistChanges {@code true} to commit changes to the metadata file, otherwise, {@code false} + */ + public void resetState(boolean rollback, boolean persistChanges, int errorCode) { + done = 0; + errCode = errorCode; + errObject = null; + unknownLength = false; + threads = null; + fallbackResumeOffset = 0; + blocks = null; + blockAcquired = null; + + if (rollback) current = 0; + + if (persistChanges) + Utility.writeToFile(metadata, DownloadMission.this); } private void initializer() { init = runAsync(DownloadInitializer.mId, new DownloadInitializer(this)); - } /** @@ -549,7 +561,7 @@ public class DownloadMission extends Mission { * if no thread is already running. */ private void writeThisToFile() { - synchronized (blockState) { + synchronized (LOCK) { if (deleted) return; Utility.writeToFile(metadata, DownloadMission.this); } @@ -562,7 +574,7 @@ public class DownloadMission extends Mission { * @return true, otherwise, false */ public boolean isFinished() { - return current >= urls.length && (postprocessingName == null || postprocessingState == 2); + return current >= urls.length && (psAlgorithm == null || psState == 2); } /** @@ -571,7 +583,13 @@ public class DownloadMission extends Mission { * @return {@code true} if this mission is unrecoverable */ public boolean isPsFailed() { - return postprocessingName != null && errCode == DownloadMission.ERROR_POSTPROCESSING && postprocessingThis; + switch (errCode) { + case ERROR_POSTPROCESSING: + case ERROR_POSTPROCESSING_STOPPED: + return psAlgorithm.worksOnSameFile; + } + + return false; } /** @@ -580,12 +598,26 @@ public class DownloadMission extends Mission { * @return true, otherwise, false */ public boolean isPsRunning() { - return postprocessingName != null && postprocessingState == 1; + return psAlgorithm != null && (psState == 1 || psState == 3); } + /** + * Indicated if the mission is ready + * + * @return true, otherwise, false + */ + public boolean isInitialized() { + return blocks != null; // DownloadMissionInitializer was executed + } + + /** + * Gets the approximated final length of the file + * + * @return the length in bytes + */ public long getLength() { long calculated; - if (postprocessingState == 1) { + if (psState == 1 || psState == 3) { calculated = length; } else { calculated = offsets[current < offsets.length ? current : (offsets.length - 1)] + length; @@ -596,30 +628,67 @@ public class DownloadMission extends Mission { return calculated > nearLength ? calculated : nearLength; } + /** + * set this mission state on the queue + * + * @param queue true to add to the queue, otherwise, false + */ + public void setEnqueued(boolean queue) { + enqueued = queue; + runAsync(-2, this::writeThisToFile); + } + + /** + * Attempts to continue a blocked post-processing + * + * @param recover {@code true} to retry, otherwise, {@code false} to cancel + */ + public void psContinue(boolean recover) { + psState = 1; + errCode = recover ? ERROR_NOTHING : ERROR_POSTPROCESSING; + threads[0].interrupt(); + } + + /** + * Indicates whatever the backed storage is invalid + * + * @return {@code true}, if storage is invalid and cannot be used + */ + public boolean hasInvalidStorage() { + return errCode == ERROR_PROGRESS_LOST || storage == null || storage.isInvalid() || !storage.existsAsFile(); + } + + /** + * Indicates whatever is possible to start the mission + * + * @return {@code true} is this mission its "healthy", otherwise, {@code false} + */ + public boolean isCorrupt() { + return (isPsFailed() || errCode == ERROR_POSTPROCESSING_HOLD) || isFinished() || hasInvalidStorage(); + } + private boolean doPostprocessing() { - if (postprocessingName == null || postprocessingState == 2) return true; + if (psAlgorithm == null || psState == 2) return true; + + errObject = null; notifyPostProcessing(1); notifyProgress(0); - Thread.currentThread().setName("[" + TAG + "] post-processing = " + postprocessingName + " filename = " + name); + if (DEBUG) + Thread.currentThread().setName("[" + TAG + "] ps = " + + psAlgorithm.getClass().getSimpleName() + + " filename = " + storage.getName() + ); + + threads = new Thread[]{Thread.currentThread()}; Exception exception = null; try { - Postprocessing - .getAlgorithm(postprocessingName, this) - .run(); + psAlgorithm.run(this); } catch (Exception err) { - StringBuilder args = new StringBuilder(" "); - if (postprocessingArgs != null) { - for (String arg : postprocessingArgs) { - args.append(", "); - args.append(arg); - } - args.delete(0, 1); - } - Log.e(TAG, String.format("Post-processing failed. algorithm = %s args = [%s]", postprocessingName, args), err); + Log.e(TAG, "Post-processing failed. " + psAlgorithm.toString(), err); if (errCode == ERROR_NOTHING) errCode = ERROR_POSTPROCESSING; @@ -639,7 +708,7 @@ public class DownloadMission extends Mission { } private boolean deleteThisFromFile() { - synchronized (blockState) { + synchronized (LOCK) { return metadata.delete(); } } @@ -669,7 +738,7 @@ public class DownloadMission extends Mission { // >=1: any download thread if (DEBUG) { - who.setName(String.format("%s[%s] %s", TAG, id, name)); + who.setName(String.format("%s[%s] %s", TAG, id, storage.getName())); } who.start(); @@ -701,7 +770,7 @@ public class DownloadMission extends Mission { static class HttpError extends Exception { - int statusCode; + final int statusCode; HttpError(int statusCode) { this.statusCode = statusCode; @@ -709,7 +778,16 @@ public class DownloadMission extends Mission { @Override public String getMessage() { - return "HTTP " + String.valueOf(statusCode); + return "HTTP " + statusCode; } } + + static class Block { + int position; + int done; + } + + private static class Lock implements Serializable { + // java.lang.Object cannot be used because is not serializable + } } diff --git a/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java b/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java index 244fbd47a..6d1d2bfbc 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java @@ -2,14 +2,18 @@ package us.shandian.giga.get; import android.util.Log; -import java.io.FileNotFoundException; +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; import java.io.InputStream; -import java.io.RandomAccessFile; import java.net.HttpURLConnection; import java.nio.channels.ClosedByInterruptException; +import us.shandian.giga.get.DownloadMission.Block; + import static org.schabi.newpipe.BuildConfig.DEBUG; + /** * Runnable to download blocks of a file until the file is completely downloaded, * an error occurs or the process is stopped. @@ -26,89 +30,85 @@ public class DownloadRunnable extends Thread { if (mission == null) throw new NullPointerException("mission is null"); mMission = mission; mId = id; - mConn = null; + } + + private void releaseBlock(Block block, long remain) { + // set the block offset to -1 if it is completed + mMission.releaseBlock(block.position, remain < 0 ? -1 : block.done); } @Override public void run() { - boolean retry = mMission.recovered; - long blockPosition = mMission.getBlockPosition(mId); + boolean retry = false; + Block block = null; + int retryCount = 0; if (DEBUG) { - Log.d(TAG, mId + ":default pos " + blockPosition); Log.d(TAG, mId + ":recovered: " + mMission.recovered); } - RandomAccessFile f; + SharpStream f; InputStream is = null; try { - f = new RandomAccessFile(mMission.getDownloadedFile(), "rw"); - } catch (FileNotFoundException e) { + f = mMission.storage.getStream(); + } catch (IOException e) { mMission.notifyError(e);// this never should happen return; } - while (mMission.running && mMission.errCode == DownloadMission.ERROR_NOTHING && blockPosition < mMission.blocks) { - - if (DEBUG && retry) { - Log.d(TAG, mId + ":retry is true. Resuming at " + blockPosition); + while (mMission.running && mMission.errCode == DownloadMission.ERROR_NOTHING) { + if (!retry) { + block = mMission.acquireBlock(); } - // Wait for an unblocked position - while (!retry && blockPosition < mMission.blocks && mMission.isBlockPreserved(blockPosition)) { - - if (DEBUG) { - Log.d(TAG, mId + ":position " + blockPosition + " preserved, passing"); - } - - blockPosition++; - } - - retry = false; - - if (blockPosition >= mMission.blocks) { + if (block == null) { + if (DEBUG) Log.d(TAG, mId + ":no more blocks left, exiting"); break; } if (DEBUG) { - Log.d(TAG, mId + ":preserving position " + blockPosition); + if (retry) + Log.d(TAG, mId + ":retry block at position=" + block.position + " from the start"); + else + Log.d(TAG, mId + ":acquired block at position=" + block.position + " done=" + block.done); } - mMission.preserveBlock(blockPosition); - mMission.setBlockPosition(mId, blockPosition); - - long start = blockPosition * DownloadMission.BLOCK_SIZE; + long start = block.position * DownloadMission.BLOCK_SIZE; long end = start + DownloadMission.BLOCK_SIZE - 1; - long offset = mMission.getThreadBytePosition(mId); - start += offset; + start += block.done; if (end >= mMission.length) { end = mMission.length - 1; } - long total = 0; - try { mConn = mMission.openConnection(mId, start, end); mMission.establishConnection(mId, mConn); // check if the download can be resumed - if (mConn.getResponseCode() == 416 && offset > 0) { - retryCount--; + if (mConn.getResponseCode() == 416) { + if (block.done > 0) { + // try again from the start (of the block) + block.done = 0; + retry = true; + mConn.disconnect(); + continue; + } + throw new DownloadMission.HttpError(416); } + retry = false; + // The server may be ignoring the range request if (mConn.getResponseCode() != 206) { - mMission.notifyError(new DownloadMission.HttpError(mConn.getResponseCode())); - if (DEBUG) { Log.e(TAG, mId + ":Unsupported " + mConn.getResponseCode()); } - + mMission.notifyError(new DownloadMission.HttpError(mConn.getResponseCode())); break; } @@ -122,22 +122,14 @@ public class DownloadRunnable extends Thread { while (start < end && mMission.running && (len = is.read(buf, 0, buf.length)) != -1) { f.write(buf, 0, len); start += len; - total += len; + block.done += len; mMission.notifyProgress(len); } if (DEBUG && mMission.running) { - Log.d(TAG, mId + ":position " + blockPosition + " finished, " + total + " bytes downloaded"); + Log.d(TAG, mId + ":position " + block.position + " stopped " + start + "/" + end); } - - if (mMission.running) - mMission.setThreadBytePosition(mId, 0L);// clear byte position for next block - else - mMission.setThreadBytePosition(mId, total);// download paused, save progress for this block - } catch (Exception e) { - mMission.setThreadBytePosition(mId, total); - if (!mMission.running || e instanceof ClosedByInterruptException) break; if (retryCount++ >= mMission.maxRetry) { @@ -145,11 +137,9 @@ public class DownloadRunnable extends Thread { break; } - if (DEBUG) { - Log.d(TAG, mId + ":position " + blockPosition + " retrying due exception", e); - } - retry = true; + } finally { + if (!retry) releaseBlock(block, end - start); } } diff --git a/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java b/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java index 4bcaeaf85..d93053881 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java @@ -1,16 +1,15 @@ package us.shandian.giga.get; -import android.annotation.SuppressLint; import android.support.annotation.NonNull; import android.util.Log; +import org.schabi.newpipe.streams.io.SharpStream; + import java.io.IOException; import java.io.InputStream; -import java.io.RandomAccessFile; import java.net.HttpURLConnection; import java.nio.channels.ClosedByInterruptException; - import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; @@ -19,21 +18,17 @@ import static org.schabi.newpipe.BuildConfig.DEBUG; * Single-threaded fallback mode */ public class DownloadRunnableFallback extends Thread { - private static final String TAG = "DownloadRunnableFallback"; + private static final String TAG = "DownloadRunnableFallbac"; private final DownloadMission mMission; - private final int mId = 1; private int mRetryCount = 0; private InputStream mIs; - private RandomAccessFile mF; + private SharpStream mF; private HttpURLConnection mConn; DownloadRunnableFallback(@NonNull DownloadMission mission) { mMission = mission; - mIs = null; - mF = null; - mConn = null; } private void dispose() { @@ -43,30 +38,34 @@ public class DownloadRunnableFallback extends Thread { // nothing to do } - try { - if (mF != null) mF.close(); - } catch (IOException e) { - // ¿ejected media storage? ¿file deleted? ¿storage ran out of space? + if (mF != null) mF.close(); + } + + private long loadPosition() { + synchronized (mMission.LOCK) { + return mMission.fallbackResumeOffset; + } + } + + private void savePosition(long position) { + synchronized (mMission.LOCK) { + mMission.fallbackResumeOffset = position; } } @Override - @SuppressLint("LongLogTag") public void run() { boolean done; + long start = loadPosition(); - long start = 0; - - if (!mMission.unknownLength) { - start = mMission.getThreadBytePosition(0); - if (DEBUG && start > 0) { - Log.i(TAG, "Resuming a single-thread download at " + start); - } + if (DEBUG && !mMission.unknownLength && start > 0) { + Log.i(TAG, "Resuming a single-thread download at " + start); } try { long rangeStart = (mMission.unknownLength || start < 1) ? -1 : start; + int mId = 1; mConn = mMission.openConnection(mId, rangeStart, -1); mMission.establishConnection(mId, mConn); @@ -81,7 +80,7 @@ public class DownloadRunnableFallback extends Thread { if (!mMission.unknownLength) mMission.unknownLength = Utility.getContentLength(mConn) == -1; - mF = new RandomAccessFile(mMission.getDownloadedFile(), "rw"); + mF = mMission.storage.getStream(); mF.seek(mMission.offsets[mMission.current] + start); mIs = mConn.getInputStream(); @@ -100,8 +99,7 @@ public class DownloadRunnableFallback extends Thread { } catch (Exception e) { dispose(); - // save position - mMission.setThreadBytePosition(0, start); + savePosition(start); if (!mMission.running || e instanceof ClosedByInterruptException) return; @@ -110,6 +108,10 @@ public class DownloadRunnableFallback extends Thread { return; } + if (DEBUG) { + Log.e(TAG, "got exception, retrying...", e); + } + run();// try again return; } @@ -119,7 +121,7 @@ public class DownloadRunnableFallback extends Thread { if (done) { mMission.notifyFinished(); } else { - mMission.setThreadBytePosition(0, start); + savePosition(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 index b7d6908a5..2a01896fe 100644 --- a/app/src/main/java/us/shandian/giga/get/FinishedMission.java +++ b/app/src/main/java/us/shandian/giga/get/FinishedMission.java @@ -1,16 +1,18 @@ package us.shandian.giga.get; +import android.support.annotation.NonNull; + public class FinishedMission extends Mission { public FinishedMission() { } - public FinishedMission(DownloadMission mission) { + public FinishedMission(@NonNull DownloadMission mission) { source = mission.source; length = mission.length;// ¿or mission.done? timestamp = mission.timestamp; - name = mission.name; - location = mission.location; kind = mission.kind; + storage = mission.storage; + } } diff --git a/app/src/main/java/us/shandian/giga/get/Mission.java b/app/src/main/java/us/shandian/giga/get/Mission.java index ec2ddaa26..a9ed08fc2 100644 --- a/app/src/main/java/us/shandian/giga/get/Mission.java +++ b/app/src/main/java/us/shandian/giga/get/Mission.java @@ -1,12 +1,14 @@ package us.shandian.giga.get; -import java.io.File; +import android.support.annotation.NonNull; + import java.io.Serializable; -import java.text.SimpleDateFormat; import java.util.Calendar; +import us.shandian.giga.io.StoredFileHelper; + public abstract class Mission implements Serializable { - private static final long serialVersionUID = 0L;// last bump: 5 october 2018 + private static final long serialVersionUID = 1L;// last bump: 27 march 2019 /** * Source url of the resource @@ -23,33 +25,24 @@ public abstract class Mission implements Serializable { */ public long timestamp; - /** - * The filename - */ - public String name; - - /** - * The directory to store the download - */ - public String location; - /** * pre-defined content type */ public char kind; /** - * get the target file on the storage - * - * @return File object + * The downloaded file */ - public File getDownloadedFile() { - return new File(location, name); - } + public StoredFileHelper storage; + /** + * Delete the downloaded file + * + * @return {@code true] if and only if the file is successfully deleted, otherwise, {@code false} + */ public boolean delete() { - deleted = true; - return getDownloadedFile().delete(); + if (storage != null) return storage.delete(); + return true; } /** @@ -57,10 +50,11 @@ public abstract class Mission implements Serializable { */ public transient boolean deleted = false; + @NonNull @Override public String toString() { Calendar calendar = Calendar.getInstance(); calendar.setTimeInMillis(timestamp); - return "[" + calendar.getTime().toString() + "] " + location + File.separator + name; + return "[" + calendar.getTime().toString() + "] " + (storage.isInvalid() ? storage.getName() : storage.getUri()); } } diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/DownloadDataSource.java b/app/src/main/java/us/shandian/giga/get/sqlite/DownloadDataSource.java deleted file mode 100644 index 4b4d5d733..000000000 --- a/app/src/main/java/us/shandian/giga/get/sqlite/DownloadDataSource.java +++ /dev/null @@ -1,73 +0,0 @@ -package us.shandian.giga.get.sqlite; - -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.util.Log; - -import java.util.ArrayList; -import java.util.List; - -import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.get.FinishedMission; -import us.shandian.giga.get.Mission; - -import static us.shandian.giga.get.sqlite.DownloadMissionHelper.KEY_LOCATION; -import static us.shandian.giga.get.sqlite.DownloadMissionHelper.KEY_NAME; -import static us.shandian.giga.get.sqlite.DownloadMissionHelper.MISSIONS_TABLE_NAME; - -public class DownloadDataSource { - - private static final String TAG = "DownloadDataSource"; - private final DownloadMissionHelper downloadMissionHelper; - - public DownloadDataSource(Context context) { - downloadMissionHelper = new DownloadMissionHelper(context); - } - - public ArrayList loadFinishedMissions() { - SQLiteDatabase database = downloadMissionHelper.getReadableDatabase(); - Cursor cursor = database.query(MISSIONS_TABLE_NAME, null, null, - null, null, null, DownloadMissionHelper.KEY_TIMESTAMP); - - int count = cursor.getCount(); - if (count == 0) return new ArrayList<>(1); - - ArrayList result = new ArrayList<>(count); - while (cursor.moveToNext()) { - result.add(DownloadMissionHelper.getMissionFromCursor(cursor)); - } - - return result; - } - - public void addMission(DownloadMission downloadMission) { - if (downloadMission == null) throw new NullPointerException("downloadMission is null"); - SQLiteDatabase database = downloadMissionHelper.getWritableDatabase(); - ContentValues values = DownloadMissionHelper.getValuesOfMission(downloadMission); - database.insert(MISSIONS_TABLE_NAME, null, values); - } - - public void deleteMission(Mission downloadMission) { - if (downloadMission == null) throw new NullPointerException("downloadMission is null"); - SQLiteDatabase database = downloadMissionHelper.getWritableDatabase(); - database.delete(MISSIONS_TABLE_NAME, - KEY_LOCATION + " = ? AND " + - KEY_NAME + " = ?", - new String[]{downloadMission.location, downloadMission.name}); - } - - public void updateMission(DownloadMission downloadMission) { - if (downloadMission == null) throw new NullPointerException("downloadMission is null"); - SQLiteDatabase database = downloadMissionHelper.getWritableDatabase(); - ContentValues values = DownloadMissionHelper.getValuesOfMission(downloadMission); - String whereClause = KEY_LOCATION + " = ? AND " + - KEY_NAME + " = ?"; - int rowsAffected = database.update(MISSIONS_TABLE_NAME, values, - whereClause, new String[]{downloadMission.location, downloadMission.name}); - if (rowsAffected != 1) { - Log.e(TAG, "Expected 1 row to be affected by update but got " + rowsAffected); - } - } -} diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionHelper.java b/app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionHelper.java deleted file mode 100644 index 6dadc98c8..000000000 --- a/app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionHelper.java +++ /dev/null @@ -1,112 +0,0 @@ -package us.shandian.giga.get.sqlite; - -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; - -import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.get.FinishedMission; - -/** - * SQLiteHelper to store finished {@link us.shandian.giga.get.DownloadMission}'s - */ -public class DownloadMissionHelper extends SQLiteOpenHelper { - private final String TAG = "DownloadMissionHelper"; - - // TODO: use NewPipeSQLiteHelper ('s constants) when playlist branch is merged (?) - private static final String DATABASE_NAME = "downloads.db"; - - private static final int DATABASE_VERSION = 3; - - /** - * The table name of download missions - */ - static final String MISSIONS_TABLE_NAME = "download_missions"; - - /** - * The key to the directory location of the mission - */ - static final String KEY_LOCATION = "location"; - /** - * The key to the urls of a mission - */ - static final String KEY_SOURCE_URL = "url"; - /** - * The key to the name of a mission - */ - static final String KEY_NAME = "name"; - - /** - * The key to the done. - */ - static final String KEY_DONE = "bytes_downloaded"; - - static final String KEY_TIMESTAMP = "timestamp"; - - static final String KEY_KIND = "kind"; - - /** - * The statement to create the table - */ - private static final String MISSIONS_CREATE_TABLE = - "CREATE TABLE " + MISSIONS_TABLE_NAME + " (" + - KEY_LOCATION + " TEXT NOT NULL, " + - KEY_NAME + " TEXT NOT NULL, " + - KEY_SOURCE_URL + " TEXT NOT NULL, " + - KEY_DONE + " INTEGER NOT NULL, " + - KEY_TIMESTAMP + " INTEGER NOT NULL, " + - KEY_KIND + " TEXT NOT NULL, " + - " UNIQUE(" + KEY_LOCATION + ", " + KEY_NAME + "));"; - - public DownloadMissionHelper(Context context) { - super(context, DATABASE_NAME, null, DATABASE_VERSION); - } - - @Override - public void onCreate(SQLiteDatabase db) { - db.execSQL(MISSIONS_CREATE_TABLE); - } - - @Override - public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - if (oldVersion == 2) { - db.execSQL("ALTER TABLE " + MISSIONS_TABLE_NAME + " ADD COLUMN " + KEY_KIND + " TEXT;"); - } - } - - /** - * Returns all values of the download mission as ContentValues. - * - * @param downloadMission the download mission - * @return the content values - */ - public static ContentValues getValuesOfMission(DownloadMission downloadMission) { - ContentValues values = new ContentValues(); - values.put(KEY_SOURCE_URL, downloadMission.source); - values.put(KEY_LOCATION, downloadMission.location); - values.put(KEY_NAME, downloadMission.name); - values.put(KEY_DONE, downloadMission.done); - values.put(KEY_TIMESTAMP, downloadMission.timestamp); - values.put(KEY_KIND, String.valueOf(downloadMission.kind)); - return values; - } - - public static FinishedMission getMissionFromCursor(Cursor cursor) { - if (cursor == null) throw new NullPointerException("cursor is null"); - - String kind = cursor.getString(cursor.getColumnIndex(KEY_KIND)); - if (kind == null || kind.isEmpty()) kind = "?"; - - FinishedMission mission = new FinishedMission(); - mission.name = cursor.getString(cursor.getColumnIndexOrThrow(KEY_NAME)); - mission.location = cursor.getString(cursor.getColumnIndexOrThrow(KEY_LOCATION)); - mission.source = cursor.getString(cursor.getColumnIndexOrThrow(KEY_SOURCE_URL));; - mission.length = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE)); - mission.timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP)); - mission.kind = kind.charAt(0); - - return mission; - } -} diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java new file mode 100644 index 000000000..4650f75d0 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java @@ -0,0 +1,237 @@ +package us.shandian.giga.get.sqlite; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.util.Log; + +import java.io.File; +import java.util.ArrayList; + +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.get.FinishedMission; +import us.shandian.giga.get.Mission; +import us.shandian.giga.io.StoredFileHelper; + +/** + * SQLite helper to store finished {@link us.shandian.giga.get.FinishedMission}'s + */ +public class FinishedMissionStore extends SQLiteOpenHelper { + + // TODO: use NewPipeSQLiteHelper ('s constants) when playlist branch is merged (?) + private static final String DATABASE_NAME = "downloads.db"; + + private static final int DATABASE_VERSION = 4; + + /** + * The table name of download missions (old) + */ + private static final String MISSIONS_TABLE_NAME_v2 = "download_missions"; + + /** + * The table name of download missions + */ + private static final String FINISHED_TABLE_NAME = "finished_missions"; + + /** + * The key to the urls of a mission + */ + private static final String KEY_SOURCE = "url"; + + + /** + * The key to the done. + */ + private static final String KEY_DONE = "bytes_downloaded"; + + private static final String KEY_TIMESTAMP = "timestamp"; + + private static final String KEY_KIND = "kind"; + + private static final String KEY_PATH = "path"; + + /** + * The statement to create the table + */ + private static final String MISSIONS_CREATE_TABLE = + "CREATE TABLE " + FINISHED_TABLE_NAME + " (" + + KEY_PATH + " TEXT NOT NULL, " + + KEY_SOURCE + " TEXT NOT NULL, " + + KEY_DONE + " INTEGER NOT NULL, " + + KEY_TIMESTAMP + " INTEGER NOT NULL, " + + KEY_KIND + " TEXT NOT NULL, " + + " UNIQUE(" + KEY_TIMESTAMP + ", " + KEY_PATH + "));"; + + + private Context context; + + public FinishedMissionStore(Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + this.context = context; + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(MISSIONS_CREATE_TABLE); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + if (oldVersion == 2) { + db.execSQL("ALTER TABLE " + MISSIONS_TABLE_NAME_v2 + " ADD COLUMN " + KEY_KIND + " TEXT;"); + oldVersion++; + } + + if (oldVersion == 3) { + final String KEY_LOCATION = "location"; + final String KEY_NAME = "name"; + + db.execSQL(MISSIONS_CREATE_TABLE); + + Cursor cursor = db.query(MISSIONS_TABLE_NAME_v2, null, null, + null, null, null, KEY_TIMESTAMP); + + int count = cursor.getCount(); + if (count > 0) { + db.beginTransaction(); + while (cursor.moveToNext()) { + ContentValues values = new ContentValues(); + values.put(KEY_SOURCE, cursor.getString(cursor.getColumnIndex(KEY_SOURCE))); + values.put(KEY_DONE, cursor.getString(cursor.getColumnIndex(KEY_DONE))); + values.put(KEY_TIMESTAMP, cursor.getLong(cursor.getColumnIndex(KEY_TIMESTAMP))); + values.put(KEY_KIND, cursor.getString(cursor.getColumnIndex(KEY_KIND))); + values.put(KEY_PATH, Uri.fromFile( + new File( + cursor.getString(cursor.getColumnIndex(KEY_LOCATION)), + cursor.getString(cursor.getColumnIndex(KEY_NAME)) + ) + ).toString()); + + db.insert(FINISHED_TABLE_NAME, null, values); + } + db.setTransactionSuccessful(); + db.endTransaction(); + } + + cursor.close(); + db.execSQL("DROP TABLE " + MISSIONS_TABLE_NAME_v2); + } + } + + /** + * Returns all values of the download mission as ContentValues. + * + * @param downloadMission the download mission + * @return the content values + */ + private ContentValues getValuesOfMission(@NonNull Mission downloadMission) { + ContentValues values = new ContentValues(); + values.put(KEY_SOURCE, downloadMission.source); + values.put(KEY_PATH, downloadMission.storage.getUri().toString()); + values.put(KEY_DONE, downloadMission.length); + values.put(KEY_TIMESTAMP, downloadMission.timestamp); + values.put(KEY_KIND, String.valueOf(downloadMission.kind)); + return values; + } + + private FinishedMission getMissionFromCursor(Cursor cursor) { + if (cursor == null) throw new NullPointerException("cursor is null"); + + String kind = cursor.getString(cursor.getColumnIndex(KEY_KIND)); + if (kind == null || kind.isEmpty()) kind = "?"; + + String path = cursor.getString(cursor.getColumnIndexOrThrow(KEY_PATH)); + + FinishedMission mission = new FinishedMission(); + + mission.source = cursor.getString(cursor.getColumnIndexOrThrow(KEY_SOURCE)); + mission.length = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE)); + mission.timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP)); + mission.kind = kind.charAt(0); + + try { + mission.storage = new StoredFileHelper(context,null, Uri.parse(path), ""); + } catch (Exception e) { + Log.e("FinishedMissionStore", "failed to load the storage path of: " + path, e); + mission.storage = new StoredFileHelper(null, path, "", ""); + } + + return mission; + } + + + ////////////////////////////////// + // Data source methods + /////////////////////////////////// + + public ArrayList loadFinishedMissions() { + SQLiteDatabase database = getReadableDatabase(); + Cursor cursor = database.query(FINISHED_TABLE_NAME, null, null, + null, null, null, KEY_TIMESTAMP + " DESC"); + + int count = cursor.getCount(); + if (count == 0) return new ArrayList<>(1); + + ArrayList result = new ArrayList<>(count); + while (cursor.moveToNext()) { + result.add(getMissionFromCursor(cursor)); + } + + return result; + } + + public void addFinishedMission(DownloadMission downloadMission) { + if (downloadMission == null) throw new NullPointerException("downloadMission is null"); + SQLiteDatabase database = getWritableDatabase(); + ContentValues values = getValuesOfMission(downloadMission); + database.insert(FINISHED_TABLE_NAME, null, values); + } + + public void deleteMission(Mission mission) { + if (mission == null) throw new NullPointerException("mission is null"); + String ts = String.valueOf(mission.timestamp); + + SQLiteDatabase database = getWritableDatabase(); + + if (mission instanceof FinishedMission) { + if (mission.storage.isInvalid()) { + database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ?", new String[]{ts}); + } else { + database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ? AND " + KEY_PATH + " = ?", new String[]{ + ts, mission.storage.getUri().toString() + }); + } + } else { + throw new UnsupportedOperationException("DownloadMission"); + } + } + + public void updateMission(Mission mission) { + if (mission == null) throw new NullPointerException("mission is null"); + SQLiteDatabase database = getWritableDatabase(); + ContentValues values = getValuesOfMission(mission); + String ts = String.valueOf(mission.timestamp); + + int rowsAffected; + + if (mission instanceof FinishedMission) { + if (mission.storage.isInvalid()) { + rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_TIMESTAMP + " = ?", new String[]{ts}); + } else { + rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_PATH + " = ?", new String[]{ + mission.storage.getUri().toString() + }); + } + } else { + throw new UnsupportedOperationException("DownloadMission"); + } + + if (rowsAffected != 1) { + Log.e("FinishedMissionStore", "Expected 1 row to be affected by update but got " + rowsAffected); + } + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/io/ChunkFileInputStream.java b/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java similarity index 78% rename from app/src/main/java/us/shandian/giga/postprocessing/io/ChunkFileInputStream.java rename to app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java index cd62c5d22..16a90fcee 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/io/ChunkFileInputStream.java +++ b/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java @@ -1,153 +1,148 @@ -package us.shandian.giga.postprocessing.io; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.File; -import java.io.IOException; -import java.io.RandomAccessFile; - -public class ChunkFileInputStream extends SharpStream { - - private RandomAccessFile source; - private final long offset; - private final long length; - private long position; - - public ChunkFileInputStream(File file, long start, long end, String mode) throws IOException { - source = new RandomAccessFile(file, mode); - offset = start; - length = end - start; - position = 0; - - if (length < 1) { - source.close(); - throw new IOException("The chunk is empty or invalid"); - } - if (source.length() < end) { - try { - throw new IOException(String.format("invalid file length. expected = %s found = %s", end, source.length())); - } finally { - source.close(); - } - } - - source.seek(offset); - } - - /** - * Get absolute position on file - * - * @return the position - */ - public long getFilePointer() { - return offset + position; - } - - @Override - public int read() throws IOException { - if ((position + 1) > length) { - return 0; - } - - int res = source.read(); - if (res >= 0) { - position++; - } - - return res; - } - - @Override - public int read(byte b[]) throws IOException { - return read(b, 0, b.length); - } - - @Override - public int read(byte b[], int off, int len) throws IOException { - if ((position + len) > length) { - len = (int) (length - position); - } - if (len == 0) { - return 0; - } - - int res = source.read(b, off, len); - position += res; - - return res; - } - - @Override - public long skip(long pos) throws IOException { - pos = Math.min(pos + position, length); - - if (pos == 0) { - return 0; - } - - source.seek(offset + pos); - - long oldPos = position; - position = pos; - - return pos - oldPos; - } - - @Override - public 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() { - } -} +package us.shandian.giga.io; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; + +public class ChunkFileInputStream extends SharpStream { + + private SharpStream source; + private final long offset; + private final long length; + private long position; + + public ChunkFileInputStream(SharpStream target, long start) throws IOException { + this(target, start, target.length()); + } + + public ChunkFileInputStream(SharpStream target, long start, long end) throws IOException { + source = target; + offset = start; + length = end - start; + position = 0; + + if (length < 1) { + source.close(); + throw new IOException("The chunk is empty or invalid"); + } + if (source.length() < end) { + try { + throw new IOException(String.format("invalid file length. expected = %s found = %s", end, source.length())); + } finally { + source.close(); + } + } + + source.seek(offset); + } + + /** + * Get absolute position on file + * + * @return the position + */ + public long getFilePointer() { + return offset + position; + } + + @Override + public int read() throws IOException { + if ((position + 1) > length) { + return 0; + } + + int res = source.read(); + if (res >= 0) { + position++; + } + + return res; + } + + @Override + public int read(byte b[]) throws IOException { + return read(b, 0, b.length); + } + + @Override + public int read(byte b[], int off, int len) throws IOException { + if ((position + len) > length) { + len = (int) (length - position); + } + if (len == 0) { + return 0; + } + + int res = source.read(b, off, len); + position += res; + + return res; + } + + @Override + public long skip(long pos) throws IOException { + pos = Math.min(pos + position, length); + + if (pos == 0) { + return 0; + } + + source.seek(offset + pos); + + long oldPos = position; + position = pos; + + return pos - oldPos; + } + + @Override + public long available() { + return (int) (length - position); + } + + @SuppressWarnings("EmptyCatchBlock") + @Override + public void close() { + source.close(); + source = null; + } + + @Override + public boolean isClosed() { + return source == null; + } + + @Override + public void rewind() throws IOException { + position = 0; + source.seek(offset); + } + + @Override + public boolean canRewind() { + return true; + } + + @Override + public boolean canRead() { + return true; + } + + @Override + public boolean canWrite() { + return false; + } + + @Override + public void write(byte value) { + } + + @Override + public void write(byte[] buffer) { + } + + @Override + public void write(byte[] buffer, int offset, int count) { + } + +} diff --git a/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java b/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java new file mode 100644 index 000000000..f9ceca6ad --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java @@ -0,0 +1,497 @@ +package us.shandian.giga.io; + +import android.support.annotation.NonNull; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; + +public class CircularFileWriter extends SharpStream { + + private final static int QUEUE_BUFFER_SIZE = 8 * 1024;// 8 KiB + private final static int COPY_BUFFER_SIZE = 128 * 1024; // 128 KiB + private final static int NOTIFY_BYTES_INTERVAL = 64 * 1024;// 64 KiB + private final static int THRESHOLD_AUX_LENGTH = 15 * 1024 * 1024;// 15 MiB + + private OffsetChecker callback; + + public ProgressReport onProgress; + public WriteErrorHandle onWriteError; + + private long reportPosition; + private long maxLengthKnown = -1; + + private BufferedFile out; + private BufferedFile aux; + + public CircularFileWriter(SharpStream target, File temp, OffsetChecker checker) throws IOException { + if (checker == null) { + throw new NullPointerException("checker is null"); + } + + if (!temp.exists()) { + if (!temp.createNewFile()) { + throw new IOException("Cannot create a temporal file"); + } + } + + aux = new BufferedFile(temp); + out = new BufferedFile(target); + + callback = checker; + + reportPosition = NOTIFY_BYTES_INTERVAL; + } + + private void flushAuxiliar(long amount) throws IOException { + if (aux.length < 1) { + return; + } + + out.flush(); + aux.flush(); + + boolean underflow = aux.offset < aux.length || out.offset < out.length; + byte[] buffer = new byte[COPY_BUFFER_SIZE]; + + aux.target.seek(0); + out.target.seek(out.length); + + long length = amount; + while (length > 0) { + int read = (int) Math.min(length, Integer.MAX_VALUE); + read = aux.target.read(buffer, 0, Math.min(read, buffer.length)); + + if (read < 1) { + amount -= length; + break; + } + + out.writeProof(buffer, read); + length -= read; + } + + if (underflow) { + if (out.offset >= out.length) { + // calculate the aux underflow pointer + if (aux.offset < amount) { + out.offset += aux.offset; + aux.offset = 0; + out.target.seek(out.offset); + } else { + aux.offset -= amount; + out.offset = out.length + amount; + } + } else { + aux.offset = 0; + } + } else { + out.offset += amount; + aux.offset -= amount; + } + + out.length += amount; + + if (out.length > maxLengthKnown) { + maxLengthKnown = out.length; + } + + if (amount < aux.length) { + // move the excess data to the beginning of the file + long readOffset = amount; + long writeOffset = 0; + + aux.length -= amount; + length = aux.length; + while (length > 0) { + int read = (int) Math.min(length, Integer.MAX_VALUE); + read = aux.target.read(buffer, 0, Math.min(read, buffer.length)); + + aux.target.seek(writeOffset); + aux.writeProof(buffer, read); + + writeOffset += read; + readOffset += read; + length -= read; + + aux.target.seek(readOffset); + } + + aux.target.setLength(aux.length); + return; + } + + if (aux.length > THRESHOLD_AUX_LENGTH) { + aux.target.setLength(THRESHOLD_AUX_LENGTH);// or setLength(0); + } + + aux.reset(); + } + + /** + * 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 { + flushAuxiliar(aux.length); + + out.flush(); + + // change file length (if required) + long length = Math.max(maxLengthKnown, out.length); + if (length != out.target.length()) { + out.target.setLength(length); + } + + close(); + + return length; + } + + /** + * Close the file without flushing any buffer + */ + @Override + public void close() { + if (out != null) { + out.close(); + out = null; + } + if (aux != null) { + aux.close(); + aux = null; + } + } + + @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 available; + long offsetOut = out.getOffset(); + long offsetAux = aux.getOffset(); + long end = callback.check(); + + if (end == -1) { + available = Integer.MAX_VALUE; + } else if (end < offsetOut) { + throw new IOException("The reported offset is invalid: " + end + "<" + offsetOut); + } else { + available = end - offsetOut; + } + + boolean usingAux = aux.length > 0 && offsetOut >= out.length; + boolean underflow = offsetAux < aux.length || offsetOut < out.length; + + if (usingAux) { + // before continue calculate the final length of aux + long length = offsetAux + len; + if (underflow) { + if (aux.length > length) { + length = aux.length;// the length is not changed + } + } else { + length = aux.length + len; + } + + aux.write(b, off, len); + + if (length >= THRESHOLD_AUX_LENGTH && length <= available) { + flushAuxiliar(available); + } + } else { + if (underflow) { + available = out.length - offsetOut; + } + + int length = Math.min(len, (int) available); + out.write(b, off, length); + + len -= length; + off += length; + + if (len > 0) { + aux.write(b, off, len); + } + } + + if (onProgress != null) { + long absoluteOffset = out.getOffset() + aux.getOffset(); + if (absoluteOffset > reportPosition) { + reportPosition = absoluteOffset + NOTIFY_BYTES_INTERVAL; + onProgress.report(absoluteOffset); + } + } + } + + @Override + public void flush() throws IOException { + aux.flush(); + out.flush(); + + long total = out.length + aux.length; + if (total > maxLengthKnown) { + maxLengthKnown = total;// save the current file length in case the method {@code rewind()} is called + } + } + + @Override + public long skip(long amount) throws IOException { + seek(out.getOffset() + aux.getOffset() + amount); + return amount; + } + + @Override + public void rewind() throws IOException { + if (onProgress != null) { + onProgress.report(-out.length - aux.length);// rollback the whole progress + } + + seek(0); + + reportPosition = NOTIFY_BYTES_INTERVAL; + } + + @Override + public void seek(long offset) throws IOException { + long total = out.length + aux.length; + + if (offset == total) { + // do not ignore the seek offset if a underflow exists + long relativeOffset = out.getOffset() + aux.getOffset(); + if (relativeOffset == total) { + return; + } + } + + // flush everything, avoid any underflow + flush(); + + if (offset < 0 || offset > total) { + throw new IOException("desired offset is outside of range=0-" + total + " offset=" + offset); + } + + if (offset > out.length) { + out.seek(out.length); + aux.seek(offset - out.length); + } else { + out.seek(offset); + aux.seek(0); + } + } + + @Override + public boolean isClosed() { + return out == null; + } + + @Override + public boolean canRewind() { + return true; + } + + @Override + public boolean canWrite() { + return true; + } + + @Override + public boolean canSeek() { + 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 long 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 { + + /** + * Report the size of the new file + * + * @param progress the new size + */ + void report(long progress); + } + + public interface WriteErrorHandle { + + /** + * Attempts to handle a I/O exception + * + * @param err the cause + * @return {@code true} to retry and continue, otherwise, {@code false} + * and throw the exception + */ + boolean handle(Exception err); + } + + class BufferedFile { + + protected final SharpStream target; + + private long offset; + protected long length; + + private byte[] queue = new byte[QUEUE_BUFFER_SIZE]; + private int queueSize; + + BufferedFile(File file) throws FileNotFoundException { + this.target = new FileStream(file); + } + + BufferedFile(SharpStream target) { + this.target = target; + } + + protected long getOffset() { + return offset + queueSize;// absolute offset in the file + } + + protected void close() { + queue = null; + target.close(); + } + + protected void write(byte b[], int off, int len) throws IOException { + while (len > 0) { + // if the queue is full, the method available() will flush the queue + int read = Math.min(available(), len); + + // enqueue incoming buffer + System.arraycopy(b, off, queue, queueSize, read); + queueSize += read; + + len -= read; + off += read; + } + + long total = offset + queueSize; + if (total > length) { + length = total;// save length + } + } + + void flush() throws IOException { + writeProof(queue, queueSize); + offset += queueSize; + queueSize = 0; + } + + protected void rewind() throws IOException { + offset = 0; + target.seek(0); + } + + protected int available() throws IOException { + if (queueSize >= queue.length) { + flush(); + return queue.length; + } + + return queue.length - queueSize; + } + + void reset() throws IOException { + offset = 0; + length = 0; + target.seek(0); + } + + protected void seek(long absoluteOffset) throws IOException { + if (absoluteOffset == offset) { + return;// nothing to do + } + offset = absoluteOffset; + target.seek(absoluteOffset); + } + + void writeProof(byte[] buffer, int length) throws IOException { + if (onWriteError == null) { + target.write(buffer, 0, length); + return; + } + + while (true) { + try { + target.write(buffer, 0, length); + return; + } catch (Exception e) { + if (!onWriteError.handle(e)) { + throw e;// give up + } + } + } + } + + @NonNull + @Override + public String toString() { + String absLength; + + try { + absLength = Long.toString(target.length()); + } catch (IOException e) { + absLength = "[" + e.getLocalizedMessage() + "]"; + } + + return String.format( + "offset=%s length=%s queue=%s absLength=%s", + offset, length, queueSize, absLength + ); + } + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/io/FileStream.java b/app/src/main/java/us/shandian/giga/io/FileStream.java similarity index 57% rename from app/src/main/java/us/shandian/giga/postprocessing/io/FileStream.java rename to app/src/main/java/us/shandian/giga/io/FileStream.java index c1b675eef..5b2033324 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/io/FileStream.java +++ b/app/src/main/java/us/shandian/giga/io/FileStream.java @@ -1,126 +1,131 @@ -package us.shandian.giga.postprocessing.io; - -import org.schabi.newpipe.streams.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); - } -} +package us.shandian.giga.io; + +import android.support.annotation.NonNull; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.RandomAccessFile; + +/** + * @author kapodamy + */ +public class FileStream extends SharpStream { + + public RandomAccessFile source; + + public FileStream(@NonNull File target) throws FileNotFoundException { + this.source = new RandomAccessFile(target, "rw"); + } + + public FileStream(@NonNull String path) throws FileNotFoundException { + this.source = new RandomAccessFile(path, "rw"); + } + + @Override + public int read() throws IOException { + return source.read(); + } + + @Override + public int read(byte b[]) throws IOException { + return source.read(b); + } + + @Override + public int read(byte b[], int off, int len) throws IOException { + return source.read(b, off, len); + } + + @Override + public long skip(long pos) throws IOException { + return source.skipBytes((int) pos); + } + + @Override + public long available() { + try { + return source.length() - source.getFilePointer(); + } catch (IOException e) { + return 0; + } + } + + @Override + public void close() { + if (source == null) return; + try { + source.close(); + } catch (IOException err) { + // nothing to do + } + source = null; + } + + @Override + public boolean isClosed() { + return source == null; + } + + @Override + public void rewind() throws IOException { + source.seek(0); + } + + @Override + public boolean canRewind() { + return true; + } + + @Override + public boolean canRead() { + return true; + } + + @Override + public boolean canWrite() { + return true; + } + + @Override + public boolean canSeek() { + return true; + } + + @Override + public boolean canSetLength() { + return true; + } + + @Override + public void write(byte value) throws IOException { + source.write(value); + } + + @Override + public void write(byte[] buffer) throws IOException { + source.write(buffer); + } + + @Override + public void write(byte[] buffer, int offset, int count) throws IOException { + source.write(buffer, offset, count); + } + + @Override + public void setLength(long length) throws IOException { + source.setLength(length); + } + + @Override + public void seek(long offset) throws IOException { + source.seek(offset); + } + + @Override + public long length() throws IOException { + return source.length(); + } +} diff --git a/app/src/main/java/us/shandian/giga/io/FileStreamSAF.java b/app/src/main/java/us/shandian/giga/io/FileStreamSAF.java new file mode 100644 index 000000000..ec6629268 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/FileStreamSAF.java @@ -0,0 +1,145 @@ +package us.shandian.giga.io; + +import android.content.ContentResolver; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.support.annotation.NonNull; +import android.util.Log; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.channels.FileChannel; + +public class FileStreamSAF extends SharpStream { + + private final FileInputStream in; + private final FileOutputStream out; + private final FileChannel channel; + private final ParcelFileDescriptor file; + + private boolean disposed; + + public FileStreamSAF(@NonNull ContentResolver contentResolver, Uri fileUri) throws IOException { + // Notes: + // the file must exists first + // ¡read-write mode must allow seek! + // It is not guaranteed to work with files in the cloud (virtual files), tested in local storage devices + + file = contentResolver.openFileDescriptor(fileUri, "rw"); + + if (file == null) { + throw new IOException("Cannot get the ParcelFileDescriptor for " + fileUri.toString()); + } + + in = new FileInputStream(file.getFileDescriptor()); + out = new FileOutputStream(file.getFileDescriptor()); + channel = out.getChannel();// or use in.getChannel() + } + + @Override + public int read() throws IOException { + return in.read(); + } + + @Override + public int read(byte[] buffer) throws IOException { + return in.read(buffer); + } + + @Override + public int read(byte[] buffer, int offset, int count) throws IOException { + return in.read(buffer, offset, count); + } + + @Override + public long skip(long amount) throws IOException { + return in.skip(amount);// ¿or use channel.position(channel.position() + amount)? + } + + @Override + public long available() { + try { + return in.available(); + } catch (IOException e) { + return 0;// ¡but not -1! + } + } + + @Override + public void rewind() throws IOException { + seek(0); + } + + @Override + public void close() { + try { + disposed = true; + + file.close(); + in.close(); + out.close(); + channel.close(); + } catch (IOException e) { + Log.e("FileStreamSAF", "close() error", e); + } + } + + @Override + public boolean isClosed() { + return disposed; + } + + @Override + public boolean canRewind() { + return true; + } + + @Override + public boolean canRead() { + return true; + } + + @Override + public boolean canWrite() { + return true; + } + + public boolean canSetLength() { + return true; + } + + public boolean canSeek() { + return true; + } + + @Override + public void write(byte value) throws IOException { + out.write(value); + } + + @Override + public void write(byte[] buffer) throws IOException { + out.write(buffer); + } + + @Override + public void write(byte[] buffer, int offset, int count) throws IOException { + out.write(buffer, offset, count); + } + + public void setLength(long length) throws IOException { + channel.truncate(length); + } + + public void seek(long offset) throws IOException { + channel.position(offset); + } + + @Override + public long length() throws IOException { + return channel.size(); + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/io/SharpInputStream.java b/app/src/main/java/us/shandian/giga/io/SharpInputStream.java similarity index 88% rename from app/src/main/java/us/shandian/giga/postprocessing/io/SharpInputStream.java rename to app/src/main/java/us/shandian/giga/io/SharpInputStream.java index 52e0775da..089101dfe 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/io/SharpInputStream.java +++ b/app/src/main/java/us/shandian/giga/io/SharpInputStream.java @@ -1,59 +1,61 @@ -/* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. - */ -package us.shandian.giga.postprocessing.io; - -import android.support.annotation.NonNull; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.IOException; -import java.io.InputStream; - -/** - * Wrapper for the classic {@link java.io.InputStream} - * @author kapodamy - */ -public class SharpInputStream extends InputStream { - - private final SharpStream base; - - public SharpInputStream(SharpStream base) throws IOException { - if (!base.canRead()) { - throw new IOException("The provided stream is not readable"); - } - this.base = base; - } - - @Override - public int read() throws IOException { - return base.read(); - } - - @Override - public int read(@NonNull byte[] bytes) throws IOException { - return base.read(bytes); - } - - @Override - public int read(@NonNull byte[] bytes, int i, int i1) throws IOException { - return base.read(bytes, i, i1); - } - - @Override - public long skip(long l) throws IOException { - return base.skip(l); - } - - @Override - public int available() { - return base.available(); - } - - @Override - public void close() { - base.dispose(); - } -} +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package us.shandian.giga.io; + +import android.support.annotation.NonNull; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Wrapper for the classic {@link java.io.InputStream} + * + * @author kapodamy + */ +public class SharpInputStream extends InputStream { + + private final SharpStream base; + + public SharpInputStream(SharpStream base) throws IOException { + if (!base.canRead()) { + throw new IOException("The provided stream is not readable"); + } + this.base = base; + } + + @Override + public int read() throws IOException { + return base.read(); + } + + @Override + public int read(@NonNull byte[] bytes) throws IOException { + return base.read(bytes); + } + + @Override + public int read(@NonNull byte[] bytes, int i, int i1) throws IOException { + return base.read(bytes, i, i1); + } + + @Override + public long skip(long l) throws IOException { + return base.skip(l); + } + + @Override + public int available() { + long res = base.available(); + return res > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) res; + } + + @Override + public void close() { + base.close(); + } +} diff --git a/app/src/main/java/us/shandian/giga/io/StoredDirectoryHelper.java b/app/src/main/java/us/shandian/giga/io/StoredDirectoryHelper.java new file mode 100644 index 000000000..aeb810479 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/StoredDirectoryHelper.java @@ -0,0 +1,289 @@ +package us.shandian.giga.io; + +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.provider.DocumentsContract; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.provider.DocumentFile; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; + +import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME; +import static android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID; + + +public class StoredDirectoryHelper { + public final static int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + + private File ioTree; + private DocumentFile docTree; + + private Context context; + + private String tag; + + public StoredDirectoryHelper(@NonNull Context context, @NonNull Uri path, String tag) throws IOException { + this.tag = tag; + + if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(path.getScheme())) { + this.ioTree = new File(URI.create(path.toString())); + return; + } + + this.context = context; + + try { + this.context.getContentResolver().takePersistableUriPermission(path, PERMISSION_FLAGS); + } catch (Exception e) { + throw new IOException(e); + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) + throw new IOException("Storage Access Framework with Directory API is not available"); + + this.docTree = DocumentFile.fromTreeUri(context, path); + + if (this.docTree == null) + throw new IOException("Failed to create the tree from Uri"); + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + public StoredDirectoryHelper(@NonNull URI location, String tag) { + ioTree = new File(location); + this.tag = tag; + } + + public StoredFileHelper createFile(String filename, String mime) { + return createFile(filename, mime, false); + } + + public StoredFileHelper createUniqueFile(String name, String mime) { + ArrayList matches = new ArrayList<>(); + String[] filename = splitFilename(name); + String lcFilename = filename[0].toLowerCase(); + + if (docTree == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + for (File file : ioTree.listFiles()) + addIfStartWith(matches, lcFilename, file.getName()); + } else { + // warning: SAF file listing is very slow + Uri docTreeChildren = DocumentsContract.buildChildDocumentsUriUsingTree( + docTree.getUri(), DocumentsContract.getDocumentId(docTree.getUri()) + ); + + String[] projection = new String[]{COLUMN_DISPLAY_NAME}; + String selection = "(LOWER(" + COLUMN_DISPLAY_NAME + ") LIKE ?%"; + ContentResolver cr = context.getContentResolver(); + + try (Cursor cursor = cr.query(docTreeChildren, projection, selection, new String[]{lcFilename}, null)) { + if (cursor != null) { + while (cursor.moveToNext()) + addIfStartWith(matches, lcFilename, cursor.getString(0)); + } + } + } + + if (matches.size() < 1) { + return createFile(name, mime, true); + } else { + // check if the filename is in use + String lcName = name.toLowerCase(); + for (String testName : matches) { + if (testName.equals(lcName)) { + lcName = null; + break; + } + } + + // check if not in use + if (lcName != null) return createFile(name, mime, true); + } + + Collections.sort(matches, String::compareTo); + + for (int i = 1; i < 1000; i++) { + if (Collections.binarySearch(matches, makeFileName(lcFilename, i, filename[1])) < 0) + return createFile(makeFileName(filename[0], i, filename[1]), mime, true); + } + + return createFile(String.valueOf(System.currentTimeMillis()).concat(filename[1]), mime, false); + } + + private StoredFileHelper createFile(String filename, String mime, boolean safe) { + StoredFileHelper storage; + + try { + if (docTree == null) + storage = new StoredFileHelper(ioTree, filename, mime); + else + storage = new StoredFileHelper(context, docTree, filename, mime, safe); + } catch (IOException e) { + return null; + } + + storage.tag = tag; + + return storage; + } + + public Uri getUri() { + return docTree == null ? Uri.fromFile(ioTree) : docTree.getUri(); + } + + public boolean exists() { + return docTree == null ? ioTree.exists() : docTree.exists(); + } + + /** + * Indicates whatever if is possible access using the {@code java.io} API + * + * @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework + */ + public boolean isDirect() { + return docTree == null; + } + + /** + * Only using Java I/O. Creates the directory named by this abstract pathname, including any + * necessary but nonexistent parent directories. Note that if this + * operation fails it may have succeeded in creating some of the necessary + * parent directories. + * + * @return true if and only if the directory was created, + * along with all necessary parent directories or already exists; false + * otherwise + */ + public boolean mkdirs() { + if (docTree == null) { + return ioTree.exists() || ioTree.mkdirs(); + } + + if (docTree.exists()) return true; + + try { + DocumentFile parent; + String child = docTree.getName(); + + while (true) { + parent = docTree.getParentFile(); + if (parent == null || child == null) break; + if (parent.exists()) return true; + + parent.createDirectory(child); + + child = parent.getName();// for the next iteration + } + } catch (Exception e) { + // no more parent directories or unsupported by the storage provider + } + + return false; + } + + public String getTag() { + return tag; + } + + public Uri findFile(String filename) { + if (docTree == null) { + File res = new File(ioTree, filename); + return res.exists() ? Uri.fromFile(res) : null; + } + + DocumentFile res = findFileSAFHelper(context, docTree, filename); + return res == null ? null : res.getUri(); + } + + public boolean canWrite() { + return docTree == null ? ioTree.canWrite() : docTree.canWrite(); + } + + @NonNull + @Override + public String toString() { + return docTree == null ? Uri.fromFile(ioTree).toString() : docTree.getUri().toString(); + } + + + //////////////////// + // Utils + /////////////////// + + private static void addIfStartWith(ArrayList list, @NonNull String base, String str) { + if (str == null || str.isEmpty()) return; + str = str.toLowerCase(); + if (str.startsWith(base)) list.add(str); + } + + private static String[] splitFilename(@NonNull String filename) { + int dotIndex = filename.lastIndexOf('.'); + + if (dotIndex < 0 || (dotIndex == filename.length() - 1)) + return new String[]{filename, ""}; + + return new String[]{filename.substring(0, dotIndex), filename.substring(dotIndex)}; + } + + private static String makeFileName(String name, int idx, String ext) { + return name.concat(" (").concat(String.valueOf(idx)).concat(")").concat(ext); + } + + /** + * Fast (but not enough) file/directory finder under the storage access framework + * + * @param context The context + * @param tree Directory where search + * @param filename Target filename + * @return A {@link android.support.v4.provider.DocumentFile} contain the reference, otherwise, null + */ + static DocumentFile findFileSAFHelper(@Nullable Context context, DocumentFile tree, String filename) { + if (context == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return tree.findFile(filename);// warning: this is very slow + } + + if (!tree.canRead()) return null;// missing read permission + + final int name = 0; + final int documentId = 1; + + // LOWER() SQL function is not supported + String selection = COLUMN_DISPLAY_NAME + " = ?"; + //String selection = COLUMN_DISPLAY_NAME + " LIKE ?%"; + + Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( + tree.getUri(), DocumentsContract.getDocumentId(tree.getUri()) + ); + String[] projection = {COLUMN_DISPLAY_NAME, COLUMN_DOCUMENT_ID}; + ContentResolver contentResolver = context.getContentResolver(); + + filename = filename.toLowerCase(); + + try (Cursor cursor = contentResolver.query(childrenUri, projection, selection, new String[]{filename}, null)) { + if (cursor == null) return null; + + while (cursor.moveToNext()) { + if (cursor.isNull(name) || !cursor.getString(name).toLowerCase().startsWith(filename)) + continue; + + return DocumentFile.fromSingleUri( + context, DocumentsContract.buildDocumentUriUsingTree( + tree.getUri(), cursor.getString(documentId) + ) + ); + } + } + + return null; + } + +} diff --git a/app/src/main/java/us/shandian/giga/io/StoredFileHelper.java b/app/src/main/java/us/shandian/giga/io/StoredFileHelper.java new file mode 100644 index 000000000..f90a756a9 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/StoredFileHelper.java @@ -0,0 +1,381 @@ +package us.shandian.giga.io; + +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.provider.DocumentsContract; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.provider.DocumentFile; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.net.URI; + +public class StoredFileHelper implements Serializable { + private static final long serialVersionUID = 0L; + public static final String DEFAULT_MIME = "application/octet-stream"; + + private transient DocumentFile docFile; + private transient DocumentFile docTree; + private transient File ioFile; + private transient Context context; + + protected String source; + private String sourceTree; + + protected String tag; + + private String srcName; + private String srcType; + + public StoredFileHelper(@Nullable Uri parent, String filename, String mime, String tag) { + this.source = null;// this instance will be "invalid" see invalidate()/isInvalid() methods + + this.srcName = filename; + this.srcType = mime == null ? DEFAULT_MIME : mime; + if (parent != null) this.sourceTree = parent.toString(); + + this.tag = tag; + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + StoredFileHelper(@Nullable Context context, DocumentFile tree, String filename, String mime, boolean safe) throws IOException { + this.docTree = tree; + this.context = context; + + DocumentFile res; + + if (safe) { + // no conflicts (the filename is not in use) + res = this.docTree.createFile(mime, filename); + if (res == null) throw new IOException("Cannot create the file"); + } else { + res = createSAF(context, mime, filename); + } + + this.docFile = res; + + this.source = docFile.getUri().toString(); + this.sourceTree = docTree.getUri().toString(); + + this.srcName = this.docFile.getName(); + this.srcType = this.docFile.getType(); + } + + StoredFileHelper(File location, String filename, String mime) throws IOException { + this.ioFile = new File(location, filename); + + if (this.ioFile.exists()) { + if (!this.ioFile.isFile() && !this.ioFile.delete()) + throw new IOException("The filename is already in use by non-file entity and cannot overwrite it"); + } else { + if (!this.ioFile.createNewFile()) + throw new IOException("Cannot create the file"); + } + + this.source = Uri.fromFile(this.ioFile).toString(); + this.sourceTree = Uri.fromFile(location).toString(); + + this.srcName = ioFile.getName(); + this.srcType = mime; + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + public StoredFileHelper(Context context, @Nullable Uri parent, @NonNull Uri path, String tag) throws IOException { + this.tag = tag; + this.source = path.toString(); + + if (path.getScheme() == null || path.getScheme().equalsIgnoreCase(ContentResolver.SCHEME_FILE)) { + this.ioFile = new File(URI.create(this.source)); + } else { + DocumentFile file = DocumentFile.fromSingleUri(context, path); + + if (file == null) throw new RuntimeException("SAF not available"); + + this.context = context; + + if (file.getName() == null) { + this.source = null; + return; + } else { + this.docFile = file; + takePermissionSAF(); + } + } + + if (parent != null) { + if (!ContentResolver.SCHEME_FILE.equals(parent.getScheme())) + this.docTree = DocumentFile.fromTreeUri(context, parent); + + this.sourceTree = parent.toString(); + } + + this.srcName = getName(); + this.srcType = getType(); + } + + + public static StoredFileHelper deserialize(@NonNull StoredFileHelper storage, Context context) throws IOException { + Uri treeUri = storage.sourceTree == null ? null : Uri.parse(storage.sourceTree); + + if (storage.isInvalid()) + return new StoredFileHelper(treeUri, storage.srcName, storage.srcType, storage.tag); + + StoredFileHelper instance = new StoredFileHelper(context, treeUri, Uri.parse(storage.source), storage.tag); + + // under SAF, if the target document is deleted, conserve the filename and mime + if (instance.srcName == null) instance.srcName = storage.srcName; + if (instance.srcType == null) instance.srcType = storage.srcType; + + return instance; + } + + public static void requestSafWithFileCreation(@NonNull Fragment who, int requestCode, String filename, String mime) { + // SAF notes: + // ACTION_OPEN_DOCUMENT Do not let you create the file, useful for overwrite files + // ACTION_CREATE_DOCUMENT No overwrite support, useless the file provider resolve the conflict + + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType(mime) + .putExtra(Intent.EXTRA_TITLE, filename) + .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | StoredDirectoryHelper.PERMISSION_FLAGS) + .putExtra("android.content.extra.SHOW_ADVANCED", true);// hack, show all storage disks + + who.startActivityForResult(intent, requestCode); + } + + + public SharpStream getStream() throws IOException { + invalid(); + + if (docFile == null) + return new FileStream(ioFile); + else + return new FileStreamSAF(context.getContentResolver(), docFile.getUri()); + } + + /** + * Indicates whatever if is possible access using the {@code java.io} API + * + * @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework + */ + public boolean isDirect() { + invalid(); + + return docFile == null; + } + + public boolean isInvalid() { + return source == null; + } + + public Uri getUri() { + invalid(); + + return docFile == null ? Uri.fromFile(ioFile) : docFile.getUri(); + } + + public Uri getParentUri() { + invalid(); + + return sourceTree == null ? null : Uri.parse(sourceTree); + } + + public void truncate() throws IOException { + invalid(); + + try (SharpStream fs = getStream()) { + fs.setLength(0); + } + } + + public boolean delete() { + if (source == null) return true; + if (docFile == null) return ioFile.delete(); + + + boolean res = docFile.delete(); + + try { + int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + context.getContentResolver().releasePersistableUriPermission(docFile.getUri(), flags); + } catch (Exception ex) { + // nothing to do + } + + return res; + } + + public long length() { + invalid(); + + return docFile == null ? ioFile.length() : docFile.length(); + } + + public boolean canWrite() { + if (source == null) return false; + return docFile == null ? ioFile.canWrite() : docFile.canWrite(); + } + + public String getName() { + if (source == null) + return srcName; + else if (docFile == null) + return ioFile.getName(); + + String name = docFile.getName(); + return name == null ? srcName : name; + } + + public String getType() { + if (source == null || docFile == null) + return srcType; + + String type = docFile.getType(); + return type == null ? srcType : type; + } + + public String getTag() { + return tag; + } + + public boolean existsAsFile() { + if (source == null) return false; + + boolean exists = docFile == null ? ioFile.exists() : docFile.exists(); + boolean isFile = docFile == null ? ioFile.isFile() : docFile.isFile();// ¿docFile.isVirtual() means is no-physical? + + return exists && isFile; + } + + public boolean create() { + invalid(); + boolean result; + + if (docFile == null) { + try { + result = ioFile.createNewFile(); + } catch (IOException e) { + return false; + } + } else if (docTree == null) { + result = false; + } else { + if (!docTree.canRead() || !docTree.canWrite()) return false; + try { + docFile = createSAF(context, srcType, srcName); + if (docFile == null || docFile.getName() == null) return false; + result = true; + } catch (IOException e) { + return false; + } + } + + if (result) { + source = (docFile == null ? Uri.fromFile(ioFile) : docFile.getUri()).toString(); + srcName = getName(); + srcType = getType(); + } + + return result; + } + + public void invalidate() { + if (source == null) return; + + srcName = getName(); + srcType = getType(); + + source = null; + + docTree = null; + docFile = null; + ioFile = null; + context = null; + } + + public boolean equals(StoredFileHelper storage) { + if (this == storage) return true; + + // note: do not compare tags, files can have the same parent folder + //if (stringMismatch(this.tag, storage.tag)) return false; + + if (stringMismatch(getLowerCase(this.sourceTree), getLowerCase(this.sourceTree))) + return false; + + if (this.isInvalid() || storage.isInvalid()) { + return this.srcName.equalsIgnoreCase(storage.srcName) && this.srcType.equalsIgnoreCase(storage.srcType); + } + + if (this.isDirect() != storage.isDirect()) return false; + + if (this.isDirect()) + return this.ioFile.getPath().equalsIgnoreCase(storage.ioFile.getPath()); + + return DocumentsContract.getDocumentId( + this.docFile.getUri() + ).equalsIgnoreCase(DocumentsContract.getDocumentId( + storage.docFile.getUri() + )); + } + + @NonNull + @Override + public String toString() { + if (source == null) + return "[Invalid state] name=" + srcName + " type=" + srcType + " tag=" + tag; + else + return "sourceFile=" + source + " treeSource=" + (sourceTree == null ? "" : sourceTree) + " tag=" + tag; + } + + + private void invalid() { + if (source == null) + throw new IllegalStateException("In invalid state"); + } + + private void takePermissionSAF() throws IOException { + try { + context.getContentResolver().takePersistableUriPermission(docFile.getUri(), StoredDirectoryHelper.PERMISSION_FLAGS); + } catch (Exception e) { + if (docFile.getName() == null) throw new IOException(e); + } + } + + private DocumentFile createSAF(@Nullable Context context, String mime, String filename) throws IOException { + DocumentFile res = StoredDirectoryHelper.findFileSAFHelper(context, docTree, filename); + + if (res != null && res.exists() && res.isDirectory()) { + if (!res.delete()) + throw new IOException("Directory with the same name found but cannot delete"); + res = null; + } + + if (res == null) { + res = this.docTree.createFile(srcType == null ? DEFAULT_MIME : mime, filename); + if (res == null) throw new IOException("Cannot create the file"); + } + + return res; + } + + private String getLowerCase(String str) { + return str == null ? null : str.toLowerCase(); + } + + private boolean stringMismatch(String str1, String str2) { + if (str1 == null && str2 == null) return false; + if ((str1 == null) != (str2 == null)) return true; + + return !str1.equals(str2); + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java b/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java new file mode 100644 index 000000000..aa5170908 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java @@ -0,0 +1,41 @@ +package us.shandian.giga.postprocessing; + +import org.schabi.newpipe.streams.Mp4DashReader; +import org.schabi.newpipe.streams.Mp4FromDashWriter; +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; + +class M4aNoDash extends Postprocessing { + + M4aNoDash() { + super(false, true, ALGORITHM_M4A_NO_DASH); + } + + @Override + boolean test(SharpStream... sources) throws IOException { + // check if the mp4 file is DASH (youtube) + + Mp4DashReader reader = new Mp4DashReader(sources[0]); + reader.parse(); + + switch (reader.getBrands()[0]) { + case 0x64617368:// DASH + case 0x69736F35:// ISO5 + return true; + default: + return false; + } + } + + @Override + int process(SharpStream out, SharpStream... sources) throws IOException { + Mp4FromDashWriter muxer = new Mp4FromDashWriter(sources[0]); + muxer.setMainBrand(0x4D344120);// binary string "M4A " + muxer.parseSources(); + muxer.selectTracks(0); + muxer.build(out); + + return OK_RESULT; + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp4DashMuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java similarity index 53% rename from app/src/main/java/us/shandian/giga/postprocessing/Mp4DashMuxer.java rename to app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java index 45c06dd4b..74cb43116 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Mp4DashMuxer.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java @@ -1,29 +1,27 @@ -package us.shandian.giga.postprocessing; - -import org.schabi.newpipe.streams.Mp4DashWriter; -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.IOException; - -import us.shandian.giga.get.DownloadMission; - -/** - * @author kapodamy - */ -class Mp4DashMuxer extends Postprocessing { - - Mp4DashMuxer(DownloadMission mission) { - super(mission, 15360 * 1024/* 15 MiB */, 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; - } - -} +package us.shandian.giga.postprocessing; + +import org.schabi.newpipe.streams.Mp4FromDashWriter; +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; + +/** + * @author kapodamy + */ +class Mp4FromDashMuxer extends Postprocessing { + + Mp4FromDashMuxer() { + super(true, true, ALGORITHM_MP4_FROM_DASH_MUXER); + } + + @Override + int process(SharpStream out, SharpStream... sources) throws IOException { + Mp4FromDashWriter muxer = new Mp4FromDashWriter(sources); + muxer.parseSources(); + muxer.selectTracks(0, 0); + muxer.build(out); + + return OK_RESULT; + } + +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp4Muxer.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp4Muxer.java deleted file mode 100644 index bf932d5c1..000000000 --- a/app/src/main/java/us/shandian/giga/postprocessing/Mp4Muxer.java +++ /dev/null @@ -1,136 +0,0 @@ -package us.shandian.giga.postprocessing; - -import android.media.MediaCodec.BufferInfo; -import android.media.MediaExtractor; -import android.media.MediaMuxer; -import android.media.MediaMuxer.OutputFormat; -import android.util.Log; - -import static org.schabi.newpipe.BuildConfig.DEBUG; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.nio.ByteBuffer; - -import us.shandian.giga.get.DownloadMission; - - -class Mp4Muxer extends Postprocessing { - private static final String TAG = "Mp4Muxer"; - private static final int NOTIFY_BYTES_INTERVAL = 128 * 1024;// 128 KiB - - Mp4Muxer(DownloadMission mission) { - super(mission, 0, false); - } - - @Override - int process(SharpStream out, SharpStream... sources) throws IOException { - File dlFile = mission.getDownloadedFile(); - File tmpFile = new File(mission.location, mission.name.concat(".tmp")); - - if (tmpFile.exists()) - if (!tmpFile.delete()) return DownloadMission.ERROR_FILE_CREATION; - - if (!tmpFile.createNewFile()) return DownloadMission.ERROR_FILE_CREATION; - - FileInputStream source = null; - MediaMuxer muxer = null; - - //noinspection TryFinallyCanBeTryWithResources - try { - source = new FileInputStream(dlFile); - MediaExtractor tracks[] = { - getMediaExtractor(source, mission.offsets[0], mission.offsets[1] - mission.offsets[0]), - getMediaExtractor(source, mission.offsets[1], mission.length - mission.offsets[1]) - }; - - muxer = new MediaMuxer(tmpFile.getAbsolutePath(), OutputFormat.MUXER_OUTPUT_MPEG_4); - - int tracksIndex[] = { - muxer.addTrack(tracks[0].getTrackFormat(0)), - muxer.addTrack(tracks[1].getTrackFormat(0)) - }; - - ByteBuffer buffer = ByteBuffer.allocate(512 * 1024);// 512 KiB - BufferInfo info = new BufferInfo(); - - long written = 0; - long nextReport = NOTIFY_BYTES_INTERVAL; - - muxer.start(); - - while (true) { - int done = 0; - - for (int i = 0; i < tracks.length; i++) { - if (tracksIndex[i] < 0) continue; - - info.set(0, - tracks[i].readSampleData(buffer, 0), - tracks[i].getSampleTime(), - tracks[i].getSampleFlags() - ); - - if (info.size >= 0) { - muxer.writeSampleData(tracksIndex[i], buffer, info); - written += info.size; - done++; - } - if (!tracks[i].advance()) { - // EOF reached - tracks[i].release(); - tracksIndex[i] = -1; - } - - if (written > nextReport) { - nextReport = written + NOTIFY_BYTES_INTERVAL; - super.progressReport(written); - } - } - - if (done < 1) break; - } - - // this part should not fail - if (!dlFile.delete()) return DownloadMission.ERROR_FILE_CREATION; - if (!tmpFile.renameTo(dlFile)) return DownloadMission.ERROR_FILE_CREATION; - - return OK_RESULT; - } finally { - try { - if (muxer != null) { - muxer.stop(); - muxer.release(); - } - } catch (Exception err) { - if (DEBUG) - Log.e(TAG, "muxer stop/release failed", err); - } - - if (source != null) { - try { - source.close(); - } catch (IOException e) { - // nothing to do - } - } - - // if the operation fails, delete the temporal file - if (tmpFile.exists()) { - //noinspection ResultOfMethodCallIgnored - tmpFile.delete(); - } - } - } - - private MediaExtractor getMediaExtractor(FileInputStream source, long offset, long length) throws IOException { - MediaExtractor extractor = new MediaExtractor(); - extractor.setDataSource(source.getFD(), offset, length); - extractor.selectTrack(0); - - return extractor; - } -} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java index 635140bd3..15c4f575d 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java @@ -1,164 +1,256 @@ -package us.shandian.giga.postprocessing; - -import android.os.Message; - -import org.schabi.newpipe.streams.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_MP4_MUXER = "mp4"; - public static final String ALGORITHM_WEBM_MUXER = "webm"; - - public static Postprocessing getAlgorithm(String algorithmName, DownloadMission mission) { - if (null == algorithmName) { - throw new NullPointerException("algorithmName"); - } else switch (algorithmName) { - case ALGORITHM_TTML_CONVERTER: - return new TtmlConverter(mission); - case ALGORITHM_MP4_DASH_MUXER: - return new Mp4DashMuxer(mission); - case ALGORITHM_MP4_MUXER: - return new Mp4Muxer(mission); - case ALGORITHM_WEBM_MUXER: - return new WebMMuxer(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; - - /** - * the download to post-process - */ - protected DownloadMission mission; - - Postprocessing(DownloadMission mission, int recommendedReserve, boolean worksOnSameFile) { - this.mission = mission; - this.recommendedReserve = recommendedReserve; - this.worksOnSameFile = worksOnSameFile; - } - - public void run() throws IOException { - File file = mission.getDownloadedFile(); - CircularFile out = null; - int result; - long finalLength = -1; - - mission.done = 0; - mission.length = file.length(); - - if (worksOnSameFile) { - ChunkFileInputStream[] sources = new ChunkFileInputStream[mission.urls.length]; - try { - int i = 0; - for (; i < sources.length - 1; i++) { - sources[i] = new ChunkFileInputStream(file, mission.offsets[i], mission.offsets[i + 1], "rw"); - } - sources[i] = new ChunkFileInputStream(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); - - result = process(out, sources); - - if (result == OK_RESULT) - finalLength = out.finalizeFile(); - } finally { - for (SharpStream source : sources) { - if (source != null && !source.isDisposed()) { - source.dispose(); - } - } - if (out != null) { - out.dispose(); - } - } - } else { - result = process(null); - } - - if (result == OK_RESULT) { - if (finalLength < 0) finalLength = file.length(); - 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 - file.delete(); - } - } - - /** - * 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]; - } - - 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); - } -} +package us.shandian.giga.postprocessing; + +import android.os.Message; +import android.support.annotation.NonNull; +import android.util.Log; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; + +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.io.ChunkFileInputStream; +import us.shandian.giga.io.CircularFileWriter; +import us.shandian.giga.io.CircularFileWriter.OffsetChecker; +import us.shandian.giga.service.DownloadManagerService; + +import static us.shandian.giga.get.DownloadMission.ERROR_NOTHING; +import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_HOLD; +import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_EXCEPTION; + +public abstract class Postprocessing implements Serializable { + + static transient final byte OK_RESULT = ERROR_NOTHING; + + public transient static final String ALGORITHM_TTML_CONVERTER = "ttml"; + public transient static final String ALGORITHM_WEBM_MUXER = "webm"; + public transient static final String ALGORITHM_MP4_FROM_DASH_MUXER = "mp4D-mp4"; + public transient static final String ALGORITHM_M4A_NO_DASH = "mp4D-m4a"; + + public static Postprocessing getAlgorithm(@NonNull String algorithmName, String[] args) { + Postprocessing instance; + + switch (algorithmName) { + case ALGORITHM_TTML_CONVERTER: + instance = new TtmlConverter(); + break; + case ALGORITHM_WEBM_MUXER: + instance = new WebMMuxer(); + break; + case ALGORITHM_MP4_FROM_DASH_MUXER: + instance = new Mp4FromDashMuxer(); + break; + case ALGORITHM_M4A_NO_DASH: + instance = new M4aNoDash(); + break; + /*case "example-algorithm": + instance = new ExampleAlgorithm();*/ + default: + throw new UnsupportedOperationException("Unimplemented post-processing algorithm: " + algorithmName); + } + + instance.args = args; + return instance; + } + + /** + * Get a boolean value that indicate if the given algorithm work on the same + * file + */ + public final boolean worksOnSameFile; + + /** + * Indicates whether the selected algorithm needs space reserved at the beginning of the file + */ + public final boolean reserveSpace; + + /** + * Gets the given algorithm short name + */ + private final String name; + + + private String[] args; + + protected transient DownloadMission mission; + + private File tempFile; + + Postprocessing(boolean reserveSpace, boolean worksOnSameFile, String algorithmName) { + this.reserveSpace = reserveSpace; + this.worksOnSameFile = worksOnSameFile; + this.name = algorithmName;// for debugging only + } + + public void setTemporalDir(@NonNull File directory) { + long rnd = (int) (Math.random() * 100000f); + tempFile = new File(directory, rnd + "_" + System.nanoTime() + ".tmp"); + } + + public void cleanupTemporalDir() { + if (tempFile != null && tempFile.exists()) { + //noinspection ResultOfMethodCallIgnored + tempFile.delete(); + } + } + + + public void run(DownloadMission target) throws IOException { + this.mission = target; + + CircularFileWriter out = null; + int result; + long finalLength = -1; + + mission.done = 0; + mission.length = mission.storage.length(); + + if (worksOnSameFile) { + ChunkFileInputStream[] sources = new ChunkFileInputStream[mission.urls.length]; + try { + int i = 0; + for (; i < sources.length - 1; i++) { + sources[i] = new ChunkFileInputStream(mission.storage.getStream(), mission.offsets[i], mission.offsets[i + 1]); + } + sources[i] = new ChunkFileInputStream(mission.storage.getStream(), mission.offsets[i]); + + if (test(sources)) { + for (SharpStream source : sources) source.rewind(); + + OffsetChecker checker = () -> { + for (ChunkFileInputStream source : sources) { + /* + * WARNING: never use rewind() in any chunk after any writing (especially on first chunks) + * or the CircularFileWriter can lead to unexpected results + */ + if (source.isClosed() || source.available() < 1) { + continue;// the selected source is not used anymore + } + + return source.getFilePointer() - 1; + } + + return -1; + }; + + out = new CircularFileWriter(mission.storage.getStream(), tempFile, checker); + out.onProgress = this::progressReport; + + out.onWriteError = (err) -> { + mission.psState = 3; + mission.notifyError(ERROR_POSTPROCESSING_HOLD, err); + + try { + synchronized (this) { + while (mission.psState == 3) + wait(); + } + } catch (InterruptedException e) { + // nothing to do + Log.e(this.getClass().getSimpleName(), "got InterruptedException"); + } + + return mission.errCode == ERROR_NOTHING; + }; + + result = process(out, sources); + + if (result == OK_RESULT) + finalLength = out.finalizeFile(); + } else { + result = OK_RESULT; + } + } finally { + for (SharpStream source : sources) { + if (source != null && !source.isClosed()) { + source.close(); + } + } + if (out != null) { + out.close(); + } + if (tempFile != null) { + //noinspection ResultOfMethodCallIgnored + tempFile.delete(); + tempFile = null; + } + } + } else { + result = test() ? process(null) : OK_RESULT; + } + + if (result == OK_RESULT) { + if (finalLength != -1) { + mission.done = finalLength; + mission.length = finalLength; + } + } else { + mission.errCode = ERROR_UNKNOWN_EXCEPTION; + mission.errObject = new RuntimeException("post-processing algorithm returned " + result); + } + + if (result != OK_RESULT && worksOnSameFile) mission.storage.delete(); + + this.mission = null; + } + + /** + * Test if the post-processing algorithm can be skipped + * + * @param sources files to be processed + * @return {@code true} if the post-processing is required, otherwise, {@code false} + * @throws IOException if an I/O error occurs. + */ + boolean test(SharpStream... sources) throws IOException { + return true; + } + + /** + * Abstract method to execute the post-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 (args == null || index >= args.length) { + return defaultValue; + } + + return args[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); + } + + @NonNull + @Override + public String toString() { + StringBuilder str = new StringBuilder(); + + str.append("name=").append(name).append('['); + + if (args != null) { + for (String arg : args) { + str.append(", "); + str.append(arg); + } + str.delete(0, 1); + } + + return str.append(']').toString(); + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java b/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java index 390061840..5a5b687f7 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java @@ -1,75 +1,72 @@ -package us.shandian.giga.postprocessing; - -import android.util.Log; - -import org.schabi.newpipe.streams.io.SharpStream; -import org.schabi.newpipe.streams.SubtitleConverter; -import org.xml.sax.SAXException; - -import java.io.IOException; -import java.text.ParseException; - -import javax.xml.parsers.ParserConfigurationException; -import javax.xml.xpath.XPathExpressionException; - -import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.postprocessing.io.SharpInputStream; - -/** - * @author kapodamy - */ -class TtmlConverter extends Postprocessing { - private static final String TAG = "TtmlConverter"; - - TtmlConverter(DownloadMission mission) { - // due how XmlPullParser works, the xml is fully loaded on the ram - super(mission, 0, 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(); - - try { - ttmlDumper.dumpTTML( - sources[0], - out, - getArgumentAt(1, "true").equals("true"), - getArgumentAt(2, "true").equals("true") - ); - } catch (Exception err) { - Log.e(TAG, "subtitle parse failed", err); - - if (err instanceof IOException) { - return 1; - } else if (err instanceof ParseException) { - return 2; - } else if (err instanceof SAXException) { - return 3; - } else if (err instanceof ParserConfigurationException) { - return 4; - } else if (err instanceof XPathExpressionException) { - return 7; - } - - return 8; - } - - return OK_RESULT; - } 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); - } - -} +package us.shandian.giga.postprocessing; + +import android.util.Log; + +import org.schabi.newpipe.streams.SubtitleConverter; +import org.schabi.newpipe.streams.io.SharpStream; +import org.xml.sax.SAXException; + +import java.io.IOException; +import java.text.ParseException; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.xpath.XPathExpressionException; + +/** + * @author kapodamy + */ +class TtmlConverter extends Postprocessing { + private static final String TAG = "TtmlConverter"; + + TtmlConverter() { + // due how XmlPullParser works, the xml is fully loaded on the ram + super(false, true, ALGORITHM_TTML_CONVERTER); + } + + @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(); + + try { + ttmlDumper.dumpTTML( + sources[0], + out, + getArgumentAt(1, "true").equals("true"), + getArgumentAt(2, "true").equals("true") + ); + } catch (Exception err) { + Log.e(TAG, "subtitle parse failed", err); + + if (err instanceof IOException) { + return 1; + } else if (err instanceof ParseException) { + return 2; + } else if (err instanceof SAXException) { + return 3; + } else if (err instanceof ParserConfigurationException) { + return 4; + } else if (err instanceof XPathExpressionException) { + return 7; + } + + return 8; + } + + return OK_RESULT; + } 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 index 2ffb0f08d..ea1676482 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java @@ -1,42 +1,44 @@ -package us.shandian.giga.postprocessing; - -import org.schabi.newpipe.streams.WebMReader.TrackKind; -import org.schabi.newpipe.streams.WebMReader.WebMTrack; -import org.schabi.newpipe.streams.WebMWriter; -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.IOException; - -import us.shandian.giga.get.DownloadMission; - -/** - * @author kapodamy - */ -class WebMMuxer extends Postprocessing { - - WebMMuxer(DownloadMission mission) { - super(mission, 2048 * 1024/* 2 MiB */, true); - } - - @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; - } - -} +package us.shandian.giga.postprocessing; + +import org.schabi.newpipe.streams.WebMReader.TrackKind; +import org.schabi.newpipe.streams.WebMReader.WebMTrack; +import org.schabi.newpipe.streams.WebMWriter; +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; + +/** + * @author kapodamy + */ +class WebMMuxer extends Postprocessing { + + WebMMuxer() { + super(true, true, ALGORITHM_WEBM_MUXER); + } + + @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" + int[] indexes = new int[sources.length]; + + for (int i = 0; i < sources.length; i++) { + WebMTrack[] tracks = muxer.getTracksFromSource(i); + for (int j = 0; j < tracks.length; j++) { + if (tracks[j].kind == TrackKind.Audio) { + indexes[i] = j; + i = sources.length; + break; + } + } + } + + muxer.selectTracks(indexes); + muxer.build(out); + + return OK_RESULT; + } + +} 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 deleted file mode 100644 index d2fc82d33..000000000 --- a/app/src/main/java/us/shandian/giga/postprocessing/io/CircularFile.java +++ /dev/null @@ -1,375 +0,0 @@ -package us.shandian.giga.postprocessing.io; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.File; -import java.io.IOException; -import java.io.RandomAccessFile; -import java.util.ArrayList; - -public class CircularFile extends SharpStream { - - private final static int AUX_BUFFER_SIZE = 1024 * 1024;// 1 MiB - private final static int AUX_BUFFER_SIZE2 = 512 * 1024;// 512 KiB - private final static int NOTIFY_BYTES_INTERVAL = 64 * 1024;// 64 KiB - private final static int QUEUE_BUFFER_SIZE = 8 * 1024;// 8 KiB - private final static boolean IMMEDIATE_AUX_BUFFER_FLUSH = false; - - 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<>(15); - 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(); - long available; - - if (end == -1) { - available = Long.MAX_VALUE; - } else { - if (end < startOffset) { - throw new IOException("The reported offset is invalid. reported offset is " + String.valueOf(end)); - } - available = end - position; - } - - // Check if possible flush one or more auxiliary buffer - if (auxiliaryBuffers.size() > 0) { - ManagedBuffer aux = auxiliaryBuffers.get(0); - - // check if there is enough space to flush it completely - while (available >= (aux.size + queue.size)) { - available -= aux.size; - writeQueue(aux.buffer, 0, aux.size); - aux.dereference(); - auxiliaryBuffers.remove(0); - - if (auxiliaryBuffers.size() < 1) { - aux = null; - break; - } - aux = auxiliaryBuffers.get(0); - } - - if (IMMEDIATE_AUX_BUFFER_FLUSH) { - // try partial flush to avoid allocate another auxiliary buffer - if (aux != null && aux.available() < len && available > queue.size) { - int size = Math.min(aux.size, (int) available - queue.size); - - writeQueue(aux.buffer, 0, size); - aux.dereference(size); - - available -= size; - } - } - } - - if (auxiliaryBuffers.size() < 1 && 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, AUX_BUFFER_SIZE2)); - auxiliaryBuffers.add(aux); - i++; - } else { - available = Math.min(len, available); - } - - aux.write(b, off, (int) available); - - len -= available; - if (len > 0) 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 + NOTIFY_BYTES_INTERVAL; - 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; - } - - if (queue.size >= queue.buffer.length) { - flushQueue(); - } - } - - 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; - } - - void dereference(int amount) { - if (amount > size) { - throw new IndexOutOfBoundsException("Invalid dereference amount (" + amount + ">=" + size + ")"); - } - size -= amount; - System.arraycopy(buffer, amount, buffer, 0, size); - } - - 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/service/DownloadManager.java b/app/src/main/java/us/shandian/giga/service/DownloadManager.java index 883c26850..c2bba7396 100644 --- a/app/src/main/java/us/shandian/giga/service/DownloadManager.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManager.java @@ -13,14 +13,15 @@ import org.schabi.newpipe.R; import java.io.File; import java.io.IOException; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.Iterator; import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.FinishedMission; import us.shandian.giga.get.Mission; -import us.shandian.giga.get.sqlite.DownloadDataSource; +import us.shandian.giga.get.sqlite.FinishedMissionStore; +import us.shandian.giga.io.StoredDirectoryHelper; +import us.shandian.giga.io.StoredFileHelper; import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; @@ -28,13 +29,16 @@ import static org.schabi.newpipe.BuildConfig.DEBUG; public class DownloadManager { private static final String TAG = DownloadManager.class.getSimpleName(); - enum NetworkState {Unavailable, WifiOperating, MobileOperating, OtherOperating} + enum NetworkState {Unavailable, Operating, MeteredOperating} 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; + static final String TAG_AUDIO = "audio"; + static final String TAG_VIDEO = "video"; + + private final FinishedMissionStore mFinishedMissionStore; private final ArrayList mMissionsPending = new ArrayList<>(); private final ArrayList mMissionsFinished; @@ -45,7 +49,12 @@ public class DownloadManager { private NetworkState mLastNetworkStatus = NetworkState.Unavailable; int mPrefMaxRetry; - boolean mPrefCrossNetwork; + boolean mPrefMeteredDownloads; + boolean mPrefQueueLimit; + private boolean mSelfMissionsControl; + + StoredDirectoryHelper mMainStorageAudio; + StoredDirectoryHelper mMainStorageVideo; /** * Create a new instance @@ -53,13 +62,15 @@ public class DownloadManager { * @param context Context for the data source for finished downloads * @param handler Thread required for Messaging */ - DownloadManager(@NonNull Context context, Handler handler) { + DownloadManager(@NonNull Context context, Handler handler, StoredDirectoryHelper storageVideo, StoredDirectoryHelper storageAudio) { if (DEBUG) { Log.d(TAG, "new DownloadManager instance. 0x" + Integer.toHexString(this.hashCode())); } - mDownloadDataSource = new DownloadDataSource(context); + mFinishedMissionStore = new FinishedMissionStore(context); mHandler = handler; + mMainStorageAudio = storageAudio; + mMainStorageVideo = storageVideo; mMissionsFinished = loadFinishedMissions(); mPendingMissionsDir = getPendingDir(context); @@ -67,7 +78,7 @@ public class DownloadManager { throw new RuntimeException("failed to create pending_downloads in data directory"); } - loadPendingMissions(); + loadPendingMissions(context); } private static File getPendingDir(@NonNull Context context) { @@ -88,29 +99,24 @@ public class DownloadManager { * Loads finished missions from the data source */ private ArrayList loadFinishedMissions() { - ArrayList finishedMissions = mDownloadDataSource.loadFinishedMissions(); + ArrayList finishedMissions = mFinishedMissionStore.loadFinishedMissions(); - // missions always is stored by creation order, simply reverse the list - ArrayList result = new ArrayList<>(finishedMissions.size()); + // check if the files exists, otherwise, forget the download for (int i = finishedMissions.size() - 1; i >= 0; i--) { FinishedMission mission = finishedMissions.get(i); - File file = mission.getDownloadedFile(); - if (!file.isFile()) { - if (DEBUG) { - Log.d(TAG, "downloaded file removed: " + file.getAbsolutePath()); - } - mDownloadDataSource.deleteMission(mission); - continue; + if (!mission.storage.existsAsFile()) { + if (DEBUG) Log.d(TAG, "downloaded file removed: " + mission.storage.getName()); + + mFinishedMissionStore.deleteMission(mission); + finishedMissions.remove(i); } - - result.add(mission); } - return result; + return finishedMissions; } - private void loadPendingMissions() { + private void loadPendingMissions(Context ctx) { File[] subs = mPendingMissionsDir.listFiles(); if (subs == null) { @@ -125,109 +131,76 @@ public class DownloadManager { } for (File sub : subs) { - if (sub.isFile()) { - DownloadMission mis = Utility.readFromFile(sub); + if (!sub.isFile()) continue; - if (mis == null) { - //noinspection ResultOfMethodCallIgnored - sub.delete(); - } else { - if (mis.isFinished()) { - //noinspection ResultOfMethodCallIgnored - sub.delete(); - continue; - } - - File dl = mis.getDownloadedFile(); - boolean exists = dl.exists(); - - if (mis.isPsRunning()) { - if (mis.postprocessingThis) { - // Incomplete post-processing results in a corrupted download file - // because the selected algorithm works on the same file to save space. - if (exists && dl.isFile() && !dl.delete()) - Log.w(TAG, "Unable to delete incomplete download file: " + sub.getPath()); - - exists = true; - } - - mis.postprocessingState = 0; - mis.errCode = DownloadMission.ERROR_POSTPROCESSING; - mis.errObject = new RuntimeException("stopped unexpectedly"); - } else if (exists && !dl.isFile()) { - // probably a folder, this should never happens - if (!sub.delete()) { - Log.w(TAG, "Unable to delete serialized file: " + sub.getPath()); - } - 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; - m.nearLength = mis.nearLength; - mis = m; - } - - mis.running = false; - mis.recovered = exists; - mis.metadata = sub; - mis.mHandler = mHandler; - - mMissionsPending.add(mis); - } + DownloadMission mis = Utility.readFromFile(sub); + if (mis == null || mis.isFinished()) { + //noinspection ResultOfMethodCallIgnored + sub.delete(); + continue; } + + boolean exists; + try { + mis.storage = StoredFileHelper.deserialize(mis.storage, ctx); + exists = !mis.storage.isInvalid() && mis.storage.existsAsFile(); + } catch (Exception ex) { + Log.e(TAG, "Failed to load the file source of " + mis.storage.toString(), ex); + mis.storage.invalidate(); + exists = false; + } + + if (mis.isPsRunning()) { + if (mis.psAlgorithm.worksOnSameFile) { + // Incomplete post-processing results in a corrupted download file + // because the selected algorithm works on the same file to save space. + // the file will be deleted if the storage API + // is Java IO (avoid showing the "Save as..." dialog) + if (exists && mis.storage.isDirect() && !mis.storage.delete()) + Log.w(TAG, "Unable to delete incomplete download file: " + sub.getPath()); + + exists = true; + } + + mis.psState = 0; + mis.errCode = DownloadMission.ERROR_POSTPROCESSING_STOPPED; + } else if (!exists) { + tryRecover(mis); + + // the progress is lost, reset mission state + if (mis.isInitialized()) + mis.resetState(true, true, DownloadMission.ERROR_PROGRESS_LOST); + } + + if (mis.psAlgorithm != null) { + mis.psAlgorithm.cleanupTemporalDir(); + mis.psAlgorithm.setTemporalDir(pickAvailableTemporalDir(ctx)); + } + + mis.recovered = exists; + mis.metadata = sub; + mis.maxRetry = mPrefMaxRetry; + mis.mHandler = mHandler; + + mMissionsPending.add(mis); } - if (mMissionsPending.size() > 1) { + 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 psName the name of the required post-processing algorithm, or {@code null} to ignore. - * @param source source url of the resource - * @param psArgs the arguments for the post-processing algorithm. + * @param mission the new download mission to add and run (if possible) */ - void startMission(String[] urls, String location, String name, char kind, int threads, - String source, String psName, String[] psArgs, long nearLength) { + void startMission(DownloadMission mission) { synchronized (this) { - // check for existing pending download - DownloadMission pendingMission = getPendingMission(location, name); - if (pendingMission != null) { - // 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, psName, psArgs); mission.timestamp = System.currentTimeMillis(); - mission.threadCount = threads; - mission.source = source; mission.mHandler = mHandler; mission.maxRetry = mPrefMaxRetry; - mission.nearLength = nearLength; + // create metadata file while (true) { mission.metadata = new File(mPendingMissionsDir, String.valueOf(mission.timestamp)); if (!mission.metadata.isFile() && !mission.metadata.exists()) { @@ -242,14 +215,25 @@ public class DownloadManager { mission.timestamp = System.currentTimeMillis(); } + mSelfMissionsControl = true; mMissionsPending.add(mission); - // Before starting, save the state in case the internet connection is not available + // Before continue, save the metadata in case the internet connection is not available Utility.writeToFile(mission.metadata, mission); - if (canDownloadInCurrentNetwork() && (getRunningMissionsCount() < 1)) { + if (mission.storage == null) { + // noting to do here + mission.errCode = DownloadMission.ERROR_FILE_CREATION; + if (mission.errObject != null) + mission.errObject = new IOException("DownloadMission.storage == NULL"); + return; + } + + boolean start = !mPrefQueueLimit || getRunningMissionsCount() < 1; + + if (canDownloadInCurrentNetwork() && start) { + mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PROGRESS); mission.start(); - mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_RUNNING); } } } @@ -257,13 +241,14 @@ public class DownloadManager { public void resumeMission(DownloadMission mission) { if (!mission.running) { + mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PROGRESS); mission.start(); - mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_RUNNING); } } public void pauseMission(DownloadMission mission) { if (mission.running) { + mission.setEnqueued(false); mission.pause(); mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PAUSED); } @@ -275,7 +260,7 @@ public class DownloadManager { mMissionsPending.remove(mission); } else if (mission instanceof FinishedMission) { mMissionsFinished.remove(mission); - mDownloadDataSource.deleteMission(mission); + mFinishedMissionStore.deleteMission(mission); } mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_DELETED); @@ -283,18 +268,54 @@ public class DownloadManager { } } + public void forgetMission(StoredFileHelper storage) { + synchronized (this) { + Mission mission = getAnyMission(storage); + if (mission == null) return; + + if (mission instanceof DownloadMission) { + mMissionsPending.remove(mission); + } else if (mission instanceof FinishedMission) { + mMissionsFinished.remove(mission); + mFinishedMissionStore.deleteMission(mission); + } + + mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_DELETED); + mission.storage = null; + mission.delete(); + } + } + + public void tryRecover(DownloadMission mission) { + StoredDirectoryHelper mainStorage = getMainStorage(mission.storage.getTag()); + + if (!mission.storage.isInvalid() && mission.storage.create()) return; + + // using javaIO cannot recreate the file + // using SAF in older devices (no tree available) + // + // force the user to pick again the save path + mission.storage.invalidate(); + + if (mainStorage == null) return; + + // if the user has changed the save path before this download, the original save path will be lost + StoredFileHelper newStorage = mainStorage.createFile(mission.storage.getName(), mission.storage.getType()); + + if (newStorage != null) mission.storage = newStorage; + } + /** - * Get a pending mission by its location and name + * Get a pending mission by its path * - * @param location the location - * @param name the name + * @param storage where the file possible is stored * @return the mission or null if no such mission exists */ @Nullable - private DownloadMission getPendingMission(String location, String name) { + private DownloadMission getPendingMission(StoredFileHelper storage) { for (DownloadMission mission : mMissionsPending) { - if (location.equalsIgnoreCase(mission.location) && name.equalsIgnoreCase(mission.name)) { + if (mission.storage.equals(storage)) { return mission; } } @@ -302,16 +323,14 @@ public class DownloadManager { } /** - * Get a finished mission by its location and name + * Get a finished mission by its path * - * @param location the location - * @param name the name + * @param storage where the file possible is stored * @return the mission index or -1 if no such mission exists */ - private int getFinishedMissionIndex(String location, String name) { + private int getFinishedMissionIndex(StoredFileHelper storage) { for (int i = 0; i < mMissionsFinished.size(); i++) { - FinishedMission mission = mMissionsFinished.get(i); - if (location.equalsIgnoreCase(mission.location) && name.equalsIgnoreCase(mission.name)) { + if (mMissionsFinished.get(i).storage.equals(storage)) { return i; } } @@ -319,12 +338,12 @@ public class DownloadManager { return -1; } - public Mission getAnyMission(String location, String name) { + private Mission getAnyMission(StoredFileHelper storage) { synchronized (this) { - Mission mission = getPendingMission(location, name); + Mission mission = getPendingMission(storage); if (mission != null) return mission; - int idx = getFinishedMissionIndex(location, name); + int idx = getFinishedMissionIndex(storage); if (idx >= 0) return mMissionsFinished.get(idx); } @@ -335,7 +354,7 @@ public class DownloadManager { int count = 0; synchronized (this) { for (DownloadMission mission : mMissionsPending) { - if (mission.running && !mission.isFinished() && !mission.isPsFailed()) + if (mission.running && !mission.isPsFailed() && !mission.isFinished()) count++; } } @@ -343,62 +362,36 @@ public class DownloadManager { return count; } - void pauseAllMissions() { + public void pauseAllMissions(boolean force) { + boolean flag = false; + synchronized (this) { - for (DownloadMission mission : mMissionsPending) mission.pause(); - } - } + for (DownloadMission mission : mMissionsPending) { + if (!mission.running || mission.isPsRunning() || mission.isFinished()) continue; + if (force) mission.threads = null;// avoid waiting for threads - /** - * 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"); + mission.pause(); + flag = true; } - } while (Arrays.binarySearch(existingName, newName) >= 0); - return newName; + } + + if (flag) mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PAUSED); + } + + public void startAllMissions() { + boolean flag = false; + + synchronized (this) { + for (DownloadMission mission : mMissionsPending) { + if (mission.running || mission.isCorrupt()) continue; + + flag = true; + mission.start(); + } + } + + if (flag) mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PROGRESS); } /** @@ -410,36 +403,41 @@ public class DownloadManager { synchronized (this) { mMissionsPending.remove(mission); mMissionsFinished.add(0, new FinishedMission(mission)); - mDownloadDataSource.addMission(mission); + mFinishedMissionStore.addFinishedMission(mission); } } /** - * runs another mission in queue if possible + * runs one or multiple missions in from queue if possible * - * @return true if exits pending missions running or a mission was started, otherwise, false + * @return true if one or multiple missions are running, otherwise, false */ - boolean runAnotherMission() { + boolean runMissions() { synchronized (this) { if (mMissionsPending.size() < 1) return false; - - int i = getRunningMissionsCount(); - if (i > 0) return true; - if (!canDownloadInCurrentNetwork()) return false; - for (DownloadMission mission : mMissionsPending) { - if (!mission.running && mission.errCode == DownloadMission.ERROR_NOTHING && mission.enqueued) { - resumeMission(mission); - return true; - } + if (mPrefQueueLimit) { + for (DownloadMission mission : mMissionsPending) + if (!mission.isFinished() && mission.running) return true; } - return false; + boolean flag = false; + for (DownloadMission mission : mMissionsPending) { + if (mission.running || !mission.enqueued || mission.isFinished() || mission.hasInvalidStorage()) + continue; + + resumeMission(mission); + if (mPrefQueueLimit) return true; + flag = true; + } + + return flag; } } public MissionIterator getIterator() { + mSelfMissionsControl = true; return new MissionIterator(); } @@ -449,7 +447,7 @@ public class DownloadManager { public void forgetFinishedDownloads() { synchronized (this) { for (FinishedMission mission : mMissionsFinished) { - mDownloadDataSource.deleteMission(mission); + mFinishedMissionStore.deleteMission(mission); } mMissionsFinished.clear(); } @@ -457,31 +455,43 @@ public class DownloadManager { private boolean canDownloadInCurrentNetwork() { if (mLastNetworkStatus == NetworkState.Unavailable) return false; - return !(mPrefCrossNetwork && mLastNetworkStatus == NetworkState.MobileOperating); + return !(mPrefMeteredDownloads && mLastNetworkStatus == NetworkState.MeteredOperating); } - void handleConnectivityChange(NetworkState currentStatus) { + void handleConnectivityState(NetworkState currentStatus, boolean updateOnly) { if (currentStatus == mLastNetworkStatus) return; mLastNetworkStatus = currentStatus; + if (currentStatus == NetworkState.Unavailable) return; - if (currentStatus == NetworkState.Unavailable) { - return; - } else if (currentStatus != NetworkState.MobileOperating || !mPrefCrossNetwork) { - return; + if (!mSelfMissionsControl || updateOnly) { + return;// don't touch anything without the user interaction } - boolean flag = false; + boolean isMetered = mPrefMeteredDownloads && mLastNetworkStatus == NetworkState.MeteredOperating; + + int running = 0; + int paused = 0; synchronized (this) { for (DownloadMission mission : mMissionsPending) { - if (mission.running && !mission.isFinished() && !mission.isPsRunning()) { - flag = true; + if (mission.isCorrupt() || mission.isPsRunning()) continue; + + if (mission.running && isMetered) { + paused++; mission.pause(); + } else if (!mission.running && !isMetered && mission.enqueued) { + running++; + mission.start(); + if (mPrefQueueLimit) break; } } } - if (flag) mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PAUSED); + if (running > 0) { + mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PROGRESS); + return; + } + if (paused > 0) mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PAUSED); } void updateMaximumAttempts() { @@ -506,21 +516,46 @@ public class DownloadManager { ), Toast.LENGTH_LONG).show(); } - void checkForRunningMission(String location, String name, DownloadManagerService.DMChecker check) { - boolean listed; - boolean finished = false; - + public MissionState checkForExistingMission(StoredFileHelper storage) { synchronized (this) { - DownloadMission mission = getPendingMission(location, name); - if (mission != null) { - listed = true; + DownloadMission pending = getPendingMission(storage); + + if (pending == null) { + if (getFinishedMissionIndex(storage) >= 0) return MissionState.Finished; } else { - listed = getFinishedMissionIndex(location, name) >= 0; - finished = listed; + if (pending.isFinished()) { + return MissionState.Finished;// this never should happen (race-condition) + } else { + return pending.running ? MissionState.PendingRunning : MissionState.Pending; + } } } - check.callback(listed, finished); + return MissionState.None; + } + + private static boolean isDirectoryAvailable(File directory) { + return directory != null && directory.canWrite() && directory.exists(); + } + + static File pickAvailableTemporalDir(@NonNull Context ctx) { + if (isDirectoryAvailable(ctx.getExternalFilesDir(null))) + return ctx.getExternalFilesDir(null); + else if (isDirectoryAvailable(ctx.getFilesDir())) + return ctx.getFilesDir(); + + // this never should happen + return ctx.getDir("tmp", Context.MODE_PRIVATE); + } + + @Nullable + private StoredDirectoryHelper getMainStorage(@NonNull String tag) { + if (tag.equals(TAG_AUDIO)) return mMainStorageAudio; + if (tag.equals(TAG_VIDEO)) return mMainStorageVideo; + + Log.w(TAG, "Unknown download category, not [audio video]: " + tag); + + return null;// this never should happen } public class MissionIterator extends DiffUtil.Callback { @@ -592,39 +627,6 @@ public class DownloadManager { 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(); @@ -647,6 +649,32 @@ public class DownloadManager { return hasFinished; } + /** + * Check if exists missions running and paused. Corrupted and hidden missions are not counted + * + * @return two-dimensional array contains the current missions state. + * 1° entry: true if has at least one mission running + * 2° entry: true if has at least one mission paused + */ + public boolean[] hasValidPendingMissions() { + boolean running = false; + boolean paused = false; + + synchronized (DownloadManager.this) { + for (DownloadMission mission : mMissionsPending) { + if (hidden.contains(mission) || mission.isCorrupt()) + continue; + + if (mission.running) + paused = true; + else + running = true; + } + } + + return new boolean[]{running, paused}; + } + @Override public int getOldListSize() { @@ -665,7 +693,14 @@ public class DownloadManager { @Override public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { - return areItemsTheSame(oldItemPosition, newItemPosition); + Object x = snapshot.get(oldItemPosition); + Object y = current.get(newItemPosition); + + if (x instanceof Mission && y instanceof Mission) { + return ((Mission) x).storage.equals(((Mission) y).storage); + } + + return false; } } diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java index a57fe1734..7f3a4bde1 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -1,21 +1,22 @@ package us.shandian.giga.service; import android.Manifest; +import android.app.AlertDialog; 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.content.SharedPreferences; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.net.ConnectivityManager; +import android.net.Network; import android.net.NetworkInfo; +import android.net.NetworkRequest; import android.net.Uri; import android.os.Binder; import android.os.Build; @@ -24,6 +25,9 @@ import android.os.IBinder; import android.os.Looper; import android.os.Message; import android.preference.PreferenceManager; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.StringRes; import android.support.v4.app.NotificationCompat; import android.support.v4.app.NotificationCompat.Builder; import android.support.v4.content.PermissionChecker; @@ -36,9 +40,13 @@ import org.schabi.newpipe.download.DownloadActivity; import org.schabi.newpipe.player.helper.LockManager; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.io.StoredDirectoryHelper; +import us.shandian.giga.io.StoredFileHelper; +import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManager.NetworkState; import static org.schabi.newpipe.BuildConfig.APPLICATION_ID; @@ -48,7 +56,6 @@ public class DownloadManagerService extends Service { private static final String TAG = "DownloadManagerService"; - public static final int MESSAGE_RUNNING = 0; public static final int MESSAGE_PAUSED = 1; public static final int MESSAGE_FINISHED = 2; public static final int MESSAGE_PROGRESS = 3; @@ -59,24 +66,25 @@ public class DownloadManagerService extends Service { private static final int DOWNLOADS_NOTIFICATION_ID = 1001; private static final String EXTRA_URLS = "DownloadManagerService.extra.urls"; - private static final String EXTRA_NAME = "DownloadManagerService.extra.name"; - private static final String EXTRA_LOCATION = "DownloadManagerService.extra.location"; private static final String EXTRA_KIND = "DownloadManagerService.extra.kind"; private static final String EXTRA_THREADS = "DownloadManagerService.extra.threads"; private static final String EXTRA_POSTPROCESSING_NAME = "DownloadManagerService.extra.postprocessingName"; private static final String EXTRA_POSTPROCESSING_ARGS = "DownloadManagerService.extra.postprocessingArgs"; private static final String EXTRA_SOURCE = "DownloadManagerService.extra.source"; private static final String EXTRA_NEAR_LENGTH = "DownloadManagerService.extra.nearLength"; + private static final String EXTRA_PATH = "DownloadManagerService.extra.storagePath"; + private static final String EXTRA_PARENT_PATH = "DownloadManagerService.extra.storageParentPath"; + private static final String EXTRA_STORAGE_TAG = "DownloadManagerService.extra.storageTag"; private static final String ACTION_RESET_DOWNLOAD_FINISHED = APPLICATION_ID + ".reset_download_finished"; private static final String ACTION_OPEN_DOWNLOADS_FINISHED = APPLICATION_ID + ".open_downloads_finished"; - private DMBinder mBinder; + private DownloadManagerBinder mBinder; private DownloadManager mManager; private Notification mNotification; private Handler mHandler; private boolean mForeground = false; - private NotificationManager notificationManager = null; + private NotificationManager mNotificationManager = null; private boolean mDownloadNotificationEnable = true; private int downloadDoneCount = 0; @@ -85,7 +93,9 @@ public class DownloadManagerService extends Service { private final ArrayList mEchoObservers = new ArrayList<>(1); - private BroadcastReceiver mNetworkStateListener; + private ConnectivityManager mConnectivityManager; + private BroadcastReceiver mNetworkStateListener = null; + private ConnectivityManager.NetworkCallback mNetworkStateListenerL = null; private SharedPreferences mPrefs = null; private final SharedPreferences.OnSharedPreferenceChangeListener mPrefChangeListener = this::handlePreferenceChange; @@ -106,10 +116,10 @@ public class DownloadManagerService extends Service { /** * notify media scanner on downloaded media file ... * - * @param file the downloaded file + * @param file the downloaded file uri */ - private void notifyMediaScanner(File file) { - sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(file))); + private void notifyMediaScanner(Uri file) { + sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, file)); } @Override @@ -120,7 +130,7 @@ public class DownloadManagerService extends Service { Log.d(TAG, "onCreate"); } - mBinder = new DMBinder(); + mBinder = new DownloadManagerBinder(); mHandler = new Handler(Looper.myLooper()) { @Override public void handleMessage(Message msg) { @@ -128,7 +138,9 @@ public class DownloadManagerService extends Service { } }; - mManager = new DownloadManager(this, mHandler); + mPrefs = PreferenceManager.getDefaultSharedPreferences(this); + + mManager = new DownloadManager(this, mHandler, loadMainVideoStorage(), loadMainAudioStorage()); Intent openDownloadListIntent = new Intent(this, DownloadActivity.class) .setAction(Intent.ACTION_MAIN); @@ -147,54 +159,55 @@ public class DownloadManagerService extends Service { .setContentText(getString(R.string.msg_running_detail)); mNotification = builder.build(); - notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - mNetworkStateListener = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - if (intent.getBooleanExtra(ConnectivityManager.EXTRA_NO_CONNECTIVITY, false)) { - handleConnectivityChange(null); - return; + mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + mConnectivityManager = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); + + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + mNetworkStateListenerL = new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(Network network) { + handleConnectivityState(false); } - handleConnectivityChange(intent.getParcelableExtra(ConnectivityManager.EXTRA_NETWORK_INFO)); - } - }; - registerReceiver(mNetworkStateListener, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); - mPrefs = PreferenceManager.getDefaultSharedPreferences(this); + @Override + public void onLost(Network network) { + handleConnectivityState(false); + } + }; + mConnectivityManager.registerNetworkCallback(new NetworkRequest.Builder().build(), mNetworkStateListenerL); + } else { + mNetworkStateListener = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + handleConnectivityState(false); + } + }; + registerReceiver(mNetworkStateListener, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + } + mPrefs.registerOnSharedPreferenceChangeListener(mPrefChangeListener); handlePreferenceChange(mPrefs, getString(R.string.downloads_cross_network)); handlePreferenceChange(mPrefs, getString(R.string.downloads_maximum_retry)); + handlePreferenceChange(mPrefs, getString(R.string.downloads_queue_limit)); mLock = new LockManager(this); } @Override - public int onStartCommand(Intent intent, int flags, int startId) { + public int onStartCommand(final Intent intent, int flags, int startId) { if (DEBUG) { - if (intent == null) { - Log.d(TAG, "Restarting"); - return START_NOT_STICKY; - } - Log.d(TAG, "Starting"); + Log.d(TAG, intent == null ? "Restarting" : "Starting"); } + + if (intent == null) return START_NOT_STICKY; + Log.i(TAG, "Got intent: " + intent); String action = intent.getAction(); if (action != null) { if (action.equals(Intent.ACTION_RUN)) { - String[] urls = intent.getStringArrayExtra(EXTRA_URLS); - String name = intent.getStringExtra(EXTRA_NAME); - String location = intent.getStringExtra(EXTRA_LOCATION); - int threads = intent.getIntExtra(EXTRA_THREADS, 1); - char kind = intent.getCharExtra(EXTRA_KIND, '?'); - String psName = intent.getStringExtra(EXTRA_POSTPROCESSING_NAME); - String[] psArgs = intent.getStringArrayExtra(EXTRA_POSTPROCESSING_ARGS); - String source = intent.getStringExtra(EXTRA_SOURCE); - long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0); - - mHandler.post(() -> mManager.startMission(urls, location, name, kind, threads, source, psName, psArgs, nearLength)); - + mHandler.post(() -> startMission(intent)); } else if (downloadDoneNotification != null) { if (action.equals(ACTION_RESET_DOWNLOAD_FINISHED) || action.equals(ACTION_OPEN_DOWNLOADS_FINISHED)) { downloadDoneCount = 0; @@ -221,32 +234,36 @@ public class DownloadManagerService extends Service { stopForeground(true); - if (notificationManager != null && downloadDoneNotification != null) { + if (mNotificationManager != null && downloadDoneNotification != null) { downloadDoneNotification.setDeleteIntent(null);// prevent NewPipe running when is killed, cleared from recent, etc - notificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification.build()); + mNotificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification.build()); } - mManager.pauseAllMissions(); - manageLock(false); - unregisterReceiver(mNetworkStateListener); + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) + mConnectivityManager.unregisterNetworkCallback(mNetworkStateListenerL); + else + unregisterReceiver(mNetworkStateListener); + mPrefs.unregisterOnSharedPreferenceChangeListener(mPrefChangeListener); if (icDownloadDone != null) icDownloadDone.recycle(); if (icDownloadFailed != null) icDownloadFailed.recycle(); if (icLauncher != null) icLauncher.recycle(); + + mManager.pauseAllMissions(true); } @Override public IBinder onBind(Intent intent) { int permissionCheck; - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { - permissionCheck = PermissionChecker.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE); - if (permissionCheck == PermissionChecker.PERMISSION_DENIED) { - Toast.makeText(this, "Permission denied (read)", Toast.LENGTH_SHORT).show(); - } - } +// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { +// permissionCheck = PermissionChecker.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE); +// if (permissionCheck == PermissionChecker.PERMISSION_DENIED) { +// Toast.makeText(this, "Permission denied (read)", Toast.LENGTH_SHORT).show(); +// } +// } permissionCheck = PermissionChecker.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE); if (permissionCheck == PermissionChecker.PERMISSION_DENIED) { @@ -261,18 +278,19 @@ public class DownloadManagerService extends Service { switch (msg.what) { case MESSAGE_FINISHED: - notifyMediaScanner(mission.getDownloadedFile()); - notifyFinishedDownload(mission.name); + notifyMediaScanner(mission.storage.getUri()); + notifyFinishedDownload(mission.storage.getName()); mManager.setFinished(mission); - updateForegroundState(mManager.runAnotherMission()); + handleConnectivityState(false); + updateForegroundState(mManager.runMissions()); break; - case MESSAGE_RUNNING: case MESSAGE_PROGRESS: updateForegroundState(true); break; case MESSAGE_ERROR: notifyFailedDownload(mission); - updateForegroundState(mManager.runAnotherMission()); + handleConnectivityState(false); + updateForegroundState(mManager.runMissions()); break; case MESSAGE_PAUSED: updateForegroundState(mManager.getRunningMissionsCount() > 0); @@ -293,46 +311,46 @@ public class DownloadManagerService extends Service { } } - private void handleConnectivityChange(NetworkInfo info) { + private void handleConnectivityState(boolean updateOnly) { + NetworkInfo info = mConnectivityManager.getActiveNetworkInfo(); 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"); + Log.i(TAG, "Active network [connectivity is unavailable]"); } 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 { + boolean connected = info.isConnected(); + boolean metered = mConnectivityManager.isActiveNetworkMetered(); + + if (connected) + status = metered ? NetworkState.MeteredOperating : NetworkState.Operating; + else status = NetworkState.Unavailable; - } - Log.i(TAG, "actual connectivity status is " + status.name()); + + Log.i(TAG, "Active network [connected=" + connected + " metered=" + metered + "] " + info.toString()); } if (mManager == null) return;// avoid race-conditions while the service is starting - mManager.handleConnectivityChange(status); + mManager.handleConnectivityState(status, updateOnly); } - private void handlePreferenceChange(SharedPreferences prefs, String key) { + private void handlePreferenceChange(SharedPreferences prefs, @NonNull String key) { if (key.equals(getString(R.string.downloads_maximum_retry))) { try { String value = prefs.getString(key, getString(R.string.downloads_maximum_retry_default)); - mManager.mPrefMaxRetry = Integer.parseInt(value); + mManager.mPrefMaxRetry = value == null ? 0 : Integer.parseInt(value); } catch (Exception e) { mManager.mPrefMaxRetry = 0; } mManager.updateMaximumAttempts(); } else if (key.equals(getString(R.string.downloads_cross_network))) { - mManager.mPrefCrossNetwork = prefs.getBoolean(key, false); + mManager.mPrefMeteredDownloads = prefs.getBoolean(key, false); + } else if (key.equals(getString(R.string.downloads_queue_limit))) { + mManager.mPrefQueueLimit = prefs.getBoolean(key, true); + } else if (key.equals(getString(R.string.download_path_video_key))) { + mManager.mMainStorageVideo = loadMainVideoStorage(); + } else if (key.equals(getString(R.string.download_path_audio_key))) { + mManager.mMainStorageAudio = loadMainAudioStorage(); } } @@ -350,46 +368,78 @@ public class DownloadManagerService extends Service { mForeground = state; } - public static void startMission(Context context, String urls[], String location, String name, char kind, + /** + * Start a new download mission + * + * @param context the activity context + * @param urls the list of urls to download + * @param storage where the file is saved + * @param kind type of file (a: audio v: video s: subtitle ?: file-extension defined) + * @param threads the number of threads maximal used to download chunks of the file. + * @param psName the name of the required post-processing algorithm, or {@code null} to ignore. + * @param source source url of the resource + * @param psArgs the arguments for the post-processing algorithm. + * @param nearLength the approximated final length of the file + */ + public static void startMission(Context context, String[] urls, StoredFileHelper storage, char kind, int threads, String source, String psName, String[] psArgs, long nearLength) { Intent intent = new Intent(context, DownloadManagerService.class); intent.setAction(Intent.ACTION_RUN); intent.putExtra(EXTRA_URLS, urls); - intent.putExtra(EXTRA_NAME, name); - intent.putExtra(EXTRA_LOCATION, location); intent.putExtra(EXTRA_KIND, kind); intent.putExtra(EXTRA_THREADS, threads); intent.putExtra(EXTRA_SOURCE, source); intent.putExtra(EXTRA_POSTPROCESSING_NAME, psName); intent.putExtra(EXTRA_POSTPROCESSING_ARGS, psArgs); intent.putExtra(EXTRA_NEAR_LENGTH, nearLength); + + intent.putExtra(EXTRA_PARENT_PATH, storage.getParentUri()); + intent.putExtra(EXTRA_PATH, storage.getUri()); + intent.putExtra(EXTRA_STORAGE_TAG, storage.getTag()); + 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 void startMission(Intent intent) { + String[] urls = intent.getStringArrayExtra(EXTRA_URLS); + Uri path = intent.getParcelableExtra(EXTRA_PATH); + Uri parentPath = intent.getParcelableExtra(EXTRA_PARENT_PATH); + int threads = intent.getIntExtra(EXTRA_THREADS, 1); + char kind = intent.getCharExtra(EXTRA_KIND, '?'); + String psName = intent.getStringExtra(EXTRA_POSTPROCESSING_NAME); + String[] psArgs = intent.getStringArrayExtra(EXTRA_POSTPROCESSING_ARGS); + String source = intent.getStringExtra(EXTRA_SOURCE); + long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0); + String tag = intent.getStringExtra(EXTRA_STORAGE_TAG); - // 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); - } + StoredFileHelper storage; + try { + storage = new StoredFileHelper(this, parentPath, path, tag); + } catch (IOException e) { + throw new RuntimeException(e);// this never should happen + } - @Override - public void onServiceDisconnected(ComponentName name) { - } - }, Context.BIND_AUTO_CREATE); + Postprocessing ps; + if (psName == null) + ps = null; + else + ps = Postprocessing.getAlgorithm(psName, psArgs); + + final DownloadMission mission = new DownloadMission(urls, storage, kind, ps); + mission.threadCount = threads; + mission.source = source; + mission.nearLength = nearLength; + + if (ps != null) + ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this)); + + handleConnectivityState(true);// first check the actual network status + + mManager.startMission(mission); } public void notifyFinishedDownload(String name) { - if (!mDownloadNotificationEnable || notificationManager == null) { + if (!mDownloadNotificationEnable || mNotificationManager == null) { return; } @@ -428,7 +478,7 @@ public class DownloadManagerService extends Service { downloadDoneNotification.setContentText(downloadDoneList); } - notificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification.build()); + mNotificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification.build()); downloadDoneCount++; } @@ -450,15 +500,15 @@ public class DownloadManagerService extends Service { if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { downloadFailedNotification.setContentTitle(getString(R.string.app_name)); downloadFailedNotification.setStyle(new NotificationCompat.BigTextStyle() - .bigText(getString(R.string.download_failed).concat(": ").concat(mission.name))); + .bigText(getString(R.string.download_failed).concat(": ").concat(mission.storage.getName()))); } else { downloadFailedNotification.setContentTitle(getString(R.string.download_failed)); - downloadFailedNotification.setContentText(mission.name); + downloadFailedNotification.setContentText(mission.storage.getName()); downloadFailedNotification.setStyle(new NotificationCompat.BigTextStyle() - .bigText(mission.name)); + .bigText(mission.storage.getName())); } - notificationManager.notify(id, downloadFailedNotification.build()); + mNotificationManager.notify(id, downloadFailedNotification.build()); } private PendingIntent makePendingIntent(String action) { @@ -487,12 +537,66 @@ public class DownloadManagerService extends Service { mLockAcquired = acquire; } - // Wrapper of DownloadManager - public class DMBinder extends Binder { + private StoredDirectoryHelper loadMainVideoStorage() { + return loadMainStorage(R.string.download_path_video_key, DownloadManager.TAG_VIDEO); + } + + private StoredDirectoryHelper loadMainAudioStorage() { + return loadMainStorage(R.string.download_path_audio_key, DownloadManager.TAG_AUDIO); + } + + private StoredDirectoryHelper loadMainStorage(@StringRes int prefKey, String tag) { + String path = mPrefs.getString(getString(prefKey), null); + + if (path == null || path.isEmpty()) return null; + + if (path.charAt(0) == File.separatorChar) { + Log.i(TAG, "Old save path style present: " + path); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) + path = Uri.fromFile(new File(path)).toString(); + else + path = ""; + + mPrefs.edit().putString(getString(prefKey), "").apply(); + } + + try { + return new StoredDirectoryHelper(this, Uri.parse(path), tag); + } catch (Exception e) { + Log.e(TAG, "Failed to load the storage of " + tag + " from " + path, e); + Toast.makeText(this, R.string.no_available_dir, Toast.LENGTH_LONG).show(); + } + + return null; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Wrappers for DownloadManager + //////////////////////////////////////////////////////////////////////////////////////////////// + + public class DownloadManagerBinder extends Binder { public DownloadManager getDownloadManager() { return mManager; } + @Nullable + public StoredDirectoryHelper getMainStorageVideo() { + return mManager.mMainStorageVideo; + } + + @Nullable + public StoredDirectoryHelper getMainStorageAudio() { + return mManager.mMainStorageAudio; + } + + public boolean askForSavePath() { + return DownloadManagerService.this.mPrefs.getBoolean( + DownloadManagerService.this.getString(R.string.downloads_storage_ask), + false + ); + } + public void addMissionEventListener(Handler handler) { manageObservers(handler, true); } @@ -502,15 +606,15 @@ public class DownloadManagerService extends Service { } public void clearDownloadNotifications() { - if (notificationManager == null) return; + if (mNotificationManager == null) return; if (downloadDoneNotification != null) { - notificationManager.cancel(DOWNLOADS_NOTIFICATION_ID); + mNotificationManager.cancel(DOWNLOADS_NOTIFICATION_ID); downloadDoneList.setLength(0); downloadDoneCount = 0; } if (downloadFailedNotification != null) { for (; downloadFailedNotificationID > DOWNLOADS_NOTIFICATION_ID; downloadFailedNotificationID--) { - notificationManager.cancel(downloadFailedNotificationID); + mNotificationManager.cancel(downloadFailedNotificationID); } mFailedDownloads.clear(); downloadFailedNotificationID++; @@ -523,8 +627,4 @@ public class DownloadManagerService extends Service { } - public interface DMChecker { - void callback(boolean listed, boolean finished); - } - } diff --git a/app/src/main/java/us/shandian/giga/service/MissionState.java b/app/src/main/java/us/shandian/giga/service/MissionState.java new file mode 100644 index 000000000..2d7802ff5 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/service/MissionState.java @@ -0,0 +1,5 @@ +package us.shandian.giga.service; + +public enum MissionState { + None, Pending, PendingRunning, Finished +} diff --git a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java index 4a35aa166..ebc6e94c2 100644 --- a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java +++ b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java @@ -8,21 +8,22 @@ 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.annotation.StringRes; 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.support.v7.widget.RecyclerView.Adapter; +import android.support.v7.widget.RecyclerView.ViewHolder; import android.util.Log; import android.util.SparseArray; +import android.view.HapticFeedbackConstants; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; @@ -36,14 +37,20 @@ import android.widget.Toast; import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.R; +import org.schabi.newpipe.report.ErrorActivity; +import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.util.NavigationHelper; import java.io.File; import java.lang.ref.WeakReference; +import java.net.URI; import java.util.ArrayList; +import java.util.Collections; import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.FinishedMission; +import us.shandian.giga.get.Mission; +import us.shandian.giga.io.StoredFileHelper; import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.ui.common.Deleter; @@ -57,11 +64,16 @@ import static us.shandian.giga.get.DownloadMission.ERROR_CONNECT_HOST; import static us.shandian.giga.get.DownloadMission.ERROR_FILE_CREATION; import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_NO_CONTENT; import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_UNSUPPORTED_RANGE; +import static us.shandian.giga.get.DownloadMission.ERROR_INSUFFICIENT_STORAGE; import static us.shandian.giga.get.DownloadMission.ERROR_NOTHING; import static us.shandian.giga.get.DownloadMission.ERROR_PATH_CREATION; import static us.shandian.giga.get.DownloadMission.ERROR_PERMISSION_DENIED; import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING; +import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_HOLD; +import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_STOPPED; +import static us.shandian.giga.get.DownloadMission.ERROR_PROGRESS_LOST; import static us.shandian.giga.get.DownloadMission.ERROR_SSL_EXCEPTION; +import static us.shandian.giga.get.DownloadMission.ERROR_TIMEOUT; import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_EXCEPTION; import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_HOST; @@ -69,6 +81,7 @@ public class MissionAdapter extends Adapter { private static final SparseArray ALGORITHMS = new SparseArray<>(); private static final String TAG = "MissionAdapter"; private static final String UNDEFINED_PROGRESS = "--.-%"; + private static final String DEFAULT_MIME_TYPE = "*/*"; static { @@ -85,9 +98,12 @@ public class MissionAdapter extends Adapter { private ArrayList mPendingDownloadsItems = new ArrayList<>(); private Handler mHandler; private MenuItem mClear; + private MenuItem mStartButton; + private MenuItem mPauseButton; private View mEmptyMessage; + private RecoverHelper mRecover; - public MissionAdapter(Context context, DownloadManager downloadManager, MenuItem clearButton, View emptyMessage) { + public MissionAdapter(Context context, @NonNull DownloadManager downloadManager, View emptyMessage) { mContext = context; mDownloadManager = downloadManager; mDeleter = null; @@ -105,10 +121,18 @@ public class MissionAdapter extends Adapter { onServiceMessage(msg); break; } + + if (mStartButton != null && mPauseButton != null) switch (msg.what) { + case DownloadManagerService.MESSAGE_DELETED: + case DownloadManagerService.MESSAGE_ERROR: + case DownloadManagerService.MESSAGE_FINISHED: + case DownloadManagerService.MESSAGE_PAUSED: + checkMasterButtonsVisibility(); + break; + } } }; - mClear = clearButton; mEmptyMessage = emptyMessage; mIterator = downloadManager.getIterator(); @@ -137,7 +161,11 @@ public class MissionAdapter extends Adapter { if (h.item.mission instanceof DownloadMission) { mPendingDownloadsItems.remove(h); - if (mPendingDownloadsItems.size() < 1) setAutoRefresh(false); + if (mPendingDownloadsItems.size() < 1) { + setAutoRefresh(false); + if (mStartButton != null) mStartButton.setVisible(false); + if (mPauseButton != null) mPauseButton.setVisible(false); + } } h.popupMenu.dismiss(); @@ -170,10 +198,10 @@ public class MissionAdapter extends Adapter { ViewHolderItem h = (ViewHolderItem) view; h.item = item; - Utility.FileType type = Utility.getFileType(item.mission.kind, item.mission.name); + Utility.FileType type = Utility.getFileType(item.mission.kind, item.mission.storage.getName()); h.icon.setImageResource(Utility.getIconForFileType(type)); - h.name.setText(item.mission.name); + h.name.setText(item.mission.storage.getName()); h.progress.setColors(Utility.getBackgroundForFileType(mContext, type), Utility.getForegroundForFileType(mContext, type)); @@ -225,8 +253,10 @@ public class MissionAdapter extends Adapter { long deltaDone = mission.done - h.lastDone; boolean hasError = mission.errCode != ERROR_NOTHING; - // on error hide marquee or show if condition (mission.done < 1 || mission.unknownLength) is true - h.progress.setMarquee(!hasError && (mission.done < 1 || mission.unknownLength)); + // hide on error + // show if current resource length is not fetched + // show if length is unknown + h.progress.setMarquee(!hasError && (!mission.isInitialized() || mission.unknownLength)); float progress; if (mission.unknownLength) { @@ -252,7 +282,7 @@ public class MissionAdapter extends Adapter { long length = mission.getLength(); int state; - if (mission.isPsFailed()) { + if (mission.isPsFailed() || mission.errCode == ERROR_POSTPROCESSING_HOLD) { state = 0; } else if (!mission.running) { state = mission.enqueued ? 1 : 2; @@ -305,36 +335,78 @@ public class MissionAdapter extends Adapter { } } - private boolean viewWithFileProvider(@NonNull File file) { - if (!file.exists()) return true; + private void viewWithFileProvider(Mission mission) { + if (checkInvalidFile(mission)) return; - String ext = Utility.getFileExt(file.getName()); - if (ext == null) return false; + String mimeType = resolveMimeType(mission); - String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.substring(1)); - Log.v(TAG, "Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID + ".provider"); + if (BuildConfig.DEBUG) + Log.v(TAG, "Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID + ".provider"); - Uri uri = FileProvider.getUriForFile(mContext, BuildConfig.APPLICATION_ID + ".provider", file); + Uri uri; + + if (mission.storage.isDirect()) { + uri = FileProvider.getUriForFile( + mContext, + BuildConfig.APPLICATION_ID + ".provider", + new File(URI.create(mission.storage.getUri().toString())) + ); + } else { + uri = mission.storage.getUri(); + } Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); 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); } - if(Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { intent.addFlags(FLAG_ACTIVITY_NEW_TASK); } + //mContext.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); - Log.v(TAG, "Starting intent: " + intent); + if (intent.resolveActivity(mContext.getPackageManager()) != null) { mContext.startActivity(intent); } else { - Toast noPlayerToast = Toast.makeText(mContext, R.string.toast_no_player, Toast.LENGTH_LONG); - noPlayerToast.show(); + Toast.makeText(mContext, R.string.toast_no_player, Toast.LENGTH_LONG).show(); + } + } + + private void shareFile(Mission mission) { + if (checkInvalidFile(mission)) return; + + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType(resolveMimeType(mission)); + intent.putExtra(Intent.EXTRA_STREAM, mission.storage.getUri()); + + mContext.startActivity(Intent.createChooser(intent, null)); + } + + private static String resolveMimeType(@NonNull Mission mission) { + String mimeType; + + if (!mission.storage.isInvalid()) { + mimeType = mission.storage.getType(); + if (mimeType != null && mimeType.length() > 0 && !mimeType.equals(StoredFileHelper.DEFAULT_MIME)) + return mimeType; } + String ext = Utility.getFileExt(mission.storage.getName()); + if (ext == null) return DEFAULT_MIME_TYPE; + + mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.substring(1)); + + return mimeType == null ? DEFAULT_MIME_TYPE : mimeType; + } + + private boolean checkInvalidFile(@NonNull Mission mission) { + if (mission.storage.existsAsFile()) return false; + + Toast.makeText(mContext, R.string.missing_file, Toast.LENGTH_SHORT).show(); return true; } @@ -343,15 +415,9 @@ public class MissionAdapter extends Adapter { } private void onServiceMessage(@NonNull Message msg) { - switch (msg.what) { - case DownloadManagerService.MESSAGE_PROGRESS: - setAutoRefresh(true); - return; - case DownloadManagerService.MESSAGE_ERROR: - case DownloadManagerService.MESSAGE_FINISHED: - break; - default: - return; + if (msg.what == DownloadManagerService.MESSAGE_PROGRESS) { + setAutoRefresh(true); + return; } for (int i = 0; i < mPendingDownloadsItems.size(); i++) { @@ -370,80 +436,112 @@ public class MissionAdapter extends Adapter { } private void showError(@NonNull DownloadMission mission) { - StringBuilder str = new StringBuilder(); - str.append(mContext.getString(R.string.label_code)); - str.append(": "); - str.append(mission.errCode); - str.append('\n'); + @StringRes int msg = R.string.general_error; + String msgEx = null; switch (mission.errCode) { case 416: - str.append(mContext.getString(R.string.error_http_requested_range_not_satisfiable)); + msg = R.string.error_http_requested_range_not_satisfiable; break; case 404: - str.append(mContext.getString(R.string.error_http_not_found)); + msg = R.string.error_http_not_found; break; case ERROR_NOTHING: - str.append("¿?"); - break; + return;// this never should happen case ERROR_FILE_CREATION: - str.append(mContext.getString(R.string.error_file_creation)); + msg = R.string.error_file_creation; break; case ERROR_HTTP_NO_CONTENT: - str.append(mContext.getString(R.string.error_http_no_content)); + msg = R.string.error_http_no_content; break; case ERROR_HTTP_UNSUPPORTED_RANGE: - str.append(mContext.getString(R.string.error_http_unsupported_range)); + msg = R.string.error_http_unsupported_range; break; case ERROR_PATH_CREATION: - str.append(mContext.getString(R.string.error_path_creation)); + msg = R.string.error_path_creation; break; case ERROR_PERMISSION_DENIED: - str.append(mContext.getString(R.string.permission_denied)); + msg = R.string.permission_denied; break; case ERROR_SSL_EXCEPTION: - str.append(mContext.getString(R.string.error_ssl_exception)); + msg = R.string.error_ssl_exception; break; case ERROR_UNKNOWN_HOST: - str.append(mContext.getString(R.string.error_unknown_host)); + msg = R.string.error_unknown_host; break; case ERROR_CONNECT_HOST: - str.append(mContext.getString(R.string.error_connect_host)); + msg = R.string.error_connect_host; + break; + case ERROR_POSTPROCESSING_STOPPED: + msg = R.string.error_postprocessing_stopped; break; case ERROR_POSTPROCESSING: - str.append(mContext.getString(R.string.error_postprocessing_failed)); + case ERROR_POSTPROCESSING_HOLD: + showError(mission.errObject, UserAction.DOWNLOAD_POSTPROCESSING, R.string.error_postprocessing_failed); + return; + case ERROR_INSUFFICIENT_STORAGE: + msg = R.string.error_insufficient_storage; + break; case ERROR_UNKNOWN_EXCEPTION: + showError(mission.errObject, UserAction.DOWNLOAD_FAILED, R.string.general_error); + return; + case ERROR_PROGRESS_LOST: + msg = R.string.error_progress_lost; + break; + case ERROR_TIMEOUT: + msg = R.string.error_timeout; break; default: if (mission.errCode >= 100 && mission.errCode < 600) { - str = new StringBuilder(8); - str.append("HTTP "); - str.append(mission.errCode); + msgEx = "HTTP " + mission.errCode; } else if (mission.errObject == null) { - str.append("(not_decelerated_error_code)"); + msgEx = "(not_decelerated_error_code)"; + } else { + showError(mission.errObject, UserAction.DOWNLOAD_FAILED, msg); + return; } break; } - if (mission.errObject != null) { - str.append("\n\n"); - str.append(mission.errObject.toString()); + AlertDialog.Builder builder = new AlertDialog.Builder(mContext); + + if (msgEx != null) + builder.setMessage(msgEx); + else + builder.setMessage(msg); + + // add report button for non-HTTP errors (range 100-599) + if (mission.errObject != null && (mission.errCode < 100 || mission.errCode >= 600)) { + @StringRes final int mMsg = msg; + builder.setPositiveButton(R.string.error_report_title, (dialog, which) -> + showError(mission.errObject, UserAction.DOWNLOAD_FAILED, mMsg) + ); } - AlertDialog.Builder builder = new AlertDialog.Builder(mContext); - builder.setTitle(mission.name) - .setMessage(str) - .setNegativeButton(android.R.string.ok, (dialog, which) -> dialog.cancel()) + builder.setNegativeButton(android.R.string.ok, (dialog, which) -> dialog.cancel()) + .setTitle(mission.storage.getName()) .create() .show(); } + private void showError(Exception exception, UserAction action, @StringRes int reason) { + ErrorActivity.reportError( + mContext, + Collections.singletonList(exception), + null, + null, + ErrorActivity.ErrorInfo.make(action, "-", "-", reason) + ); + } + public void clearFinishedDownloads() { mDownloadManager.forgetFinishedDownloads(); applyChanges(); } private boolean handlePopupItem(@NonNull ViewHolderItem h, @NonNull MenuItem option) { + if (h.item == null) return true; + int id = option.getItemId(); DownloadMission mission = h.item.mission instanceof DownloadMission ? (DownloadMission) h.item.mission : null; @@ -466,16 +564,33 @@ public class MissionAdapter extends Adapter { showError(mission); return true; case R.id.queue: - h.queue.setChecked(!h.queue.isChecked()); - mission.enqueued = h.queue.isChecked(); + boolean flag = !h.queue.isChecked(); + h.queue.setChecked(flag); + mission.setEnqueued(flag); updateProgress(h); return true; + case R.id.retry: + if (mission.hasInvalidStorage()) { + mDownloadManager.tryRecover(mission); + if (mission.storage.isInvalid()) + mRecover.tryRecover(mission); + else + recoverMission(mission); + + return true; + } + mission.psContinue(true); + return true; + case R.id.cancel: + mission.psContinue(false); + return false; } } switch (id) { - case R.id.open: - return viewWithFileProvider(h.item.mission.getDownloadedFile()); + case R.id.menu_item_share: + shareFile(h.item.mission); + return true; case R.id.delete: if (mDeleter == null) { mDownloadManager.deleteMission(h.item.mission); @@ -486,7 +601,7 @@ public class MissionAdapter extends Adapter { return true; case R.id.md5: case R.id.sha1: - new ChecksumTask(mContext).execute(h.item.mission.getDownloadedFile().getAbsolutePath(), ALGORITHMS.get(id)); + new ChecksumTask(mContext).execute(h.item.mission.storage, ALGORITHMS.get(id)); return true; case R.id.source: /*Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(h.item.mission.source)); @@ -529,29 +644,74 @@ public class MissionAdapter extends Adapter { } public void setClearButton(MenuItem clearButton) { - if (mClear == null) clearButton.setVisible(mIterator.hasFinishedMissions()); + if (mClear == null) + clearButton.setVisible(mIterator.hasFinishedMissions()); + mClear = clearButton; } + public void setMasterButtons(MenuItem startButton, MenuItem pauseButton) { + boolean init = mStartButton == null || mPauseButton == null; + + mStartButton = startButton; + mPauseButton = pauseButton; + + if (init) checkMasterButtonsVisibility(); + } + private void checkEmptyMessageVisibility() { int flag = mIterator.getOldListSize() > 0 ? View.GONE : View.VISIBLE; if (mEmptyMessage.getVisibility() != flag) mEmptyMessage.setVisibility(flag); } + private void checkMasterButtonsVisibility() { + boolean[] state = mIterator.hasValidPendingMissions(); - public void deleterDispose(Bundle bundle) { - if (mDeleter != null) mDeleter.dispose(bundle); + mStartButton.setVisible(state[0]); + mPauseButton.setVisible(state[1]); } - public void deleterLoad(Bundle bundle, View view) { + public void ensurePausedMissions() { + for (ViewHolderItem h : mPendingDownloadsItems) { + if (((DownloadMission) h.item.mission).running) continue; + updateProgress(h); + h.lastTimeStamp = -1; + h.lastDone = -1; + } + } + + + public void deleterDispose(boolean commitChanges) { + if (mDeleter != null) mDeleter.dispose(commitChanges); + } + + public void deleterLoad(View view) { if (mDeleter == null) - mDeleter = new Deleter(bundle, view, mContext, this, mDownloadManager, mIterator, mHandler); + mDeleter = new Deleter(view, mContext, this, mDownloadManager, mIterator, mHandler); } public void deleterResume() { if (mDeleter != null) mDeleter.resume(); } + public void recoverMission(DownloadMission mission) { + for (ViewHolderItem h : mPendingDownloadsItems) { + if (mission != h.item.mission) continue; + + mission.errObject = null; + mission.resetState(true, false, DownloadMission.ERROR_NOTHING); + + h.status.setText(UNDEFINED_PROGRESS); + h.state = -1; + h.size.setText(Utility.formatBytes(mission.getLength())); + h.progress.setMarquee(true); + + mDownloadManager.resumeMission(mission); + return; + } + + } + private boolean mUpdaterRunning = false; private final Runnable rUpdater = this::updater; @@ -593,6 +753,10 @@ public class MissionAdapter extends Adapter { return Float.isNaN(value) || Float.isInfinite(value); } + public void setRecover(@NonNull RecoverHelper callback) { + mRecover = callback; + } + class ViewHolderItem extends RecyclerView.ViewHolder { DownloadManager.MissionItem item; @@ -604,6 +768,8 @@ public class MissionAdapter extends Adapter { ProgressDrawable progress; PopupMenu popupMenu; + MenuItem retry; + MenuItem cancel; MenuItem start; MenuItem pause; MenuItem open; @@ -636,22 +802,34 @@ public class MissionAdapter extends Adapter { button.setOnClickListener(v -> showPopupMenu()); Menu menu = popupMenu.getMenu(); + retry = menu.findItem(R.id.retry); + cancel = menu.findItem(R.id.cancel); start = menu.findItem(R.id.start); pause = menu.findItem(R.id.pause); - open = menu.findItem(R.id.open); + open = menu.findItem(R.id.menu_item_share); 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); - itemView.setOnClickListener((v) -> { + itemView.setHapticFeedbackEnabled(true); + + itemView.setOnClickListener(v -> { if (item.mission instanceof FinishedMission) - viewWithFileProvider(item.mission.getDownloadedFile()); + viewWithFileProvider(item.mission); + }); + + itemView.setOnLongClickListener(v -> { + v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + showPopupMenu(); + return true; }); } private void showPopupMenu() { + retry.setVisible(false); + cancel.setVisible(false); start.setVisible(false); pause.setVisible(false); open.setVisible(false); @@ -664,7 +842,20 @@ public class MissionAdapter extends Adapter { DownloadMission mission = item.mission instanceof DownloadMission ? (DownloadMission) item.mission : null; if (mission != null) { - if (!mission.isPsRunning()) { + if (mission.hasInvalidStorage()) { + retry.setVisible(true); + delete.setVisible(true); + showError.setVisible(true); + } else if (mission.isPsRunning()) { + switch (mission.errCode) { + case ERROR_INSUFFICIENT_STORAGE: + case ERROR_POSTPROCESSING_HOLD: + retry.setVisible(true); + cancel.setVisible(true); + showError.setVisible(true); + break; + } + } else { if (mission.running) { pause.setVisible(true); } else { @@ -713,7 +904,7 @@ public class MissionAdapter extends Adapter { } - static class ChecksumTask extends AsyncTask { + static class ChecksumTask extends AsyncTask { ProgressDialog progressDialog; WeakReference weakReference; @@ -736,8 +927,8 @@ public class MissionAdapter extends Adapter { } @Override - protected String doInBackground(String... params) { - return Utility.checksum(params[0], params[1]); + protected String doInBackground(Object... params) { + return Utility.checksum((StoredFileHelper) params[0], (String) params[1]); } @Override @@ -764,4 +955,8 @@ public class MissionAdapter extends Adapter { } } + public interface RecoverHelper { + void tryRecover(DownloadMission mission); + } + } diff --git a/app/src/main/java/us/shandian/giga/ui/common/Deleter.java b/app/src/main/java/us/shandian/giga/ui/common/Deleter.java index 6407ab019..573bead94 100644 --- a/app/src/main/java/us/shandian/giga/ui/common/Deleter.java +++ b/app/src/main/java/us/shandian/giga/ui/common/Deleter.java @@ -3,8 +3,6 @@ package us.shandian.giga.ui.common; import android.content.Context; import android.content.Intent; import android.graphics.Color; -import android.net.Uri; -import android.os.Bundle; import android.os.Handler; import android.support.design.widget.Snackbar; import android.view.View; @@ -23,8 +21,6 @@ public class Deleter { private static final int TIMEOUT = 5000;// ms private static final int DELAY = 350;// ms private static final int DELAY_RESUME = 400;// ms - private static final String BUNDLE_NAMES = "us.shandian.giga.ui.common.deleter.names"; - private static final String BUNDLE_LOCATIONS = "us.shandian.giga.ui.common.deleter.locations"; private Snackbar snackbar; private ArrayList items; @@ -41,7 +37,7 @@ public class Deleter { private final Runnable rNext; private final Runnable rCommit; - public Deleter(Bundle b, View v, Context c, MissionAdapter a, DownloadManager d, MissionIterator i, Handler h) { + public Deleter(View v, Context c, MissionAdapter a, DownloadManager d, MissionIterator i, Handler h) { mView = v; mContext = c; mAdapter = a; @@ -55,27 +51,6 @@ public class Deleter { rCommit = this::commit; items = new ArrayList<>(2); - - if (b != null) { - String[] names = b.getStringArray(BUNDLE_NAMES); - String[] locations = b.getStringArray(BUNDLE_LOCATIONS); - - if (names == null || locations == null) return; - if (names.length < 1 || locations.length < 1) return; - if (names.length != locations.length) return; - - items.ensureCapacity(names.length); - - for (int j = 0; j < locations.length; j++) { - Mission mission = mDownloadManager.getAnyMission(locations[j], names[j]); - if (mission == null) continue; - - items.add(mission); - mIterator.hide(mission); - } - - if (items.size() > 0) resume(); - } } public void append(Mission item) { @@ -104,7 +79,7 @@ public class Deleter { private void next() { if (items.size() < 1) return; - String msg = mContext.getString(R.string.file_deleted).concat(":\n").concat(items.get(0).name); + String msg = mContext.getString(R.string.file_deleted).concat(":\n").concat(items.get(0).storage.getName()); snackbar = Snackbar.make(mView, msg, Snackbar.LENGTH_INDEFINITE); snackbar.setAction(R.string.undo, s -> forget()); @@ -125,7 +100,7 @@ public class Deleter { mDownloadManager.deleteMission(mission); if (mission instanceof FinishedMission) { - mContext.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(mission.getDownloadedFile()))); + mContext.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, mission.storage.getUri())); } break; } @@ -151,27 +126,14 @@ public class Deleter { mHandler.postDelayed(rShow, DELAY_RESUME); } - public void dispose(Bundle bundle) { + public void dispose(boolean commitChanges) { if (items.size() < 1) return; pause(); - if (bundle == null) { - for (Mission mission : items) mDownloadManager.deleteMission(mission); - items = null; - return; - } + if (!commitChanges) return; - String[] names = new String[items.size()]; - String[] locations = new String[items.size()]; - - for (int i = 0; i < items.size(); i++) { - Mission mission = items.get(i); - names[i] = mission.name; - locations[i] = mission.location; - } - - bundle.putStringArray(BUNDLE_NAMES, names); - bundle.putStringArray(BUNDLE_LOCATIONS, locations); + for (Mission mission : items) mDownloadManager.deleteMission(mission); + items = null; } } diff --git a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java index c4fd3b5fd..f8cecbed9 100644 --- a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java +++ b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java @@ -1,7 +1,7 @@ package us.shandian.giga.ui.fragment; import android.app.Activity; -import android.app.Fragment; +import android.app.AlertDialog; import android.content.ComponentName; import android.content.Context; import android.content.Intent; @@ -10,6 +10,8 @@ import android.content.SharedPreferences; import android.os.Bundle; import android.os.IBinder; import android.preference.PreferenceManager; +import android.support.annotation.NonNull; +import android.support.v4.app.Fragment; import android.support.v7.widget.GridLayoutManager; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; @@ -18,23 +20,31 @@ import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; +import android.widget.Toast; import org.schabi.newpipe.R; import org.schabi.newpipe.util.ThemeHelper; +import java.io.IOException; + +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.io.StoredFileHelper; import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; -import us.shandian.giga.service.DownloadManagerService.DMBinder; +import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; import us.shandian.giga.ui.adapter.MissionAdapter; public class MissionsFragment extends Fragment { private static final int SPAN_SIZE = 2; + private static final int REQUEST_DOWNLOAD_PATH_SAF = 0x1230; private SharedPreferences mPrefs; private boolean mLinear; private MenuItem mSwitch; private MenuItem mClear = null; + private MenuItem mStart = null; + private MenuItem mPause = null; private RecyclerView mList; private View mEmpty; @@ -43,21 +53,24 @@ public class MissionsFragment extends Fragment { private LinearLayoutManager mLinearManager; private Context mContext; - private DMBinder mBinder; - private Bundle mBundle; + private DownloadManagerBinder mBinder; private boolean mForceUpdate; + private DownloadMission unsafeMissionTarget = null; + private ServiceConnection mConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder binder) { - mBinder = (DownloadManagerService.DMBinder) binder; + mBinder = (DownloadManagerBinder) binder; mBinder.clearDownloadNotifications(); - mAdapter = new MissionAdapter(mContext, mBinder.getDownloadManager(), mClear, mEmpty); - mAdapter.deleterLoad(mBundle, getView()); + mAdapter = new MissionAdapter(mContext, mBinder.getDownloadManager(), mEmpty); + mAdapter.deleterLoad(getView()); - mBundle = null; + mAdapter.setRecover(MissionsFragment.this::recoverMission); + + setAdapterButtons(); mBinder.addMissionEventListener(mAdapter.getMessenger()); mBinder.enableNotifications(false); @@ -74,15 +87,12 @@ public class MissionsFragment extends Fragment { }; @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View v = inflater.inflate(R.layout.missions, container, false); mPrefs = PreferenceManager.getDefaultSharedPreferences(getActivity()); mLinear = mPrefs.getBoolean("linear", false); - //mContext = getActivity().getApplicationContext(); - mBundle = savedInstanceState; - // Bind the service mContext.bindService(new Intent(mContext, DownloadManagerService.class), mConnection, Context.BIND_AUTO_CREATE); @@ -132,7 +142,7 @@ public class MissionsFragment extends Fragment { public void onAttach(Activity activity) { super.onAttach(activity); - mContext = activity.getApplicationContext(); + mContext = activity; } @@ -144,7 +154,7 @@ public class MissionsFragment extends Fragment { mBinder.removeMissionEventListener(mAdapter.getMessenger()); mBinder.enableNotifications(true); mContext.unbindService(mConnection); - mAdapter.deleterDispose(null); + mAdapter.deleterDispose(true); mBinder = null; mAdapter = null; @@ -154,7 +164,11 @@ public class MissionsFragment extends Fragment { public void onPrepareOptionsMenu(Menu menu) { mSwitch = menu.findItem(R.id.switch_mode); mClear = menu.findItem(R.id.clear_list); - if (mAdapter != null) mAdapter.setClearButton(mClear); + mStart = menu.findItem(R.id.start_downloads); + mPause = menu.findItem(R.id.pause_downloads); + + if (mAdapter != null) setAdapterButtons(); + super.onPrepareOptionsMenu(menu); } @@ -166,8 +180,23 @@ public class MissionsFragment extends Fragment { updateList(); return true; case R.id.clear_list: - mAdapter.clearFinishedDownloads(); + AlertDialog.Builder prompt = new AlertDialog.Builder(mContext); + prompt.setTitle(R.string.clear_finished_download); + prompt.setMessage(R.string.confirm_prompt); + prompt.setPositiveButton(android.R.string.ok, (dialog, which) -> mAdapter.clearFinishedDownloads()); + prompt.setNegativeButton(R.string.cancel, null); + prompt.create().show(); return true; + case R.id.start_downloads: + item.setVisible(false); + mPause.setVisible(true); + mBinder.getDownloadManager().startAllMissions(); + return true; + case R.id.pause_downloads: + item.setVisible(false); + mStart.setVisible(true); + mBinder.getDownloadManager().pauseAllMissions(false); + mAdapter.ensurePausedMissions();// update items view default: return super.onOptionsItemSelected(item); } @@ -193,9 +222,9 @@ public class MissionsFragment extends Fragment { int icon; if (mLinear) - icon = isLight ? R.drawable.ic_list_black_24dp : R.drawable.ic_list_white_24dp; - else icon = isLight ? R.drawable.ic_grid_black_24dp : R.drawable.ic_grid_white_24dp; + else + icon = isLight ? R.drawable.ic_list_black_24dp : R.drawable.ic_list_white_24dp; mSwitch.setIcon(icon); mSwitch.setTitle(mLinear ? R.string.grid : R.string.list); @@ -203,12 +232,29 @@ public class MissionsFragment extends Fragment { } } + private void setAdapterButtons() { + if (mClear == null || mStart == null || mPause == null) return; + + mAdapter.setClearButton(mClear); + mAdapter.setMasterButtons(mStart, mPause); + } + + private void recoverMission(@NonNull DownloadMission mission) { + unsafeMissionTarget = mission; + StoredFileHelper.requestSafWithFileCreation( + MissionsFragment.this, + REQUEST_DOWNLOAD_PATH_SAF, + mission.storage.getName(), + mission.storage.getType() + ); + } + @Override - public void onSaveInstanceState(Bundle outState) { + public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); if (mAdapter != null) { - mAdapter.deleterDispose(outState); + mAdapter.deleterDispose(false); mForceUpdate = true; mBinder.removeMissionEventListener(mAdapter.getMessenger()); } @@ -237,4 +283,23 @@ public class MissionsFragment extends Fragment { if (mAdapter != null) mAdapter.onPaused(); if (mBinder != null) mBinder.enableNotifications(true); } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode != REQUEST_DOWNLOAD_PATH_SAF || resultCode != Activity.RESULT_OK) return; + + if (unsafeMissionTarget == null || data.getData() == null) { + return;// unsafeMissionTarget cannot be null + } + + try { + String tag = unsafeMissionTarget.storage.getTag(); + unsafeMissionTarget.storage = new StoredFileHelper(mContext, null, data.getData(), tag); + mAdapter.recoverMission(unsafeMissionTarget); + } catch (IOException e) { + Toast.makeText(mContext, R.string.general_error, Toast.LENGTH_LONG).show(); + } + } } diff --git a/app/src/main/java/us/shandian/giga/util/Utility.java b/app/src/main/java/us/shandian/giga/util/Utility.java index e5149cf9b..dc6a67b4b 100644 --- a/app/src/main/java/us/shandian/giga/util/Utility.java +++ b/app/src/main/java/us/shandian/giga/util/Utility.java @@ -9,14 +9,15 @@ import android.support.annotation.DrawableRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.content.ContextCompat; +import android.util.Log; import android.widget.Toast; import org.schabi.newpipe.R; +import org.schabi.newpipe.streams.io.SharpStream; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileInputStream; -import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectInputStream; @@ -25,7 +26,8 @@ import java.io.Serializable; import java.net.HttpURLConnection; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.util.Locale; + +import us.shandian.giga.io.StoredFileHelper; public class Utility { @@ -80,6 +82,7 @@ public class Utility { objectInputStream = new ObjectInputStream(new FileInputStream(file)); object = (T) objectInputStream.readObject(); } catch (Exception e) { + Log.e("Utility", "Failed to deserialize the object", e); object = null; } @@ -206,7 +209,7 @@ public class Utility { Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); } - public static String checksum(String path, String algorithm) { + public static String checksum(StoredFileHelper source, String algorithm) { MessageDigest md; try { @@ -215,11 +218,11 @@ public class Utility { throw new RuntimeException(e); } - FileInputStream i; + SharpStream i; try { - i = new FileInputStream(path); - } catch (FileNotFoundException e) { + i = source.getStream(); + } catch (Exception e) { throw new RuntimeException(e); } @@ -247,15 +250,15 @@ public class Utility { } @SuppressWarnings("ResultOfMethodCallIgnored") - public static boolean mkdir(File path, boolean allDirs) { - if (path.exists()) return true; + public static boolean mkdir(File p, boolean allDirs) { + if (p.exists()) return true; if (allDirs) - path.mkdirs(); + p.mkdirs(); else - path.mkdir(); + p.mkdir(); - return path.exists(); + return p.exists(); } public static long getContentLength(HttpURLConnection connection) { @@ -264,8 +267,7 @@ public class Utility { } try { - long length = Long.parseLong(connection.getHeaderField("Content-Length")); - if (length >= 0) return length; + return Long.parseLong(connection.getHeaderField("Content-Length")); } catch (Exception err) { // nothing to do } diff --git a/app/src/main/res/drawable-hdpi/ic_pause_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_pause_black_24dp.png new file mode 100644 index 000000000..3770b9124 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pause_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pause_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_pause_white_24dp.png new file mode 100644 index 000000000..3e3de2dce Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pause_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pause_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_pause_black_24dp.png new file mode 100644 index 000000000..6e81d3ad4 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pause_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pause_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_pause_white_24dp.png new file mode 100644 index 000000000..f5236e8aa Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pause_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pause_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_pause_black_24dp.png new file mode 100644 index 000000000..e6de3973a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pause_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pause_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_pause_white_24dp.png new file mode 100644 index 000000000..b94b2ae40 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pause_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pause_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_pause_black_24dp.png new file mode 100644 index 000000000..2691adeb3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pause_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pause_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_pause_white_24dp.png new file mode 100644 index 000000000..15cb0b51c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pause_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_pause_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_pause_black_24dp.png new file mode 100644 index 000000000..3fbcd0326 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_pause_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_pause_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_pause_white_24dp.png new file mode 100644 index 000000000..12a49bc12 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_pause_white_24dp.png differ diff --git a/app/src/main/res/drawable/progress_soundcloud_horizontal_dark.xml b/app/src/main/res/drawable/progress_soundcloud_horizontal_dark.xml new file mode 100644 index 000000000..1ec9f67b6 --- /dev/null +++ b/app/src/main/res/drawable/progress_soundcloud_horizontal_dark.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/progress_soundcloud_horizontal_light.xml b/app/src/main/res/drawable/progress_soundcloud_horizontal_light.xml new file mode 100644 index 000000000..c326c5c04 --- /dev/null +++ b/app/src/main/res/drawable/progress_soundcloud_horizontal_light.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/progress_youtube_horizontal_dark.xml b/app/src/main/res/drawable/progress_youtube_horizontal_dark.xml new file mode 100644 index 000000000..404410f98 --- /dev/null +++ b/app/src/main/res/drawable/progress_youtube_horizontal_dark.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/progress_youtube_horizontal_light.xml b/app/src/main/res/drawable/progress_youtube_horizontal_light.xml new file mode 100644 index 000000000..120a6e5fb --- /dev/null +++ b/app/src/main/res/drawable/progress_youtube_horizontal_light.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-large-land/activity_main_player.xml b/app/src/main/res/layout-large-land/activity_main_player.xml index 5f484267b..8428d489a 100644 --- a/app/src/main/res/layout-large-land/activity_main_player.xml +++ b/app/src/main/res/layout-large-land/activity_main_player.xml @@ -249,7 +249,7 @@ android:layout_height="wrap_content" android:layout_alignParentRight="true" android:layout_marginLeft="2dp" - android:padding="5dp" + android:padding="5dp" android:clickable="true" android:focusable="true" android:scaleType="fitXY" @@ -305,7 +305,7 @@ tools:text="English" /> + + +