Просмотр исходного кода

Introduce `FileCacheStrategy`;
Rework `ExternalImage`, introduce `ReusableInput` for multiple attempts when uploading images;
Add `BotConfiguration.fileCacheStrategy`, defaults use cache system from host OS;
Introduce `DeferredReusableInput` for `*.toExternalImage` on JVM.
Deprecate `*.suspendToExternalImage` as no longer need to be suspend.
Open input only when required, close input after uploading files, fix #302

Him188 5 лет назад
Родитель
Сommit
2d9db234d7
17 измененных файлов с 497 добавлено и 291 удалено
  1. 6 2
      mirai-core-qqandroid/src/commonMain/kotlin/net/mamoe/mirai/qqandroid/contact/FriendImpl.kt
  2. 4 0
      mirai-core-qqandroid/src/commonMain/kotlin/net/mamoe/mirai/qqandroid/contact/GroupImpl.kt
  3. 8 6
      mirai-core-qqandroid/src/commonMain/kotlin/net/mamoe/mirai/qqandroid/network/highway/HighwayHelper.kt
  4. 2 2
      mirai-core-qqandroid/src/commonMain/kotlin/net/mamoe/mirai/qqandroid/network/highway/highway.kt
  5. 0 46
      mirai-core/src/commonMain/kotlin/net.mamoe.mirai/Mirai.kt
  6. 39 35
      mirai-core/src/commonMain/kotlin/net.mamoe.mirai/utils/BotConfiguration.kt
  7. 16 19
      mirai-core/src/commonMain/kotlin/net.mamoe.mirai/utils/ExternalImage.kt
  8. 60 0
      mirai-core/src/commonMain/kotlin/net.mamoe.mirai/utils/FileCacheStrategy.common.kt
  9. 11 0
      mirai-core/src/commonMain/kotlin/net.mamoe.mirai/utils/internal/DeferredReusableInput.common.kt
  10. 13 0
      mirai-core/src/commonMain/kotlin/net.mamoe.mirai/utils/internal/ReusableInput.kt
  11. 2 5
      mirai-core/src/commonMain/kotlin/net.mamoe.mirai/utils/internal/asReusableInput.common.kt
  12. 0 46
      mirai-core/src/jvmMain/kotlin/net/mamoe/mirai/Mirai.kt
  13. 10 10
      mirai-core/src/jvmMain/kotlin/net/mamoe/mirai/message/SendImageUtilsJvm.kt
  14. 35 114
      mirai-core/src/jvmMain/kotlin/net/mamoe/mirai/utils/ExternalImageJvm.kt
  15. 213 0
      mirai-core/src/jvmMain/kotlin/net/mamoe/mirai/utils/FileCacheStrategy.jvm.kt
  16. 49 0
      mirai-core/src/jvmMain/kotlin/net/mamoe/mirai/utils/internal/DeferredReusableInput.jvm.kt
  17. 29 6
      mirai-core/src/jvmMain/kotlin/net/mamoe/mirai/utils/internal/asReusableInput.jvm.kt

+ 6 - 2
mirai-core-qqandroid/src/commonMain/kotlin/net/mamoe/mirai/qqandroid/contact/FriendImpl.kt

@@ -87,6 +87,10 @@ internal class FriendImpl(
     @JvmSynthetic
     @OptIn(MiraiInternalAPI::class, ExperimentalStdlibApi::class, ExperimentalTime::class)
     override suspend fun uploadImage(image: ExternalImage): Image = try {
+        @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
+        if (image.input is net.mamoe.mirai.utils.internal.DeferredReusableInput) {
+            image.input.init(bot.configuration.fileCacheStrategy)
+        }
         if (BeforeImageUploadEvent(this, image).broadcast().isCancelled) {
             throw EventCancelledException("cancelled by BeforeImageUploadEvent.ToGroup")
         }
@@ -96,10 +100,10 @@ internal class FriendImpl(
                     srcUin = bot.id.toInt(),
                     dstUin = id.toInt(),
                     fileId = 0,
-                    fileMd5 = image.md5,
+                    fileMd5 = @Suppress("INVISIBLE_MEMBER") image.md5,
                     fileSize = @Suppress("INVISIBLE_MEMBER")
                     image.input.size.toInt(),
-                    fileName = image.md5.toUHexString("") + "." + ExternalImage.defaultFormatName,
+                    fileName = @Suppress("INVISIBLE_MEMBER") image.md5.toUHexString("") + "." + ExternalImage.defaultFormatName,
                     imgOriginal = 1
                 )
             ).sendAndExpect<LongConn.OffPicUp.Response>()

+ 4 - 0
mirai-core-qqandroid/src/commonMain/kotlin/net/mamoe/mirai/qqandroid/contact/GroupImpl.kt

@@ -406,6 +406,10 @@ internal class GroupImpl(
     @OptIn(ExperimentalTime::class)
     @JvmSynthetic
     override suspend fun uploadImage(image: ExternalImage): OfflineGroupImage = try {
+        @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
+        if (image.input is net.mamoe.mirai.utils.internal.DeferredReusableInput) {
+            image.input.init(bot.configuration.fileCacheStrategy)
+        }
         if (BeforeImageUploadEvent(this, image).broadcast().isCancelled) {
             throw EventCancelledException("cancelled by BeforeImageUploadEvent.ToGroup")
         }

+ 8 - 6
mirai-core-qqandroid/src/commonMain/kotlin/net/mamoe/mirai/qqandroid/network/highway/HighwayHelper.kt

@@ -7,6 +7,8 @@
  * https://github.com/mamoe/mirai/blob/master/LICENSE
  */
 
+@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
+
 package net.mamoe.mirai.qqandroid.network.highway
 
 import io.ktor.client.HttpClient
@@ -34,9 +36,9 @@ import net.mamoe.mirai.qqandroid.utils.addSuppressedMirai
 import net.mamoe.mirai.qqandroid.utils.io.serialization.readProtoBuf
 import net.mamoe.mirai.qqandroid.utils.io.withUse
 import net.mamoe.mirai.qqandroid.utils.toIpV4AddressString
-import net.mamoe.mirai.utils.ExternalImage
 import net.mamoe.mirai.utils.MiraiExperimentalAPI
 import net.mamoe.mirai.utils.MiraiInternalAPI
+import net.mamoe.mirai.utils.internal.ReusableInput
 import net.mamoe.mirai.utils.verbose
 import kotlin.coroutines.EmptyCoroutineContext
 import kotlin.math.roundToInt
@@ -44,12 +46,12 @@ import kotlin.time.ExperimentalTime
 import kotlin.time.measureTime
 
 @OptIn(MiraiInternalAPI::class, InternalSerializationApi::class)
-@Suppress("SpellCheckingInspection", "INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
+@Suppress("SpellCheckingInspection")
 internal suspend fun HttpClient.postImage(
     htcmd: String,
     uin: Long,
     groupcode: Long?,
-    imageInput: ExternalImage.ReusableInput, // Input from kotlinx.io, InputStream from kotlinx.io MPP, ByteReadChannel from ktor
+    imageInput: ReusableInput,
     uKeyHex: String
 ): Boolean = post<HttpStatusCode> {
     url {
@@ -90,7 +92,7 @@ internal object HighwayHelper {
         bot: QQAndroidBot,
         servers: List<Pair<Int, Int>>,
         uKey: ByteArray,
-        image: ExternalImage.ReusableInput,
+        image: ReusableInput,
         kind: String,
         commandId: Int
     ) = uploadImageToServers(bot, servers, uKey, image.md5, image, kind, commandId)
@@ -102,7 +104,7 @@ internal object HighwayHelper {
         servers: List<Pair<Int, Int>>,
         uKey: ByteArray,
         md5: ByteArray,
-        input: ExternalImage.ReusableInput,
+        input: ReusableInput,
         kind: String,
         commandId: Int
     ) = servers.retryWithServers(
@@ -139,7 +141,7 @@ internal object HighwayHelper {
         serverIp: String,
         serverPort: Int,
         ticket: ByteArray,
-        imageInput: ExternalImage.ReusableInput,
+        imageInput: ReusableInput,
         fileMd5: ByteArray,
         commandId: Int  // group=2, friend=1
     ) {

+ 2 - 2
mirai-core-qqandroid/src/commonMain/kotlin/net/mamoe/mirai/qqandroid/network/highway/highway.kt

@@ -21,10 +21,10 @@ import net.mamoe.mirai.qqandroid.network.protocol.packet.EMPTY_BYTE_ARRAY
 import net.mamoe.mirai.qqandroid.utils.ByteArrayPool
 import net.mamoe.mirai.qqandroid.utils.MiraiPlatformUtils
 import net.mamoe.mirai.qqandroid.utils.io.serialization.toByteArray
-import net.mamoe.mirai.utils.ExternalImage
 import net.mamoe.mirai.utils.MiraiInternalAPI
 import net.mamoe.mirai.utils.internal.ChunkedFlowSession
 import net.mamoe.mirai.utils.internal.ChunkedInput
+import net.mamoe.mirai.utils.internal.ReusableInput
 import net.mamoe.mirai.utils.internal.map
 
 @OptIn(MiraiInternalAPI::class, InternalSerializationApi::class)
@@ -37,7 +37,7 @@ internal fun createImageDataPacketSequence(
     commandId: Int,
     localId: Int = 2052,
     ticket: ByteArray,
-    data: ExternalImage.ReusableInput,
+    data: ReusableInput,
     fileMd5: ByteArray,
     sizePerPacket: Int = ByteArrayPool.BUFFER_SIZE
 ): ChunkedFlowSession<ByteReadPacket> {

+ 0 - 46
mirai-core/src/commonMain/kotlin/net.mamoe.mirai/Mirai.kt

@@ -1,46 +0,0 @@
-/*
- * Copyright 2020 Mamoe Technologies and contributors.
- *
- * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
- * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
- *
- * https://github.com/mamoe/mirai/blob/master/LICENSE
- */
-
-package net.mamoe.mirai
-
-import io.ktor.utils.io.ByteReadChannel
-import kotlinx.io.core.Input
-import net.mamoe.mirai.utils.ExternalImage
-import net.mamoe.mirai.utils.MiraiExperimentalAPI
-import net.mamoe.mirai.utils.SinceMirai
-import net.mamoe.mirai.utils.internal.InputStream
-import kotlin.jvm.JvmStatic
-
-/**
- * Mirai 全局环境.
- */
-@SinceMirai("1.0.0")
-expect object Mirai {
-
-    @JvmStatic
-    var fileCacheStrategy: FileCacheStrategy
-
-    /**
-     * 缓存策略.
-     *
-     * 图片上传时默认使用文件缓存.
-     */
-    interface FileCacheStrategy {
-        @MiraiExperimentalAPI
-        fun newImageCache(input: Input): ExternalImage
-
-        @MiraiExperimentalAPI
-        fun newImageCache(input: ByteReadChannel): ExternalImage
-
-        @MiraiExperimentalAPI
-        fun newImageCache(input: InputStream): ExternalImage
-
-        companion object Default : FileCacheStrategy
-    }
-}

+ 39 - 35
mirai-core/src/commonMain/kotlin/net.mamoe.mirai/utils/BotConfiguration.kt

@@ -10,6 +10,7 @@
 
 package net.mamoe.mirai.utils
 
+import kotlinx.coroutines.Job
 import net.mamoe.mirai.Bot
 import net.mamoe.mirai.network.BotNetworkHandler
 import kotlin.coroutines.CoroutineContext
@@ -24,30 +25,20 @@ import kotlin.jvm.JvmStatic
  */
 @Suppress("PropertyName")
 open class BotConfiguration {
-    /**
-     * 日志记录器
-     */
+    /** 日志记录器 */
     var botLoggerSupplier: ((Bot) -> MiraiLogger) = { DefaultLogger("Bot(${it.id})") }
 
-    /**
-     * 网络层日志构造器
-     */
+    /** 网络层日志构造器 */
     @OptIn(MiraiInternalAPI::class)
     var networkLoggerSupplier: ((BotNetworkHandler) -> MiraiLogger) = { DefaultLogger("Network(${it.bot.id})") }
 
-    /**
-     * 设备信息覆盖. 默认使用随机的设备信息.
-     */
+    /** 设备信息覆盖. 默认使用随机的设备信息. */
     var deviceInfo: ((Context) -> DeviceInfo)? = null
 
-    /**
-     * 父 [CoroutineContext]
-     */
+    /** 父 [CoroutineContext]. [Bot] 创建后会覆盖其 [Job], 但会将这个 [Job] 作为父 [Job] */
     var parentCoroutineContext: CoroutineContext = EmptyCoroutineContext
 
-    /**
-     * 心跳周期. 过长会导致被服务器断开连接.
-     */
+    /** 心跳周期. 过长会导致被服务器断开连接. */
     var heartbeatPeriodMillis: Long = 60.secondsToMillis
 
     /**
@@ -56,31 +47,26 @@ open class BotConfiguration {
      */
     var heartbeatTimeoutMillis: Long = 2.secondsToMillis
 
-    /**
-     * 心跳失败后的第一次重连前的等待时间.
-     */
+    /** 心跳失败后的第一次重连前的等待时间. */
     var firstReconnectDelayMillis: Long = 5.secondsToMillis
 
-    /**
-     * 重连失败后, 继续尝试的每次等待时间
-     */
+    /** 重连失败后, 继续尝试的每次等待时间 */
     var reconnectPeriodMillis: Long = 5.secondsToMillis
 
-    /**
-     * 最多尝试多少次重连
-     */
+    /** 最多尝试多少次重连 */
     var reconnectionRetryTimes: Int = Int.MAX_VALUE
 
-    /**
-     * 验证码处理器
-     */
+    /** 验证码处理器 */
     var loginSolver: LoginSolver = LoginSolver.Default
 
-    /**
-     * 使用协议类型
-     */
+    /** 使用协议类型 */
+    @SinceMirai("1.0.0")
+    var protocol: MiraiProtocol = MiraiProtocol.ANDROID_PAD
+
+    /** 缓存策略  */
     @SinceMirai("1.0.0")
-    val protocol: MiraiProtocol = MiraiProtocol.ANDROID_PAD
+    @MiraiExperimentalAPI
+    var fileCacheStrategy: FileCacheStrategy = FileCacheStrategy.PlatformDefault
 
     @SinceMirai("1.0.0")
     enum class MiraiProtocol(
@@ -105,9 +91,7 @@ open class BotConfiguration {
     }
 
     companion object {
-        /**
-         * 默认的配置实例
-         */
+        /** 默认的配置实例. 可以进行修改 */
         @JvmStatic
         val Default = BotConfiguration()
     }
@@ -144,11 +128,31 @@ open class BotConfiguration {
      * ```
      */
     @ConfigurationDsl
-    suspend fun inheritCoroutineContext() {
+    suspend inline fun inheritCoroutineContext() {
         parentCoroutineContext = coroutineContext
     }
+
     @DslMarker
     annotation class ConfigurationDsl
+
+    @SinceMirai("1.0.0")
+    fun copy(): BotConfiguration {
+        @OptIn(MiraiExperimentalAPI::class)
+        return BotConfiguration().also { new ->
+            new.botLoggerSupplier = botLoggerSupplier
+            new.networkLoggerSupplier = networkLoggerSupplier
+            new.deviceInfo = deviceInfo
+            new.parentCoroutineContext = parentCoroutineContext
+            new.heartbeatPeriodMillis = heartbeatPeriodMillis
+            new.heartbeatTimeoutMillis = heartbeatTimeoutMillis
+            new.firstReconnectDelayMillis = firstReconnectDelayMillis
+            new.reconnectPeriodMillis = reconnectPeriodMillis
+            new.reconnectionRetryTimes = reconnectionRetryTimes
+            new.loginSolver = loginSolver
+            new.protocol = protocol
+            new.fileCacheStrategy = fileCacheStrategy
+        }
+    }
 }
 
 @OptIn(ExperimentalMultiplatform::class)

+ 16 - 19
mirai-core/src/commonMain/kotlin/net.mamoe.mirai/utils/ExternalImage.kt

@@ -11,15 +11,14 @@
 
 package net.mamoe.mirai.utils
 
-import io.ktor.utils.io.ByteWriteChannel
 import net.mamoe.mirai.contact.Contact
 import net.mamoe.mirai.contact.Group
 import net.mamoe.mirai.contact.User
 import net.mamoe.mirai.message.MessageReceipt
 import net.mamoe.mirai.message.data.Image
 import net.mamoe.mirai.message.data.sendTo
-import net.mamoe.mirai.utils.internal.ChunkedFlowSession
-import net.mamoe.mirai.utils.internal.ChunkedInput
+import net.mamoe.mirai.utils.internal.DeferredReusableInput
+import net.mamoe.mirai.utils.internal.ReusableInput
 import kotlin.jvm.JvmField
 import kotlin.jvm.JvmSynthetic
 
@@ -31,24 +30,16 @@ import kotlin.jvm.JvmSynthetic
  * @see ExternalImage.sendTo 上传图片并以纯图片消息发送给联系人
  * @See ExternalImage.upload 上传图片并得到 [Image] 消息
  */
-@OptIn(MiraiInternalAPI::class)
 class ExternalImage internal constructor(
     @JvmField
-    internal val input: ReusableInput // Input from kotlinx.io, InputStream from kotlinx.io MPP, ByteReadChannel from ktor
+    internal val input: ReusableInput
 ) {
-    val md5: ByteArray get() = this.input.md5
-
-    @SinceMirai("1.0.0")
-    internal interface ReusableInput {
-        val md5: ByteArray
-        val size: Long
-
-        fun chunkedFlow(sizePerPacket: Int): ChunkedFlowSession<ChunkedInput>
-        suspend fun writeTo(out: ByteWriteChannel): Long
-    }
+    internal val md5: ByteArray get() = this.input.md5
 
     init {
-        require(input.size < 30L * 1024 * 1024) { "Image file is too big. Maximum is 30 MiB, but recommended to be 20 MiB" }
+        if (input !is DeferredReusableInput) {
+            require(input.size < 30L * 1024 * 1024) { "Image file is too big. Maximum is 30 MiB, but recommended to be 20 MiB" }
+        }
     }
 
     companion object {
@@ -75,10 +66,16 @@ class ExternalImage internal constructor(
      *  SHARPP: 1004
      */
 
+    override fun toString(): String {
+        if (input is DeferredReusableInput) {
+            if (!input.initialized) {
+                return "ExternalImage(uninitialized)"
+            }
+        }
+        return "ExternalImage(${generateUUID(md5)})"
+    }
 
-    override fun toString(): String = "[ExternalImage(${generateUUID(md5)})]"
-
-    fun calculateImageResourceId(): String = generateImageId(md5)
+    internal fun calculateImageResourceId(): String = generateImageId(md5)
 }
 
 /**

+ 60 - 0
mirai-core/src/commonMain/kotlin/net.mamoe.mirai/utils/FileCacheStrategy.common.kt

@@ -0,0 +1,60 @@
+package net.mamoe.mirai.utils
+
+import kotlinx.io.core.Input
+import kotlinx.io.errors.IOException
+import net.mamoe.mirai.utils.internal.InputStream
+
+/**
+ * 缓存策略.
+ *
+ * 图片上传时默认使用文件缓存.
+ */
+@MiraiExperimentalAPI
+expect interface FileCacheStrategy {
+    /**
+     * 将 [input] 缓存为 [ExternalImage].
+     * 此函数应 close 这个 [Input]
+     */
+    @MiraiExperimentalAPI
+    @Throws(IOException::class)
+    fun newImageCache(input: Input): ExternalImage
+
+    /**
+     * 将 [input] 缓存为 [ExternalImage].
+     * 此函数应 close 这个 [InputStream]
+     */
+    @MiraiExperimentalAPI
+    @Throws(IOException::class)
+    fun newImageCache(input: InputStream): ExternalImage
+
+    /**
+     * 将 [input] 缓存为 [ExternalImage].
+     * 此 [input] 的内容应是不变的.
+     */
+    @MiraiExperimentalAPI
+    @Throws(IOException::class)
+    fun newImageCache(input: ByteArray): ExternalImage
+
+    /**
+     * 默认的缓存方案. 在 JVM 平台使用系统临时文件.
+     */
+    @MiraiExperimentalAPI
+    object PlatformDefault : FileCacheStrategy
+
+    /**
+     * 使用内存直接存储所有图片文件.
+     */
+    object MemoryCache : FileCacheStrategy {
+        @MiraiExperimentalAPI
+        @Throws(IOException::class)
+        override fun newImageCache(input: Input): ExternalImage
+
+        @MiraiExperimentalAPI
+        @Throws(IOException::class)
+        override fun newImageCache(input: InputStream): ExternalImage
+
+        @MiraiExperimentalAPI
+        @Throws(IOException::class)
+        override fun newImageCache(input: ByteArray): ExternalImage
+    }
+}

+ 11 - 0
mirai-core/src/commonMain/kotlin/net.mamoe.mirai/utils/internal/DeferredReusableInput.common.kt

@@ -0,0 +1,11 @@
+package net.mamoe.mirai.utils.internal
+
+import net.mamoe.mirai.utils.FileCacheStrategy
+import net.mamoe.mirai.utils.MiraiExperimentalAPI
+
+internal expect class DeferredReusableInput(input: Any, extraArg: Any?) : ReusableInput {
+    val initialized: Boolean
+
+    @OptIn(MiraiExperimentalAPI::class)
+    suspend fun init(strategy: FileCacheStrategy)
+}

+ 13 - 0
mirai-core/src/commonMain/kotlin/net.mamoe.mirai/utils/internal/ReusableInput.kt

@@ -0,0 +1,13 @@
+package net.mamoe.mirai.utils.internal
+
+import io.ktor.utils.io.ByteWriteChannel
+import net.mamoe.mirai.utils.SinceMirai
+
+@SinceMirai("1.0.0")
+internal interface ReusableInput {
+    val md5: ByteArray
+    val size: Long
+
+    fun chunkedFlow(sizePerPacket: Int): ChunkedFlowSession<ChunkedInput>
+    suspend fun writeTo(out: ByteWriteChannel): Long
+}

+ 2 - 5
mirai-core/src/commonMain/kotlin/net.mamoe.mirai/utils/internal/asReusableInput.common.kt

@@ -9,9 +9,6 @@
 
 package net.mamoe.mirai.utils.internal
 
-import net.mamoe.mirai.utils.ExternalImage
+internal expect fun ByteArray.asReusableInput(): ReusableInput
 
-
-internal expect fun ByteArray.asReusableInput(): ExternalImage.ReusableInput
-
-internal fun asReusableInput0(input: ByteArray): ExternalImage.ReusableInput = input.asReusableInput()
+internal fun asReusableInput0(input: ByteArray): ReusableInput = input.asReusableInput()

+ 0 - 46
mirai-core/src/jvmMain/kotlin/net/mamoe/mirai/Mirai.kt

@@ -1,46 +0,0 @@
-package net.mamoe.mirai
-
-import io.ktor.utils.io.ByteReadChannel
-import kotlinx.io.core.Input
-import net.mamoe.mirai.utils.ExternalImage
-import net.mamoe.mirai.utils.MiraiExperimentalAPI
-import net.mamoe.mirai.utils.internal.InputStream
-
-/**
- * Mirai 全局环境.
- */
-actual object Mirai {
-    actual var fileCacheStrategy: FileCacheStrategy
-        get() = TODO("Not yet implemented")
-        set(value) {}
-
-    actual interface FileCacheStrategy {
-        @MiraiExperimentalAPI
-        actual fun newImageCache(input: Input): ExternalImage
-
-        @MiraiExperimentalAPI
-        actual fun newImageCache(input: ByteReadChannel): ExternalImage
-
-        @MiraiExperimentalAPI
-        actual fun newImageCache(input: InputStream): ExternalImage
-
-        actual companion object Default : FileCacheStrategy {
-            @MiraiExperimentalAPI
-            actual override fun newImageCache(input: Input): ExternalImage {
-                TODO("Not yet implemented")
-            }
-
-            @MiraiExperimentalAPI
-            actual override fun newImageCache(input: ByteReadChannel): ExternalImage {
-                TODO("Not yet implemented")
-            }
-
-            @MiraiExperimentalAPI
-            actual override fun newImageCache(input: InputStream): ExternalImage {
-                TODO("Not yet implemented")
-            }
-        }
-
-    }
-
-}

+ 10 - 10
mirai-core/src/jvmMain/kotlin/net/mamoe/mirai/message/SendImageUtilsJvm.kt

@@ -37,7 +37,7 @@ import java.net.URL
  */
 @Throws(OverFileSizeMaxException::class)
 suspend fun <C : Contact> BufferedImage.sendTo(contact: C): MessageReceipt<C> =
-    withContext(Dispatchers.IO) { toExternalImage() }.sendTo(contact)
+    toExternalImage().sendTo(contact)
 
 /**
  * 在 [Dispatchers.IO] 中下载 [URL] 到临时文件并将其作为图片发送到指定联系人
@@ -45,7 +45,7 @@ suspend fun <C : Contact> BufferedImage.sendTo(contact: C): MessageReceipt<C> =
  */
 @Throws(OverFileSizeMaxException::class)
 suspend fun <C : Contact> URL.sendAsImageTo(contact: C): MessageReceipt<C> =
-    withContext(Dispatchers.IO) { toExternalImage() }.sendTo(contact)
+    toExternalImage().sendTo(contact)
 
 /**
  * 在 [Dispatchers.IO] 中读取 [Input] 到临时文件并将其作为图片发送到指定联系人
@@ -53,7 +53,7 @@ suspend fun <C : Contact> URL.sendAsImageTo(contact: C): MessageReceipt<C> =
  */
 @Throws(OverFileSizeMaxException::class)
 suspend fun <C : Contact> Input.sendAsImageTo(contact: C): MessageReceipt<C> =
-    withContext(Dispatchers.IO) { toExternalImage() }.sendTo(contact)
+    toExternalImage().sendTo(contact)
 
 /**
  * 在 [Dispatchers.IO] 中读取 [InputStream] 到临时文件并将其作为图片发送到指定联系人
@@ -61,7 +61,7 @@ suspend fun <C : Contact> Input.sendAsImageTo(contact: C): MessageReceipt<C> =
  */
 @Throws(OverFileSizeMaxException::class)
 suspend fun <C : Contact> InputStream.sendAsImageTo(contact: C): MessageReceipt<C> =
-    withContext(Dispatchers.IO) { toExternalImage() }.sendTo(contact)
+    toExternalImage().sendTo(contact)
 
 /**
  * 在 [Dispatchers.IO] 中将文件作为图片发送到指定联系人
@@ -70,7 +70,7 @@ suspend fun <C : Contact> InputStream.sendAsImageTo(contact: C): MessageReceipt<
 @Throws(OverFileSizeMaxException::class)
 suspend fun <C : Contact> File.sendAsImageTo(contact: C): MessageReceipt<C> {
     require(this.exists() && this.canRead())
-    return withContext(Dispatchers.IO) { toExternalImage() }.sendTo(contact)
+    return toExternalImage().sendTo(contact)
 }
 
 // endregion
@@ -84,7 +84,7 @@ suspend fun <C : Contact> File.sendAsImageTo(contact: C): MessageReceipt<C> {
 @JvmSynthetic
 @Throws(OverFileSizeMaxException::class)
 suspend fun BufferedImage.upload(contact: Contact): Image =
-    withContext(Dispatchers.IO) { toExternalImage() }.upload(contact)
+    toExternalImage().upload(contact)
 
 /**
  * 在 [Dispatchers.IO] 中下载 [URL] 到临时文件并将其作为图片上传后构造 [Image]
@@ -92,7 +92,7 @@ suspend fun BufferedImage.upload(contact: Contact): Image =
  */
 @Throws(OverFileSizeMaxException::class)
 suspend fun URL.uploadAsImage(contact: Contact): Image =
-    withContext(Dispatchers.IO) { toExternalImage() }.upload(contact)
+    toExternalImage().upload(contact)
 
 /**
  * 在 [Dispatchers.IO] 中读取 [Input] 到临时文件并将其作为图片上传后构造 [Image]
@@ -100,7 +100,7 @@ suspend fun URL.uploadAsImage(contact: Contact): Image =
  */
 @Throws(OverFileSizeMaxException::class)
 suspend fun Input.uploadAsImage(contact: Contact): Image =
-    withContext(Dispatchers.IO) { toExternalImage() }.upload(contact)
+    toExternalImage().upload(contact)
 
 /**
  * 在 [Dispatchers.IO] 中读取 [InputStream] 到临时文件并将其作为图片上传后构造 [Image]
@@ -108,7 +108,7 @@ suspend fun Input.uploadAsImage(contact: Contact): Image =
  */
 @Throws(OverFileSizeMaxException::class)
 suspend fun InputStream.uploadAsImage(contact: Contact): Image =
-    withContext(Dispatchers.IO) { toExternalImage() }.upload(contact)
+    toExternalImage().upload(contact)
 
 /**
  * 在 [Dispatchers.IO] 中将文件作为图片上传后构造 [Image]
@@ -117,7 +117,7 @@ suspend fun InputStream.uploadAsImage(contact: Contact): Image =
 @Throws(OverFileSizeMaxException::class)
 suspend fun File.uploadAsImage(contact: Contact): Image {
     require(this.isFile && this.exists() && this.canRead()) { "file ${this.path} is not readable" }
-    return withContext(Dispatchers.IO) { toExternalImage() }.upload(contact)
+    return toExternalImage().upload(contact)
 }
 
 // endregion

+ 35 - 114
mirai-core/src/jvmMain/kotlin/net/mamoe/mirai/utils/ExternalImageJvm.kt

@@ -11,22 +11,14 @@
 
 package net.mamoe.mirai.utils
 
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.io.ByteReadChannel
-import kotlinx.coroutines.withContext
 import kotlinx.io.core.Input
-import kotlinx.io.core.copyTo
-import kotlinx.io.errors.IOException
-import kotlinx.io.streams.asOutput
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.utils.internal.DeferredReusableInput
 import net.mamoe.mirai.utils.internal.asReusableInput
-import net.mamoe.mirai.utils.internal.md5
 import java.awt.image.BufferedImage
 import java.io.File
 import java.io.InputStream
-import java.io.OutputStream
 import java.net.URL
-import java.security.MessageDigest
-import javax.imageio.ImageIO
 
 /*
  * 将各类型图片容器转为 [ExternalImage]
@@ -34,126 +26,55 @@ import javax.imageio.ImageIO
 
 
 /**
- * 将 [BufferedImage] 保存临时文件, 然后构造 [ExternalImage]
+ * 将 [BufferedImage] 保存临时文件, 然后构造 [ExternalImage]
  */
 @JvmOverloads
-@Throws(IOException::class)
-fun BufferedImage.toExternalImage(formatName: String = "png"): ExternalImage {
-    val file = createTempFile().apply { deleteOnExit() }
-
-    val digest = MessageDigest.getInstance("md5")
-    digest.reset()
-
-    file.outputStream().use { out ->
-        ImageIO.write(this@toExternalImage, formatName, object : OutputStream() {
-            override fun write(b: Int) {
-                out.write(b)
-                digest.update(b.toByte())
-            }
-
-            override fun write(b: ByteArray) {
-                out.write(b)
-                digest.update(b)
-            }
-
-            override fun write(b: ByteArray, off: Int, len: Int) {
-                out.write(b, off, len)
-                digest.update(b, off, len)
-            }
-        })
-    }
-
-    @Suppress("DEPRECATION_ERROR")
-    return ExternalImage(file.asReusableInput())
-}
-
-suspend inline fun BufferedImage.suspendToExternalImage(): ExternalImage = withContext(IO) { toExternalImage() }
+fun BufferedImage.toExternalImage(formatName: String = "png"): ExternalImage =
+    ExternalImage(DeferredReusableInput(this, formatName))
 
 /**
- * 直接使用文件 [inputStream] 构造 [ExternalImage]
+ * 将文件作为 [ExternalImage] 使用. 只会在需要的时候打开文件并读取数据.
+ * @param deleteOnClose 若为 `true`, 图片发送后将会删除这个文件
  */
-@OptIn(MiraiInternalAPI::class)
-@Throws(IOException::class)
-fun File.toExternalImage(): ExternalImage {
-    @Suppress("DEPRECATION_ERROR")
-    return ExternalImage(
-        input = this.asReusableInput()
-    )
-}
+@JvmOverloads
+fun File.toExternalImage(deleteOnClose: Boolean = false): ExternalImage = ExternalImage(asReusableInput(deleteOnClose))
 
 /**
- * 在 [IO] 中进行 [File.toExternalImage]
+ * 将 [URL] 委托为 [ExternalImage].
+ * 只会在上传图片时才读取 [URL] 的内容. 具体行为取决于相关 [Bot] 的 [FileCacheStrategy]
  */
-suspend inline fun File.suspendToExternalImage(): ExternalImage = withContext(IO) { toExternalImage() }
+fun URL.toExternalImage(): ExternalImage = ExternalImage(DeferredReusableInput(this, null))
 
 /**
- * 下载文件到临时目录然后调用 [File.toExternalImage]
+ * 将 [InputStream] 委托为 [ExternalImage].
+ * 只会在上传图片时才读取 [InputStream] 的内容. 具体行为取决于相关 [Bot] 的 [FileCacheStrategy]
  */
-@Throws(IOException::class)
-fun URL.toExternalImage(): ExternalImage {
-    val file = createTempFile().apply { deleteOnExit() }
-    file.outputStream().use { output ->
-        openStream().use { input ->
-            input.copyTo(output)
-        }
-        output.flush()
-    }
-    return file.toExternalImage()
-}
+@JvmName("toExternalImage")
+fun InputStream.toExternalImage(): ExternalImage = ExternalImage(DeferredReusableInput(this, null))
 
 /**
- * 在 [IO] 中进行 [URL.toExternalImage]
+ * 将 [Input] 委托为 [ExternalImage].
+ * 只会在上传图片时才读取 [Input] 的内容. 具体行为取决于相关 [Bot] 的 [FileCacheStrategy]
  */
-suspend inline fun URL.suspendToExternalImage(): ExternalImage = withContext(IO) { toExternalImage() }
+fun Input.toExternalImage(): ExternalImage = ExternalImage(DeferredReusableInput(this, null))
 
-/**
- * 保存为临时文件然后调用 [File.toExternalImage]
- */
-@Throws(IOException::class)
-fun InputStream.toExternalImage(): ExternalImage {
-    val file = createTempFile().apply { deleteOnExit() }
-    file.outputStream().use {
-        this.copyTo(it)
-        it.flush()
-    }
-    this.close()
-    return file.toExternalImage()
-}
 
-/**
- * 在 [IO] 中进行 [InputStream.toExternalImage]
- */
-suspend inline fun InputStream.suspendToExternalImage(): ExternalImage = withContext(IO) { toExternalImage() }
+@PlannedRemoval("1.2.0")
+@Deprecated("no need", ReplaceWith("toExternalImage()"))
+fun Input.suspendToExternalImage(): ExternalImage = toExternalImage()
 
-/**
- * 保存为临时文件然后调用 [File.toExternalImage].
- *
- * 需要函数调用者 close [this]
- */
-@Throws(IOException::class)
-fun Input.toExternalImage(): ExternalImage {
-    val file = createTempFile().apply { deleteOnExit() }
-    file.outputStream().asOutput().use {
-        this.copyTo(it)
-        it.flush()
-    }
-    return file.toExternalImage()
-}
+@PlannedRemoval("1.2.0")
+@Deprecated("no need", ReplaceWith("toExternalImage()"))
+fun InputStream.suspendToExternalImage(): ExternalImage = toExternalImage()
 
-/**
- * 在 [IO] 中进行 [Input.toExternalImage]
- */
-suspend inline fun Input.suspendToExternalImage(): ExternalImage = withContext(IO) { toExternalImage() }
+@PlannedRemoval("1.2.0")
+@Deprecated("no need", ReplaceWith("toExternalImage()"))
+fun URL.suspendToExternalImage(): ExternalImage = toExternalImage()
 
-/**
- * 保存为临时文件然后调用 [File.toExternalImage].
- */
-suspend fun ByteReadChannel.toExternalImage(): ExternalImage {
-    val file = createTempFile().apply { deleteOnExit() }
-    file.outputStream().use {
-        withContext(IO) { copyTo(it) }
-        it.flush()
-    }
-
-    return file.suspendToExternalImage()
-}
+@PlannedRemoval("1.2.0")
+@Deprecated("no need", ReplaceWith("toExternalImage()"))
+fun File.suspendToExternalImage(): ExternalImage = toExternalImage()
+
+@PlannedRemoval("1.2.0")
+@Deprecated("no need", ReplaceWith("toExternalImage()"))
+fun BufferedImage.suspendToExternalImage(): ExternalImage = toExternalImage()

+ 213 - 0
mirai-core/src/jvmMain/kotlin/net/mamoe/mirai/utils/FileCacheStrategy.jvm.kt

@@ -0,0 +1,213 @@
+@file:Suppress("MemberVisibilityCanBePrivate")
+
+package net.mamoe.mirai.utils
+
+import kotlinx.io.core.Closeable
+import kotlinx.io.core.Input
+import kotlinx.io.core.readAvailable
+import kotlinx.io.core.readBytes
+import net.mamoe.mirai.Bot
+import net.mamoe.mirai.utils.internal.InputStream
+import net.mamoe.mirai.utils.internal.asReusableInput
+import java.awt.image.BufferedImage
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.IOException
+import java.io.OutputStream
+import java.net.URL
+import java.security.MessageDigest
+import javax.imageio.ImageIO
+import kotlin.contracts.ExperimentalContracts
+import kotlin.contracts.InvocationKind
+import kotlin.contracts.contract
+
+/**
+ * 缓存策略.
+ *
+ * 图片上传时默认使用文件缓存.
+ *
+ * @see BotConfiguration.fileCacheStrategy 为 [Bot] 指定缓存策略
+ */
+@MiraiExperimentalAPI
+actual interface FileCacheStrategy {
+    /**
+     * 将 [input] 缓存为 [ExternalImage].
+     * 此函数应 close 这个 [Input]
+     */
+    @MiraiExperimentalAPI
+    @Throws(IOException::class)
+    actual fun newImageCache(input: Input): ExternalImage
+
+    /**
+     * 将 [input] 缓存为 [ExternalImage].
+     * 此函数应 close 这个 [InputStream]
+     */
+    @MiraiExperimentalAPI
+    @Throws(IOException::class)
+    actual fun newImageCache(input: InputStream): ExternalImage
+
+    /**
+     * 将 [input] 缓存为 [ExternalImage].
+     * 此 [input] 的内容应是不变的.
+     */
+    @MiraiExperimentalAPI
+    @Throws(IOException::class)
+    actual fun newImageCache(input: ByteArray): ExternalImage
+
+    /**
+     * 将 [input] 缓存为 [ExternalImage].
+     * 此 [input] 的内容应是不变的.
+     */
+    @MiraiExperimentalAPI
+    @Throws(IOException::class)
+    fun newImageCache(input: BufferedImage, format: String = "png"): ExternalImage
+
+    /**
+     * 将 [input] 缓存为 [ExternalImage].
+     */
+    @MiraiExperimentalAPI
+    @Throws(IOException::class)
+    fun newImageCache(input: URL, format: String = "png"): ExternalImage
+
+    /**
+     * 默认的缓存方案, 使用系统临时文件夹存储.
+     */
+    @MiraiExperimentalAPI
+    actual object PlatformDefault : FileCacheStrategy by TempCache(null)
+
+    /**
+     * 使用内存直接存储所有图片文件.
+     */
+    actual object MemoryCache : FileCacheStrategy {
+        @MiraiExperimentalAPI
+        @Throws(IOException::class)
+        actual override fun newImageCache(input: Input): ExternalImage {
+            return newImageCache(input.readBytes())
+        }
+
+        @MiraiExperimentalAPI
+        @Throws(IOException::class)
+        actual override fun newImageCache(input: InputStream): ExternalImage {
+            return newImageCache(input.readBytes())
+        }
+
+        @MiraiExperimentalAPI
+        @Throws(IOException::class)
+        actual override fun newImageCache(input: ByteArray): ExternalImage {
+            return ExternalImage(input.asReusableInput())
+        }
+
+        @MiraiExperimentalAPI
+        override fun newImageCache(input: BufferedImage, format: String): ExternalImage {
+            val out = ByteArrayOutputStream()
+            ImageIO.write(input, format, out)
+            return newImageCache(out.toByteArray())
+        }
+
+        @MiraiExperimentalAPI
+        override fun newImageCache(input: URL, format: String): ExternalImage {
+            val out = ByteArrayOutputStream()
+            input.openConnection().getInputStream().use { it.copyTo(out) }
+            return newImageCache(out.toByteArray())
+        }
+    }
+
+    /**
+     * 使用系统临时文件夹缓存图片文件. 在图片使用完毕后删除临时文件.
+     */
+    @MiraiExperimentalAPI
+    class TempCache @JvmOverloads constructor(
+        /**
+         * 缓存图片存放位置
+         */
+        val directory: File? = null
+    ) : FileCacheStrategy {
+        @MiraiExperimentalAPI
+        @Throws(IOException::class)
+        override fun newImageCache(input: Input): ExternalImage {
+            return ExternalImage(createTempFile(directory = directory).apply {
+                deleteOnExit()
+                input.withOut(this.outputStream()) { copyTo(it) }
+            }.asReusableInput(true))
+        }
+
+        @MiraiExperimentalAPI
+        @Throws(IOException::class)
+        override fun newImageCache(input: InputStream): ExternalImage {
+            return ExternalImage(createTempFile(directory = directory).apply {
+                deleteOnExit()
+                input.withOut(this.outputStream()) { copyTo(it) }
+            }.asReusableInput(true))
+        }
+
+        @MiraiExperimentalAPI
+        @Throws(IOException::class)
+        override fun newImageCache(input: ByteArray): ExternalImage {
+            return ExternalImage(input.asReusableInput())
+        }
+
+        @MiraiExperimentalAPI
+        override fun newImageCache(input: BufferedImage, format: String): ExternalImage {
+            val file = createTempFile(directory = directory).apply { deleteOnExit() }
+
+            val digest = MessageDigest.getInstance("md5")
+            digest.reset()
+
+            file.outputStream().use { out ->
+                ImageIO.write(input, format, object : OutputStream() {
+                    override fun write(b: Int) {
+                        out.write(b)
+                        digest.update(b.toByte())
+                    }
+
+                    override fun write(b: ByteArray) {
+                        out.write(b)
+                        digest.update(b)
+                    }
+
+                    override fun write(b: ByteArray, off: Int, len: Int) {
+                        out.write(b, off, len)
+                        digest.update(b, off, len)
+                    }
+                })
+            }
+
+            @Suppress("DEPRECATION_ERROR")
+            return ExternalImage(file.asReusableInput(true, digest.digest()))
+        }
+
+        @MiraiExperimentalAPI
+        override fun newImageCache(input: URL, format: String): ExternalImage {
+            return ExternalImage(createTempFile(directory = directory).apply {
+                deleteOnExit()
+                input.openConnection().getInputStream().withOut(this.outputStream()) { copyTo(it) }
+            }.asReusableInput(true))
+        }
+    }
+}
+
+@OptIn(ExperimentalContracts::class)
+internal inline fun <I : Closeable, O : Closeable, R> I.withOut(output: O, block: I.(output: O) -> R): R {
+    contract {
+        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
+    }
+    return use { output.use { block(this, output) } }
+}
+
+/**
+ * Copies this stream to the given output stream, returning the number of bytes copied
+ *
+ * **Note** It is the caller's responsibility to close both of these resources.
+ */
+@Throws(IOException::class)
+internal fun Input.copyTo(out: OutputStream, bufferSize: Int = DEFAULT_BUFFER_SIZE): Long {
+    var bytesCopied: Long = 0
+    val buffer = ByteArray(bufferSize)
+    var bytes = readAvailable(buffer)
+    while (bytes >= 0) {
+        out.write(buffer, 0, bytes)
+        bytesCopied += bytes
+        bytes = readAvailable(buffer)
+    }
+    return bytesCopied
+}

+ 49 - 0
mirai-core/src/jvmMain/kotlin/net/mamoe/mirai/utils/internal/DeferredReusableInput.jvm.kt

@@ -0,0 +1,49 @@
+package net.mamoe.mirai.utils.internal
+
+import io.ktor.utils.io.ByteWriteChannel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.io.core.Input
+import net.mamoe.mirai.utils.FileCacheStrategy
+import net.mamoe.mirai.utils.MiraiExperimentalAPI
+import java.awt.image.BufferedImage
+import java.net.URL
+
+internal actual class DeferredReusableInput actual constructor(
+    val input: Any,
+    val extraArg: Any?
+) : ReusableInput {
+
+
+    @OptIn(MiraiExperimentalAPI::class)
+    actual suspend fun init(strategy: FileCacheStrategy) = withContext(Dispatchers.IO) {
+        if (delegate != null) {
+            return@withContext
+        }
+        delegate = when (input) {
+            is InputStream -> strategy.newImageCache(input)
+            is ByteArray -> strategy.newImageCache(input)
+            is Input -> strategy.newImageCache(input)
+            is BufferedImage -> strategy.newImageCache(input, extraArg as String)
+            is URL -> strategy.newImageCache(input)
+            else -> error("Internal error: unsupported DeferredReusableInput.input: ${input::class.qualifiedName}")
+        }.input
+    }
+
+    private var delegate: ReusableInput? = null
+
+    override val md5: ByteArray
+        get() = delegate?.md5 ?: error("DeferredReusableInput not yet initialized")
+    override val size: Long
+        get() = delegate?.size ?: error("DeferredReusableInput not yet initialized")
+
+    override fun chunkedFlow(sizePerPacket: Int): ChunkedFlowSession<ChunkedInput> {
+        return delegate?.chunkedFlow(sizePerPacket) ?: error("DeferredReusableInput not yet initialized")
+    }
+
+    override suspend fun writeTo(out: ByteWriteChannel): Long {
+        return delegate?.writeTo(out) ?: error("DeferredReusableInput not yet initialized")
+    }
+
+    actual val initialized: Boolean get() = delegate != null
+}

+ 29 - 6
mirai-core/src/jvmMain/kotlin/net/mamoe/mirai/utils/internal/asReusableInput.jvm.kt

@@ -5,12 +5,11 @@ import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.withContext
 import net.mamoe.mirai.message.data.toLongUnsigned
-import net.mamoe.mirai.utils.ExternalImage
 import java.io.File
 import java.io.InputStream
 
-internal actual fun ByteArray.asReusableInput(): ExternalImage.ReusableInput {
-    return object : ExternalImage.ReusableInput {
+internal actual fun ByteArray.asReusableInput(): ReusableInput {
+    return object : ReusableInput {
         override val md5: ByteArray = md5()
         override val size: Long get() = [email protected]()
 
@@ -32,8 +31,8 @@ internal actual fun ByteArray.asReusableInput(): ExternalImage.ReusableInput {
     }
 }
 
-internal fun File.asReusableInput(): ExternalImage.ReusableInput {
-    return object : ExternalImage.ReusableInput {
+internal fun File.asReusableInput(deleteOnClose: Boolean): ReusableInput {
+    return object : ReusableInput {
         override val md5: ByteArray = inputStream().use { it.md5() }
         override val size: Long get() = length()
 
@@ -41,7 +40,10 @@ internal fun File.asReusableInput(): ExternalImage.ReusableInput {
             val stream = inputStream()
             return object : ChunkedFlowSession<ChunkedInput> {
                 override val flow: Flow<ChunkedInput> = stream.chunkedFlow(sizePerPacket)
-                override fun close() = stream.close()
+                override fun close() {
+                    stream.close()
+                    if (deleteOnClose) [email protected]()
+                }
             }
         }
 
@@ -51,6 +53,27 @@ internal fun File.asReusableInput(): ExternalImage.ReusableInput {
     }
 }
 
+internal fun File.asReusableInput(deleteOnClose: Boolean, md5: ByteArray): ReusableInput {
+    return object : ReusableInput {
+        override val md5: ByteArray get() = md5
+        override val size: Long get() = length()
+
+        override fun chunkedFlow(sizePerPacket: Int): ChunkedFlowSession<ChunkedInput> {
+            val stream = inputStream()
+            return object : ChunkedFlowSession<ChunkedInput> {
+                override val flow: Flow<ChunkedInput> = stream.chunkedFlow(sizePerPacket)
+                override fun close() {
+                    stream.close()
+                    if (deleteOnClose) [email protected]()
+                }
+            }
+        }
+
+        override suspend fun writeTo(out: ByteWriteChannel): Long {
+            return inputStream().use { it.copyTo(out) }
+        }
+    }
+}
 
 private suspend fun InputStream.copyTo(out: ByteWriteChannel): Long = withContext(Dispatchers.IO) {
     var bytesCopied: Long = 0