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 493ed44ea..ace143b13 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -11,6 +11,7 @@ import android.support.v4.app.DialogFragment; import android.support.v7.app.AlertDialog; import android.support.v7.widget.Toolbar; import android.util.Log; +import android.util.SparseArray; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -37,6 +38,7 @@ import org.schabi.newpipe.settings.NewPipeSettings; import org.schabi.newpipe.util.FilenameUtils; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.PermissionHelper; +import org.schabi.newpipe.util.SecondaryStreamHelper; import org.schabi.newpipe.util.StreamItemAdapter; import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper; import org.schabi.newpipe.util.ThemeHelper; @@ -55,17 +57,24 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck private static final String TAG = "DialogFragment"; private static final boolean DEBUG = MainActivity.DEBUG; - @State protected StreamInfo currentInfo; - @State protected StreamSizeWrapper wrappedAudioStreams = StreamSizeWrapper.empty(); - @State protected StreamSizeWrapper wrappedVideoStreams = StreamSizeWrapper.empty(); - @State protected StreamSizeWrapper wrappedSubtitleStreams = StreamSizeWrapper.empty(); - @State protected int selectedVideoIndex = 0; - @State protected int selectedAudioIndex = 0; - @State protected int selectedSubtitleIndex = 0; + @State + protected StreamInfo currentInfo; + @State + protected StreamSizeWrapper wrappedAudioStreams = StreamSizeWrapper.empty(); + @State + protected StreamSizeWrapper wrappedVideoStreams = StreamSizeWrapper.empty(); + @State + protected StreamSizeWrapper wrappedSubtitleStreams = StreamSizeWrapper.empty(); + @State + protected int selectedVideoIndex = 0; + @State + protected int selectedAudioIndex = 0; + @State + protected int selectedSubtitleIndex = 0; - private StreamItemAdapter audioStreamsAdapter; - private StreamItemAdapter videoStreamsAdapter; - private StreamItemAdapter subtitleStreamsAdapter; + private StreamItemAdapter audioStreamsAdapter; + private StreamItemAdapter videoStreamsAdapter; + private StreamItemAdapter subtitleStreamsAdapter; private final CompositeDisposable disposables = new CompositeDisposable(); @@ -144,7 +153,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (DEBUG) Log.d(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]"); + if (DEBUG) + Log.d(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]"); if (!PermissionHelper.checkStoragePermissions(getActivity(), PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { getDialog().dismiss(); return; @@ -153,14 +163,29 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(getContext())); Icepick.restoreInstanceState(this, savedInstanceState); - this.videoStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedVideoStreams, true); + SparseArray> secondaryStreams = new SparseArray<>(4); + List videoStreams = wrappedVideoStreams.getStreamsList(); + + for (int i = 0; i < videoStreams.size(); i++) { + if (!videoStreams.get(i).isVideoOnly()) continue; + AudioStream audioStream = SecondaryStreamHelper.getAudioStreamFor(wrappedAudioStreams.getStreamsList(), videoStreams.get(i)); + + if (audioStream != null) { + secondaryStreams.append(i, new SecondaryStreamHelper<>(wrappedAudioStreams, audioStream)); + } else if (DEBUG) { + Log.w(TAG, "No audio stream candidates for video format " + videoStreams.get(i).getFormat().name()); + } + } + + this.videoStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedVideoStreams, secondaryStreams); this.audioStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedAudioStreams); this.subtitleStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedSubtitleStreams); } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - if (DEBUG) Log.d(TAG, "onCreateView() called with: inflater = [" + inflater + "], container = [" + container + "], savedInstanceState = [" + savedInstanceState + "]"); + if (DEBUG) + Log.d(TAG, "onCreateView() called with: inflater = [" + inflater + "], container = [" + container + "], savedInstanceState = [" + savedInstanceState + "]"); return inflater.inflate(R.layout.download_dialog, container); } @@ -293,7 +318,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck @Override public void onCheckedChanged(RadioGroup group, @IdRes int checkedId) { - if (DEBUG) Log.d(TAG, "onCheckedChanged() called with: group = [" + group + "], checkedId = [" + checkedId + "]"); + if (DEBUG) + Log.d(TAG, "onCheckedChanged() called with: group = [" + group + "], checkedId = [" + checkedId + "]"); boolean flag = true; switch (checkedId) { @@ -318,7 +344,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck @Override public void onItemSelected(AdapterView parent, View view, int position, long id) { - if (DEBUG) Log.d(TAG, "onItemSelected() called with: parent = [" + parent + "], view = [" + view + "], position = [" + position + "], id = [" + id + "]"); + if (DEBUG) + Log.d(TAG, "onItemSelected() called with: parent = [" + parent + "], view = [" + view + "], position = [" + position + "], id = [" + id + "]"); switch (radioVideoAudioGroup.getCheckedRadioButtonId()) { case R.id.audio_button: selectedAudioIndex = position; @@ -458,57 +485,41 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck String[] urls; String psName = null; String[] psArgs = null; - String secondaryStream = null; + String secondaryStreamUrl = null; + long nearLength = 0; if (selectedStream instanceof VideoStream) { - VideoStream videoStream = (VideoStream) selectedStream; - if (videoStream.isVideoOnly() && videoStream.getFormat() != MediaFormat.v3GPP) { - boolean m4v = videoStream.getFormat() == MediaFormat.MPEG_4; + SecondaryStreamHelper secondaryStream = videoStreamsAdapter + .getAllSecondary() + .get(wrappedVideoStreams.getStreamsList().indexOf(selectedStream)); - for (AudioStream audio : audioStreamsAdapter.getAll()) { - if (audio.getFormat() == (m4v ? MediaFormat.M4A : MediaFormat.WEBMA)) { - secondaryStream = audio.getUrl(); - break; - } - } + if (secondaryStream != null) { + secondaryStreamUrl = secondaryStream.getStream().getUrl(); + psName = selectedStream.getFormat() == MediaFormat.MPEG_4 ? Postprocessing.ALGORITHM_MP4_DASH_MUXER : Postprocessing.ALGORITHM_WEBM_MUXER; + psArgs = null; + long videoSize = wrappedVideoStreams.getSizeInBytes((VideoStream) selectedStream); - if (secondaryStream == null) { - // retry, but this time in reverse order - List audioStreams = audioStreamsAdapter.getAll(); - for (int i = audioStreams.size() - 1; i >= 0; i--) { - AudioStream audio = audioStreams.get(i); - if (audio.getFormat() == (m4v ? MediaFormat.MP3 : MediaFormat.OPUS)) { - secondaryStream = audio.getUrl(); - break; - } - } - } - - if (secondaryStream == null) { - Log.w(TAG, "No audio stream candidates for video format " + videoStream.getFormat().name()); - psName = null; - psArgs = null; - } else { - psName = m4v ? Postprocessing.ALGORITHM_MP4_DASH_MUXER : Postprocessing.ALGORITHM_WEBM_MUXER; - psArgs = null; + // set nearLength, only, if both sizes are fetched or known. this probably does not work on weak internet connections + if (secondaryStream.getSizeInBytes() > 0 && videoSize > 0) { + nearLength = secondaryStream.getSizeInBytes() + videoSize; } } } else if ((selectedStream instanceof SubtitlesStream) && selectedStream.getFormat() == MediaFormat.TTML) { psName = Postprocessing.ALGORITHM_TTML_CONVERTER; psArgs = new String[]{ selectedStream.getFormat().getSuffix(), - "false",//ignore empty frames - "false",// detect youtube duplicateLines + "false",// ignore empty frames + "false",// detect youtube duplicate lines }; } - if (secondaryStream == null) { + if (secondaryStreamUrl == null) { urls = new String[]{selectedStream.getUrl()}; } else { - urls = new String[]{selectedStream.getUrl(), secondaryStream}; + urls = new String[]{selectedStream.getUrl(), secondaryStreamUrl}; } - DownloadManagerService.startMission(context, urls, location, fileName, kind, threads, currentInfo.getUrl(), psName, psArgs); + DownloadManagerService.startMission(context, urls, location, fileName, kind, threads, currentInfo.getUrl(), psName, psArgs, nearLength); getDialog().dismiss(); } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index c7c668f40..8bcd2c66d 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 @@ -746,7 +746,7 @@ public class VideoDetailFragment sortedVideoStreams = ListHelper.getSortedStreamVideosList(activity, info.getVideoStreams(), info.getVideoOnlyStreams(), false); selectedVideoStreamIndex = ListHelper.getDefaultResolutionIndex(activity, sortedVideoStreams); - final StreamItemAdapter streamsAdapter = new StreamItemAdapter<>(activity, new StreamSizeWrapper<>(sortedVideoStreams, activity), isExternalPlayerEnabled); + final StreamItemAdapter streamsAdapter = new StreamItemAdapter<>(activity, new StreamSizeWrapper<>(sortedVideoStreams, activity), isExternalPlayerEnabled); spinnerToolbar.setAdapter(streamsAdapter); spinnerToolbar.setSelection(selectedVideoStreamIndex); spinnerToolbar.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @@ -1335,4 +1335,4 @@ public class VideoDetailFragment relatedStreamRootLayout.setVisibility(visibility); } } -} \ No newline at end of file +} 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 ec2419734..271929d47 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java @@ -257,7 +257,7 @@ public class Mp4DashReader { private String boxName(int type) { try { - return new String(ByteBuffer.allocate(4).putInt(type).array(), "US-ASCII"); + return new String(ByteBuffer.allocate(4).putInt(type).array(), "UTF-8"); } catch (UnsupportedEncodingException e) { return "0x" + Integer.toHexString(type); } diff --git a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java new file mode 100644 index 000000000..a5d3ea3eb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java @@ -0,0 +1,66 @@ +package org.schabi.newpipe.util; + +import android.support.annotation.NonNull; + +import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.Stream; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper; + +import java.util.List; + +public class SecondaryStreamHelper { + private final int position; + private final StreamSizeWrapper streams; + + public SecondaryStreamHelper(StreamSizeWrapper streams, T selectedStream) { + this.streams = streams; + this.position = streams.getStreamsList().indexOf(selectedStream); + if (this.position < 0) throw new RuntimeException("selected stream not found"); + } + + public T getStream() { + return streams.getStreamsList().get(position); + } + + public long getSizeInBytes() { + return streams.getSizeInBytes(position); + } + + /** + * find the correct audio stream for the desired video stream + * + * @param audioStreams list of audio streams + * @param videoStream desired video ONLY stream + * @return selected audio stream or null if a candidate was not found + */ + public static AudioStream getAudioStreamFor(@NonNull List audioStreams, @NonNull VideoStream videoStream) { + // TODO: check if m4v and m4a selected streams are DASH compliant + switch (videoStream.getFormat()) { + case WEBM: + case MPEG_4: + break; + default: + return null; + } + + boolean m4v = videoStream.getFormat() == MediaFormat.MPEG_4; + + for (AudioStream audio : audioStreams) { + if (audio.getFormat() == (m4v ? MediaFormat.M4A : MediaFormat.WEBMA)) { + return audio; + } + } + + // retry, but this time in reverse order + for (int i = audioStreams.size() - 1; i >= 0; i--) { + AudioStream audio = audioStreams.get(i); + if (audio.getFormat() == (m4v ? MediaFormat.MP3 : MediaFormat.OPUS)) { + return audio; + } + } + + return null; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java index 6a1e80fea..eb106f91d 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.util; import android.content.Context; +import android.util.SparseArray; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -29,26 +30,34 @@ import us.shandian.giga.util.Utility; /** * A list adapter for a list of {@link Stream streams}, currently supporting {@link VideoStream} and {@link AudioStream}. */ -public class StreamItemAdapter extends BaseAdapter { +public class StreamItemAdapter extends BaseAdapter { private final Context context; private final StreamSizeWrapper streamsWrapper; - private final boolean showIconNoAudio; + private final SparseArray> secondaryStreams; - public StreamItemAdapter(Context context, StreamSizeWrapper streamsWrapper, boolean showIconNoAudio) { + public StreamItemAdapter(Context context, StreamSizeWrapper streamsWrapper, SparseArray> secondaryStreams) { this.context = context; this.streamsWrapper = streamsWrapper; - this.showIconNoAudio = showIconNoAudio; + this.secondaryStreams = secondaryStreams; + } + + public StreamItemAdapter(Context context, StreamSizeWrapper streamsWrapper, boolean showIconNoAudio) { + this(context, streamsWrapper, showIconNoAudio ? new SparseArray<>() : null); } public StreamItemAdapter(Context context, StreamSizeWrapper streamsWrapper) { - this(context, streamsWrapper, false); + this(context, streamsWrapper, null); } public List getAll() { return streamsWrapper.getStreamsList(); } + public SparseArray> getAllSecondary() { + return secondaryStreams; + } + @Override public int getCount() { return streamsWrapper.getStreamsList().size(); @@ -90,22 +99,15 @@ public class StreamItemAdapter extends BaseAdapter { String qualityString; if (stream instanceof VideoStream) { - qualityString = ((VideoStream) stream).getResolution(); + VideoStream videoStream = ((VideoStream) stream); + qualityString = videoStream.getResolution(); - if (!showIconNoAudio) { - woSoundIconVisibility = View.GONE; - } else if (((VideoStream) stream).isVideoOnly()) { - switch (stream.getFormat()) { - case WEBM:// fully supported - case MPEG_4:// ¿is DASH MPEG-4 format? - woSoundIconVisibility = View.INVISIBLE; - break; - default: - woSoundIconVisibility = View.VISIBLE; - break; + if (secondaryStreams != null) { + if (videoStream.isVideoOnly()) { + woSoundIconVisibility = secondaryStreams.get(position) == null ? View.VISIBLE : View.INVISIBLE; + } else if (isDropdownItem) { + woSoundIconVisibility = View.INVISIBLE; } - } else if (isDropdownItem) { - woSoundIconVisibility = View.INVISIBLE; } } else if (stream instanceof AudioStream) { qualityString = ((AudioStream) stream).getAverageBitrate() + "kbps"; @@ -119,7 +121,13 @@ public class StreamItemAdapter extends BaseAdapter { } if (streamsWrapper.getSizeInBytes(position) > 0) { - sizeView.setText(streamsWrapper.getFormattedSize(position)); + SecondaryStreamHelper secondary = secondaryStreams == null ? null : secondaryStreams.get(position); + if (secondary != null) { + long size = secondary.getSizeInBytes() + streamsWrapper.getSizeInBytes(position); + sizeView.setText(Utility.formatBytes(size)); + } else { + sizeView.setText(streamsWrapper.getFormattedSize(position)); + } sizeView.setVisibility(View.VISIBLE); } else { sizeView.setVisibility(View.GONE); 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 190bac285..2ea097062 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java @@ -74,7 +74,7 @@ public class DownloadInitializer implements Runnable { } } else { // if one thread is solicited don't calculate blocks, is useless - mMission.blocks = 0; + mMission.blocks = 1; mMission.fallback = true; mMission.unknownLength = false; } 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 d27046c76..8e34981cc 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -103,6 +103,11 @@ public class DownloadMission extends Mission { */ public int maxRetry; + /** + * Approximated final length, this represent the sum of all resources sizes + */ + public long nearLength; + public int threadCount = 3; boolean fallback; private int finishCount; @@ -432,7 +437,7 @@ public class DownloadMission extends Mission { return; } - if (DEBUG && blocks < 1) { + if (DEBUG && blocks == 0) { Log.w(TAG, "pausing a download that can not be resumed."); } @@ -507,6 +512,13 @@ public class DownloadMission extends Mission { return current >= urls.length && postprocessingName == null; } + public long getLength() { + long near = offsets[current < offsets.length ? current : (offsets.length - 1)] + length; + near -= offsets[0];// don't count reserved space + + return near > nearLength ? near : nearLength; + } + private boolean doPostprocessing() { if (postprocessingName == null) return true; diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp4DashMuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp4DashMuxer.java index 5e7a5f80d..b303b66cd 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Mp4DashMuxer.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Mp4DashMuxer.java @@ -14,7 +14,7 @@ class Mp4DashMuxer extends Postprocessing { Mp4DashMuxer(DownloadMission mission) { super(mission); - recommendedReserve = 2048 * 1024;// 2 MiB + recommendedReserve = 15360 * 1024;// 15 MiB worksOnSameFile = true; } 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 2c6dc776b..88cc337fd 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java @@ -91,6 +91,8 @@ public abstract class Postprocessing { out = new CircularFile(file, 0, this::progressReport, checker); mission.done = 0; + mission.length = mission.getLength(); + int result = process(out, sources); if (result == OK_RESULT) { 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 c69809e00..009a9a66b 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java @@ -16,7 +16,7 @@ class WebMMuxer extends Postprocessing { WebMMuxer(DownloadMission mission) { super(mission); - recommendedReserve = (1024 + 512) * 1024;// 1.50 MiB + recommendedReserve = 2048 * 1024;// 2 MiB worksOnSameFile = true; } 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 index 531e0587e..1454c1f2d 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/io/CircularFile.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/io/CircularFile.java @@ -10,7 +10,7 @@ import java.util.ArrayList; public class CircularFile extends SharpStream { private final static int AUX_BUFFER_SIZE = 1024 * 1024;// 1 MiB - private final static int AUX2_BUFFER_SIZE = 256 * 1024;// 256 KiB + private final static int NOTIFY_BYTES_INTERVAL = 256 * 1024;// 256 KiB private final static int QUEUE_BUFFER_SIZE = 8 * 1024;// 8 KiB private RandomAccessFile out; @@ -108,32 +108,56 @@ public class CircularFile extends SharpStream { } long end = callback.check(); - int available; + long available; if (end == -1) { - available = Integer.MAX_VALUE; + available = Long.MAX_VALUE; } else { if (end < startOffset) { throw new IOException("The reported offset is invalid. reported offset is " + String.valueOf(end)); } - available = (int) (end - position); + available = end - position; } while (available > 0 && auxiliaryBuffers.size() > 0) { ManagedBuffer aux = auxiliaryBuffers.get(0); - if ((queue.size + aux.size) > available) { - available = 0;// wait for next check - break; + // check if there is enough space to dump the auxiliar buffer + if (available >= (aux.size + queue.size)) { + available -= aux.size; + writeQueue(aux.buffer, 0, aux.size); + aux.dereference(); + auxiliaryBuffers.remove(0); + continue; } - writeQueue(aux.buffer, 0, aux.size); - available -= aux.size; - aux.dereference(); - auxiliaryBuffers.remove(0); + // try flush contents to avoid allocate another auxiliar buffer + if (aux.available() < len && available > queue.size) { + int size = Math.min(len, aux.available()); + aux.write(b, off, size); + + off += size; + len -= size; + + size = Math.min(aux.size, (int) available - queue.size); + if (size < 1) { + break; + } + + writeQueue(aux.buffer, 0, size); + aux.dereference(size); + + available -= size; + } + + break; } - if (available > (len + queue.size)) { + if (len < 1) { + return; + } + + if (auxiliaryBuffers.size() < 1 && available > (len + queue.size)) { writeQueue(b, off, len); } else { int i = auxiliaryBuffers.size() - 1; @@ -150,14 +174,14 @@ public class CircularFile extends SharpStream { if (available < 1) { // secondary auxiliary buffer available = len; - aux = new ManagedBuffer(Math.max(len, AUX2_BUFFER_SIZE)); + aux = new ManagedBuffer(Math.max(len, AUX_BUFFER_SIZE)); auxiliaryBuffers.add(aux); i++; } else { available = Math.min(len, available); } - aux.write(b, off, available); + aux.write(b, off, (int) available); len -= available; if (len < 1) { @@ -173,7 +197,7 @@ public class CircularFile extends SharpStream { position += length; if (onProgress != null && position > reportPosition) { - reportPosition = position + AUX2_BUFFER_SIZE;// notify every 256 KiB (approx) + reportPosition = position + NOTIFY_BYTES_INTERVAL; onProgress.report(position); } } @@ -195,6 +219,10 @@ public class CircularFile extends SharpStream { offset += size; length -= size; } + + if (queue.size >= queue.buffer.length) { + flushQueue(); + } } private void flushQueue() throws IOException { @@ -238,7 +266,9 @@ public class CircularFile extends SharpStream { flush(); out.seek(startOffset); - if (onProgress != null) onProgress.report(-position); + if (onProgress != null) { + onProgress.report(-position); + } position = startOffset; reportPosition = startOffset; @@ -327,6 +357,18 @@ public class CircularFile extends SharpStream { size = 0; } + void dereference(int amount) { + if (amount > size) { + throw new IndexOutOfBoundsException("Invalid dereference amount (" + amount + ">=" + size + ")"); + } + + size -= amount; + + for (int i = 0; i < size; i++) { + buffer[i] = buffer[amount + i]; + } + } + protected int available() { return buffer.length - size; } 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 97a0da523..52b49a0ae 100644 --- a/app/src/main/java/us/shandian/giga/service/DownloadManager.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManager.java @@ -116,7 +116,6 @@ public class DownloadManager { return result; } - @SuppressWarnings("ResultOfMethodCallIgnored") private void loadPendingMissions() { File[] subs = mPendingMissionsDir.listFiles(); @@ -136,9 +135,11 @@ public class DownloadManager { DownloadMission mis = Utility.readFromFile(sub); if (mis == null) { + //noinspection ResultOfMethodCallIgnored sub.delete(); } else { if (mis.isFinished()) { + //noinspection ResultOfMethodCallIgnored sub.delete(); continue; } @@ -173,6 +174,7 @@ public class DownloadManager { m.threadCount = mis.threadCount; m.source = mis.source; m.maxRetry = mis.maxRetry; + m.nearLength = mis.nearLength; mis = m; } @@ -204,7 +206,7 @@ public class DownloadManager { * @param postProcessingArgs the arguments for the post-processing algorithm. */ void startMission(String[] urls, String location, String name, char kind, int threads, String source, - String postprocessingName, String[] postProcessingArgs) { + String postprocessingName, String[] postProcessingArgs, long nearLength) { synchronized (this) { // check for existing pending download DownloadMission pendingMission = getPendingMission(location, name); @@ -229,6 +231,7 @@ public class DownloadManager { mission.source = source; mission.mHandler = mHandler; mission.maxRetry = mPrefs.getInt(mPrefMaxRetry, 3); + mission.nearLength = nearLength; while (true) { mission.metadata = new File(mPendingMissionsDir, String.valueOf(mission.timestamp)); @@ -406,26 +409,30 @@ public class DownloadManager { * Set a pending download as finished * * @param mission the desired mission - * @return true if exits pending missions running, otherwise, false */ - boolean setFinished(DownloadMission mission) { + void setFinished(DownloadMission mission) { synchronized (this) { - int i = mMissionsPending.indexOf(mission); - mMissionsPending.remove(i); - + mMissionsPending.remove(mission); mMissionsFinished.add(0, new FinishedMission(mission)); mDownloadDataSource.addMission(mission); + } + } + /** + * runs another mission in queue if possible + * @return true if exits pending missions running or a mission was started, otherwise, false + */ + boolean runAnotherMission() { + synchronized (this) { if (mMissionsPending.size() < 1) return false; - i = getRunningMissionsCount(); + int i = getRunningMissionsCount(); if (i > 0) return true; - // before returning, check the queue if (!canDownloadInCurrentNetwork()) return false; - for (DownloadMission mission1 : mMissionsPending) { - if (!mission1.running && mission.errCode != DownloadMission.ERROR_POSTPROCESSING_FAILED && mission1.enqueued) { + for (DownloadMission mission : mMissionsPending) { + if (!mission.running && mission.errCode != DownloadMission.ERROR_POSTPROCESSING_FAILED && mission.enqueued) { resumeMission(mMissionsPending.get(i)); return true; } @@ -481,6 +488,12 @@ public class DownloadManager { if (flag) mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PAUSED); } + void updateMaximumAttempts(int maxRetry) { + synchronized (this) { + for (DownloadMission mission : mMissionsPending) mission.maxRetry = maxRetry; + } + } + /** * Fast check for pending downloads. If exists, the user will be notified * TODO: call this method in somewhere 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 bddc41718..52485e9d8 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -11,6 +11,7 @@ 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; @@ -22,6 +23,8 @@ import android.os.Handler; import android.os.IBinder; import android.os.Looper; import android.os.Message; +import android.preference.PreferenceManager; +import android.support.v4.app.NotificationCompat; import android.support.v4.app.NotificationCompat.Builder; import android.support.v4.content.PermissionChecker; import android.util.Log; @@ -29,6 +32,7 @@ import android.widget.Toast; import org.schabi.newpipe.R; import org.schabi.newpipe.download.DownloadActivity; +import org.schabi.newpipe.player.helper.LockManager; import java.io.File; import java.util.ArrayList; @@ -61,6 +65,7 @@ public class DownloadManagerService extends Service { 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 ACTION_RESET_DOWNLOAD_COUNT = APPLICATION_ID + ".reset_download_count"; @@ -73,11 +78,22 @@ public class DownloadManagerService extends Service { private StringBuilder downloadDoneList = null; NotificationManager notificationManager = null; private boolean mForeground = false; - + private final ArrayList mEchoObservers = new ArrayList<>(1); private BroadcastReceiver mNetworkStateListener; + private SharedPreferences mPrefs = null; + private final SharedPreferences.OnSharedPreferenceChangeListener mPrefChangeListener = this::handlePreferenceChange; + + private boolean wakeLockAcquired = false; + private LockManager wakeLock = null; + + private int downloadFailedNotificationID = DOWNLOADS_NOTIFICATION_ID + 1; + + private Bitmap icLauncher; + private Bitmap icDownloadDone; + /** * notify media scanner on downloaded media file ... * @@ -112,12 +128,12 @@ public class DownloadManagerService extends Service { openDownloadListIntent, PendingIntent.FLAG_UPDATE_CURRENT); - Bitmap iconBitmap = BitmapFactory.decodeResource(this.getResources(), R.mipmap.ic_launcher); + icLauncher = BitmapFactory.decodeResource(this.getResources(), R.mipmap.ic_launcher); Builder builder = new Builder(this, getString(R.string.notification_channel_id)) .setContentIntent(pendingIntent) .setSmallIcon(android.R.drawable.stat_sys_download) - .setLargeIcon(iconBitmap) + .setLargeIcon(icLauncher) .setContentTitle(getString(R.string.msg_running)) .setContentText(getString(R.string.msg_running_detail)); @@ -135,6 +151,11 @@ public class DownloadManagerService extends Service { } }; registerReceiver(mNetworkStateListener, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + + mPrefs = PreferenceManager.getDefaultSharedPreferences(this); + mPrefs.registerOnSharedPreferenceChangeListener(mPrefChangeListener); + + wakeLock = new LockManager(this); } @Override @@ -158,8 +179,9 @@ public class DownloadManagerService extends Service { 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)); + mHandler.post(() -> mManager.startMission(urls, location, name, kind, threads, source, psName, psArgs, nearLength)); } else if (downloadDoneNotification != null && action.equals(ACTION_RESET_DOWNLOAD_COUNT)) { downloadDoneCount = 0; @@ -184,10 +206,15 @@ public class DownloadManagerService extends Service { notificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification.build()); } - unregisterReceiver(mNetworkStateListener); - mManager.pauseAllMissions(); + if (wakeLockAcquired) wakeLock.releaseWifiAndCpu(); + + unregisterReceiver(mNetworkStateListener); + mPrefs.unregisterOnSharedPreferenceChangeListener(mPrefChangeListener); + + icDownloadDone.recycle(); + icLauncher.recycle(); } @Override @@ -209,19 +236,24 @@ public class DownloadManagerService extends Service { } public void handleMessage(Message msg) { + DownloadMission mission = (DownloadMission) msg.obj; + switch (msg.what) { case MESSAGE_FINISHED: - DownloadMission mission = (DownloadMission) msg.obj; notifyMediaScanner(mission.getDownloadedFile()); notifyFinishedDownload(mission.name); - updateForegroundState(mManager.setFinished(mission)); + mManager.setFinished(mission); + updateForegroundState(mManager.runAnotherMission()); break; case MESSAGE_RUNNING: case MESSAGE_PROGRESS: updateForegroundState(true); break; - case MESSAGE_PAUSED: case MESSAGE_ERROR: + notifyFailedDownload(mission.name); + updateForegroundState(mManager.runAnotherMission()); + break; + case MESSAGE_PAUSED: updateForegroundState(mManager.getRunningMissionsCount() > 0); break; } @@ -272,21 +304,28 @@ public class DownloadManagerService extends Service { mManager.handleConnectivityChange(status); } + private void handlePreferenceChange(SharedPreferences prefs, String key) { + if (key.equals(getString(R.string.downloads_max_retry))) { + mManager.updateMaximumAttempts(prefs.getInt(key, 3)); + } + } + public void updateForegroundState(boolean state) { if (state == mForeground) return; if (state) { startForeground(FOREGROUND_NOTIFICATION_ID, mNotification); + if (!wakeLockAcquired) wakeLock.acquireWifiAndCpu(); } else { stopForeground(true); + if (wakeLockAcquired) wakeLock.releaseWifiAndCpu(); } mForeground = state; } - public static void startMission(Context context, String urls[], String location, String name, - char kind, int threads, String source, String postprocessingName, - String[] postprocessingArgs) { + public static void startMission(Context context, String urls[], String location, String name, 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); @@ -295,8 +334,9 @@ public class DownloadManagerService extends Service { intent.putExtra(EXTRA_KIND, kind); intent.putExtra(EXTRA_THREADS, threads); intent.putExtra(EXTRA_SOURCE, source); - intent.putExtra(EXTRA_POSTPROCESSING_NAME, postprocessingName); - intent.putExtra(EXTRA_POSTPROCESSING_ARGS, postprocessingArgs); + intent.putExtra(EXTRA_POSTPROCESSING_NAME, psName); + intent.putExtra(EXTRA_POSTPROCESSING_ARGS, psArgs); + intent.putExtra(EXTRA_NEAR_LENGTH, nearLength); context.startService(intent); } @@ -330,16 +370,19 @@ public class DownloadManagerService extends Service { if (downloadDoneNotification == null) { downloadDoneList = new StringBuilder(name.length()); - Bitmap icon = BitmapFactory.decodeResource(this.getResources(), android.R.drawable.stat_sys_download_done); + icDownloadDone = BitmapFactory.decodeResource(this.getResources(), android.R.drawable.stat_sys_download_done); downloadDoneNotification = new Builder(this, getString(R.string.notification_channel_id)) .setAutoCancel(true) - .setLargeIcon(icon) + .setLargeIcon(icDownloadDone) .setSmallIcon(android.R.drawable.stat_sys_download_done) .setDeleteIntent(PendingIntent.getService(this, (int) System.currentTimeMillis(), new Intent(this, DownloadManagerService.class) .setAction(ACTION_RESET_DOWNLOAD_COUNT) , PendingIntent.FLAG_UPDATE_CURRENT)) - .setContentIntent(mNotification.contentIntent); + .setContentIntent(PendingIntent.getService(this, (int) System.currentTimeMillis() + 1, + new Intent(this, DownloadActivity.class) + .setAction(Intent.ACTION_MAIN), + PendingIntent.FLAG_UPDATE_CURRENT)); } if (downloadDoneCount < 1) { @@ -347,27 +390,61 @@ public class DownloadManagerService extends Service { if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { downloadDoneNotification.setContentTitle(getString(R.string.app_name)); - downloadDoneNotification.setContentText(getString(R.string.download_finished, name)); } else { - downloadDoneNotification.setContentTitle(getString(R.string.download_finished, name)); - downloadDoneNotification.setContentText(null); + downloadDoneNotification.setContentTitle(null); } + + downloadDoneNotification.setContentText(getString(R.string.download_finished)); + downloadDoneNotification.setStyle(new NotificationCompat.BigTextStyle() + .setBigContentTitle(getString(R.string.download_finished)) + .bigText(name) + ); } else { - downloadDoneList.append(", "); + downloadDoneList.append('\n'); downloadDoneList.append(name); + downloadDoneNotification.setStyle(new NotificationCompat.BigTextStyle().bigText(downloadDoneList)); downloadDoneNotification.setContentTitle(getString(R.string.download_finished_more, String.valueOf(downloadDoneCount + 1))); - downloadDoneNotification.setContentText(downloadDoneList.toString()); + downloadDoneNotification.setContentText(downloadDoneList); } notificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification.build()); downloadDoneCount++; } + public void notifyFailedDownload(String name) { + if (icDownloadDone == null) { + // TODO: use a proper icon for failed downloads + icDownloadDone = BitmapFactory.decodeResource(this.getResources(), android.R.drawable.stat_sys_download_done); + } + + Builder notification = new Builder(this, getString(R.string.notification_channel_id)) + .setAutoCancel(true) + .setLargeIcon(icDownloadDone) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentIntent(PendingIntent.getService(this, (int) System.currentTimeMillis() + 1, + new Intent(this, DownloadActivity.class) + .setAction(Intent.ACTION_MAIN), + PendingIntent.FLAG_UPDATE_CURRENT)); + + if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + notification.setContentTitle(getString(R.string.app_name)); + notification.setStyle(new NotificationCompat.BigTextStyle() + .bigText(getString(R.string.download_failed).concat(": ").concat(name))); + } else { + notification.setContentTitle(getString(R.string.download_failed)); + notification.setContentText(name); + notification.setStyle(new NotificationCompat.BigTextStyle() + .bigText(name)); + } + + notificationManager.notify(downloadFailedNotificationID++, notification.build()); + } + private void manageObservers(Handler handler, boolean add) { synchronized (mEchoObservers) { if (add) { - mEchoObservers.add(handler); + mEchoObservers.add(handler); } else { mEchoObservers.remove(handler); } 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 bb5af1b0d..c4eb663f9 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 @@ -142,7 +142,7 @@ public class MissionAdapter extends RecyclerView.Adapter { str = R.string.missions_header_pending; } else { str = R.string.missions_header_finished; - mClear.setVisible(true); + setClearButtonVisibility(true); } ((ViewHolderHeader) view).header.setText(str); @@ -233,8 +233,7 @@ public class MissionAdapter extends RecyclerView.Adapter { } } - long length = mission.offsets[mission.current < mission.offsets.length ? mission.current : (mission.offsets.length - 1)]; - length += mission.length; + long length = mission.getLength(); int state = 0; if (!mission.isFinished()) { @@ -274,7 +273,7 @@ public class MissionAdapter extends RecyclerView.Adapter { return; } - + if (deltaTime > 1000 && deltaDone > 0) { float speed = (float) deltaDone / deltaTime; String speedStr = Utility.formatSpeed(speed * 1000); @@ -297,7 +296,7 @@ public class MissionAdapter extends RecyclerView.Adapter { Log.v(TAG, "Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID + ".provider"); Uri uri = FileProvider.getUriForFile(mContext, BuildConfig.APPLICATION_ID + ".provider", file); - + Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); intent.setDataAndType(uri, mimeType); @@ -390,7 +389,7 @@ public class MissionAdapter extends RecyclerView.Adapter { str.append(mContext.getString(R.string.error_connect_host)); break; case DownloadMission.ERROR_POSTPROCESSING_FAILED: - str.append(R.string.error_postprocessing_failed); + str.append(mContext.getString(R.string.error_postprocessing_failed)); case DownloadMission.ERROR_UNKNOWN_EXCEPTION: break; default: @@ -418,7 +417,7 @@ public class MissionAdapter extends RecyclerView.Adapter { public void clearFinishedDownloads() { mDownloadManager.forgetFinishedDownloads(); applyChanges(); - mClear.setVisible(false); + setClearButtonVisibility(false); } private boolean handlePopupItem(@NonNull ViewHolderItem h, @NonNull MenuItem option) { @@ -429,7 +428,7 @@ public class MissionAdapter extends RecyclerView.Adapter { switch (id) { case R.id.start: h.state = -1; - h.size.setText(Utility.formatBytes(mission.length)); + h.size.setText(Utility.formatBytes(mission.getLength())); mDownloadManager.resumeMission(mission); return true; case R.id.pause: @@ -466,11 +465,11 @@ public class MissionAdapter extends RecyclerView.Adapter { new ChecksumTask(mContext).execute(h.item.mission.getDownloadedFile().getAbsolutePath(), ALGORITHMS.get(id)); return true; case R.id.source: - /*Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(h.item.mission.source)); - mContext.startActivity(intent);*/ + /*Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(h.item.mission.source)); + mContext.startActivity(intent);*/ try { Intent intent = NavigationHelper.getIntentByLink(mContext, h.item.mission.source); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); mContext.startActivity(intent); } catch (Exception e) { Log.w(TAG, "Selected item has a invalid source", e); @@ -490,7 +489,7 @@ public class MissionAdapter extends RecyclerView.Adapter { if (mIterator.getOldListSize() > 0) { int lastItemType = mIterator.getSpecialAtItem(mIterator.getOldListSize() - 1); - mClear.setVisible(lastItemType == DownloadManager.SPECIAL_FINISHED); + setClearButtonVisibility(lastItemType == DownloadManager.SPECIAL_FINISHED); } } @@ -498,6 +497,10 @@ public class MissionAdapter extends RecyclerView.Adapter { mIterator.start(); mIterator.end(); + for (ViewHolderItem item: mPendingDownloadsItems) { + item.lastTimeStamp = -1; + } + notifyDataSetChanged(); } @@ -505,6 +508,18 @@ public class MissionAdapter extends RecyclerView.Adapter { mLayout = isLinear ? R.layout.mission_item_linear : R.layout.mission_item; } + public void setClearButton(MenuItem clearButton) { + if (mClear == null) { + int lastItemType = mIterator.getSpecialAtItem(mIterator.getOldListSize() - 1); + clearButton.setVisible(lastItemType == DownloadManager.SPECIAL_FINISHED); + } + mClear = clearButton; + } + + private void setClearButtonVisibility(boolean flag) { + mClear.setVisible(flag); + } + private void checkEmptyMessageVisibility() { int flag = mIterator.getOldListSize() > 0 ? View.GONE : View.VISIBLE; if (mEmptyMessage.getVisibility() != flag) mEmptyMessage.setVisibility(flag); @@ -577,8 +592,8 @@ public class MissionAdapter extends RecyclerView.Adapter { checksum = menu.findItem(R.id.checksum); itemView.setOnClickListener((v) -> { - if(((DownloadMission)item.mission).isFinished()) - viewWithFileProvider(item.mission.getDownloadedFile()); + if (((DownloadMission) item.mission).isFinished()) + viewWithFileProvider(item.mission.getDownloadedFile()); }); //h.itemView.setOnClickListener(v -> showDetail(h)); @@ -607,9 +622,9 @@ public class MissionAdapter extends RecyclerView.Adapter { queue.setChecked(mission.enqueued); - start.setVisible(mission.errCode != DownloadMission.ERROR_POSTPROCESSING_FAILED); delete.setVisible(true); - queue.setVisible(true); + start.setVisible(mission.errCode != DownloadMission.ERROR_POSTPROCESSING_FAILED); + queue.setVisible(mission.errCode != DownloadMission.ERROR_POSTPROCESSING_FAILED); } } } else { 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 00d7f9695..f04361f19 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 @@ -33,7 +33,7 @@ public class MissionsFragment extends Fragment { private SharedPreferences mPrefs; private boolean mLinear; private MenuItem mSwitch; - private MenuItem mClear; + private MenuItem mClear = null; private RecyclerView mList; private View mEmpty; @@ -152,6 +152,7 @@ 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); super.onPrepareOptionsMenu(menu); } diff --git a/app/src/main/res/layout/mission_item_linear.xml b/app/src/main/res/layout/mission_item_linear.xml index 0133d0c3f..7fff76235 100644 --- a/app/src/main/res/layout/mission_item_linear.xml +++ b/app/src/main/res/layout/mission_item_linear.xml @@ -56,6 +56,7 @@ android:layout_toRightOf="@id/item_size" android:padding="6dp" android:singleLine="true" + android:textStyle="bold" android:text="0%" android:textColor="@color/white" android:textSize="12sp" /> diff --git a/app/src/main/res/layout/missions_header.xml b/app/src/main/res/layout/missions_header.xml index f5226e3dd..9505a2fce 100644 --- a/app/src/main/res/layout/missions_header.xml +++ b/app/src/main/res/layout/missions_header.xml @@ -2,7 +2,6 @@ + android:textStyle="bold" + android:text="relative header"/> Archivo borrado - - Descarga finalizada: %s + + Descarga fallida + Descarga finalizada %s descargas finalizadas diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 04656aefa..ade72ccad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -539,8 +539,9 @@ Action denied by the system - - Download finished: %s + + Download failed + Download finished %s downloads finished