SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentUploader.kt

419 lines
17 KiB
Kotlin

package jp.juggler.subwaytooter.util
import android.os.Handler
import android.os.SystemClock
import androidx.annotation.WorkerThread
import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.TootApiCallback
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.auth.AuthBase
import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.InstanceType
import jp.juggler.subwaytooter.api.entity.ServiceType
import jp.juggler.subwaytooter.api.entity.TootAttachment
import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachment
import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.api.entity.parseItem
import jp.juggler.subwaytooter.api.runApiTask
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.coroutine.launchIO
import jp.juggler.util.data.GetContentResultEntry
import jp.juggler.util.data.asciiPattern
import jp.juggler.util.data.buildJsonObject
import jp.juggler.util.data.encodeHex
import jp.juggler.util.data.encodeUTF8
import jp.juggler.util.data.getDocumentName
import jp.juggler.util.data.groupEx
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
import jp.juggler.util.log.withCaption
import jp.juggler.util.media.ResizeConfig
import jp.juggler.util.media.ResizeType
import jp.juggler.util.network.toPost
import jp.juggler.util.network.toPostRequestBuilder
import jp.juggler.util.network.toPut
import jp.juggler.util.network.toPutRequestBuilder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import okhttp3.MultipartBody
import java.nio.channels.ClosedChannelException
import kotlin.coroutines.coroutineContext
class AttachmentUploader(
activity: AppCompatActivity,
private val handler: Handler,
) {
companion object {
val log = LogCategory("AttachmentUploader")
}
private val safeContext = activity.applicationContext!!
private var lastAttachmentAdd = 0L
private var lastAttachmentComplete = 0L
private val channel = Channel<AttachmentRequest>(capacity = Channel.UNLIMITED)
init {
launchIO {
while (true) {
try {
val request = channel.receive()
if (request.pa.isCancelled) continue
withContext(AppDispatchers.MainImmediate) {
val pa = request.pa
pa.status = try {
withContext(pa.job + AppDispatchers.IO) {
request.upload()
}
request.pa.progress = ""
val now = System.currentTimeMillis()
if (now - lastAttachmentComplete >= 5000L) {
safeContext.showToast(false, R.string.attachment_uploaded)
}
lastAttachmentComplete = now
PostAttachment.Status.Ok
} catch (ex: Throwable) {
if (ex is CancellationException) {
// キャンセルはメッセージを出さない
} else if (ex.message?.contains("cancel", ignoreCase = true) == true) {
// キャンセルはメッセージを出さない
} else if (ex is IllegalStateException) {
safeContext.showToast(true, "${ex.message}")
} else {
safeContext.showToast(true, ex.withCaption("upload failed."))
}
PostAttachment.Status.Error
}
// 投稿中に画面回転があった場合、新しい画面のコールバックを呼び出す必要がある
pa.callback?.onPostAttachmentComplete(pa)
}
} catch (ex: Throwable) {
when (ex) {
is CancellationException -> {
log.i("AttachmentUploader: channel cancelled.")
break
}
is ClosedChannelException, is ClosedReceiveChannelException -> {
log.i("AttachmentUploader: channel closed.")
break
}
else -> safeContext.showToast(ex)
}
}
}
}
}
fun onActivityDestroy() {
try {
channel.close()
} catch (ignored: Throwable) {
}
}
fun addRequest(request: AttachmentRequest) {
request.pa.progress = safeContext.getString(R.string.attachment_handling_start)
// マストドンは添付メディアをID順に表示するため
// 画像が複数ある場合は一つずつ処理する必要がある
// 投稿画面ごとに作成したチャネルにsendして、受け側は順次処理する
launchIO {
try {
val now = System.currentTimeMillis()
// アップロード開始トースト(連発しない)
if (now - lastAttachmentAdd >= 5000L) {
safeContext.showToast(false, R.string.attachment_uploading)
}
lastAttachmentAdd = now
channel.send(request)
} catch (ex: Throwable) {
log.e(ex, "addRequest failed.")
}
}
}
@WorkerThread
private suspend fun AttachmentRequest.upload() {
val account = this.account
val instance = this.instance()
val mediaConfig = this.mediaConfig()
// ensure mimeType
this.mimeType
if (instance.instanceType == InstanceType.Pixelfed && isReply) {
error(safeContext.getString(R.string.pixelfed_does_not_allow_reply_with_media))
}
val client = TootApiClient(safeContext, callback = object : TootApiCallback {
override suspend fun isApiCancelled() = !coroutineContext.isActive
})
client.account = account
client.currentCallCallback = {}
// 入力データの変換など
val opener = this.createOpener()
try {
val maxBytes = when (opener.isImage) {
true -> maxBytesImage(instance, mediaConfig)
else -> maxBytesVideo(instance, mediaConfig)
}
if (opener.contentLength > maxBytes.toLong()) {
error(safeContext.getString(R.string.file_size_too_big, maxBytes / 1_000_000))
} else if (!opener.mimeType.mimeTypeIsSupported(instance)) {
error(safeContext.getString(R.string.mime_type_not_acceptable, opener.mimeType))
}
val fileName = fixDocumentName(getDocumentName(safeContext.contentResolver, uri))
pa.progress = safeContext.getString(R.string.attachment_handling_uploading, 0)
fun writeProgress(percent: Int) {
pa.progress = if (percent < 100) {
safeContext.getString(R.string.attachment_handling_uploading, percent)
} else {
safeContext.getString(R.string.attachment_handling_waiting)
}
}
if (account.isMisskey) {
val multipartBuilder = MultipartBody.Builder()
.setType(MultipartBody.FORM)
val apiKey = account.tokenJson?.string(AuthBase.KEY_API_KEY_MISSKEY)
if (apiKey?.isNotEmpty() == true) {
multipartBuilder.addFormDataPart("i", apiKey)
}
multipartBuilder.addFormDataPart(
"file",
fileName,
opener.toRequestBody { writeProgress(it) },
)
val result = client.request(
"/api/drive/files/create",
multipartBuilder.build().toPost()
)
opener.deleteTempFile()
result ?: throw CancellationException()
val jsonObject = result.jsonObject
?: error(result.error ?: "missing error detail")
pa.attachment =
parseItem(jsonObject) { tootAttachment(ServiceType.MISSKEY, it) }
?: error("TootAttachment.parse failed")
} else {
suspend fun postMedia(path: String) = client.request(
path,
MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"file",
fileName,
opener.toRequestBody { writeProgress(it) },
)
.build().toPost()
)
suspend fun postV1() = postMedia("/api/v1/media")
suspend fun postV2(): TootApiResult? {
// 3.1.3未満は v1 APIを使う
if (!instance.versionGE(TootInstance.VERSION_3_1_3)) {
return postV1()
}
// v2 APIを試す
val result = postMedia("/api/v2/media")
?: throw CancellationException()
val code = result.response?.code // complete,or 4xx error
when {
// 404ならv1 APIにフォールバック
code == 404 -> return postV1()
// 202 accepted 以外はポーリングしない
code != 202 -> return result
}
// ポーリングして処理完了を待つ
pa.progress = safeContext.getString(R.string.attachment_handling_waiting_async)
val id = parseItem(result.jsonObject) {
tootAttachment(ServiceType.MASTODON, it)
}?.id ?: error("/api/v2/media did not return the media ID.")
var lastResponse = SystemClock.elapsedRealtime()
while (true) {
delay(1000L)
val r2 = client.request("/api/v1/media/$id")
?: throw CancellationException()
val now = SystemClock.elapsedRealtime()
when (r2.response?.code) {
// complete,or 4xx error
200, in 400 until 500 -> return r2
// continue to wait
206 -> lastResponse = now
// temporary errors, check timeout without 206 response.
else -> if (now - lastResponse >= 120000L) error("timeout.")
}
}
}
val result = postV2()
?: throw CancellationException()
val jsonObject = result.jsonObject
?: error(result.error ?: "missing error detail")
pa.attachment = parseItem(jsonObject) { tootAttachment(ServiceType.MASTODON, it) }
?: error("TootAttachment.parse failed")
}
} finally {
opener.deleteTempFile()
}
}
private fun fixDocumentName(s: String): String {
val sLength = s.length
val m = """([^\x20-\x7f])""".asciiPattern().matcher(s)
m.reset()
val sb = StringBuilder(sLength)
var lastEnd = 0
while (m.find()) {
sb.append(s.substring(lastEnd, m.start()))
val escaped = m.groupEx(1)!!.encodeUTF8().encodeHex()
sb.append(escaped)
lastEnd = m.end()
}
if (lastEnd < sLength) sb.append(s.substring(lastEnd, sLength))
return sb.toString()
}
///////////////////////////////////////////////////////////////
// 添付データのカスタムサムネイル
suspend fun uploadCustomThumbnail(
account: SavedAccount,
src: GetContentResultEntry,
pa: PostAttachment,
): TootApiResult? = try {
safeContext.runApiTask(account) { client ->
val ar = AttachmentRequest(
context = safeContext,
account = account,
pa = pa,
uri = src.uri,
mimeTypeArg = src.mimeType,
imageResizeConfig = ResizeConfig(ResizeType.SquarePixel, 400),
maxBytesImage = { _, _ -> 1000000 },
maxBytesVideo = { _, _ -> 1000000 },
)
val instance = ar.instance()
val mediaConfig = ar.mediaConfig()
val maxBytesImage = ar.maxBytesImage(instance, mediaConfig)
val opener = ar.createOpener()
try {
if (opener.contentLength > maxBytesImage.toLong()) {
return@runApiTask TootApiResult(
getString(
R.string.file_size_too_big,
maxBytesImage / 1000000
)
)
}
val fileName =
fixDocumentName(getDocumentName(safeContext.contentResolver, src.uri))
if (account.isMisskey) {
TootApiResult("custom thumbnail is not supported on misskey account.")
} else {
val result = client.request(
"/api/v1/media/${pa.attachment?.id}",
MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"thumbnail",
fileName,
opener.toRequestBody(),
)
.build().toPut()
)
val jsonObject = result?.jsonObject
if (jsonObject != null) {
val a = parseItem(jsonObject) { tootAttachment(ServiceType.MASTODON, it) }
if (a == null) {
result.error = "TootAttachment.parse failed"
} else {
pa.attachment = a
}
}
result
}
} finally {
opener.deleteTempFile()
}
}
} catch (ex: Throwable) {
TootApiResult(ex.withCaption("uploadCustomThumbnail failed."))
}
suspend fun setAttachmentDescription(
account: SavedAccount,
attachmentId: EntityId,
description: String,
): Pair<TootApiResult?, TootAttachment?> {
var resultAttachment: TootAttachment? = null
val result = try {
safeContext.runApiTask(account) { client ->
if (account.isMisskey) {
client.request(
"/api/drive/files/update",
account.putMisskeyApiToken().apply {
put("fileId", attachmentId.toString())
put("comment", description)
}.toPostRequestBuilder()
)?.also { result ->
resultAttachment = parseItem(result.jsonObject) {
tootAttachment(ServiceType.MISSKEY, it)
}
}
} else {
client.request(
"/api/v1/media/$attachmentId",
buildJsonObject {
put("description", description)
}.toPutRequestBuilder()
)?.also { result ->
resultAttachment = parseItem(result.jsonObject) {
tootAttachment(ServiceType.MASTODON, it)
}
}
}
}
} catch (ex: Throwable) {
log.e(ex, "setAttachmentDescription failed.")
TootApiResult(ex.withCaption("setAttachmentDescription failed."))
}
return Pair(result, resultAttachment)
}
}