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

967 lines
36 KiB
Kotlin
Raw Normal View History

2021-06-23 06:14:25 +02:00
package jp.juggler.subwaytooter.util
import android.content.ContentResolver
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.os.Handler
import android.os.SystemClock
import androidx.annotation.WorkerThread
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.entity.*
import jp.juggler.subwaytooter.api.runApiTask
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.*
import jp.juggler.util.VideoInfo.Companion.videoInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
2021-06-23 06:14:25 +02:00
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
2021-06-23 06:14:25 +02:00
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okio.BufferedSink
import java.io.*
import java.util.*
import java.util.concurrent.CancellationException
import kotlin.coroutines.coroutineContext
import kotlin.math.min
2021-06-23 06:14:25 +02:00
class AttachmentRequest(
val account: SavedAccount,
val pa: PostAttachment,
val uri: Uri,
val mimeType: String,
val isReply: Boolean,
)
class AttachmentUploader(
contextArg: Context,
private val handler: Handler,
) {
companion object {
val log = LogCategory("AttachmentUploader")
internal const val MIME_TYPE_JPEG = "image/jpeg"
internal const val MIME_TYPE_PNG = "image/png"
val acceptableMimeTypes = HashSet<String>().apply {
//
add("image/*") // Android標準のギャラリーが image/* を出してくることがあるらしい
add("video/*") // Android標準のギャラリーが image/* を出してくることがあるらしい
add("audio/*") // Android標準のギャラリーが image/* を出してくることがあるらしい
//
add("image/jpeg")
add("image/png")
add("image/gif")
add("video/webm")
add("video/mp4")
add("video/quicktime")
//
add("audio/webm")
add("audio/ogg")
add("audio/mpeg")
add("audio/mp3")
add("audio/wav")
add("audio/wave")
add("audio/x-wav")
add("audio/x-pn-wav")
add("audio/flac")
add("audio/x-flac")
// https://github.com/tootsuite/mastodon/pull/11342
add("audio/aac")
add("audio/m4a")
add("audio/3gpp")
}
val acceptableMimeTypesPixelfed = HashSet<String>().apply {
//
add("image/*") // Android標準のギャラリーが image/* を出してくることがあるらしい
add("video/*") // Android標準のギャラリーが image/* を出してくることがあるらしい
//
add("image/jpeg")
add("image/png")
add("image/gif")
add("video/mp4")
add("video/m4v")
}
private val imageHeaderList = listOf(
2021-06-23 06:14:25 +02:00
Pair(
"image/jpeg",
intArrayOf(0xff, 0xd8, 0xff).toByteArray()
),
Pair(
"image/png",
intArrayOf(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A).toByteArray()
),
Pair(
"image/gif",
"GIF".toByteArray(Charsets.UTF_8)
2021-06-23 06:14:25 +02:00
),
Pair(
"audio/wav",
"RIFF".toByteArray(Charsets.UTF_8),
2021-06-23 06:14:25 +02:00
),
Pair(
"audio/ogg",
"OggS".toByteArray(Charsets.UTF_8),
2021-06-23 06:14:25 +02:00
),
Pair(
"audio/flac",
"fLaC".toByteArray(Charsets.UTF_8),
),
Pair(
"image/bmp",
"BM".toByteArray(Charsets.UTF_8),
),
Pair(
"image/webp",
"RIFF****WEBP".toByteArray(Charsets.UTF_8),
),
).sortedByDescending { it.second.size }
2021-06-23 06:14:25 +02:00
private val sig3gp = arrayOf(
"3ge6",
"3ge7",
"3gg6",
"3gp1",
"3gp2",
"3gp3",
"3gp4",
"3gp5",
"3gp6",
"3gp7",
"3gr6",
"3gr7",
"3gs6",
"3gs7",
"kddi"
).map { it.toCharArray().toLowerByteArray() }
private val sigM4a = arrayOf(
"M4A ",
"M4B ",
"M4P "
).map { it.toCharArray().toLowerByteArray() }
private val sigFtyp = "ftyp".toCharArray().toLowerByteArray()
private fun matchSig(
data: ByteArray,
dataOffset: Int,
sig: ByteArray,
sigSize: Int = sig.size,
): Boolean {
for (i in 0 until sigSize) {
if (data[dataOffset + i] != sig[i]) return false
}
return true
}
private const val wild = '?'.code.toByte()
private fun ByteArray.startWithWildcard(
key: ByteArray,
thisOffset: Int = 0,
keyOffset: Int = 0,
length: Int = key.size - keyOffset,
): Boolean {
if (thisOffset + length > this.size || keyOffset + length > key.size) {
return false
}
for (i in 0 until length) {
val cThis = this[i + thisOffset]
val cKey = key[i + keyOffset]
if (cKey != wild && cKey != cThis) return false
}
return true
}
2021-06-23 06:14:25 +02:00
}
private val context = contextArg.applicationContext!!
2021-06-23 06:14:25 +02:00
private var lastAttachmentAdd = 0L
private var lastAttachmentComplete = 0L
private var channel: Channel<AttachmentRequest>? = null
private fun prepareChannel(): Channel<AttachmentRequest> {
// double check before/after lock
channel?.let { return it }
synchronized(this) {
channel?.let { return it }
return Channel<AttachmentRequest>(capacity = Channel.UNLIMITED)
.also {
channel = it
launchIO {
while (true) {
val request = try {
it.receive()
} catch (ex: Throwable) {
when (ex) {
is CancellationException, is ClosedReceiveChannelException -> break
else -> {
context.showToast(ex)
continue
}
}
}
val result = try {
if (request.pa.isCancelled) continue
withContext(request.pa.job + Dispatchers.IO) {
request.upload()
}
} catch (ex: Throwable) {
TootApiResult(ex.withCaption("upload failed."))
}
try {
request.pa.progress = ""
withContext(Dispatchers.Main) {
handleResult(request, result)
}
} catch (ex: Throwable) {
when (ex) {
is CancellationException, is ClosedReceiveChannelException -> break
else -> {
context.showToast(ex)
continue
}
}
}
}
}
}
}
}
2021-06-23 06:14:25 +02:00
fun onActivityDestroy() {
try {
synchronized(this) {
channel?.close()
channel = null
}
} catch (ex: Throwable) {
log.e(ex)
}
2021-06-23 06:14:25 +02:00
}
fun addRequest(request: AttachmentRequest) {
request.pa.progress = context.getString(R.string.attachment_handling_start)
2021-06-23 06:14:25 +02:00
// アップロード開始トースト(連発しない)
val now = System.currentTimeMillis()
if (now - lastAttachmentAdd >= 5000L) {
context.showToast(false, R.string.attachment_uploading)
}
lastAttachmentAdd = now
// マストドンは添付メディアをID順に表示するため
// 画像が複数ある場合は一つずつ処理する必要がある
// 投稿画面ごとに1スレッドだけ作成してバックグラウンド処理を行う
launchIO { prepareChannel().send(request) }
2021-06-23 06:14:25 +02:00
}
@WorkerThread
private suspend fun AttachmentRequest.upload(): TootApiResult? {
try {
if (mimeType.isEmpty()) return TootApiResult("mime_type is empty.")
val client = TootApiClient(context, callback = object : TootApiCallback {
override suspend fun isApiCancelled() = !coroutineContext.isActive
})
2021-06-23 06:14:25 +02:00
client.account = account
client.currentCallCallback = {}
2021-06-23 06:14:25 +02:00
val (ti, tiResult) = TootInstance.get(client)
ti ?: return tiResult
if (ti.instanceType == InstanceType.Pixelfed) {
if (isReply) {
return TootApiResult(context.getString(R.string.pixelfed_does_not_allow_reply_with_media))
}
if (!acceptableMimeTypesPixelfed.contains(mimeType)) {
return TootApiResult(
context.getString(
R.string.mime_type_not_acceptable,
mimeType
)
)
2021-06-23 06:14:25 +02:00
}
}
val mediaConfig = ti.configuration?.jsonObject("media_attachments")
val serverMaxSqPixel = mediaConfig?.int("image_matrix_limit")?.takeIf { it > 0 }
val imageResizeConfig = account.getResizeConfig()
// 入力データの変換など
val opener = createOpener(
account,
uri,
mimeType,
mediaConfig = mediaConfig,
imageResizeConfig = imageResizeConfig,
postAttachment = pa,
serverMaxSqPixel = serverMaxSqPixel,
)
2021-06-23 06:14:25 +02:00
val mediaSizeMax = when {
mimeType.startsWith("video") || mimeType.startsWith("audio") -> min(
account.getMovieMaxBytes(ti),
mediaConfig?.int("video_size_limit")
?.takeIf { it > 0 } ?: Int.MAX_VALUE,
)
2021-06-23 06:14:25 +02:00
else -> min(
account.getImageMaxBytes(ti),
mediaConfig?.int("image_size_limit")
?.takeIf { it > 0 } ?: Int.MAX_VALUE,
)
}
2021-06-23 06:14:25 +02:00
if (opener.contentLength > mediaSizeMax) {
return TootApiResult(
context.getString(R.string.file_size_too_big, mediaSizeMax / 1000000)
)
}
2021-06-23 06:14:25 +02:00
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()
2021-06-23 06:14:25 +02:00
}
if (lastEnd < sLength) sb.append(s.substring(lastEnd, sLength))
return sb.toString()
}
2021-06-23 06:14:25 +02:00
val fileName = fixDocumentName(getDocumentName(context.contentResolver, uri))
pa.progress = context.getString(R.string.attachment_handling_uploading, 0)
fun writeProgress(percent: Int) {
if (percent < 100) {
pa.progress =
context.getString(R.string.attachment_handling_uploading, percent)
} else {
pa.progress = context.getString(R.string.attachment_handling_waiting)
}
}
2021-06-23 06:14:25 +02:00
return if (account.isMisskey) {
val multipartBuilder = MultipartBody.Builder()
.setType(MultipartBody.FORM)
2021-06-23 06:14:25 +02:00
val apiKey = account.token_info?.string(TootApiClient.KEY_API_KEY_MISSKEY)
if (apiKey?.isNotEmpty() == true) {
multipartBuilder.addFormDataPart("i", apiKey)
2021-06-23 06:14:25 +02:00
}
multipartBuilder.addFormDataPart(
"file",
fileName,
opener.toRequestBody { writeProgress(it) },
)
2021-06-23 06:14:25 +02:00
val result = client.request(
"/api/drive/files/create",
multipartBuilder.build().toPost()
)
opener.deleteTempFile()
2021-06-23 06:14:25 +02:00
val jsonObject = result?.jsonObject
if (jsonObject != null) {
val a = parseItem(::TootAttachment, ServiceType.MISSKEY, jsonObject)
if (a == null) {
result.error = "TootAttachment.parse failed"
} else {
pa.attachment = a
2021-06-23 06:14:25 +02:00
}
}
result
} else {
suspend fun postMedia(path: String) = client.request(
path,
MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"file",
fileName,
opener.toRequestBody { writeProgress(it) },
)
.build().toPost()
)
2021-06-23 06:14:25 +02:00
suspend fun postV1() = postMedia("/api/v1/media")
2021-06-23 06:14:25 +02:00
suspend fun postV2(): TootApiResult? {
// 3.1.3未満は v1 APIを使う
if (!ti.versionGE(TootInstance.VERSION_3_1_3)) {
return postV1()
}
2021-06-23 06:14:25 +02:00
// v2 APIを試す
val result = postMedia("/api/v2/media")
val code = result?.response?.code // complete,or 4xx error
when {
// 404ならv1 APIにフォールバック
code == 404 -> return postV1()
// 202 accepted 以外はポーリングしない
code != 202 -> return result
}
2021-06-23 06:14:25 +02:00
// ポーリングして処理完了を待つ
pa.progress = context.getString(R.string.attachment_handling_waiting_async)
val id = parseItem(::TootAttachment, ServiceType.MASTODON, result?.jsonObject)
?.id
?: return TootApiResult("/api/v2/media did not return the media ID.")
2021-06-23 06:14:25 +02:00
var lastResponse = SystemClock.elapsedRealtime()
loop@ while (true) {
2021-06-23 06:14:25 +02:00
delay(1000L)
val r2 = client.request("/api/v1/media/$id")
?: return null // cancelled
2021-06-23 06:14:25 +02:00
val now = SystemClock.elapsedRealtime()
when (r2.response?.code) {
// complete,or 4xx error
200, in 400 until 500 -> return r2
2021-06-23 06:14:25 +02:00
// continue to wait
206 -> lastResponse = now
2021-06-23 06:14:25 +02:00
// temporary errors, check timeout without 206 response.
else -> if (now - lastResponse >= 120000L) {
return TootApiResult("timeout.")
2021-06-23 06:14:25 +02:00
}
}
}
}
2021-06-23 06:14:25 +02:00
val result = postV2()
opener.deleteTempFile()
2021-06-23 06:14:25 +02:00
val jsonObject = result?.jsonObject
if (jsonObject != null) {
when (val a =
parseItem(::TootAttachment, ServiceType.MASTODON, jsonObject)) {
null -> result.error = "TootAttachment.parse failed"
else -> pa.attachment = a
2021-06-23 06:14:25 +02:00
}
}
result
2021-06-23 06:14:25 +02:00
}
} catch (ex: Throwable) {
return TootApiResult(ex.withCaption("read failed."))
2021-06-23 06:14:25 +02:00
}
}
private fun handleResult(request: AttachmentRequest, result: TootApiResult?) {
val pa = request.pa
pa.status = when (pa.attachment) {
null -> {
if (result != null) {
when {
// キャンセルはトーストを出さない
result.error?.contains("cancel", ignoreCase = true) == true -> Unit
else -> context.showToast(
true,
"${result.error} ${result.response?.request?.method} ${result.response?.request?.url}"
)
}
}
PostAttachment.Status.Error
}
else -> {
val now = System.currentTimeMillis()
if (now - lastAttachmentComplete >= 5000L) {
context.showToast(false, R.string.attachment_uploaded)
}
lastAttachmentComplete = now
PostAttachment.Status.Ok
}
}
2021-06-23 06:14:25 +02:00
// 投稿中に画面回転があった場合、新しい画面のコールバックを呼び出す必要がある
pa.callback?.onPostAttachmentComplete(pa)
}
// contentLengthの測定などで複数回オープンする必要がある
private abstract class InputStreamOpener {
abstract val mimeType: String
2021-06-23 06:14:25 +02:00
@Throws(IOException::class)
abstract fun open(): InputStream
abstract fun deleteTempFile()
val contentLength by lazy { getStreamSize(true, open()) }
// okhttpのRequestBodyにする
fun toRequestBody(onWrote: (percent: Int) -> Unit = {}) =
object : RequestBody() {
override fun contentType() = mimeType.toMediaType()
@Throws(IOException::class)
override fun contentLength(): Long = contentLength
2021-06-23 06:14:25 +02:00
@Throws(IOException::class)
override fun writeTo(sink: BufferedSink) {
val length = contentLength.toFloat()
open().use { inStream ->
val tmp = ByteArray(4096)
var nWrite = 0L
while (true) {
val delta = inStream.read(tmp, 0, tmp.size)
if (delta <= 0) break
sink.write(tmp, 0, delta)
nWrite += delta
val percent = (100f * nWrite.toFloat() / length).toInt()
onWrote(percent)
}
}
}
}
2021-06-23 06:14:25 +02:00
}
private fun contentUriOpener(contentResolver: ContentResolver, uri: Uri, mimeType: String) =
object : InputStreamOpener() {
override val mimeType = mimeType
@Throws(IOException::class)
override fun open(): InputStream {
return contentResolver.openInputStream(uri)
?: error("openInputStream returns null")
}
override fun deleteTempFile() = Unit
}
private fun tempFileOpener(mimeType: String, file: File) =
object : InputStreamOpener() {
override val mimeType = mimeType
@Throws(IOException::class)
override fun open() = FileInputStream(file)
override fun deleteTempFile() {
file.delete()
}
}
private suspend fun createOpener(
account: SavedAccount,
2021-06-23 06:14:25 +02:00
uri: Uri,
mimeType: String,
imageResizeConfig: ResizeConfig,
serverMaxSqPixel: Int? = null,
mediaConfig: JsonObject? = null,
postAttachment: PostAttachment? = null,
2021-06-23 06:14:25 +02:00
): InputStreamOpener {
2022-01-05 23:35:34 +01:00
when {
// 静止画(失敗したらオリジナルデータにフォールバックする)
2022-01-05 23:35:34 +01:00
mimeType == MIME_TYPE_JPEG || mimeType == MIME_TYPE_PNG -> try {
return createResizedImageOpener(
2021-06-23 06:14:25 +02:00
uri,
mimeType,
imageResizeConfig,
postAttachment = postAttachment,
serverMaxSqPixel = serverMaxSqPixel,
2021-06-23 06:14:25 +02:00
)
} catch (ex: Throwable) {
log.w(ex, "createResizedImageOpener failed. fall back to original image.")
}
2022-01-05 23:35:34 +01:00
// 静止画(変換必須)
// 例外を投げるかもしれない
2022-01-05 23:35:34 +01:00
mimeType.startsWith("image/") ->
return createResizedImageOpener(
uri,
mimeType,
imageResizeConfig,
postAttachment = postAttachment,
forcePng = true,
serverMaxSqPixel = serverMaxSqPixel,
2022-01-05 23:35:34 +01:00
)
// 動画のトランスコード(失敗したらオリジナルデータにフォールバックする)
2022-01-05 23:35:34 +01:00
mimeType.startsWith("video/") -> try {
return createResizedMovieOpener(
account,
uri,
mimeType,
mediaConfig = mediaConfig,
postAttachment = postAttachment,
)
} catch (ex: Throwable) {
log.w(ex, "createResizedMovieOpener failed. fall back to original movie.")
2021-06-23 06:14:25 +02:00
}
}
return contentUriOpener(context.contentResolver, uri, mimeType)
2021-06-23 06:14:25 +02:00
}
private fun createResizedImageOpener(
uri: Uri,
mimeType: String,
imageResizeConfig: ResizeConfig,
serverMaxSqPixel: Int?,
postAttachment: PostAttachment? = null,
forcePng: Boolean = false,
): InputStreamOpener {
val cacheDir = context.externalCacheDir
?.apply { mkdirs() }
?: error("getExternalCacheDir returns null.")
val outputMimeType = if (forcePng || mimeType == MIME_TYPE_PNG) {
MIME_TYPE_PNG
} else {
MIME_TYPE_JPEG
}
val tempFile = File(cacheDir, "tmp." + Thread.currentThread().id)
val bitmap = createResizedBitmap(
context,
uri,
imageResizeConfig,
skipIfNoNeedToResizeAndRotate = !forcePng,
serverMaxSqPixel = serverMaxSqPixel
) ?: error("createResizedBitmap returns null.")
postAttachment?.progress = context.getString(R.string.attachment_handling_compress)
try {
FileOutputStream(tempFile).use { os ->
if (outputMimeType == MIME_TYPE_PNG) {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, os)
} else {
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, os)
}
}
return tempFileOpener(outputMimeType, tempFile)
} finally {
bitmap.recycle()
}
}
private suspend fun createResizedMovieOpener(
account: SavedAccount,
uri: Uri,
mimeType: String,
mediaConfig: JsonObject?,
postAttachment: PostAttachment?,
): InputStreamOpener {
val cacheDir = context.externalCacheDir
?.apply { mkdirs() }
?: error("getExternalCacheDir returns null.")
val tempFile = File(cacheDir, "movie." + Thread.currentThread().id + ".tmp")
val outFile = File(cacheDir, "movie." + Thread.currentThread().id + ".mp4")
var resultFile: File? = null
// 入力ファイルをコピーする
(context.contentResolver.openInputStream(uri)
?: error("openInputStream returns null.")).use { inStream ->
FileOutputStream(tempFile).use { inStream.copyTo(it) }
}
try {
// 動画のメタデータを調べる
val info = tempFile.videoInfo
// サーバに指定されたファイルサイズ上限と入力動画の時間長があれば、ビットレート上限を制限する
val duration = info.duration?.takeIf { it >= 0.1f }
val limitFileSize = mediaConfig?.float("video_size_limit")?.takeIf { it >= 1f }
val limitBitrate = when {
duration != null && limitFileSize != null ->
(limitFileSize / duration).toLong()
else -> null
}
// アカウント別の動画トランスコード設定
2022-01-05 23:42:31 +01:00
// ビットレート、フレームレート、平方ピクセル数をサーバからの情報によりさらに制限する
val movieResizeConfig = account.getMovieResizeConfig()
.restrict(
limitBitrate = limitBitrate,
limitFrameRate = mediaConfig?.int("video_frame_rate_limit")
?.takeIf { it >= 1f },
limitSquarePixels = mediaConfig?.int("video_matrix_limit")
?.takeIf { it > 1 },
)
val result = transcodeVideo(
info,
tempFile,
outFile,
movieResizeConfig,
) {
val percent = (it * 100f).toInt()
postAttachment?.progress =
context.getString(R.string.attachment_handling_compress_ratio, percent)
}
resultFile = result
return tempFileOpener(
when (result) {
tempFile -> mimeType
else -> "video/mp4"
},
result
)
} finally {
if (outFile != resultFile) outFile.delete()
if (tempFile != resultFile) tempFile.delete()
}
}
2021-06-23 06:14:25 +02:00
fun getMimeType(uri: Uri, mimeTypeArg: String?): String? {
// image/j()pg だの image/j(e)pg だの、mime type を誤記するアプリがあまりに多い
// クレームで消耗するのを減らすためにファイルヘッダを確認する
if (mimeTypeArg == null || mimeTypeArg.startsWith("image/")) {
val sv = findMimeTypeByFileHeader(context.contentResolver, uri)
if (sv != null) return sv
}
// 既に引数で与えられてる
if (mimeTypeArg?.isNotEmpty() == true) {
return mimeTypeArg
}
// ContentResolverに尋ねる
var sv = context.contentResolver.getType(uri)
if (sv?.isNotEmpty() == true) return sv
// gboardのステッカーではUriのクエリパラメータにmimeType引数がある
sv = uri.getQueryParameter("mimeType")
if (sv?.isNotEmpty() == true) return sv
return null
}
private fun findMimeTypeByFileHeader(
contentResolver: ContentResolver,
uri: Uri,
): String? {
try {
contentResolver.openInputStream(uri)?.use { inStream ->
val data = ByteArray(65536)
val nRead = inStream.read(data, 0, data.size)
for (pair in imageHeaderList) {
val type = pair.first
val header = pair.second
if (nRead >= header.size && data.startWithWildcard(header)) return type
2021-06-23 06:14:25 +02:00
}
// scan frame header
for (i in 0 until nRead - 8) {
if (!matchSig(data, i, sigFtyp)) continue
// 3gpp check
for (s in sig3gp) {
if (matchSig(data, i + 4, s)) return "audio/3gpp"
}
// m4a check
for (s in sigM4a) {
if (matchSig(data, i + 4, s)) return "audio/m4a"
}
}
// scan frame header
loop@ for (i in 0 until nRead - 2) {
// mpeg frame header
val b0 = data[i].toInt() and 255
if (b0 != 255) continue
val b1 = data[i + 1].toInt() and 255
if ((b1 and 0b11100000) != 0b11100000) continue
val mpegVersionId = ((b1 shr 3) and 3)
// 00 mpeg 2.5
// 01 not used
// 10 (mp3) mpeg 2 / (AAC) mpeg-4
// 11 (mp3) mpeg 1 / (AAC) mpeg-2
@Suppress("MoveVariableDeclarationIntoWhen")
val mpegLayerId = ((b1 shr 1) and 3)
// 00 (mp3)not used / (AAC) always 0
// 01 (mp3)layer III
// 10 (mp3)layer II
// 11 (mp3)layer I
when (mpegLayerId) {
0 -> when (mpegVersionId) {
2, 3 -> return "audio/aac"
else -> {
}
}
1 -> when (mpegVersionId) {
0, 2, 3 -> return "audio/mp3"
else -> {
}
}
}
}
}
} catch (ex: Throwable) {
log.e(ex, "findMimeTypeByFileHeader failed.")
}
return null
}
///////////////////////////////////////////////////////////////
// 添付データのカスタムサムネイル
2021-06-23 06:14:25 +02:00
suspend fun uploadCustomThumbnail(
account: SavedAccount,
src: GetContentResultEntry,
pa: PostAttachment,
): TootApiResult? = try {
context.runApiTask(account) { client ->
val mimeType = getMimeType(src.uri, src.mimeType)
if (mimeType?.isEmpty() != false) {
return@runApiTask TootApiResult(context.getString(R.string.mime_type_missing))
}
val (ti, ri) = TootInstance.get(client)
ti ?: return@runApiTask ri
val opener = createOpener(
account,
src.uri,
mimeType,
imageResizeConfig = ResizeConfig(ResizeType.SquarePixel, 400),
)
2021-06-23 06:14:25 +02:00
val mediaSizeMax = 1000000
if (opener.contentLength > mediaSizeMax) {
2021-06-23 06:14:25 +02:00
return@runApiTask TootApiResult(
getString(R.string.file_size_too_big, mediaSizeMax / 1000000)
)
}
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()
}
val fileName = fixDocumentName(getDocumentName(context.contentResolver, src.uri))
if (account.isMisskey) {
opener.deleteTempFile()
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(),
2021-06-23 06:14:25 +02:00
)
.build().toPut()
)
opener.deleteTempFile()
val jsonObject = result?.jsonObject
if (jsonObject != null) {
val a = parseItem(::TootAttachment, ServiceType.MASTODON, jsonObject)
if (a == null) {
result.error = "TootAttachment.parse failed"
} else {
pa.attachment = a
}
}
result
}
}
} 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 {
context.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(::TootAttachment, ServiceType.MISSKEY, result.jsonObject)
}
}else {
client.request(
"/api/v1/media/$attachmentId",
jsonObject {
put("description", description)
}
.toPutRequestBuilder()
)?.also { result ->
resultAttachment =
parseItem(::TootAttachment, ServiceType.MASTODON, result.jsonObject)
2021-06-23 06:14:25 +02:00
}
}
}
} catch (ex: Throwable) {
log.trace(ex, "setAttachmentDescription failed.")
TootApiResult(ex.withCaption("setAttachmentDescription failed."))
}
return Pair(result, resultAttachment)
}
fun isAcceptableMimeType(
instance: TootInstance?,
mimeType: String,
isReply: Boolean,
): Boolean {
2021-06-23 06:14:25 +02:00
if (instance?.instanceType == InstanceType.Pixelfed) {
if (isReply) {
context.showToast(true, R.string.pixelfed_does_not_allow_reply_with_media)
return false
}
if (!acceptableMimeTypesPixelfed.contains(mimeType)) {
context.showToast(true, R.string.mime_type_not_acceptable, mimeType)
return false
}
} else {
if (!acceptableMimeTypes.contains(mimeType)) {
context.showToast(true, R.string.mime_type_not_acceptable, mimeType)
return false
}
}
return true
}
}