Fix Lint: Inconsistent line separators

This commit is contained in:
TobiGr 2020-11-22 10:16:27 +01:00
parent 18fb0a13d7
commit 6f3dfad550
8 changed files with 1032 additions and 1032 deletions

View File

@ -1,416 +1,416 @@
package org.schabi.newpipe.streams;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
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.Closeable;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
/**
* @author kapodamy
*/
public class OggFromWebMWriter implements Closeable {
private static final byte FLAG_UNSET = 0x00;
//private static final byte FLAG_CONTINUED = 0x01;
private static final byte FLAG_FIRST = 0x02;
private static final byte FLAG_LAST = 0x04;
private static final byte HEADER_CHECKSUM_OFFSET = 22;
private static final byte HEADER_SIZE = 27;
private static final int TIME_SCALE_NS = 1000000000;
private boolean done = false;
private boolean parsed = false;
private final SharpStream source;
private final SharpStream output;
private int sequenceCount = 0;
private final int streamId;
private byte packetFlag = FLAG_FIRST;
private WebMReader webm = null;
private WebMTrack webmTrack = null;
private Segment webmSegment = null;
private Cluster webmCluster = null;
private SimpleBlock webmBlock = null;
private long webmBlockLastTimecode = 0;
private long webmBlockNearDuration = 0;
private short segmentTableSize = 0;
private final byte[] segmentTable = new byte[255];
private long segmentTableNextTimestamp = TIME_SCALE_NS;
private final int[] crc32Table = new int[256];
public OggFromWebMWriter(@NonNull final SharpStream source, @NonNull final SharpStream target) {
if (!source.canRead() || !source.canRewind()) {
throw new IllegalArgumentException("source stream must be readable and allows seeking");
}
if (!target.canWrite() || !target.canRewind()) {
throw new IllegalArgumentException("output stream must be writable and allows seeking");
}
this.source = source;
this.output = target;
this.streamId = (int) System.currentTimeMillis();
populateCrc32Table();
}
public boolean isDone() {
return done;
}
public boolean isParsed() {
return parsed;
}
public WebMTrack[] getTracksFromSource() throws IllegalStateException {
if (!parsed) {
throw new IllegalStateException("source must be parsed first");
}
return webm.getAvailableTracks();
}
public void parseSource() throws IOException, IllegalStateException {
if (done) {
throw new IllegalStateException("already done");
}
if (parsed) {
throw new IllegalStateException("already parsed");
}
try {
webm = new WebMReader(source);
webm.parse();
webmSegment = webm.getNextSegment();
} finally {
parsed = true;
}
}
public void selectTrack(final int trackIndex) throws IOException {
if (!parsed) {
throw new IllegalStateException("source must be parsed first");
}
if (done) {
throw new IOException("already done");
}
if (webmTrack != null) {
throw new IOException("tracks already selected");
}
switch (webm.getAvailableTracks()[trackIndex].kind) {
case Audio:
case Video:
break;
default:
throw new UnsupportedOperationException("the track must an audio or video stream");
}
try {
webmTrack = webm.selectTrack(trackIndex);
} finally {
parsed = true;
}
}
@Override
public void close() throws IOException {
done = true;
parsed = true;
webmTrack = null;
webm = null;
if (!output.isClosed()) {
output.flush();
}
source.close();
output.close();
}
public void build() throws IOException {
final float resolution;
SimpleBlock bloq;
final ByteBuffer header = ByteBuffer.allocate(27 + (255 * 255));
final ByteBuffer page = ByteBuffer.allocate(64 * 1024);
header.order(ByteOrder.LITTLE_ENDIAN);
/* step 1: get the amount of frames per seconds */
switch (webmTrack.kind) {
case Audio:
resolution = getSampleFrequencyFromTrack(webmTrack.bMetadata);
if (resolution == 0f) {
throw new RuntimeException("cannot get the audio sample rate");
}
break;
case Video:
// WARNING: untested
if (webmTrack.defaultDuration == 0) {
throw new RuntimeException("missing default frame time");
}
resolution = 1000f / ((float) webmTrack.defaultDuration
/ webmSegment.info.timecodeScale);
break;
default:
throw new RuntimeException("not implemented");
}
/* step 2: create packet with code init data */
if (webmTrack.codecPrivate != null) {
addPacketSegment(webmTrack.codecPrivate.length);
makePacketheader(0x00, header, webmTrack.codecPrivate);
write(header);
output.write(webmTrack.codecPrivate);
}
/* step 3: create packet with metadata */
final byte[] buffer = makeMetadata();
if (buffer != null) {
addPacketSegment(buffer.length);
makePacketheader(0x00, header, buffer);
write(header);
output.write(buffer);
}
/* step 4: calculate amount of packets */
while (webmSegment != null) {
bloq = getNextBlock();
if (bloq != null && addPacketSegment(bloq)) {
final int pos = page.position();
//noinspection ResultOfMethodCallIgnored
bloq.data.read(page.array(), pos, bloq.dataSize);
page.position(pos + bloq.dataSize);
continue;
}
// calculate the current packet duration using the next block
double elapsedNs = webmTrack.codecDelay;
if (bloq == null) {
packetFlag = FLAG_LAST; // note: if the flag is FLAG_CONTINUED, is changed
elapsedNs += webmBlockLastTimecode;
if (webmTrack.defaultDuration > 0) {
elapsedNs += webmTrack.defaultDuration;
} else {
// hardcoded way, guess the sample duration
elapsedNs += webmBlockNearDuration;
}
} else {
elapsedNs += bloq.absoluteTimeCodeNs;
}
// get the sample count in the page
elapsedNs = elapsedNs / TIME_SCALE_NS;
elapsedNs = Math.ceil(elapsedNs * resolution);
// create header and calculate page checksum
int checksum = makePacketheader((long) elapsedNs, header, null);
checksum = calcCrc32(checksum, page.array(), page.position());
header.putInt(HEADER_CHECKSUM_OFFSET, checksum);
// dump data
write(header);
write(page);
webmBlock = bloq;
}
}
private int makePacketheader(final long granPos, @NonNull final ByteBuffer buffer,
final byte[] immediatePage) {
short length = HEADER_SIZE;
buffer.putInt(0x5367674f); // "OggS" binary string in little-endian
buffer.put((byte) 0x00); // version
buffer.put(packetFlag); // type
buffer.putLong(granPos); // granulate position
buffer.putInt(streamId); // bitstream serial number
buffer.putInt(sequenceCount++); // page sequence number
buffer.putInt(0x00); // page checksum
buffer.put((byte) segmentTableSize); // segment table
buffer.put(segmentTable, 0, segmentTableSize); // segment size
length += segmentTableSize;
clearSegmentTable(); // clear segment table for next header
int checksumCrc32 = calcCrc32(0x00, buffer.array(), length);
if (immediatePage != null) {
checksumCrc32 = calcCrc32(checksumCrc32, immediatePage, immediatePage.length);
buffer.putInt(HEADER_CHECKSUM_OFFSET, checksumCrc32);
segmentTableNextTimestamp -= TIME_SCALE_NS;
}
return checksumCrc32;
}
@Nullable
private byte[] makeMetadata() {
if ("A_OPUS".equals(webmTrack.codecId)) {
return new byte[]{
0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string
0x00, 0x00, 0x00, 0x00, // writing application string size (not present)
0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags)
};
} else if ("A_VORBIS".equals(webmTrack.codecId)) {
return new byte[]{
0x03, // ¿¿¿???
0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, // "vorbis" binary string
0x00, 0x00, 0x00, 0x00, // writing application string size (not present)
0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags)
};
}
// not implemented for the desired codec
return null;
}
private void write(final ByteBuffer buffer) throws IOException {
output.write(buffer.array(), 0, buffer.position());
buffer.position(0);
}
@Nullable
private SimpleBlock getNextBlock() throws IOException {
SimpleBlock res;
if (webmBlock != null) {
res = webmBlock;
webmBlock = null;
return res;
}
if (webmSegment == null) {
webmSegment = webm.getNextSegment();
if (webmSegment == null) {
return null; // no more blocks in the selected track
}
}
if (webmCluster == null) {
webmCluster = webmSegment.getNextCluster();
if (webmCluster == null) {
webmSegment = null;
return getNextBlock();
}
}
res = webmCluster.getNextSimpleBlock();
if (res == null) {
webmCluster = null;
return getNextBlock();
}
webmBlockNearDuration = res.absoluteTimeCodeNs - webmBlockLastTimecode;
webmBlockLastTimecode = res.absoluteTimeCodeNs;
return res;
}
private float getSampleFrequencyFromTrack(final byte[] bMetadata) {
// hardcoded way
final ByteBuffer buffer = ByteBuffer.wrap(bMetadata);
while (buffer.remaining() >= 6) {
final int id = buffer.getShort() & 0xFFFF;
if (id == 0x0000B584) {
return buffer.getFloat();
}
}
return 0.0f;
}
private void clearSegmentTable() {
segmentTableNextTimestamp += TIME_SCALE_NS;
packetFlag = FLAG_UNSET;
segmentTableSize = 0;
}
private boolean addPacketSegment(final SimpleBlock block) {
final long timestamp = block.absoluteTimeCodeNs + webmTrack.codecDelay;
if (timestamp >= segmentTableNextTimestamp) {
return false;
}
return addPacketSegment(block.dataSize);
}
private boolean addPacketSegment(final int size) {
if (size > 65025) {
throw new UnsupportedOperationException("page size cannot be larger than 65025");
}
int available = (segmentTable.length - segmentTableSize) * 255;
final boolean extra = (size % 255) == 0;
if (extra) {
// add a zero byte entry in the table
// required to indicate the sample size is multiple of 255
available -= 255;
}
// check if possible add the segment, without overflow the table
if (available < size) {
return false; // not enough space on the page
}
for (int seg = size; seg > 0; seg -= 255) {
segmentTable[segmentTableSize++] = (byte) Math.min(seg, 255);
}
if (extra) {
segmentTable[segmentTableSize++] = 0x00;
}
return true;
}
private void populateCrc32Table() {
for (int i = 0; i < 0x100; i++) {
int crc = i << 24;
for (int j = 0; j < 8; j++) {
final long b = crc >>> 31;
crc <<= 1;
crc ^= (int) (0x100000000L - b) & 0x04c11db7;
}
crc32Table[i] = crc;
}
}
private int calcCrc32(final int initialCrc, final byte[] buffer, final int size) {
int crc = initialCrc;
for (int i = 0; i < size; i++) {
final int reg = (crc >>> 24) & 0xff;
crc = (crc << 8) ^ crc32Table[reg ^ (buffer[i] & 0xff)];
}
return crc;
}
}
package org.schabi.newpipe.streams;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
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.Closeable;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
/**
* @author kapodamy
*/
public class OggFromWebMWriter implements Closeable {
private static final byte FLAG_UNSET = 0x00;
//private static final byte FLAG_CONTINUED = 0x01;
private static final byte FLAG_FIRST = 0x02;
private static final byte FLAG_LAST = 0x04;
private static final byte HEADER_CHECKSUM_OFFSET = 22;
private static final byte HEADER_SIZE = 27;
private static final int TIME_SCALE_NS = 1000000000;
private boolean done = false;
private boolean parsed = false;
private final SharpStream source;
private final SharpStream output;
private int sequenceCount = 0;
private final int streamId;
private byte packetFlag = FLAG_FIRST;
private WebMReader webm = null;
private WebMTrack webmTrack = null;
private Segment webmSegment = null;
private Cluster webmCluster = null;
private SimpleBlock webmBlock = null;
private long webmBlockLastTimecode = 0;
private long webmBlockNearDuration = 0;
private short segmentTableSize = 0;
private final byte[] segmentTable = new byte[255];
private long segmentTableNextTimestamp = TIME_SCALE_NS;
private final int[] crc32Table = new int[256];
public OggFromWebMWriter(@NonNull final SharpStream source, @NonNull final SharpStream target) {
if (!source.canRead() || !source.canRewind()) {
throw new IllegalArgumentException("source stream must be readable and allows seeking");
}
if (!target.canWrite() || !target.canRewind()) {
throw new IllegalArgumentException("output stream must be writable and allows seeking");
}
this.source = source;
this.output = target;
this.streamId = (int) System.currentTimeMillis();
populateCrc32Table();
}
public boolean isDone() {
return done;
}
public boolean isParsed() {
return parsed;
}
public WebMTrack[] getTracksFromSource() throws IllegalStateException {
if (!parsed) {
throw new IllegalStateException("source must be parsed first");
}
return webm.getAvailableTracks();
}
public void parseSource() throws IOException, IllegalStateException {
if (done) {
throw new IllegalStateException("already done");
}
if (parsed) {
throw new IllegalStateException("already parsed");
}
try {
webm = new WebMReader(source);
webm.parse();
webmSegment = webm.getNextSegment();
} finally {
parsed = true;
}
}
public void selectTrack(final int trackIndex) throws IOException {
if (!parsed) {
throw new IllegalStateException("source must be parsed first");
}
if (done) {
throw new IOException("already done");
}
if (webmTrack != null) {
throw new IOException("tracks already selected");
}
switch (webm.getAvailableTracks()[trackIndex].kind) {
case Audio:
case Video:
break;
default:
throw new UnsupportedOperationException("the track must an audio or video stream");
}
try {
webmTrack = webm.selectTrack(trackIndex);
} finally {
parsed = true;
}
}
@Override
public void close() throws IOException {
done = true;
parsed = true;
webmTrack = null;
webm = null;
if (!output.isClosed()) {
output.flush();
}
source.close();
output.close();
}
public void build() throws IOException {
final float resolution;
SimpleBlock bloq;
final ByteBuffer header = ByteBuffer.allocate(27 + (255 * 255));
final ByteBuffer page = ByteBuffer.allocate(64 * 1024);
header.order(ByteOrder.LITTLE_ENDIAN);
/* step 1: get the amount of frames per seconds */
switch (webmTrack.kind) {
case Audio:
resolution = getSampleFrequencyFromTrack(webmTrack.bMetadata);
if (resolution == 0f) {
throw new RuntimeException("cannot get the audio sample rate");
}
break;
case Video:
// WARNING: untested
if (webmTrack.defaultDuration == 0) {
throw new RuntimeException("missing default frame time");
}
resolution = 1000f / ((float) webmTrack.defaultDuration
/ webmSegment.info.timecodeScale);
break;
default:
throw new RuntimeException("not implemented");
}
/* step 2: create packet with code init data */
if (webmTrack.codecPrivate != null) {
addPacketSegment(webmTrack.codecPrivate.length);
makePacketheader(0x00, header, webmTrack.codecPrivate);
write(header);
output.write(webmTrack.codecPrivate);
}
/* step 3: create packet with metadata */
final byte[] buffer = makeMetadata();
if (buffer != null) {
addPacketSegment(buffer.length);
makePacketheader(0x00, header, buffer);
write(header);
output.write(buffer);
}
/* step 4: calculate amount of packets */
while (webmSegment != null) {
bloq = getNextBlock();
if (bloq != null && addPacketSegment(bloq)) {
final int pos = page.position();
//noinspection ResultOfMethodCallIgnored
bloq.data.read(page.array(), pos, bloq.dataSize);
page.position(pos + bloq.dataSize);
continue;
}
// calculate the current packet duration using the next block
double elapsedNs = webmTrack.codecDelay;
if (bloq == null) {
packetFlag = FLAG_LAST; // note: if the flag is FLAG_CONTINUED, is changed
elapsedNs += webmBlockLastTimecode;
if (webmTrack.defaultDuration > 0) {
elapsedNs += webmTrack.defaultDuration;
} else {
// hardcoded way, guess the sample duration
elapsedNs += webmBlockNearDuration;
}
} else {
elapsedNs += bloq.absoluteTimeCodeNs;
}
// get the sample count in the page
elapsedNs = elapsedNs / TIME_SCALE_NS;
elapsedNs = Math.ceil(elapsedNs * resolution);
// create header and calculate page checksum
int checksum = makePacketheader((long) elapsedNs, header, null);
checksum = calcCrc32(checksum, page.array(), page.position());
header.putInt(HEADER_CHECKSUM_OFFSET, checksum);
// dump data
write(header);
write(page);
webmBlock = bloq;
}
}
private int makePacketheader(final long granPos, @NonNull final ByteBuffer buffer,
final byte[] immediatePage) {
short length = HEADER_SIZE;
buffer.putInt(0x5367674f); // "OggS" binary string in little-endian
buffer.put((byte) 0x00); // version
buffer.put(packetFlag); // type
buffer.putLong(granPos); // granulate position
buffer.putInt(streamId); // bitstream serial number
buffer.putInt(sequenceCount++); // page sequence number
buffer.putInt(0x00); // page checksum
buffer.put((byte) segmentTableSize); // segment table
buffer.put(segmentTable, 0, segmentTableSize); // segment size
length += segmentTableSize;
clearSegmentTable(); // clear segment table for next header
int checksumCrc32 = calcCrc32(0x00, buffer.array(), length);
if (immediatePage != null) {
checksumCrc32 = calcCrc32(checksumCrc32, immediatePage, immediatePage.length);
buffer.putInt(HEADER_CHECKSUM_OFFSET, checksumCrc32);
segmentTableNextTimestamp -= TIME_SCALE_NS;
}
return checksumCrc32;
}
@Nullable
private byte[] makeMetadata() {
if ("A_OPUS".equals(webmTrack.codecId)) {
return new byte[]{
0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string
0x00, 0x00, 0x00, 0x00, // writing application string size (not present)
0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags)
};
} else if ("A_VORBIS".equals(webmTrack.codecId)) {
return new byte[]{
0x03, // ¿¿¿???
0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, // "vorbis" binary string
0x00, 0x00, 0x00, 0x00, // writing application string size (not present)
0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags)
};
}
// not implemented for the desired codec
return null;
}
private void write(final ByteBuffer buffer) throws IOException {
output.write(buffer.array(), 0, buffer.position());
buffer.position(0);
}
@Nullable
private SimpleBlock getNextBlock() throws IOException {
SimpleBlock res;
if (webmBlock != null) {
res = webmBlock;
webmBlock = null;
return res;
}
if (webmSegment == null) {
webmSegment = webm.getNextSegment();
if (webmSegment == null) {
return null; // no more blocks in the selected track
}
}
if (webmCluster == null) {
webmCluster = webmSegment.getNextCluster();
if (webmCluster == null) {
webmSegment = null;
return getNextBlock();
}
}
res = webmCluster.getNextSimpleBlock();
if (res == null) {
webmCluster = null;
return getNextBlock();
}
webmBlockNearDuration = res.absoluteTimeCodeNs - webmBlockLastTimecode;
webmBlockLastTimecode = res.absoluteTimeCodeNs;
return res;
}
private float getSampleFrequencyFromTrack(final byte[] bMetadata) {
// hardcoded way
final ByteBuffer buffer = ByteBuffer.wrap(bMetadata);
while (buffer.remaining() >= 6) {
final int id = buffer.getShort() & 0xFFFF;
if (id == 0x0000B584) {
return buffer.getFloat();
}
}
return 0.0f;
}
private void clearSegmentTable() {
segmentTableNextTimestamp += TIME_SCALE_NS;
packetFlag = FLAG_UNSET;
segmentTableSize = 0;
}
private boolean addPacketSegment(final SimpleBlock block) {
final long timestamp = block.absoluteTimeCodeNs + webmTrack.codecDelay;
if (timestamp >= segmentTableNextTimestamp) {
return false;
}
return addPacketSegment(block.dataSize);
}
private boolean addPacketSegment(final int size) {
if (size > 65025) {
throw new UnsupportedOperationException("page size cannot be larger than 65025");
}
int available = (segmentTable.length - segmentTableSize) * 255;
final boolean extra = (size % 255) == 0;
if (extra) {
// add a zero byte entry in the table
// required to indicate the sample size is multiple of 255
available -= 255;
}
// check if possible add the segment, without overflow the table
if (available < size) {
return false; // not enough space on the page
}
for (int seg = size; seg > 0; seg -= 255) {
segmentTable[segmentTableSize++] = (byte) Math.min(seg, 255);
}
if (extra) {
segmentTable[segmentTableSize++] = 0x00;
}
return true;
}
private void populateCrc32Table() {
for (int i = 0; i < 0x100; i++) {
int crc = i << 24;
for (int j = 0; j < 8; j++) {
final long b = crc >>> 31;
crc <<= 1;
crc ^= (int) (0x100000000L - b) & 0x04c11db7;
}
crc32Table[i] = crc;
}
}
private int calcCrc32(final int initialCrc, final byte[] buffer, final int size) {
int crc = initialCrc;
for (int i = 0; i < size; i++) {
final int reg = (crc >>> 24) & 0xff;
crc = (crc << 8) ^ crc32Table[reg ^ (buffer[i] & 0xff)];
}
return crc;
}
}

View File

@ -1,313 +1,313 @@
package us.shandian.giga.get;
import android.util.Log;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.StreamExtractor;
import org.schabi.newpipe.extractor.stream.SubtitlesStream;
import org.schabi.newpipe.extractor.stream.VideoStream;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.net.HttpURLConnection;
import java.nio.channels.ClosedByInterruptException;
import java.util.List;
import us.shandian.giga.get.DownloadMission.HttpError;
import static us.shandian.giga.get.DownloadMission.ERROR_RESOURCE_GONE;
public class DownloadMissionRecover extends Thread {
private static final String TAG = "DownloadMissionRecover";
static final int mID = -3;
private final DownloadMission mMission;
private final boolean mNotInitialized;
private final int mErrCode;
private HttpURLConnection mConn;
private MissionRecoveryInfo mRecovery;
private StreamExtractor mExtractor;
DownloadMissionRecover(DownloadMission mission, int errCode) {
mMission = mission;
mNotInitialized = mission.blocks == null && mission.current == 0;
mErrCode = errCode;
}
@Override
public void run() {
if (mMission.source == null) {
mMission.notifyError(mErrCode, null);
return;
}
Exception err = null;
int attempt = 0;
while (attempt++ < mMission.maxRetry) {
try {
tryRecover();
return;
} catch (InterruptedIOException | ClosedByInterruptException e) {
return;
} catch (Exception e) {
if (!mMission.running || super.isInterrupted()) return;
err = e;
}
}
// give up
mMission.notifyError(mErrCode, err);
}
private void tryRecover() throws ExtractionException, IOException, HttpError {
if (mExtractor == null) {
try {
StreamingService svr = NewPipe.getServiceByUrl(mMission.source);
mExtractor = svr.getStreamExtractor(mMission.source);
mExtractor.fetchPage();
} catch (ExtractionException e) {
mExtractor = null;
throw e;
}
}
// maybe the following check is redundant
if (!mMission.running || super.isInterrupted()) return;
if (!mNotInitialized) {
// set the current download url to null in case if the recovery
// process is canceled. Next time start() method is called the
// recovery will be executed, saving time
mMission.urls[mMission.current] = null;
mRecovery = mMission.recoveryInfo[mMission.current];
resolveStream();
return;
}
Log.w(TAG, "mission is not fully initialized, this will take a while");
try {
for (; mMission.current < mMission.urls.length; mMission.current++) {
mRecovery = mMission.recoveryInfo[mMission.current];
if (test()) continue;
if (!mMission.running) return;
resolveStream();
if (!mMission.running) return;
// before continue, check if the current stream was resolved
if (mMission.urls[mMission.current] == null) {
break;
}
}
} finally {
mMission.current = 0;
}
mMission.writeThisToFile();
if (!mMission.running || super.isInterrupted()) return;
mMission.running = false;
mMission.start();
}
private void resolveStream() throws IOException, ExtractionException, HttpError {
// FIXME: this getErrorMessage() always returns "video is unavailable"
/*if (mExtractor.getErrorMessage() != null) {
mMission.notifyError(mErrCode, new ExtractionException(mExtractor.getErrorMessage()));
return;
}*/
String url = null;
switch (mRecovery.getKind()) {
case 'a':
for (AudioStream audio : mExtractor.getAudioStreams()) {
if (audio.average_bitrate == mRecovery.getDesiredBitrate() && audio.getFormat() == mRecovery.getFormat()) {
url = audio.getUrl();
break;
}
}
break;
case 'v':
List<VideoStream> videoStreams;
if (mRecovery.isDesired2())
videoStreams = mExtractor.getVideoOnlyStreams();
else
videoStreams = mExtractor.getVideoStreams();
for (VideoStream video : videoStreams) {
if (video.resolution.equals(mRecovery.getDesired()) && video.getFormat() == mRecovery.getFormat()) {
url = video.getUrl();
break;
}
}
break;
case 's':
for (SubtitlesStream subtitles : mExtractor.getSubtitles(mRecovery.getFormat())) {
String tag = subtitles.getLanguageTag();
if (tag.equals(mRecovery.getDesired()) && subtitles.isAutoGenerated() == mRecovery.isDesired2()) {
url = subtitles.getUrl();
break;
}
}
break;
default:
throw new RuntimeException("Unknown stream type");
}
resolve(url);
}
private void resolve(String url) throws IOException, HttpError {
if (mRecovery.getValidateCondition() == null) {
Log.w(TAG, "validation condition not defined, the resource can be stale");
}
if (mMission.unknownLength || mRecovery.getValidateCondition() == null) {
recover(url, false);
return;
}
///////////////////////////////////////////////////////////////////////
////// Validate the http resource doing a range request
/////////////////////
try {
mConn = mMission.openConnection(url, true, mMission.length - 10, mMission.length);
mConn.setRequestProperty("If-Range", mRecovery.getValidateCondition());
mMission.establishConnection(mID, mConn);
int code = mConn.getResponseCode();
switch (code) {
case 200:
case 413:
// stale
recover(url, true);
return;
case 206:
// in case of validation using the Last-Modified date, check the resource length
long[] contentRange = parseContentRange(mConn.getHeaderField("Content-Range"));
boolean lengthMismatch = contentRange[2] != -1 && contentRange[2] != mMission.length;
recover(url, lengthMismatch);
return;
}
throw new HttpError(code);
} finally {
disconnect();
}
}
private void recover(String url, boolean stale) {
Log.i(TAG,
String.format("recover() name=%s isStale=%s url=%s", mMission.storage.getName(), stale, url)
);
mMission.urls[mMission.current] = url;
if (url == null) {
mMission.urls = new String[0];
mMission.notifyError(ERROR_RESOURCE_GONE, null);
return;
}
if (mNotInitialized) return;
if (stale) {
mMission.resetState(false, false, DownloadMission.ERROR_NOTHING);
}
mMission.writeThisToFile();
if (!mMission.running || super.isInterrupted()) return;
mMission.running = false;
mMission.start();
}
private long[] parseContentRange(String value) {
long[] range = new long[3];
if (value == null) {
// this never should happen
return range;
}
try {
value = value.trim();
if (!value.startsWith("bytes")) {
return range;// unknown range type
}
int space = value.lastIndexOf(' ') + 1;
int dash = value.indexOf('-', space) + 1;
int bar = value.indexOf('/', dash);
// start
range[0] = Long.parseLong(value.substring(space, dash - 1));
// end
range[1] = Long.parseLong(value.substring(dash, bar));
// resource length
value = value.substring(bar + 1);
if (value.equals("*")) {
range[2] = -1;// unknown length received from the server but should be valid
} else {
range[2] = Long.parseLong(value);
}
} catch (Exception e) {
// nothing to do
}
return range;
}
private boolean test() {
if (mMission.urls[mMission.current] == null) return false;
try {
mConn = mMission.openConnection(mMission.urls[mMission.current], true, -1, -1);
mMission.establishConnection(mID, mConn);
if (mConn.getResponseCode() == 200) return true;
} catch (Exception e) {
// nothing to do
} finally {
disconnect();
}
return false;
}
private void disconnect() {
try {
try {
mConn.getInputStream().close();
} finally {
mConn.disconnect();
}
} catch (Exception e) {
// nothing to do
} finally {
mConn = null;
}
}
@Override
public void interrupt() {
super.interrupt();
if (mConn != null) disconnect();
}
}
package us.shandian.giga.get;
import android.util.Log;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.StreamExtractor;
import org.schabi.newpipe.extractor.stream.SubtitlesStream;
import org.schabi.newpipe.extractor.stream.VideoStream;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.net.HttpURLConnection;
import java.nio.channels.ClosedByInterruptException;
import java.util.List;
import us.shandian.giga.get.DownloadMission.HttpError;
import static us.shandian.giga.get.DownloadMission.ERROR_RESOURCE_GONE;
public class DownloadMissionRecover extends Thread {
private static final String TAG = "DownloadMissionRecover";
static final int mID = -3;
private final DownloadMission mMission;
private final boolean mNotInitialized;
private final int mErrCode;
private HttpURLConnection mConn;
private MissionRecoveryInfo mRecovery;
private StreamExtractor mExtractor;
DownloadMissionRecover(DownloadMission mission, int errCode) {
mMission = mission;
mNotInitialized = mission.blocks == null && mission.current == 0;
mErrCode = errCode;
}
@Override
public void run() {
if (mMission.source == null) {
mMission.notifyError(mErrCode, null);
return;
}
Exception err = null;
int attempt = 0;
while (attempt++ < mMission.maxRetry) {
try {
tryRecover();
return;
} catch (InterruptedIOException | ClosedByInterruptException e) {
return;
} catch (Exception e) {
if (!mMission.running || super.isInterrupted()) return;
err = e;
}
}
// give up
mMission.notifyError(mErrCode, err);
}
private void tryRecover() throws ExtractionException, IOException, HttpError {
if (mExtractor == null) {
try {
StreamingService svr = NewPipe.getServiceByUrl(mMission.source);
mExtractor = svr.getStreamExtractor(mMission.source);
mExtractor.fetchPage();
} catch (ExtractionException e) {
mExtractor = null;
throw e;
}
}
// maybe the following check is redundant
if (!mMission.running || super.isInterrupted()) return;
if (!mNotInitialized) {
// set the current download url to null in case if the recovery
// process is canceled. Next time start() method is called the
// recovery will be executed, saving time
mMission.urls[mMission.current] = null;
mRecovery = mMission.recoveryInfo[mMission.current];
resolveStream();
return;
}
Log.w(TAG, "mission is not fully initialized, this will take a while");
try {
for (; mMission.current < mMission.urls.length; mMission.current++) {
mRecovery = mMission.recoveryInfo[mMission.current];
if (test()) continue;
if (!mMission.running) return;
resolveStream();
if (!mMission.running) return;
// before continue, check if the current stream was resolved
if (mMission.urls[mMission.current] == null) {
break;
}
}
} finally {
mMission.current = 0;
}
mMission.writeThisToFile();
if (!mMission.running || super.isInterrupted()) return;
mMission.running = false;
mMission.start();
}
private void resolveStream() throws IOException, ExtractionException, HttpError {
// FIXME: this getErrorMessage() always returns "video is unavailable"
/*if (mExtractor.getErrorMessage() != null) {
mMission.notifyError(mErrCode, new ExtractionException(mExtractor.getErrorMessage()));
return;
}*/
String url = null;
switch (mRecovery.getKind()) {
case 'a':
for (AudioStream audio : mExtractor.getAudioStreams()) {
if (audio.average_bitrate == mRecovery.getDesiredBitrate() && audio.getFormat() == mRecovery.getFormat()) {
url = audio.getUrl();
break;
}
}
break;
case 'v':
List<VideoStream> videoStreams;
if (mRecovery.isDesired2())
videoStreams = mExtractor.getVideoOnlyStreams();
else
videoStreams = mExtractor.getVideoStreams();
for (VideoStream video : videoStreams) {
if (video.resolution.equals(mRecovery.getDesired()) && video.getFormat() == mRecovery.getFormat()) {
url = video.getUrl();
break;
}
}
break;
case 's':
for (SubtitlesStream subtitles : mExtractor.getSubtitles(mRecovery.getFormat())) {
String tag = subtitles.getLanguageTag();
if (tag.equals(mRecovery.getDesired()) && subtitles.isAutoGenerated() == mRecovery.isDesired2()) {
url = subtitles.getUrl();
break;
}
}
break;
default:
throw new RuntimeException("Unknown stream type");
}
resolve(url);
}
private void resolve(String url) throws IOException, HttpError {
if (mRecovery.getValidateCondition() == null) {
Log.w(TAG, "validation condition not defined, the resource can be stale");
}
if (mMission.unknownLength || mRecovery.getValidateCondition() == null) {
recover(url, false);
return;
}
///////////////////////////////////////////////////////////////////////
////// Validate the http resource doing a range request
/////////////////////
try {
mConn = mMission.openConnection(url, true, mMission.length - 10, mMission.length);
mConn.setRequestProperty("If-Range", mRecovery.getValidateCondition());
mMission.establishConnection(mID, mConn);
int code = mConn.getResponseCode();
switch (code) {
case 200:
case 413:
// stale
recover(url, true);
return;
case 206:
// in case of validation using the Last-Modified date, check the resource length
long[] contentRange = parseContentRange(mConn.getHeaderField("Content-Range"));
boolean lengthMismatch = contentRange[2] != -1 && contentRange[2] != mMission.length;
recover(url, lengthMismatch);
return;
}
throw new HttpError(code);
} finally {
disconnect();
}
}
private void recover(String url, boolean stale) {
Log.i(TAG,
String.format("recover() name=%s isStale=%s url=%s", mMission.storage.getName(), stale, url)
);
mMission.urls[mMission.current] = url;
if (url == null) {
mMission.urls = new String[0];
mMission.notifyError(ERROR_RESOURCE_GONE, null);
return;
}
if (mNotInitialized) return;
if (stale) {
mMission.resetState(false, false, DownloadMission.ERROR_NOTHING);
}
mMission.writeThisToFile();
if (!mMission.running || super.isInterrupted()) return;
mMission.running = false;
mMission.start();
}
private long[] parseContentRange(String value) {
long[] range = new long[3];
if (value == null) {
// this never should happen
return range;
}
try {
value = value.trim();
if (!value.startsWith("bytes")) {
return range;// unknown range type
}
int space = value.lastIndexOf(' ') + 1;
int dash = value.indexOf('-', space) + 1;
int bar = value.indexOf('/', dash);
// start
range[0] = Long.parseLong(value.substring(space, dash - 1));
// end
range[1] = Long.parseLong(value.substring(dash, bar));
// resource length
value = value.substring(bar + 1);
if (value.equals("*")) {
range[2] = -1;// unknown length received from the server but should be valid
} else {
range[2] = Long.parseLong(value);
}
} catch (Exception e) {
// nothing to do
}
return range;
}
private boolean test() {
if (mMission.urls[mMission.current] == null) return false;
try {
mConn = mMission.openConnection(mMission.urls[mMission.current], true, -1, -1);
mMission.establishConnection(mID, mConn);
if (mConn.getResponseCode() == 200) return true;
} catch (Exception e) {
// nothing to do
} finally {
disconnect();
}
return false;
}
private void disconnect() {
try {
try {
mConn.getInputStream().close();
} finally {
mConn.disconnect();
}
} catch (Exception e) {
// nothing to do
} finally {
mConn = null;
}
}
@Override
public void interrupt() {
super.interrupt();
if (mConn != null) disconnect();
}
}

View File

@ -1,18 +1,18 @@
package us.shandian.giga.get;
import androidx.annotation.NonNull;
public class FinishedMission extends Mission {
public FinishedMission() {
}
public FinishedMission(@NonNull DownloadMission mission) {
source = mission.source;
length = mission.length;
timestamp = mission.timestamp;
kind = mission.kind;
storage = mission.storage;
}
}
package us.shandian.giga.get;
import androidx.annotation.NonNull;
public class FinishedMission extends Mission {
public FinishedMission() {
}
public FinishedMission(@NonNull DownloadMission mission) {
source = mission.source;
length = mission.length;
timestamp = mission.timestamp;
kind = mission.kind;
storage = mission.storage;
}
}

View File

@ -1,64 +1,64 @@
package us.shandian.giga.get;
import androidx.annotation.NonNull;
import java.io.Serializable;
import java.util.Calendar;
import us.shandian.giga.io.StoredFileHelper;
public abstract class Mission implements Serializable {
private static final long serialVersionUID = 1L;// last bump: 27 march 2019
/**
* Source url of the resource
*/
public String source;
/**
* Length of the current resource
*/
public long length;
/**
* creation timestamp (and maybe unique identifier)
*/
public long timestamp;
/**
* pre-defined content type
*/
public char kind;
/**
* The downloaded file
*/
public StoredFileHelper storage;
public long getTimestamp() {
return timestamp;
}
/**
* Delete the downloaded file
*
* @return {@code true] if and only if the file is successfully deleted, otherwise, {@code false}
*/
public boolean delete() {
if (storage != null) return storage.delete();
return true;
}
/**
* Indicate if this mission is deleted whatever is stored
*/
public transient boolean deleted = false;
@NonNull
@Override
public String toString() {
Calendar calendar = Calendar.getInstance();
calendar.setTimeInMillis(timestamp);
return "[" + calendar.getTime().toString() + "] " + (storage.isInvalid() ? storage.getName() : storage.getUri());
}
}
package us.shandian.giga.get;
import androidx.annotation.NonNull;
import java.io.Serializable;
import java.util.Calendar;
import us.shandian.giga.io.StoredFileHelper;
public abstract class Mission implements Serializable {
private static final long serialVersionUID = 1L;// last bump: 27 march 2019
/**
* Source url of the resource
*/
public String source;
/**
* Length of the current resource
*/
public long length;
/**
* creation timestamp (and maybe unique identifier)
*/
public long timestamp;
/**
* pre-defined content type
*/
public char kind;
/**
* The downloaded file
*/
public StoredFileHelper storage;
public long getTimestamp() {
return timestamp;
}
/**
* Delete the downloaded file
*
* @return {@code true] if and only if the file is successfully deleted, otherwise, {@code false}
*/
public boolean delete() {
if (storage != null) return storage.delete();
return true;
}
/**
* Indicate if this mission is deleted whatever is stored
*/
public transient boolean deleted = false;
@NonNull
@Override
public String toString() {
Calendar calendar = Calendar.getInstance();
calendar.setTimeInMillis(timestamp);
return "[" + calendar.getTime().toString() + "] " + (storage.isInvalid() ? storage.getName() : storage.getUri());
}
}

View File

@ -1,11 +1,11 @@
package us.shandian.giga.io;
public interface ProgressReport {
/**
* Report the size of the new file
*
* @param progress the new size
*/
void report(long progress);
package us.shandian.giga.io;
public interface ProgressReport {
/**
* Report the size of the new file
*
* @param progress the new size
*/
void report(long progress);
}

View File

@ -1,44 +1,44 @@
package us.shandian.giga.postprocessing;
import androidx.annotation.NonNull;
import org.schabi.newpipe.streams.OggFromWebMWriter;
import org.schabi.newpipe.streams.io.SharpStream;
import java.io.IOException;
import java.nio.ByteBuffer;
class OggFromWebmDemuxer extends Postprocessing {
OggFromWebmDemuxer() {
super(true, true, ALGORITHM_OGG_FROM_WEBM_DEMUXER);
}
@Override
boolean test(SharpStream... sources) throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(4);
sources[0].read(buffer.array());
// youtube uses WebM as container, but the file extension (format suffix) is "*.opus"
// check if the file is a webm/mkv file before proceed
switch (buffer.getInt()) {
case 0x1a45dfa3:
return true;// webm/mkv
case 0x4F676753:
return false;// ogg
}
throw new UnsupportedOperationException("file not recognized, failed to demux the audio stream");
}
@Override
int process(SharpStream out, @NonNull SharpStream... sources) throws IOException {
OggFromWebMWriter demuxer = new OggFromWebMWriter(sources[0], out);
demuxer.parseSource();
demuxer.selectTrack(0);
demuxer.build();
return OK_RESULT;
}
}
package us.shandian.giga.postprocessing;
import androidx.annotation.NonNull;
import org.schabi.newpipe.streams.OggFromWebMWriter;
import org.schabi.newpipe.streams.io.SharpStream;
import java.io.IOException;
import java.nio.ByteBuffer;
class OggFromWebmDemuxer extends Postprocessing {
OggFromWebmDemuxer() {
super(true, true, ALGORITHM_OGG_FROM_WEBM_DEMUXER);
}
@Override
boolean test(SharpStream... sources) throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(4);
sources[0].read(buffer.array());
// youtube uses WebM as container, but the file extension (format suffix) is "*.opus"
// check if the file is a webm/mkv file before proceed
switch (buffer.getInt()) {
case 0x1a45dfa3:
return true;// webm/mkv
case 0x4F676753:
return false;// ogg
}
throw new UnsupportedOperationException("file not recognized, failed to demux the audio stream");
}
@Override
int process(SharpStream out, @NonNull SharpStream... sources) throws IOException {
OggFromWebMWriter demuxer = new OggFromWebMWriter(sources[0], out);
demuxer.parseSource();
demuxer.selectTrack(0);
demuxer.build();
return OK_RESULT;
}
}

View File

@ -1,138 +1,138 @@
package us.shandian.giga.ui.common;
import android.content.Context;
import android.content.Intent;
import android.graphics.Color;
import android.os.Handler;
import android.view.View;
import com.google.android.material.snackbar.Snackbar;
import org.schabi.newpipe.R;
import java.util.ArrayList;
import us.shandian.giga.get.FinishedMission;
import us.shandian.giga.get.Mission;
import us.shandian.giga.service.DownloadManager;
import us.shandian.giga.service.DownloadManager.MissionIterator;
import us.shandian.giga.ui.adapter.MissionAdapter;
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 Snackbar snackbar;
private ArrayList<Mission> items;
private boolean running = true;
private final Context mContext;
private final MissionAdapter mAdapter;
private final DownloadManager mDownloadManager;
private final MissionIterator mIterator;
private final Handler mHandler;
private final View mView;
private final Runnable rShow;
private final Runnable rNext;
private final Runnable rCommit;
public Deleter(View v, Context c, MissionAdapter a, DownloadManager d, MissionIterator i, Handler h) {
mView = v;
mContext = c;
mAdapter = a;
mDownloadManager = d;
mIterator = i;
mHandler = h;
// use variables to know the reference of the lambdas
rShow = this::show;
rNext = this::next;
rCommit = this::commit;
items = new ArrayList<>(2);
}
public void append(Mission item) {
mIterator.hide(item);
items.add(0, item);
show();
}
private void forget() {
mIterator.unHide(items.remove(0));
mAdapter.applyChanges();
show();
}
private void show() {
if (items.size() < 1) return;
pause();
running = true;
mHandler.postDelayed(rNext, DELAY);
}
private void next() {
if (items.size() < 1) return;
String msg = mContext.getString(R.string.file_deleted).concat(":\n").concat(items.get(0).storage.getName());
snackbar = Snackbar.make(mView, msg, Snackbar.LENGTH_INDEFINITE);
snackbar.setAction(R.string.undo, s -> forget());
snackbar.setActionTextColor(Color.YELLOW);
snackbar.show();
mHandler.postDelayed(rCommit, TIMEOUT);
}
private void commit() {
if (items.size() < 1) return;
while (items.size() > 0) {
Mission mission = items.remove(0);
if (mission.deleted) continue;
mIterator.unHide(mission);
mDownloadManager.deleteMission(mission);
if (mission instanceof FinishedMission) {
mContext.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, mission.storage.getUri()));
}
break;
}
if (items.size() < 1) {
pause();
return;
}
show();
}
public void pause() {
running = false;
mHandler.removeCallbacks(rNext);
mHandler.removeCallbacks(rShow);
mHandler.removeCallbacks(rCommit);
if (snackbar != null) snackbar.dismiss();
}
public void resume() {
if (running) return;
mHandler.postDelayed(rShow, DELAY_RESUME);
}
public void dispose() {
if (items.size() < 1) return;
pause();
for (Mission mission : items) mDownloadManager.deleteMission(mission);
items = null;
}
}
package us.shandian.giga.ui.common;
import android.content.Context;
import android.content.Intent;
import android.graphics.Color;
import android.os.Handler;
import android.view.View;
import com.google.android.material.snackbar.Snackbar;
import org.schabi.newpipe.R;
import java.util.ArrayList;
import us.shandian.giga.get.FinishedMission;
import us.shandian.giga.get.Mission;
import us.shandian.giga.service.DownloadManager;
import us.shandian.giga.service.DownloadManager.MissionIterator;
import us.shandian.giga.ui.adapter.MissionAdapter;
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 Snackbar snackbar;
private ArrayList<Mission> items;
private boolean running = true;
private final Context mContext;
private final MissionAdapter mAdapter;
private final DownloadManager mDownloadManager;
private final MissionIterator mIterator;
private final Handler mHandler;
private final View mView;
private final Runnable rShow;
private final Runnable rNext;
private final Runnable rCommit;
public Deleter(View v, Context c, MissionAdapter a, DownloadManager d, MissionIterator i, Handler h) {
mView = v;
mContext = c;
mAdapter = a;
mDownloadManager = d;
mIterator = i;
mHandler = h;
// use variables to know the reference of the lambdas
rShow = this::show;
rNext = this::next;
rCommit = this::commit;
items = new ArrayList<>(2);
}
public void append(Mission item) {
mIterator.hide(item);
items.add(0, item);
show();
}
private void forget() {
mIterator.unHide(items.remove(0));
mAdapter.applyChanges();
show();
}
private void show() {
if (items.size() < 1) return;
pause();
running = true;
mHandler.postDelayed(rNext, DELAY);
}
private void next() {
if (items.size() < 1) return;
String msg = mContext.getString(R.string.file_deleted).concat(":\n").concat(items.get(0).storage.getName());
snackbar = Snackbar.make(mView, msg, Snackbar.LENGTH_INDEFINITE);
snackbar.setAction(R.string.undo, s -> forget());
snackbar.setActionTextColor(Color.YELLOW);
snackbar.show();
mHandler.postDelayed(rCommit, TIMEOUT);
}
private void commit() {
if (items.size() < 1) return;
while (items.size() > 0) {
Mission mission = items.remove(0);
if (mission.deleted) continue;
mIterator.unHide(mission);
mDownloadManager.deleteMission(mission);
if (mission instanceof FinishedMission) {
mContext.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, mission.storage.getUri()));
}
break;
}
if (items.size() < 1) {
pause();
return;
}
show();
}
public void pause() {
running = false;
mHandler.removeCallbacks(rNext);
mHandler.removeCallbacks(rShow);
mHandler.removeCallbacks(rCommit);
if (snackbar != null) snackbar.dismiss();
}
public void resume() {
if (running) return;
mHandler.postDelayed(rShow, DELAY_RESUME);
}
public void dispose() {
if (items.size() < 1) return;
pause();
for (Mission mission : items) mDownloadManager.deleteMission(mission);
items = null;
}
}

View File

@ -1,29 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="30dp"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:orientation="vertical">
<TextView
android:id="@+id/item_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:text="relative header"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="16sp"
android:textStyle="bold" />
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:background="@color/black_settings_accent_color" />
</LinearLayout>
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="30dp"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:orientation="vertical">
<TextView
android:id="@+id/item_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:text="relative header"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="16sp"
android:textStyle="bold" />
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:background="@color/black_settings_accent_color" />
</LinearLayout>