添付データのMime Type 判定の改善。サーバ側issueのあるHEIC,HEIF,AVIFを強制的に変換する
This commit is contained in:
parent
f45ebd780f
commit
1bf4d2b8c9
|
@ -483,7 +483,6 @@ class ActPost : AppCompatActivity(),
|
|||
|
||||
views.scrollView.viewTreeObserver.addOnScrollChangedListener(scrollListener)
|
||||
|
||||
views.etContent.contentMineTypeArray = AttachmentUploader.acceptableMimeTypes.toTypedArray()
|
||||
views.etContent.contentCallback = { addAttachment(it) }
|
||||
|
||||
views.spLanguage.adapter = ArrayAdapter(
|
||||
|
|
|
@ -28,6 +28,7 @@ import jp.juggler.subwaytooter.pref.PrefB
|
|||
import jp.juggler.subwaytooter.util.AttachmentRequest
|
||||
import jp.juggler.subwaytooter.util.AttachmentUploader
|
||||
import jp.juggler.subwaytooter.util.PostAttachment
|
||||
import jp.juggler.subwaytooter.util.resolveMimeType
|
||||
import jp.juggler.subwaytooter.view.MyNetworkImageView
|
||||
import jp.juggler.util.coroutine.launchAndShowError
|
||||
import jp.juggler.util.coroutine.launchMain
|
||||
|
@ -128,9 +129,19 @@ fun ActPost.addAttachment(
|
|||
// onUploadEnd: () -> Unit = {},
|
||||
) {
|
||||
val account = this.account
|
||||
val mimeType = attachmentUploader.getMimeType(uri, mimeTypeArg)?.notEmpty()
|
||||
if (account == null) {
|
||||
dialogOrToast(R.string.account_select_please)
|
||||
return
|
||||
}
|
||||
val instance = TootInstance.getCached(account)
|
||||
if (instance == null) {
|
||||
dialogOrToast("missing instance imformation.")
|
||||
return
|
||||
}
|
||||
val mimeType = uri.resolveMimeType(mimeTypeArg, this, instance)
|
||||
?.notEmpty()
|
||||
|
||||
val isReply = states.inReplyToId != null
|
||||
val instance = account?.let { TootInstance.getCached(it) }
|
||||
|
||||
when {
|
||||
attachmentList.size >= 4 -> {
|
||||
|
@ -138,21 +149,11 @@ fun ActPost.addAttachment(
|
|||
return
|
||||
}
|
||||
|
||||
account == null -> {
|
||||
dialogOrToast(R.string.account_select_please)
|
||||
return
|
||||
}
|
||||
|
||||
mimeType == null -> {
|
||||
dialogOrToast(R.string.mime_type_missing)
|
||||
return
|
||||
}
|
||||
|
||||
instance == null -> {
|
||||
dialogOrToast("missing instance information")
|
||||
return
|
||||
}
|
||||
|
||||
instance.instanceType == InstanceType.Pixelfed && isReply -> {
|
||||
AttachmentUploader.log.e("pixelfed_does_not_allow_reply_with_media")
|
||||
dialogOrToast(R.string.pixelfed_does_not_allow_reply_with_media)
|
||||
|
|
|
@ -7,13 +7,9 @@ import android.os.Build
|
|||
import jp.juggler.media.generateTempFile
|
||||
import jp.juggler.media.transcodeAudio
|
||||
import jp.juggler.subwaytooter.R
|
||||
import jp.juggler.subwaytooter.api.entity.InstanceType
|
||||
import jp.juggler.subwaytooter.api.entity.TootInstance
|
||||
import jp.juggler.subwaytooter.pref.PrefB
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.subwaytooter.util.AttachmentUploader.Companion.MIME_TYPE_JPEG
|
||||
import jp.juggler.subwaytooter.util.AttachmentUploader.Companion.MIME_TYPE_PNG
|
||||
import jp.juggler.subwaytooter.util.AttachmentUploader.Companion.MIME_TYPE_WEBP
|
||||
import jp.juggler.util.data.JsonObject
|
||||
import jp.juggler.util.data.getStreamSize
|
||||
import jp.juggler.util.log.LogCategory
|
||||
|
@ -65,7 +61,7 @@ class AttachmentRequest(
|
|||
suspend fun createOpener(): InputStreamOpener {
|
||||
|
||||
// GIFはそのまま投げる
|
||||
if (mimeType == AttachmentUploader.MIME_TYPE_GIF) {
|
||||
if (mimeType == MIME_TYPE_GIF) {
|
||||
return contentUriOpener(context.contentResolver, uri, mimeType, isImage = true)
|
||||
}
|
||||
|
||||
|
@ -132,28 +128,26 @@ class AttachmentRequest(
|
|||
)
|
||||
}
|
||||
|
||||
private fun hasServerSupport(mimeType: String) =
|
||||
mediaConfig?.jsonArray("supported_mime_types")?.contains(mimeType)
|
||||
?: when (instance.instanceType) {
|
||||
InstanceType.Pixelfed -> AttachmentUploader.acceptableMimeTypesPixelfed
|
||||
else -> AttachmentUploader.acceptableMimeTypes
|
||||
}.contains(mimeType)
|
||||
|
||||
private fun createResizedImageOpener(): InputStreamOpener {
|
||||
try {
|
||||
pa.progress = context.getString(R.string.attachment_handling_compress)
|
||||
|
||||
val canUseWebP = try {
|
||||
hasServerSupport(MIME_TYPE_WEBP) && PrefB.bpUseWebP.value
|
||||
MIME_TYPE_WEBP.mimeTypeIsSupported(instance) && PrefB.bpUseWebP.value
|
||||
} catch (ex: Throwable) {
|
||||
log.w(ex, "can't check canUseWebP")
|
||||
false
|
||||
}
|
||||
|
||||
// サーバが読めない形式の画像なら強制的に再圧縮をかける
|
||||
// もしくは、PNG画像も可能ならWebPに変換したい
|
||||
val canUseOriginal = hasServerSupport(mimeType) &&
|
||||
!(mimeType == MIME_TYPE_PNG && canUseWebP)
|
||||
val canUseOriginal = when {
|
||||
// WebPを使っていい場合、PNG画像をWebPに変換したい
|
||||
canUseWebP && mimeType == MIME_TYPE_PNG -> false
|
||||
// WebPを使わない場合、入力がWebPなら強制的にPNGかJPEGにする
|
||||
!canUseWebP && mimeType == MIME_TYPE_WEBP -> false
|
||||
// ほか、サーバが受け入れる形式でリサイズ不要ならオリジナルのまま送信
|
||||
// ただしHEICやHEIFはサーバ側issueが落ち着くまで変換必須とする
|
||||
else -> mimeType.mimeTypeIsSupported(instance)
|
||||
}
|
||||
|
||||
createResizedBitmap(
|
||||
context,
|
||||
|
@ -338,7 +332,7 @@ class AttachmentRequest(
|
|||
|
||||
private suspend fun createResizedAudioOpener(srcBytes: Long): InputStreamOpener =
|
||||
when {
|
||||
hasServerSupport(mimeType) &&
|
||||
mimeType.mimeTypeIsSupported(instance) &&
|
||||
goodAudioType.contains(mimeType) &&
|
||||
srcBytes <= maxBytesVideo -> contentUriOpener(
|
||||
context.contentResolver,
|
||||
|
|
|
@ -1,40 +1,46 @@
|
|||
package jp.juggler.subwaytooter.util
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.SystemClock
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.media3.common.MimeTypes
|
||||
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.*
|
||||
import jp.juggler.subwaytooter.api.entity.EntityId
|
||||
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.*
|
||||
import jp.juggler.util.coroutine.AppDispatchers
|
||||
import jp.juggler.util.coroutine.launchIO
|
||||
import jp.juggler.util.data.*
|
||||
import jp.juggler.util.log.*
|
||||
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 jp.juggler.util.ui.*
|
||||
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.io.*
|
||||
import java.util.concurrent.CancellationException
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
|
@ -44,145 +50,6 @@ class AttachmentUploader(
|
|||
) {
|
||||
companion object {
|
||||
val log = LogCategory("AttachmentUploader")
|
||||
|
||||
internal const val MIME_TYPE_JPEG = "image/jpeg"
|
||||
internal const val MIME_TYPE_PNG = "image/png"
|
||||
internal const val MIME_TYPE_GIF = "image/gif"
|
||||
internal const val MIME_TYPE_WEBP = "image/webp"
|
||||
|
||||
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(
|
||||
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)
|
||||
),
|
||||
Pair(
|
||||
"audio/wav",
|
||||
"RIFF".toByteArray(Charsets.UTF_8),
|
||||
),
|
||||
Pair(
|
||||
"audio/ogg",
|
||||
"OggS".toByteArray(Charsets.UTF_8),
|
||||
),
|
||||
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 }
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private val safeContext = activity.applicationContext!!
|
||||
|
@ -212,7 +79,7 @@ class AttachmentUploader(
|
|||
}
|
||||
}
|
||||
val result = try {
|
||||
if (request.pa.isCancelled == true) continue
|
||||
if (request.pa.isCancelled) continue
|
||||
withContext(request.pa.job + AppDispatchers.IO) {
|
||||
request.upload()
|
||||
}
|
||||
|
@ -294,16 +161,7 @@ class AttachmentUploader(
|
|||
)
|
||||
}
|
||||
|
||||
val isAccepteble = instance.configuration
|
||||
?.jsonObject("media_attachments")
|
||||
?.jsonArray("supported_mime_types")
|
||||
?.contains(opener.mimeType)
|
||||
?: when (instance.instanceType) {
|
||||
InstanceType.Pixelfed -> acceptableMimeTypesPixelfed
|
||||
else -> acceptableMimeTypes
|
||||
}.contains(opener.mimeType)
|
||||
|
||||
if (!isAccepteble) {
|
||||
if (!opener.mimeType.mimeTypeIsSupported(instance)) {
|
||||
return TootApiResult(
|
||||
safeContext.getString(R.string.mime_type_not_acceptable, opener.mimeType)
|
||||
)
|
||||
|
@ -480,123 +338,25 @@ class AttachmentUploader(
|
|||
pa.callback?.onPostAttachmentComplete(pa)
|
||||
}
|
||||
|
||||
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(safeContext.contentResolver, uri)
|
||||
if (sv != null) return sv
|
||||
}
|
||||
|
||||
// 既に引数で与えられてる
|
||||
if (mimeTypeArg?.isNotEmpty() == true) {
|
||||
return mimeTypeArg
|
||||
}
|
||||
|
||||
// ContentResolverに尋ねる
|
||||
var sv = safeContext.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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 添付データのカスタムサムネイル
|
||||
// 添付データのカスタムサムネイル
|
||||
suspend fun uploadCustomThumbnail(
|
||||
account: SavedAccount,
|
||||
src: GetContentResultEntry,
|
||||
pa: PostAttachment,
|
||||
): TootApiResult? = try {
|
||||
safeContext.runApiTask(account) { client ->
|
||||
val mimeType = getMimeType(src.uri, src.mimeType)?.notEmpty()
|
||||
val (ti, ri) = TootInstance.get(client)
|
||||
ti ?: return@runApiTask ri
|
||||
|
||||
val mimeType = src.uri.resolveMimeType(src.mimeType, safeContext, ti)
|
||||
if (mimeType.isNullOrEmpty()) {
|
||||
return@runApiTask TootApiResult(safeContext.getString(R.string.mime_type_missing))
|
||||
}
|
||||
|
||||
val (ti, ri) = TootInstance.get(client)
|
||||
ti ?: return@runApiTask ri
|
||||
val mediaConfig = ti.configuration?.jsonObject("media_attachments")
|
||||
val ar = AttachmentRequest(
|
||||
context = applicationContext,
|
||||
context = safeContext,
|
||||
account = account,
|
||||
pa = pa,
|
||||
uri = src.uri,
|
||||
|
@ -611,7 +371,10 @@ class AttachmentUploader(
|
|||
val opener = ar.createOpener()
|
||||
if (opener.contentLength > ar.maxBytesImage) {
|
||||
return@runApiTask TootApiResult(
|
||||
getString(R.string.file_size_too_big, ar.maxBytesImage / 1000000)
|
||||
getString(
|
||||
R.string.file_size_too_big,
|
||||
ar.maxBytesImage / 1000000
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,319 @@
|
|||
package jp.juggler.subwaytooter.util
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import jp.juggler.subwaytooter.api.entity.InstanceType
|
||||
import jp.juggler.subwaytooter.api.entity.TootInstance
|
||||
import jp.juggler.util.data.notEmpty
|
||||
import jp.juggler.util.data.toByteArray
|
||||
import jp.juggler.util.data.toLowerByteArray
|
||||
import jp.juggler.util.log.LogCategory
|
||||
import jp.juggler.util.media.bitmapMimeType
|
||||
|
||||
private val log = LogCategory("MimeTypeUtils")
|
||||
|
||||
const val MIME_TYPE_JPEG = "image/jpeg"
|
||||
const val MIME_TYPE_PNG = "image/png"
|
||||
const val MIME_TYPE_GIF = "image/gif"
|
||||
const val MIME_TYPE_WEBP = "image/webp"
|
||||
|
||||
private 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")
|
||||
}
|
||||
|
||||
private 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(
|
||||
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)
|
||||
),
|
||||
Pair(
|
||||
"audio/wav",
|
||||
"RIFF".toByteArray(Charsets.UTF_8),
|
||||
),
|
||||
Pair(
|
||||
"audio/ogg",
|
||||
"OggS".toByteArray(Charsets.UTF_8),
|
||||
),
|
||||
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 }
|
||||
|
||||
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 const val wild = '?'.code.toByte()
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
private fun String.isProblematicImageType(instance: TootInstance) = when (instance.instanceType) {
|
||||
InstanceType.Mastodon -> when (this) {
|
||||
// https://github.com/mastodon/mastodon/issues/23588
|
||||
"image/heic", "image/heif" -> true
|
||||
// https://github.com/mastodon/mastodon/issues/20834
|
||||
"image/avif" -> true
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
InstanceType.Pixelfed -> when (this) {
|
||||
// Pixelfed は PC Web UI で画像を開くダイアログの時点でHEIC,HEIF,AVIF を選択できない
|
||||
"image/heic", "image/heif", "image/avif" -> true
|
||||
else -> false
|
||||
}
|
||||
// PleromaやMisskeyでの問題は調べてない
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun String.mimeTypeIsSupportedByServer(instance: TootInstance) =
|
||||
instance.configuration
|
||||
?.jsonObject("media_attachments")
|
||||
?.jsonArray("supported_mime_types")
|
||||
?.contains(this)
|
||||
?: when (instance.instanceType) {
|
||||
InstanceType.Pixelfed -> acceptableMimeTypesPixelfed
|
||||
else -> acceptableMimeTypes
|
||||
}.contains(this)
|
||||
|
||||
fun String.mimeTypeIsSupported(instance: TootInstance) = when {
|
||||
isProblematicImageType(instance) -> false
|
||||
else -> mimeTypeIsSupportedByServer(instance)
|
||||
}
|
||||
|
||||
fun Uri.resolveMimeType(
|
||||
mimeTypeArg1: String?,
|
||||
context: Context,
|
||||
instance: TootInstance,
|
||||
): String? {
|
||||
// image/j()pg だの image/j(e)pg だの、mime type を誤記するアプリがあまりに多い
|
||||
// application/octet-stream などが誤設定されてることもある
|
||||
// Androidが静止画を読めるならそのmimeType
|
||||
bitmapMimeType(context.contentResolver)?.notEmpty()?.let { return it }
|
||||
|
||||
// 動画の一部は音声かもしれない
|
||||
// データに動画や音声が含まれるか調べる
|
||||
try {
|
||||
MediaMetadataRetriever().use { mmr ->
|
||||
mmr.setDataSource(context, this)
|
||||
mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE)
|
||||
}?.notEmpty()?.let { return it }
|
||||
} catch (ex: Throwable) {
|
||||
log.w(ex, "not video or audio.")
|
||||
}
|
||||
|
||||
// 引数のmimeTypeがサーバでサポートされているならソレ
|
||||
try {
|
||||
mimeTypeArg1
|
||||
?.notEmpty()
|
||||
?.takeIf { it.mimeTypeIsSupportedByServer(instance) }
|
||||
?.let { return it }
|
||||
} catch (ex: Throwable) {
|
||||
AttachmentUploader.log.w(ex, "mimeTypeArg1 check failed.")
|
||||
}
|
||||
// ContentResolverに尋ねる
|
||||
try {
|
||||
context.contentResolver.getType(this)
|
||||
?.notEmpty()
|
||||
?.takeIf { it.mimeTypeIsSupportedByServer(instance) }
|
||||
?.let { return it }
|
||||
} catch (ex: Throwable) {
|
||||
AttachmentUploader.log.w(ex, "contentResolver.getType failed.")
|
||||
}
|
||||
// gboardのステッカーではUriのクエリパラメータにmimeType引数がある
|
||||
try {
|
||||
getQueryParameter("mimeType")
|
||||
?.notEmpty()
|
||||
?.takeIf { it.mimeTypeIsSupportedByServer(instance) }
|
||||
?.let { return it }
|
||||
} catch (ex: Throwable) {
|
||||
log.w(ex, "getQueryParameter(mimeType) failed.")
|
||||
}
|
||||
|
||||
// ファイルヘッダを読んで判定する
|
||||
findMimeTypeByFileHeader(context.contentResolver, this)
|
||||
?.notEmpty()?.let { return it }
|
||||
|
||||
return null
|
||||
}
|
|
@ -32,8 +32,6 @@ class MyEditText @JvmOverloads constructor(
|
|||
///////////////////////////////////////////////////////
|
||||
// IMEから画像を送られてくることがあるらしい
|
||||
|
||||
var contentMineTypeArray: Array<String>? = null
|
||||
|
||||
private val receiveContentListener =
|
||||
OnReceiveContentListener { _: View, payload: ContentInfoCompat ->
|
||||
// コールバックが設定されていないなら何も受け取らない
|
||||
|
|
|
@ -11,6 +11,8 @@ import jp.juggler.util.log.LogCategory
|
|||
import okhttp3.internal.closeQuietly
|
||||
import java.io.InputStream
|
||||
|
||||
private val log = LogCategory("StorageUtils")
|
||||
|
||||
// internal object StorageUtils{
|
||||
//
|
||||
// private val log = LogCategory("StorageUtils")
|
||||
|
@ -285,8 +287,14 @@ data class GetContentResultEntry(
|
|||
fun Intent.handleGetContentResult(contentResolver: ContentResolver): ArrayList<GetContentResultEntry> {
|
||||
val urlList = ArrayList<GetContentResultEntry>()
|
||||
// 単一選択
|
||||
this.data?.let {
|
||||
urlList.add(GetContentResultEntry(it, this.type))
|
||||
data?.let {
|
||||
val mimeType = try {
|
||||
type ?: contentResolver.getType(it)
|
||||
} catch (ex: Throwable) {
|
||||
log.w(ex, "contentResolver.getType failed. uri=$it")
|
||||
null
|
||||
}
|
||||
urlList.add(GetContentResultEntry(it, mimeType))
|
||||
}
|
||||
// 複数選択
|
||||
this.clipData?.let { clipData ->
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package jp.juggler.util.media
|
||||
|
||||
//import it.sephiroth.android.library.exif2.ExifInterface
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
|
@ -12,6 +13,7 @@ import android.graphics.PointF
|
|||
import android.net.Uri
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import jp.juggler.util.data.notEmpty
|
||||
import jp.juggler.util.log.LogCategory
|
||||
import jp.juggler.util.log.showToast
|
||||
import java.io.FileNotFoundException
|
||||
|
@ -124,6 +126,20 @@ fun createResizedBitmap(
|
|||
skipIfNoNeedToResizeAndRotate = skipIfNoNeedToResizeAndRotate
|
||||
)
|
||||
|
||||
fun Uri.bitmapMimeType(contentResolver: ContentResolver): String? =
|
||||
try {
|
||||
val options = BitmapFactory.Options()
|
||||
options.inJustDecodeBounds = true
|
||||
options.inScaled = false
|
||||
contentResolver.openInputStream(this)?.use {
|
||||
BitmapFactory.decodeStream(it, null, options)
|
||||
}
|
||||
options.outMimeType?.notEmpty()
|
||||
} catch (ex: Throwable) {
|
||||
log.w(ex, "bitmapMimeType: can't check bitmap mime type.")
|
||||
null
|
||||
}
|
||||
|
||||
fun createResizedBitmap(
|
||||
context: Context,
|
||||
|
||||
|
|
Loading…
Reference in New Issue