Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
SDK
/
exoplayer
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Settings
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Commit
ade3452e
authored
Jun 09, 2022
by
Marc Baechinger
Browse files
Options
_('Browse Files')
Download
Plain Diff
Merge pull request #53 from ittiam-systems:rtp_opus
PiperOrigin-RevId: 453490088
parents
527db57b
165e706a
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
372 additions
and
0 deletions
library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtpPayloadFormat.java
library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspMediaTrack.java
library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/reader/DefaultRtpPayloadReaderFactory.java
library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/reader/RtpOpusReader.java
library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/reader/RtpOpusReaderTest.java
library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtpPayloadFormat.java
View file @
ade3452e
...
@@ -44,6 +44,7 @@ public final class RtpPayloadFormat {
...
@@ -44,6 +44,7 @@ public final class RtpPayloadFormat {
private
static
final
String
RTP_MEDIA_MPEG4_VIDEO
=
"MP4V-ES"
;
private
static
final
String
RTP_MEDIA_MPEG4_VIDEO
=
"MP4V-ES"
;
private
static
final
String
RTP_MEDIA_H264
=
"H264"
;
private
static
final
String
RTP_MEDIA_H264
=
"H264"
;
private
static
final
String
RTP_MEDIA_H265
=
"H265"
;
private
static
final
String
RTP_MEDIA_H265
=
"H265"
;
private
static
final
String
RTP_MEDIA_OPUS
=
"OPUS"
;
private
static
final
String
RTP_MEDIA_PCM_L8
=
"L8"
;
private
static
final
String
RTP_MEDIA_PCM_L8
=
"L8"
;
private
static
final
String
RTP_MEDIA_PCM_L16
=
"L16"
;
private
static
final
String
RTP_MEDIA_PCM_L16
=
"L16"
;
private
static
final
String
RTP_MEDIA_PCMA
=
"PCMA"
;
private
static
final
String
RTP_MEDIA_PCMA
=
"PCMA"
;
...
@@ -61,6 +62,7 @@ public final class RtpPayloadFormat {
...
@@ -61,6 +62,7 @@ public final class RtpPayloadFormat {
case
RTP_MEDIA_H265:
case
RTP_MEDIA_H265:
case
RTP_MEDIA_MPEG4_VIDEO:
case
RTP_MEDIA_MPEG4_VIDEO:
case
RTP_MEDIA_MPEG4_GENERIC:
case
RTP_MEDIA_MPEG4_GENERIC:
case
RTP_MEDIA_OPUS:
case
RTP_MEDIA_PCM_L8:
case
RTP_MEDIA_PCM_L8:
case
RTP_MEDIA_PCM_L16:
case
RTP_MEDIA_PCM_L16:
case
RTP_MEDIA_PCMA:
case
RTP_MEDIA_PCMA:
...
@@ -90,6 +92,8 @@ public final class RtpPayloadFormat {
...
@@ -90,6 +92,8 @@ public final class RtpPayloadFormat {
return
MimeTypes
.
AUDIO_AMR_WB
;
return
MimeTypes
.
AUDIO_AMR_WB
;
case
RTP_MEDIA_MPEG4_GENERIC:
case
RTP_MEDIA_MPEG4_GENERIC:
return
MimeTypes
.
AUDIO_AAC
;
return
MimeTypes
.
AUDIO_AAC
;
case
RTP_MEDIA_OPUS:
return
MimeTypes
.
AUDIO_OPUS
;
case
RTP_MEDIA_PCM_L8:
case
RTP_MEDIA_PCM_L8:
case
RTP_MEDIA_PCM_L16:
case
RTP_MEDIA_PCM_L16:
return
MimeTypes
.
AUDIO_RAW
;
return
MimeTypes
.
AUDIO_RAW
;
...
...
library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/RtspMediaTrack.java
View file @
ade3452e
...
@@ -99,6 +99,9 @@ import com.google.common.collect.ImmutableMap;
...
@@ -99,6 +99,9 @@ import com.google.common.collect.ImmutableMap;
*/
*/
private
static
final
int
DEFAULT_VP8_HEIGHT
=
240
;
private
static
final
int
DEFAULT_VP8_HEIGHT
=
240
;
/** RFC7587 Section 6.1 Sampling rate for OPUS is fixed at 48KHz. */
private
static
final
int
OPUS_CLOCK_RATE
=
48_000
;
/**
/**
* Default width for VP9.
* Default width for VP9.
*
*
...
@@ -199,6 +202,12 @@ import com.google.common.collect.ImmutableMap;
...
@@ -199,6 +202,12 @@ import com.google.common.collect.ImmutableMap;
!
fmtpParameters
.
containsKey
(
PARAMETER_AMR_INTERLEAVING
),
!
fmtpParameters
.
containsKey
(
PARAMETER_AMR_INTERLEAVING
),
"Interleaving mode is not currently supported."
);
"Interleaving mode is not currently supported."
);
break
;
break
;
case
MimeTypes
.
AUDIO_OPUS
:
checkArgument
(
channelCount
!=
C
.
INDEX_UNSET
);
// RFC7587 Section 6.1: the RTP timestamp is incremented with a 48000 Hz clock rate
// for all modes of Opus and all sampling rates.
checkArgument
(
clockRate
==
OPUS_CLOCK_RATE
,
"Invalid OPUS clock rate."
);
break
;
case
MimeTypes
.
VIDEO_MP4V
:
case
MimeTypes
.
VIDEO_MP4V
:
checkArgument
(!
fmtpParameters
.
isEmpty
());
checkArgument
(!
fmtpParameters
.
isEmpty
());
processMPEG4FmtpAttribute
(
formatBuilder
,
fmtpParameters
);
processMPEG4FmtpAttribute
(
formatBuilder
,
fmtpParameters
);
...
...
library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/reader/DefaultRtpPayloadReaderFactory.java
View file @
ade3452e
...
@@ -37,6 +37,8 @@ import com.google.android.exoplayer2.util.MimeTypes;
...
@@ -37,6 +37,8 @@ import com.google.android.exoplayer2.util.MimeTypes;
case
MimeTypes
.
AUDIO_AMR_NB
:
case
MimeTypes
.
AUDIO_AMR_NB
:
case
MimeTypes
.
AUDIO_AMR_WB
:
case
MimeTypes
.
AUDIO_AMR_WB
:
return
new
RtpAmrReader
(
payloadFormat
);
return
new
RtpAmrReader
(
payloadFormat
);
case
MimeTypes
.
AUDIO_OPUS
:
return
new
RtpOpusReader
(
payloadFormat
);
case
MimeTypes
.
AUDIO_RAW
:
case
MimeTypes
.
AUDIO_RAW
:
case
MimeTypes
.
AUDIO_ALAW
:
case
MimeTypes
.
AUDIO_ALAW
:
case
MimeTypes
.
AUDIO_MLAW
:
case
MimeTypes
.
AUDIO_MLAW
:
...
...
library/rtsp/src/main/java/com/google/android/exoplayer2/source/rtsp/reader/RtpOpusReader.java
0 → 100644
View file @
ade3452e
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package
com
.
google
.
android
.
exoplayer2
.
source
.
rtsp
.
reader
;
import
static
com
.
google
.
android
.
exoplayer2
.
util
.
Assertions
.
checkArgument
;
import
static
com
.
google
.
android
.
exoplayer2
.
util
.
Assertions
.
checkStateNotNull
;
import
com.google.android.exoplayer2.C
;
import
com.google.android.exoplayer2.Format
;
import
com.google.android.exoplayer2.audio.OpusUtil
;
import
com.google.android.exoplayer2.extractor.ExtractorOutput
;
import
com.google.android.exoplayer2.extractor.TrackOutput
;
import
com.google.android.exoplayer2.source.rtsp.RtpPacket
;
import
com.google.android.exoplayer2.source.rtsp.RtpPayloadFormat
;
import
com.google.android.exoplayer2.util.Log
;
import
com.google.android.exoplayer2.util.ParsableByteArray
;
import
com.google.android.exoplayer2.util.Util
;
import
java.util.List
;
import
org.checkerframework.checker.nullness.qual.MonotonicNonNull
;
/**
* Parses an OPUS byte stream carried on RTP packets and extracts individual samples. Refer to
* RFC7845 for more details.
*/
/* package */
final
class
RtpOpusReader
implements
RtpPayloadReader
{
private
static
final
String
TAG
=
"RtpOpusReader"
;
/* Opus uses a fixed 48KHz media clock RFC7845 Section 4. */
private
static
final
long
MEDIA_CLOCK_FREQUENCY
=
48_000
;
private
final
RtpPayloadFormat
payloadFormat
;
private
@MonotonicNonNull
TrackOutput
trackOutput
;
/**
* First received RTP timestamp. All RTP timestamps are dimension-less, the time base is defined
* by {@link #MEDIA_CLOCK_FREQUENCY}.
*/
private
long
firstReceivedTimestamp
;
private
long
startTimeOffsetUs
;
private
int
previousSequenceNumber
;
private
boolean
foundOpusIDHeader
;
private
boolean
foundOpusCommentHeader
;
/** Creates an instance. */
public
RtpOpusReader
(
RtpPayloadFormat
payloadFormat
)
{
this
.
payloadFormat
=
payloadFormat
;
this
.
firstReceivedTimestamp
=
C
.
INDEX_UNSET
;
this
.
previousSequenceNumber
=
C
.
INDEX_UNSET
;
}
// RtpPayloadReader implementation.
@Override
public
void
createTracks
(
ExtractorOutput
extractorOutput
,
int
trackId
)
{
trackOutput
=
extractorOutput
.
track
(
trackId
,
C
.
TRACK_TYPE_AUDIO
);
trackOutput
.
format
(
payloadFormat
.
format
);
}
@Override
public
void
onReceivingFirstPacket
(
long
timestamp
,
int
sequenceNumber
)
{
this
.
firstReceivedTimestamp
=
timestamp
;
}
@Override
public
void
consume
(
ParsableByteArray
data
,
long
timestamp
,
int
sequenceNumber
,
boolean
rtpMarker
)
{
checkStateNotNull
(
trackOutput
);
/* RFC7845 Section 3.
* +---------+ +----------------+ +--------------------+ +-----
* |ID Header| | Comment Header | |Audio Data Packet 1 | | ...
* +---------+ +----------------+ +--------------------+ +-----
*/
if
(!
foundOpusIDHeader
)
{
validateOpusIdHeader
(
data
);
List
<
byte
[]>
initializationData
=
OpusUtil
.
buildInitializationData
(
data
.
getData
());
Format
.
Builder
formatBuilder
=
payloadFormat
.
format
.
buildUpon
();
formatBuilder
.
setInitializationData
(
initializationData
);
trackOutput
.
format
(
formatBuilder
.
build
());
foundOpusIDHeader
=
true
;
}
else
if
(!
foundOpusCommentHeader
)
{
// Comment Header RFC7845 Section 5.2.
int
sampleSize
=
data
.
limit
();
checkArgument
(
sampleSize
>=
8
,
"Comment Header has insufficient data"
);
String
header
=
data
.
readString
(
8
);
checkArgument
(
header
.
equals
(
"OpusTags"
),
"Comment Header should follow ID Header"
);
foundOpusCommentHeader
=
true
;
}
else
{
// Check that this packet is in the sequence of the previous packet.
int
expectedSequenceNumber
=
RtpPacket
.
getNextSequenceNumber
(
previousSequenceNumber
);
if
(
sequenceNumber
!=
expectedSequenceNumber
)
{
Log
.
w
(
TAG
,
Util
.
formatInvariant
(
"Received RTP packet with unexpected sequence number. Expected: %d; received: %d."
,
expectedSequenceNumber
,
sequenceNumber
));
}
// sending opus data.
int
size
=
data
.
bytesLeft
();
trackOutput
.
sampleData
(
data
,
size
);
long
timeUs
=
toSampleTimeUs
(
startTimeOffsetUs
,
timestamp
,
firstReceivedTimestamp
);
trackOutput
.
sampleMetadata
(
timeUs
,
C
.
BUFFER_FLAG_KEY_FRAME
,
size
,
/* offset*/
0
,
/* cryptoData*/
null
);
}
previousSequenceNumber
=
sequenceNumber
;
}
@Override
public
void
seek
(
long
nextRtpTimestamp
,
long
timeUs
)
{
firstReceivedTimestamp
=
nextRtpTimestamp
;
startTimeOffsetUs
=
timeUs
;
}
// Internal methods.
/**
* Validates the OPUS ID Header at {@code data}'s current position, throws {@link
* IllegalArgumentException} if the header is invalid.
*
* <p>{@code data}'s position does not change after returning.
*/
private
static
void
validateOpusIdHeader
(
ParsableByteArray
data
)
{
int
currPosition
=
data
.
getPosition
();
int
sampleSize
=
data
.
limit
();
checkArgument
(
sampleSize
>
18
,
"ID Header has insufficient data"
);
String
header
=
data
.
readString
(
8
);
// Identification header RFC7845 Section 5.1.
checkArgument
(
header
.
equals
(
"OpusHead"
),
"ID Header missing"
);
checkArgument
(
data
.
readUnsignedByte
()
==
1
,
"version number must always be 1"
);
data
.
setPosition
(
currPosition
);
}
/** Returns the correct sample time from RTP timestamp, accounting for the OPUS sampling rate. */
private
static
long
toSampleTimeUs
(
long
startTimeOffsetUs
,
long
rtpTimestamp
,
long
firstReceivedRtpTimestamp
)
{
return
startTimeOffsetUs
+
Util
.
scaleLargeTimestamp
(
rtpTimestamp
-
firstReceivedRtpTimestamp
,
/* multiplier= */
C
.
MICROS_PER_SECOND
,
/* divisor= */
MEDIA_CLOCK_FREQUENCY
);
}
}
library/rtsp/src/test/java/com/google/android/exoplayer2/source/rtsp/reader/RtpOpusReaderTest.java
0 → 100644
View file @
ade3452e
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package
com
.
google
.
android
.
exoplayer2
.
source
.
rtsp
.
reader
;
import
static
com
.
google
.
android
.
exoplayer2
.
util
.
Util
.
getBytesFromHexString
;
import
static
com
.
google
.
common
.
truth
.
Truth
.
assertThat
;
import
static
org
.
junit
.
Assert
.
assertThrows
;
import
static
org
.
mockito
.
ArgumentMatchers
.
anyInt
;
import
static
org
.
mockito
.
Mockito
.
when
;
import
androidx.test.ext.junit.runners.AndroidJUnit4
;
import
com.google.android.exoplayer2.Format
;
import
com.google.android.exoplayer2.extractor.ExtractorOutput
;
import
com.google.android.exoplayer2.source.rtsp.RtpPacket
;
import
com.google.android.exoplayer2.source.rtsp.RtpPayloadFormat
;
import
com.google.android.exoplayer2.testutil.FakeTrackOutput
;
import
com.google.android.exoplayer2.util.MimeTypes
;
import
com.google.android.exoplayer2.util.ParsableByteArray
;
import
com.google.common.collect.ImmutableMap
;
import
org.junit.Before
;
import
org.junit.Rule
;
import
org.junit.Test
;
import
org.junit.runner.RunWith
;
import
org.mockito.Mock
;
import
org.mockito.junit.MockitoJUnit
;
import
org.mockito.junit.MockitoRule
;
/** Unit test for {@link RtpOpusReader}. */
@RunWith
(
AndroidJUnit4
.
class
)
public
final
class
RtpOpusReaderTest
{
private
static
final
RtpPayloadFormat
OPUS_FORMAT
=
new
RtpPayloadFormat
(
new
Format
.
Builder
()
.
setChannelCount
(
6
)
.
setSampleMimeType
(
MimeTypes
.
AUDIO_OPUS
)
.
setSampleRate
(
48_000
)
.
build
(),
/* rtpPayloadType= */
97
,
/* clockRate= */
48_000
,
/* fmtpParameters= */
ImmutableMap
.
of
());
private
static
final
RtpPacket
OPUS_HEADER
=
createRtpPacket
(
/* timestamp= */
2599168056L
,
/* sequenceNumber= */
40289
,
/* payloadData= */
getBytesFromHexString
(
"4F707573486561640102000000000000000000"
));
private
static
final
RtpPacket
OPUS_TAGS
=
createRtpPacket
(
/* timestamp= */
2599168056L
,
/* sequenceNumber= */
40290
,
/* payloadData= */
getBytesFromHexString
(
"4F707573546167730000000000000000000000"
));
private
static
final
RtpPacket
OPUS_FRAME_1
=
createRtpPacket
(
/* timestamp= */
2599168056L
,
/* sequenceNumber= */
40292
,
/* payloadData= */
getBytesFromHexString
(
"010203"
));
private
static
final
RtpPacket
OPUS_FRAME_2
=
createRtpPacket
(
/* timestamp= */
2599169592L
,
/* sequenceNumber= */
40293
,
/* payloadData= */
getBytesFromHexString
(
"04050607"
));
@Rule
public
final
MockitoRule
mockito
=
MockitoJUnit
.
rule
();
private
ParsableByteArray
packetData
;
private
RtpOpusReader
opusReader
;
private
FakeTrackOutput
trackOutput
;
@Mock
private
ExtractorOutput
extractorOutput
;
@Before
public
void
setUp
()
{
packetData
=
new
ParsableByteArray
();
trackOutput
=
new
FakeTrackOutput
(
/* deduplicateConsecutiveFormats= */
true
);
when
(
extractorOutput
.
track
(
anyInt
(),
anyInt
())).
thenReturn
(
trackOutput
);
opusReader
=
new
RtpOpusReader
(
OPUS_FORMAT
);
opusReader
.
createTracks
(
extractorOutput
,
/* trackId= */
0
);
}
@Test
public
void
consume_validPackets
()
{
opusReader
.
onReceivingFirstPacket
(
OPUS_HEADER
.
timestamp
,
OPUS_HEADER
.
sequenceNumber
);
consume
(
OPUS_HEADER
);
consume
(
OPUS_TAGS
);
consume
(
OPUS_FRAME_1
);
consume
(
OPUS_FRAME_2
);
assertThat
(
trackOutput
.
getSampleCount
()).
isEqualTo
(
2
);
assertThat
(
trackOutput
.
getSampleData
(
0
)).
isEqualTo
(
getBytesFromHexString
(
"010203"
));
assertThat
(
trackOutput
.
getSampleTimeUs
(
0
)).
isEqualTo
(
0
);
assertThat
(
trackOutput
.
getSampleData
(
1
)).
isEqualTo
(
getBytesFromHexString
(
"04050607"
));
assertThat
(
trackOutput
.
getSampleTimeUs
(
1
)).
isEqualTo
(
32000
);
}
@Test
public
void
consume_opusHeaderWithInvalidHeader_throwsIllegalArgumentException
()
{
opusReader
.
onReceivingFirstPacket
(
OPUS_HEADER
.
timestamp
,
OPUS_HEADER
.
sequenceNumber
);
assertThrows
(
IllegalArgumentException
.
class
,
()
->
consume
(
createRtpPacket
(
/* timestamp= */
2599168056L
,
/* sequenceNumber= */
40289
,
// Modify "OpusHead" -> "OrusHead" (First 8 bytes).
/* payloadData= */
getBytesFromHexString
(
"4F727573486561640102000000000000000000"
))));
}
@Test
public
void
consume_opusHeaderWithInvalidSampleSize_throwsIllegalArgumentException
()
{
opusReader
.
onReceivingFirstPacket
(
OPUS_HEADER
.
timestamp
,
OPUS_HEADER
.
sequenceNumber
);
assertThrows
(
IllegalArgumentException
.
class
,
()
->
consume
(
createRtpPacket
(
/* timestamp= */
2599168056L
,
/* sequenceNumber= */
40289
,
// Truncate the opusHeader payload data.
/* payloadData= */
getBytesFromHexString
(
"4F707573486561640102"
))));
}
@Test
public
void
consume_opusHeaderWithInvalidVersionNumber_throwsIllegalArgumentException
()
{
opusReader
.
onReceivingFirstPacket
(
OPUS_HEADER
.
timestamp
,
OPUS_HEADER
.
sequenceNumber
);
assertThrows
(
IllegalArgumentException
.
class
,
()
->
consume
(
createRtpPacket
(
/* timestamp= */
2599168056L
,
/* sequenceNumber= */
40289
,
// Modify version 1 -> 2 (9th byte)
/* payloadData= */
getBytesFromHexString
(
"4f707573486561640202000000000000000000"
))));
}
@Test
public
void
consume_invalidOpusTags_throwsIllegalArgumentException
()
{
opusReader
.
onReceivingFirstPacket
(
OPUS_HEADER
.
timestamp
,
OPUS_HEADER
.
sequenceNumber
);
consume
(
OPUS_HEADER
);
assertThrows
(
IllegalArgumentException
.
class
,
()
->
consume
(
createRtpPacket
(
/* timestamp= */
2599168056L
,
/* sequenceNumber= */
40290
,
// Modify "OpusTags" -> "OpusTggs" (First 8 bytes)
/* payloadData= */
getBytesFromHexString
(
"4F70757354676773"
))));
}
@Test
public
void
consume_skipOpusTags_throwsIllegalArgumentException
()
{
opusReader
.
onReceivingFirstPacket
(
OPUS_HEADER
.
timestamp
,
OPUS_HEADER
.
sequenceNumber
);
consume
(
OPUS_HEADER
);
assertThrows
(
IllegalArgumentException
.
class
,
()
->
consume
(
OPUS_FRAME_1
));
}
@Test
public
void
consume_skipOpusHeader_throwsIllegalArgumentException
()
{
opusReader
.
onReceivingFirstPacket
(
OPUS_HEADER
.
timestamp
,
OPUS_HEADER
.
sequenceNumber
);
assertThrows
(
IllegalArgumentException
.
class
,
()
->
consume
(
OPUS_TAGS
));
}
@Test
public
void
consume_skipOpusHeaderAndOpusTags_throwsIllegalArgumentException
()
{
opusReader
.
onReceivingFirstPacket
(
OPUS_HEADER
.
timestamp
,
OPUS_HEADER
.
sequenceNumber
);
assertThrows
(
IllegalArgumentException
.
class
,
()
->
consume
(
OPUS_FRAME_1
));
}
private
static
RtpPacket
createRtpPacket
(
long
timestamp
,
int
sequenceNumber
,
byte
[]
payloadData
)
{
return
new
RtpPacket
.
Builder
()
.
setTimestamp
(
timestamp
)
.
setSequenceNumber
(
sequenceNumber
)
.
setMarker
(
false
)
.
setPayloadData
(
payloadData
)
.
build
();
}
private
void
consume
(
RtpPacket
rtpPacket
)
{
packetData
.
reset
(
rtpPacket
.
payloadData
);
opusReader
.
consume
(
packetData
,
rtpPacket
.
timestamp
,
rtpPacket
.
sequenceNumber
,
rtpPacket
.
marker
);
}
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment