424 lines
15 KiB
Kotlin
424 lines
15 KiB
Kotlin
package jp.juggler.subwaytooter.util
|
|
|
|
import android.content.Context
|
|
import android.graphics.Bitmap
|
|
import android.net.Uri
|
|
import android.os.Build
|
|
import jp.juggler.media.generateTempFile
|
|
import jp.juggler.media.transcodeAudio
|
|
import jp.juggler.subwaytooter.R
|
|
import jp.juggler.subwaytooter.api.TootApiCallback
|
|
import jp.juggler.subwaytooter.api.TootApiClient
|
|
import jp.juggler.subwaytooter.api.entity.TootInstance
|
|
import jp.juggler.subwaytooter.pref.PrefB
|
|
import jp.juggler.subwaytooter.table.SavedAccount
|
|
import jp.juggler.util.data.JsonObject
|
|
import jp.juggler.util.data.getStreamSize
|
|
import jp.juggler.util.data.notEmpty
|
|
import jp.juggler.util.log.LogCategory
|
|
import jp.juggler.util.log.errorEx
|
|
import jp.juggler.util.media.ResizeConfig
|
|
import jp.juggler.util.media.VideoInfo
|
|
import jp.juggler.util.media.VideoInfo.Companion.videoInfo
|
|
import jp.juggler.util.media.createResizedBitmap
|
|
import jp.juggler.util.media.transcodeVideoMedia3Transformer
|
|
import java.io.File
|
|
import java.io.FileOutputStream
|
|
import java.util.concurrent.CancellationException
|
|
import kotlin.math.min
|
|
|
|
class AttachmentRequest(
|
|
val context: Context,
|
|
val account: SavedAccount,
|
|
val pa: PostAttachment,
|
|
val uri: Uri,
|
|
var mimeTypeArg: String?,
|
|
val imageResizeConfig: ResizeConfig,
|
|
val maxBytesVideo: (instance: TootInstance, mediaConfig: JsonObject?) -> Int,
|
|
val maxBytesImage: (instance: TootInstance, mediaConfig: JsonObject?) -> Int,
|
|
val isReply: Boolean = false,
|
|
) {
|
|
companion object {
|
|
private val log = LogCategory("AttachmentRequest")
|
|
|
|
private val goodAudioType = setOf(
|
|
"audio/flac",
|
|
"audio/mp3",
|
|
"audio/ogg",
|
|
"audio/vnd.wave",
|
|
"audio/vorbis",
|
|
"audio/wav",
|
|
"audio/wave",
|
|
"audio/webm",
|
|
"audio/x-pn-wave",
|
|
"audio/x-wav",
|
|
"audio/3gpp",
|
|
)
|
|
|
|
// val badAudioType = setOf(
|
|
// "audio/mpeg","audio/aac",
|
|
// "audio/m4a","audio/x-m4a","audio/mp4",
|
|
// "video/x-ms-asf",
|
|
// )
|
|
private suspend fun Context.getInstance(account: SavedAccount): TootInstance {
|
|
val client = TootApiClient(
|
|
context = this,
|
|
callback = object : TootApiCallback {
|
|
override suspend fun isApiCancelled() = false
|
|
}
|
|
).apply {
|
|
this.account = account
|
|
}
|
|
val (instance, ri) = TootInstance.get(client = client)
|
|
if (instance != null) return instance
|
|
when (ri) {
|
|
null -> throw CancellationException()
|
|
else -> error("missing instance information. ${ri.error}")
|
|
}
|
|
}
|
|
}
|
|
|
|
private var _instance: TootInstance? = null
|
|
|
|
suspend fun instance(): TootInstance {
|
|
_instance?.let { return it }
|
|
return context.getInstance(account).also { _instance = it }
|
|
}
|
|
|
|
suspend fun mediaConfig(): JsonObject? =
|
|
instance().configuration?.jsonObject("media_attachments")
|
|
|
|
private suspend fun serverMaxSqPixel(): Int? =
|
|
mediaConfig()?.int("image_matrix_limit")?.takeIf { it > 0 }
|
|
|
|
val mimeType
|
|
get() = uri.resolveMimeType(mimeTypeArg, context)?.notEmpty()
|
|
?: error(context.getString(R.string.mime_type_missing))
|
|
|
|
suspend fun createOpener(): InputStreamOpener {
|
|
val mimeType = this.mimeType
|
|
|
|
if (mimeType == MIME_TYPE_GIF) {
|
|
// GIFはそのまま投げる
|
|
return contentUriOpener(context.contentResolver, uri, mimeType, isImage = true)
|
|
} else if (mimeType.startsWith("image")) {
|
|
// 静止画
|
|
return createResizedImageOpener()
|
|
}
|
|
|
|
// 音声と動画のファイル区分は曖昧なので
|
|
// MediaMetadataRetriever で調べる
|
|
|
|
// コンテンツの長さを調べる
|
|
|
|
val contentLength = context.contentResolver.openInputStream(uri)
|
|
?.use { getStreamSize(false, it) }
|
|
?: error("openInputStream returns null")
|
|
|
|
// 動画の一部は音声かもしれない
|
|
// データに動画や音声が含まれるか調べる
|
|
val vi = try {
|
|
uri.videoInfo(context, contentLength)
|
|
} catch (ex: Throwable) {
|
|
log.e(ex, "can't get videoInfo.")
|
|
error("can't get videoInfo. $mimeType $uri")
|
|
}
|
|
|
|
val isVideo = when {
|
|
vi.hasVideo == true -> true
|
|
vi.hasAudio == true -> false
|
|
mimeType.startsWith("video") -> true
|
|
mimeType.startsWith("audio") -> false
|
|
else -> null
|
|
}
|
|
|
|
when (isVideo) {
|
|
true -> try {
|
|
// 動画のトランスコード(失敗したらオリジナルデータにフォールバックする)
|
|
return createResizedVideoOpener(vi)
|
|
} catch (ex: Throwable) {
|
|
log.w(
|
|
ex,
|
|
"createResizedVideoOpener failed. fall back to original data."
|
|
)
|
|
}
|
|
|
|
false -> try {
|
|
// 音声のトランスコード(失敗したらオリジナルデータにフォールバックする)
|
|
return createResizedAudioOpener(contentLength)
|
|
} catch (ex: Throwable) {
|
|
log.w(
|
|
ex,
|
|
"createResizedAudioOpener failed. fall back to original data."
|
|
)
|
|
}
|
|
|
|
null -> Unit
|
|
}
|
|
|
|
return contentUriOpener(
|
|
context.contentResolver,
|
|
uri,
|
|
mimeType,
|
|
isImage = false,
|
|
)
|
|
}
|
|
|
|
private suspend fun createResizedImageOpener(): InputStreamOpener {
|
|
try {
|
|
pa.progress = context.getString(R.string.attachment_handling_compress)
|
|
|
|
val instance = instance()
|
|
|
|
val canUseWebP = try {
|
|
PrefB.bpUseWebP.value && MIME_TYPE_WEBP.mimeTypeIsSupported(instance)
|
|
} catch (ex: Throwable) {
|
|
log.w(ex, "can't check canUseWebP")
|
|
false
|
|
}
|
|
|
|
val canUseOriginal: Boolean = 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,
|
|
uri,
|
|
imageResizeConfig,
|
|
canSkip = canUseOriginal,
|
|
serverMaxSqPixel = serverMaxSqPixel()
|
|
)?.let { bitmap ->
|
|
try {
|
|
return bitmap.compressAutoType(canUseWebP)
|
|
} finally {
|
|
bitmap.recycle()
|
|
}
|
|
}
|
|
// nullを返す場合もここを通る
|
|
} catch (ex: Throwable) {
|
|
log.w(ex, "createResizedBitmap failed.")
|
|
}
|
|
|
|
// 元のデータを返す
|
|
return contentUriOpener(context.contentResolver, uri, mimeType, isImage = true)
|
|
}
|
|
|
|
private fun Bitmap.compressAutoType(canUseWebP: Boolean): InputStreamOpener {
|
|
if (canUseWebP) {
|
|
try {
|
|
val format = when {
|
|
Build.VERSION.SDK_INT >= 30 ->
|
|
Bitmap.CompressFormat.WEBP_LOSSY
|
|
|
|
else ->
|
|
@Suppress("DEPRECATION")
|
|
Bitmap.CompressFormat.WEBP
|
|
}
|
|
return compressToTempFileOpener("webp", MIME_TYPE_WEBP, format, 90)
|
|
} catch (ex: Throwable) {
|
|
log.w(ex, "compress to WebP lossy failed.")
|
|
// 失敗したらJPEG or PNG にフォールバック
|
|
}
|
|
}
|
|
try {
|
|
// check bitmap has translucent pixels
|
|
val hasAlpha = when {
|
|
mimeType == MIME_TYPE_JPEG -> false
|
|
!hasAlpha() -> false
|
|
else -> scanAlpha()
|
|
}
|
|
return when (hasAlpha) {
|
|
true -> compressToTempFileOpener(
|
|
"png",
|
|
MIME_TYPE_PNG,
|
|
Bitmap.CompressFormat.PNG,
|
|
100
|
|
)
|
|
|
|
else -> compressToTempFileOpener(
|
|
"jpg",
|
|
MIME_TYPE_JPEG,
|
|
Bitmap.CompressFormat.JPEG,
|
|
95
|
|
)
|
|
}
|
|
} catch (ex: Throwable) {
|
|
errorEx(ex, "compress to JPEG/PNG failed.")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bitmapを指定フォーマットで圧縮して tempFileOpener を返す
|
|
* 失敗したら例外を投げる
|
|
*/
|
|
private fun Bitmap.compressToTempFileOpener(
|
|
fixExt: String?,
|
|
outMimeType: String,
|
|
format: Bitmap.CompressFormat,
|
|
quality: Int,
|
|
): InputStreamOpener {
|
|
val tempFile = context.generateTempFile("createResizedImageOpener")
|
|
try {
|
|
FileOutputStream(tempFile).use { compress(format, quality, it) }
|
|
return tempFileOpener(tempFile, outMimeType, isImage = true, fixExt = fixExt)
|
|
} catch (ex: Throwable) {
|
|
tempFile.delete()
|
|
throw ex
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ビットマップのアルファ値が0xFFではないピクセルがあれば真
|
|
*/
|
|
private fun Bitmap.scanAlpha(): Boolean {
|
|
try {
|
|
val w = this.width
|
|
val h = this.height
|
|
if (w > 0 && h > 0) {
|
|
val hStep = 64
|
|
val pixels = IntArray(w * min(hStep, h))
|
|
for (y in 0 until h step hStep) {
|
|
val hPart = min(hStep, h - y)
|
|
getPixels(
|
|
/* pixels */ pixels,
|
|
/* offset */ 0,
|
|
/* stride */ w,
|
|
/* x */ 0,
|
|
/* y */ y,
|
|
/* width */ w,
|
|
/* height */ hPart,
|
|
)
|
|
for (i in 0 until (w * hPart)) {
|
|
if (pixels[i].ushr(24) != 0xff) return true
|
|
}
|
|
}
|
|
}
|
|
} catch (ex: Throwable) {
|
|
log.w(ex, "scanAlpha failed.")
|
|
}
|
|
return false
|
|
}
|
|
|
|
private suspend fun createResizedVideoOpener(srcInfo: VideoInfo): 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
|
|
try {
|
|
|
|
// 入力ファイルをコピーする
|
|
(context.contentResolver.openInputStream(uri)
|
|
?: error("openInputStream returns null.")).use { inStream ->
|
|
FileOutputStream(tempFile).use { inStream.copyTo(it) }
|
|
}
|
|
|
|
val mediaConfig = mediaConfig()
|
|
|
|
// 動画のメタデータを調べる
|
|
|
|
// サーバに指定されたファイルサイズ上限と入力動画の時間長があれば、ビットレート上限を制限する
|
|
val duration = srcInfo.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
|
|
}
|
|
|
|
// アカウント別の動画トランスコード設定
|
|
// ビットレート、フレームレート、平方ピクセル数をサーバからの情報によりさらに制限する
|
|
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(
|
|
// srcInfo,
|
|
// tempFile,
|
|
// outFile,
|
|
// movieResizeConfig,
|
|
// ) {
|
|
// val percent = (it * 100f).toInt()
|
|
// pa.progress =
|
|
// context.getString(R.string.attachment_handling_compress_ratio, percent)
|
|
// }
|
|
val result = transcodeVideoMedia3Transformer(
|
|
context = context,
|
|
info = srcInfo,
|
|
inFile = tempFile,
|
|
outFile = outFile,
|
|
resizeConfig = movieResizeConfig,
|
|
) {
|
|
val percent = (it * 100f).toInt()
|
|
pa.progress =
|
|
context.getString(R.string.attachment_handling_compress_ratio, percent)
|
|
}
|
|
resultFile = result
|
|
return tempFileOpener(
|
|
result,
|
|
when (result) {
|
|
tempFile -> mimeType
|
|
else -> "video/mp4"
|
|
},
|
|
isImage = false,
|
|
fixExt = when (result) {
|
|
tempFile -> null
|
|
else -> "mp4"
|
|
},
|
|
)
|
|
} finally {
|
|
if (outFile != resultFile) outFile.delete()
|
|
if (tempFile != resultFile) tempFile.delete()
|
|
}
|
|
}
|
|
|
|
private suspend fun createResizedAudioOpener(srcBytes: Long): InputStreamOpener {
|
|
val instance = instance()
|
|
val mediaConfig = mediaConfig()
|
|
return when {
|
|
mimeType.mimeTypeIsSupported(instance) &&
|
|
goodAudioType.contains(mimeType) &&
|
|
srcBytes <= maxBytesVideo(instance, mediaConfig).toLong()
|
|
-> contentUriOpener(
|
|
context.contentResolver,
|
|
uri,
|
|
mimeType,
|
|
isImage = false,
|
|
)
|
|
|
|
else -> {
|
|
pa.progress = context.getString(R.string.attachment_handling_compress)
|
|
val (tempFile, outMimeType) = transcodeAudio(
|
|
context,
|
|
uri,
|
|
mimeType
|
|
)
|
|
// このワークアラウンドはうまくいかなかった
|
|
// outMimeType = when (outMimeType) {
|
|
// "audio/mp4" -> "audio/x-m4a"
|
|
// else -> outMimeType
|
|
// }
|
|
tempFileOpener(
|
|
tempFile,
|
|
outMimeType,
|
|
isImage = false,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|