Move length encoding/decoding from Rs2{Decoder,Encoder} to PacketCodec

There are now three additional abstract PacketCodec classes:
FixedPacketCodec, VariableBytePacketCodec and VariableShortPacketCodec.

The PacketLength class has been removed, as it is no longer required.

The main reason for this change is that the create suggested names
packet is a bit of an oddball: its length field measures the size of the
packet in longs, not bytes. The codec for this packet will be able to
inherit from PacketCodec directly to implement the custom length logic.

Signed-off-by: Graham <gpe@openrs2.org>
Graham 2 years ago
parent 431685124a
commit 1bb244b7f7
  1. 10
      archive/src/main/kotlin/org/openrs2/archive/cache/nxt/InitJs5RemoteConnectionCodec.kt
  2. 8
      archive/src/main/kotlin/org/openrs2/archive/cache/nxt/Js5OkCodec.kt
  3. 2
      protocol/src/main/kotlin/org/openrs2/protocol/EmptyPacketCodec.kt
  4. 37
      protocol/src/main/kotlin/org/openrs2/protocol/FixedPacketCodec.kt
  5. 31
      protocol/src/main/kotlin/org/openrs2/protocol/PacketCodec.kt
  6. 6
      protocol/src/main/kotlin/org/openrs2/protocol/PacketLength.kt
  7. 16
      protocol/src/main/kotlin/org/openrs2/protocol/Rs2Decoder.kt
  8. 29
      protocol/src/main/kotlin/org/openrs2/protocol/Rs2Encoder.kt
  9. 47
      protocol/src/main/kotlin/org/openrs2/protocol/VariableBytePacketCodec.kt
  10. 47
      protocol/src/main/kotlin/org/openrs2/protocol/VariableShortPacketCodec.kt
  11. 8
      protocol/src/main/kotlin/org/openrs2/protocol/login/upstream/CheckWorldSuitabilityCodec.kt
  12. 4
      protocol/src/main/kotlin/org/openrs2/protocol/login/upstream/CreateCheckDateOfBirthCountryCodec.kt
  13. 4
      protocol/src/main/kotlin/org/openrs2/protocol/login/upstream/CreateCheckNameCodec.kt
  14. 8
      protocol/src/main/kotlin/org/openrs2/protocol/login/upstream/InitGameConnectionCodec.kt
  15. 4
      protocol/src/main/kotlin/org/openrs2/protocol/login/upstream/InitJs5RemoteConnectionCodec.kt
  16. 4
      protocol/src/main/kotlin/org/openrs2/protocol/login/upstream/RequestWorldListCodec.kt
  17. 8
      protocol/src/main/kotlin/org/openrs2/protocol/world/downstream/WorldListResponseCodec.kt
  18. 2
      protocol/src/test/kotlin/org/openrs2/protocol/LengthMismatchPacketCodec.kt
  19. 16
      protocol/src/test/kotlin/org/openrs2/protocol/Rs2DecoderTest.kt
  20. 14
      protocol/src/test/kotlin/org/openrs2/protocol/Rs2EncoderTest.kt
  21. 2
      protocol/src/test/kotlin/org/openrs2/protocol/TestFixedPacketCodec.kt
  22. 5
      protocol/src/test/kotlin/org/openrs2/protocol/TestVariableBytePacketCodec.kt
  23. 5
      protocol/src/test/kotlin/org/openrs2/protocol/TestVariableShortPacketCodec.kt
  24. 5
      protocol/src/test/kotlin/org/openrs2/protocol/VariableByteOptimisedPacketCodec.kt
  25. 5
      protocol/src/test/kotlin/org/openrs2/protocol/VariableShortOptimisedPacketCodec.kt

@ -4,13 +4,11 @@ import io.netty.buffer.ByteBuf
import org.openrs2.buffer.readString
import org.openrs2.buffer.writeString
import org.openrs2.crypto.StreamCipher
import org.openrs2.protocol.PacketCodec
import org.openrs2.protocol.PacketLength
import org.openrs2.protocol.VariableBytePacketCodec
public object InitJs5RemoteConnectionCodec : PacketCodec<InitJs5RemoteConnection>(
length = PacketLength.VARIABLE_BYTE,
opcode = 15,
type = InitJs5RemoteConnection::class.java
public object InitJs5RemoteConnectionCodec : VariableBytePacketCodec<InitJs5RemoteConnection>(
type = InitJs5RemoteConnection::class.java,
opcode = 15
) {
override fun decode(input: ByteBuf, cipher: StreamCipher): InitJs5RemoteConnection {
val buildMajor = input.readInt()

@ -2,12 +2,12 @@ package org.openrs2.archive.cache.nxt
import io.netty.buffer.ByteBuf
import org.openrs2.crypto.StreamCipher
import org.openrs2.protocol.PacketCodec
import org.openrs2.protocol.FixedPacketCodec
public object Js5OkCodec : PacketCodec<LoginResponse.Js5Ok>(
public object Js5OkCodec : FixedPacketCodec<LoginResponse.Js5Ok>(
type = LoginResponse.Js5Ok::class.java,
opcode = 0,
length = LoginResponse.Js5Ok.LOADING_REQUIREMENTS * 4,
type = LoginResponse.Js5Ok::class.java
length = LoginResponse.Js5Ok.LOADING_REQUIREMENTS * 4
) {
override fun decode(input: ByteBuf, cipher: StreamCipher): LoginResponse.Js5Ok {
val loadingRequirements = mutableListOf<Int>()

@ -6,7 +6,7 @@ import org.openrs2.crypto.StreamCipher
public abstract class EmptyPacketCodec<T : Packet>(
private val packet: T,
opcode: Int
) : PacketCodec<T>(packet.javaClass, opcode, length = 0) {
) : FixedPacketCodec<T>(packet.javaClass, opcode, length = 0) {
override fun decode(input: ByteBuf, cipher: StreamCipher): T {
return packet
}

@ -0,0 +1,37 @@
package org.openrs2.protocol
import io.netty.buffer.ByteBuf
import io.netty.buffer.ByteBufAllocator
import io.netty.handler.codec.EncoderException
public abstract class FixedPacketCodec<T : Packet>(
type: Class<T>,
opcode: Int,
public val length: Int
) : PacketCodec<T>(type, opcode) {
override fun isLengthReadable(input: ByteBuf): Boolean {
return true
}
override fun readLength(input: ByteBuf): Int {
return length
}
override fun writeLengthPlaceholder(output: ByteBuf) {
// empty
}
override fun setLength(output: ByteBuf, index: Int, written: Int) {
if (written != length) {
throw EncoderException("Fixed payload length mismatch (expected $length bytes, got $written bytes)")
}
}
override fun allocateBuffer(alloc: ByteBufAllocator, input: T, preferDirect: Boolean): ByteBuf {
return if (preferDirect) {
alloc.ioBuffer(1 + length, 1 + length)
} else {
alloc.heapBuffer(1 + length, 1 + length)
}
}
}

@ -6,43 +6,26 @@ import org.openrs2.crypto.StreamCipher
public abstract class PacketCodec<T : Packet>(
public val type: Class<T>,
public val opcode: Int,
public val length: Int
public val opcode: Int
) {
init {
require(opcode in 0 until 256)
require(length >= PacketLength.VARIABLE_SHORT)
}
public abstract fun decode(input: ByteBuf, cipher: StreamCipher): T
public abstract fun encode(input: T, output: ByteBuf, cipher: StreamCipher)
public open fun getLength(input: T): Int {
return length
}
public abstract fun isLengthReadable(input: ByteBuf): Boolean
public abstract fun readLength(input: ByteBuf): Int
public abstract fun writeLengthPlaceholder(output: ByteBuf)
public abstract fun setLength(output: ByteBuf, index: Int, written: Int)
public fun allocateBuffer(alloc: ByteBufAllocator, input: T, preferDirect: Boolean): ByteBuf {
val payloadLen = getLength(input)
if (payloadLen < 0) {
public open fun allocateBuffer(alloc: ByteBufAllocator, input: T, preferDirect: Boolean): ByteBuf {
return if (preferDirect) {
alloc.ioBuffer()
} else {
alloc.heapBuffer()
}
}
val headerLen = when (length) {
PacketLength.VARIABLE_BYTE -> 2
PacketLength.VARIABLE_SHORT -> 3
else -> 1
}
val totalLen = headerLen + payloadLen
return if (preferDirect) {
alloc.ioBuffer(totalLen, totalLen)
} else {
alloc.heapBuffer(totalLen, totalLen)
}
}
}

@ -1,6 +0,0 @@
package org.openrs2.protocol
public object PacketLength {
public const val VARIABLE_SHORT: Int = -2
public const val VARIABLE_BYTE: Int = -1
}

@ -32,28 +32,16 @@ public class Rs2Decoder(public var protocol: Protocol) : ByteToMessageDecoder()
val opcode = (input.readUnsignedByte().toInt() - cipher.nextInt()) and 0xFF
decoder = protocol.getDecoder(opcode) ?: throw DecoderException("Unsupported opcode: $opcode")
length = decoder.length
state = State.READ_LENGTH
}
if (state == State.READ_LENGTH) {
when (length) {
PacketLength.VARIABLE_BYTE -> {
if (!input.isReadable) {
if (!decoder.isLengthReadable(input)) {
return
}
length = input.readUnsignedByte().toInt()
}
PacketLength.VARIABLE_SHORT -> {
if (input.readableBytes() < 2) {
return
}
length = input.readUnsignedShort()
}
}
length = decoder.readLength(input)
state = State.READ_PAYLOAD
}

@ -16,39 +16,14 @@ public class Rs2Encoder(public var protocol: Protocol) : MessageToByteEncoder<Pa
out.writeByte(encoder.opcode + cipher.nextInt())
val len = encoder.length
val lenIndex = out.writerIndex()
when (len) {
PacketLength.VARIABLE_BYTE -> out.writeZero(1)
PacketLength.VARIABLE_SHORT -> out.writeZero(2)
}
encoder.writeLengthPlaceholder(out)
val payloadIndex = out.writerIndex()
encoder.encode(msg, out, cipher)
val written = out.writerIndex() - payloadIndex
when (len) {
PacketLength.VARIABLE_BYTE -> {
if (written >= 256) {
throw EncoderException("Variable byte payload too long: $written bytes")
}
out.setByte(lenIndex, written)
}
PacketLength.VARIABLE_SHORT -> {
if (written >= 65536) {
throw EncoderException("Variable short payload too long: $written bytes")
}
out.setShort(lenIndex, written)
}
else -> {
if (written != len) {
throw EncoderException("Fixed payload length mismatch (expected $len bytes, got $written bytes)")
}
}
}
encoder.setLength(out, lenIndex, written)
}
override fun allocateBuffer(ctx: ChannelHandlerContext, msg: Packet, preferDirect: Boolean): ByteBuf {

@ -0,0 +1,47 @@
package org.openrs2.protocol
import io.netty.buffer.ByteBuf
import io.netty.buffer.ByteBufAllocator
import io.netty.handler.codec.EncoderException
public abstract class VariableBytePacketCodec<T : Packet>(
type: Class<T>,
opcode: Int
) : PacketCodec<T>(type, opcode) {
override fun isLengthReadable(input: ByteBuf): Boolean {
return input.isReadable
}
override fun readLength(input: ByteBuf): Int {
return input.readUnsignedByte().toInt()
}
override fun writeLengthPlaceholder(output: ByteBuf) {
output.writeZero(1)
}
override fun setLength(output: ByteBuf, index: Int, written: Int) {
if (written >= 256) {
throw EncoderException("Variable byte payload too long: $written bytes")
}
output.setByte(index, written)
}
public open fun getLength(input: T): Int {
return -1
}
override fun allocateBuffer(alloc: ByteBufAllocator, input: T, preferDirect: Boolean): ByteBuf {
val length = getLength(input)
if (length < 0) {
return super.allocateBuffer(alloc, input, preferDirect)
}
return if (preferDirect) {
alloc.ioBuffer(2 + length, 2 + length)
} else {
alloc.heapBuffer(2 + length, 2 + length)
}
}
}

@ -0,0 +1,47 @@
package org.openrs2.protocol
import io.netty.buffer.ByteBuf
import io.netty.buffer.ByteBufAllocator
import io.netty.handler.codec.EncoderException
public abstract class VariableShortPacketCodec<T : Packet>(
type: Class<T>,
opcode: Int
) : PacketCodec<T>(type, opcode) {
override fun isLengthReadable(input: ByteBuf): Boolean {
return input.readableBytes() >= 2
}
override fun readLength(input: ByteBuf): Int {
return input.readUnsignedShort()
}
override fun writeLengthPlaceholder(output: ByteBuf) {
output.writeZero(2)
}
override fun setLength(output: ByteBuf, index: Int, written: Int) {
if (written >= 65536) {
throw EncoderException("Variable short payload too long: $written bytes")
}
output.setShort(index, written)
}
public open fun getLength(input: T): Int {
return -2
}
override fun allocateBuffer(alloc: ByteBufAllocator, input: T, preferDirect: Boolean): ByteBuf {
val length = getLength(input)
if (length < 0) {
return super.allocateBuffer(alloc, input, preferDirect)
}
return if (preferDirect) {
alloc.ioBuffer(3 + length, 3 + length)
} else {
alloc.heapBuffer(3 + length, 3 + length)
}
}
}

@ -9,8 +9,7 @@ import org.openrs2.crypto.Rsa
import org.openrs2.crypto.StreamCipher
import org.openrs2.crypto.rsa
import org.openrs2.crypto.secureRandom
import org.openrs2.protocol.PacketCodec
import org.openrs2.protocol.PacketLength
import org.openrs2.protocol.VariableBytePacketCodec
import org.openrs2.util.Base37
import javax.inject.Inject
import javax.inject.Singleton
@ -18,10 +17,9 @@ import javax.inject.Singleton
@Singleton
public class CheckWorldSuitabilityCodec @Inject constructor(
private val key: RSAPrivateCrtKeyParameters
) : PacketCodec<LoginRequest.CheckWorldSuitability>(
) : VariableBytePacketCodec<LoginRequest.CheckWorldSuitability>(
type = LoginRequest.CheckWorldSuitability::class.java,
opcode = 24,
length = PacketLength.VARIABLE_BYTE
opcode = 24
) {
override fun decode(input: ByteBuf, cipher: StreamCipher): LoginRequest.CheckWorldSuitability {
val build = input.readShort().toInt()

@ -2,12 +2,12 @@ package org.openrs2.protocol.login.upstream
import io.netty.buffer.ByteBuf
import org.openrs2.crypto.StreamCipher
import org.openrs2.protocol.PacketCodec
import org.openrs2.protocol.FixedPacketCodec
import java.time.LocalDate
import javax.inject.Singleton
@Singleton
public class CreateCheckDateOfBirthCountryCodec : PacketCodec<LoginRequest.CreateCheckDateOfBirthCountry>(
public class CreateCheckDateOfBirthCountryCodec : FixedPacketCodec<LoginRequest.CreateCheckDateOfBirthCountry>(
type = LoginRequest.CreateCheckDateOfBirthCountry::class.java,
opcode = 20,
length = 6

@ -2,12 +2,12 @@ package org.openrs2.protocol.login.upstream
import io.netty.buffer.ByteBuf
import org.openrs2.crypto.StreamCipher
import org.openrs2.protocol.PacketCodec
import org.openrs2.protocol.FixedPacketCodec
import org.openrs2.util.Base37
import javax.inject.Singleton
@Singleton
public class CreateCheckNameCodec : PacketCodec<LoginRequest.CreateCheckName>(
public class CreateCheckNameCodec : FixedPacketCodec<LoginRequest.CreateCheckName>(
type = LoginRequest.CreateCheckName::class.java,
opcode = 21,
length = 8

@ -2,14 +2,14 @@ package org.openrs2.protocol.login.upstream
import io.netty.buffer.ByteBuf
import org.openrs2.crypto.StreamCipher
import org.openrs2.protocol.PacketCodec
import org.openrs2.protocol.FixedPacketCodec
import javax.inject.Singleton
@Singleton
public class InitGameConnectionCodec : PacketCodec<LoginRequest.InitGameConnection>(
public class InitGameConnectionCodec : FixedPacketCodec<LoginRequest.InitGameConnection>(
type = LoginRequest.InitGameConnection::class.java,
opcode = 14,
length = 1,
type = LoginRequest.InitGameConnection::class.java
length = 1
) {
override fun decode(input: ByteBuf, cipher: StreamCipher): LoginRequest.InitGameConnection {
val usernameHash = input.readUnsignedByte().toInt()

@ -2,11 +2,11 @@ package org.openrs2.protocol.login.upstream
import io.netty.buffer.ByteBuf
import org.openrs2.crypto.StreamCipher
import org.openrs2.protocol.PacketCodec
import org.openrs2.protocol.FixedPacketCodec
import javax.inject.Singleton
@Singleton
public class InitJs5RemoteConnectionCodec : PacketCodec<LoginRequest.InitJs5RemoteConnection>(
public class InitJs5RemoteConnectionCodec : FixedPacketCodec<LoginRequest.InitJs5RemoteConnection>(
type = LoginRequest.InitJs5RemoteConnection::class.java,
opcode = 15,
length = 4

@ -2,11 +2,11 @@ package org.openrs2.protocol.login.upstream
import io.netty.buffer.ByteBuf
import org.openrs2.crypto.StreamCipher
import org.openrs2.protocol.PacketCodec
import org.openrs2.protocol.FixedPacketCodec
import javax.inject.Singleton
@Singleton
public class RequestWorldListCodec : PacketCodec<LoginRequest.RequestWorldList>(
public class RequestWorldListCodec : FixedPacketCodec<LoginRequest.RequestWorldList>(
type = LoginRequest.RequestWorldList::class.java,
opcode = 23,
length = 4

@ -6,15 +6,13 @@ import org.openrs2.buffer.readVersionedString
import org.openrs2.buffer.writeUnsignedShortSmart
import org.openrs2.buffer.writeVersionedString
import org.openrs2.crypto.StreamCipher
import org.openrs2.protocol.PacketCodec
import org.openrs2.protocol.PacketLength
import org.openrs2.protocol.VariableShortPacketCodec
import javax.inject.Singleton
@Singleton
public class WorldListResponseCodec : PacketCodec<WorldListResponse>(
public class WorldListResponseCodec : VariableShortPacketCodec<WorldListResponse>(
type = WorldListResponse::class.java,
opcode = 0,
length = PacketLength.VARIABLE_SHORT
opcode = 0
) {
override fun decode(input: ByteBuf, cipher: StreamCipher): WorldListResponse {
val version = input.readUnsignedByte().toInt()

@ -3,7 +3,7 @@ package org.openrs2.protocol
import io.netty.buffer.ByteBuf
import org.openrs2.crypto.StreamCipher
internal object LengthMismatchPacketCodec : PacketCodec<FixedPacket>(
internal object LengthMismatchPacketCodec : FixedPacketCodec<FixedPacket>(
type = FixedPacket::class.java,
opcode = 0,
length = 5

@ -35,7 +35,7 @@ class Rs2DecoderTest {
@Test
fun testEncryptedOpcode() {
val decoder = Rs2Decoder(Protocol(FixedPacketCodec))
val decoder = Rs2Decoder(Protocol(TestFixedPacketCodec))
decoder.cipher = TestStreamCipher
val channel = EmbeddedChannel(decoder)
@ -47,7 +47,7 @@ class Rs2DecoderTest {
@Test
fun testSwitchProtocol() {
val decoder = Rs2Decoder(Protocol(FixedPacketCodec))
val decoder = Rs2Decoder(Protocol(TestFixedPacketCodec))
val channel = EmbeddedChannel(decoder)
channel.writeInbound(wrappedBuffer(0, 0x11, 0x22, 0x33, 0x44))
@ -73,9 +73,9 @@ class Rs2DecoderTest {
val channel = EmbeddedChannel(
Rs2Decoder(
Protocol(
FixedPacketCodec,
VariableBytePacketCodec,
VariableShortPacketCodec,
TestFixedPacketCodec,
TestVariableBytePacketCodec,
TestVariableShortPacketCodec,
VariableByteOptimisedPacketCodec,
VariableShortOptimisedPacketCodec,
TestEmptyPacketCodec
@ -92,9 +92,9 @@ class Rs2DecoderTest {
val channel = EmbeddedChannel(
Rs2Decoder(
Protocol(
FixedPacketCodec,
VariableBytePacketCodec,
VariableShortPacketCodec,
TestFixedPacketCodec,
TestVariableBytePacketCodec,
TestVariableShortPacketCodec,
VariableByteOptimisedPacketCodec,
VariableShortOptimisedPacketCodec
)

@ -24,8 +24,8 @@ class Rs2EncoderTest {
val channel = EmbeddedChannel(
Rs2Encoder(
Protocol(
VariableBytePacketCodec,
VariableShortPacketCodec
TestVariableBytePacketCodec,
TestVariableShortPacketCodec
)
)
)
@ -71,7 +71,7 @@ class Rs2EncoderTest {
@Test
fun testEncryptedOpcode() {
val encoder = Rs2Encoder(Protocol(FixedPacketCodec))
val encoder = Rs2Encoder(Protocol(TestFixedPacketCodec))
encoder.cipher = TestStreamCipher
val channel = EmbeddedChannel(encoder)
@ -86,7 +86,7 @@ class Rs2EncoderTest {
@Test
fun testSwitchProtocol() {
val encoder = Rs2Encoder(Protocol(FixedPacketCodec))
val encoder = Rs2Encoder(Protocol(TestFixedPacketCodec))
val channel = EmbeddedChannel(encoder)
channel.writeOutbound(FixedPacket(0x11223344))
@ -115,9 +115,9 @@ class Rs2EncoderTest {
val channel = EmbeddedChannel(
Rs2Encoder(
Protocol(
FixedPacketCodec,
VariableBytePacketCodec,
VariableShortPacketCodec,
TestFixedPacketCodec,
TestVariableBytePacketCodec,
TestVariableShortPacketCodec,
VariableByteOptimisedPacketCodec,
VariableShortOptimisedPacketCodec,
TestEmptyPacketCodec

@ -3,7 +3,7 @@ package org.openrs2.protocol
import io.netty.buffer.ByteBuf
import org.openrs2.crypto.StreamCipher
internal object FixedPacketCodec : PacketCodec<FixedPacket>(
internal object TestFixedPacketCodec : FixedPacketCodec<FixedPacket>(
type = FixedPacket::class.java,
opcode = 0,
length = 4

@ -3,10 +3,9 @@ package org.openrs2.protocol
import io.netty.buffer.ByteBuf
import org.openrs2.crypto.StreamCipher
internal object VariableBytePacketCodec : PacketCodec<VariableBytePacket>(
internal object TestVariableBytePacketCodec : VariableBytePacketCodec<VariableBytePacket>(
type = VariableBytePacket::class.java,
opcode = 1,
length = PacketLength.VARIABLE_BYTE
opcode = 1
) {
override fun decode(input: ByteBuf, cipher: StreamCipher): VariableBytePacket {
val value = ByteArray(input.readableBytes())

@ -3,10 +3,9 @@ package org.openrs2.protocol
import io.netty.buffer.ByteBuf
import org.openrs2.crypto.StreamCipher
internal object VariableShortPacketCodec : PacketCodec<VariableShortPacket>(
internal object TestVariableShortPacketCodec : VariableShortPacketCodec<VariableShortPacket>(
type = VariableShortPacket::class.java,
opcode = 2,
length = PacketLength.VARIABLE_SHORT
opcode = 2
) {
override fun decode(input: ByteBuf, cipher: StreamCipher): VariableShortPacket {
val value = ByteArray(input.readableBytes())

@ -3,10 +3,9 @@ package org.openrs2.protocol
import io.netty.buffer.ByteBuf
import org.openrs2.crypto.StreamCipher
internal object VariableByteOptimisedPacketCodec : PacketCodec<VariableByteOptimisedPacket>(
internal object VariableByteOptimisedPacketCodec : VariableBytePacketCodec<VariableByteOptimisedPacket>(
type = VariableByteOptimisedPacket::class.java,
opcode = 3,
length = PacketLength.VARIABLE_BYTE
opcode = 3
) {
override fun decode(input: ByteBuf, cipher: StreamCipher): VariableByteOptimisedPacket {
val value = ByteArray(input.readableBytes())

@ -3,10 +3,9 @@ package org.openrs2.protocol
import io.netty.buffer.ByteBuf
import org.openrs2.crypto.StreamCipher
internal object VariableShortOptimisedPacketCodec : PacketCodec<VariableShortOptimisedPacket>(
internal object VariableShortOptimisedPacketCodec : VariableShortPacketCodec<VariableShortOptimisedPacket>(
type = VariableShortOptimisedPacket::class.java,
opcode = 4,
length = PacketLength.VARIABLE_SHORT
opcode = 4
) {
override fun decode(input: ByteBuf, cipher: StreamCipher): VariableShortOptimisedPacket {
val value = ByteArray(input.readableBytes())

Loading…
Cancel
Save