投稿の編集時に添付データを追加した場合の挙動を改善

This commit is contained in:
tateisu 2023-04-28 18:35:13 +09:00
parent 0dd2bd3252
commit 332b4dc5a0
21 changed files with 610 additions and 467 deletions

View File

@ -37,6 +37,7 @@ import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchMain import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.data.* import jp.juggler.util.data.*
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.dialogOrToast
import jp.juggler.util.log.showToast import jp.juggler.util.log.showToast
import jp.juggler.util.log.withCaption import jp.juggler.util.log.withCaption
import jp.juggler.util.media.imageOrientation import jp.juggler.util.media.imageOrientation

View File

@ -17,7 +17,35 @@ import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.action.saveWindowSize import jp.juggler.subwaytooter.action.saveWindowSize
import jp.juggler.subwaytooter.actpost.* import jp.juggler.subwaytooter.actpost.ActPostStates
import jp.juggler.subwaytooter.actpost.CompletionHelper
import jp.juggler.subwaytooter.actpost.FeaturedTagCache
import jp.juggler.subwaytooter.actpost.addAttachment
import jp.juggler.subwaytooter.actpost.applyMushroomText
import jp.juggler.subwaytooter.actpost.onPickCustomThumbnailImpl
import jp.juggler.subwaytooter.actpost.onPostAttachmentCompleteImpl
import jp.juggler.subwaytooter.actpost.openAttachment
import jp.juggler.subwaytooter.actpost.openMushroom
import jp.juggler.subwaytooter.actpost.openVisibilityPicker
import jp.juggler.subwaytooter.actpost.performAccountChooser
import jp.juggler.subwaytooter.actpost.performAttachmentClick
import jp.juggler.subwaytooter.actpost.performMore
import jp.juggler.subwaytooter.actpost.performPost
import jp.juggler.subwaytooter.actpost.performSchedule
import jp.juggler.subwaytooter.actpost.removeReply
import jp.juggler.subwaytooter.actpost.resetSchedule
import jp.juggler.subwaytooter.actpost.restoreState
import jp.juggler.subwaytooter.actpost.saveDraft
import jp.juggler.subwaytooter.actpost.saveState
import jp.juggler.subwaytooter.actpost.showContentWarningEnabled
import jp.juggler.subwaytooter.actpost.showMediaAttachment
import jp.juggler.subwaytooter.actpost.showMediaAttachmentProgress
import jp.juggler.subwaytooter.actpost.showPoll
import jp.juggler.subwaytooter.actpost.showQuotedRenote
import jp.juggler.subwaytooter.actpost.showReplyTo
import jp.juggler.subwaytooter.actpost.showVisibility
import jp.juggler.subwaytooter.actpost.updateText
import jp.juggler.subwaytooter.actpost.updateTextCount
import jp.juggler.subwaytooter.api.entity.TootScheduled import jp.juggler.subwaytooter.api.entity.TootScheduled
import jp.juggler.subwaytooter.api.entity.TootStatus import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.databinding.ActPostBinding import jp.juggler.subwaytooter.databinding.ActPostBinding
@ -25,7 +53,11 @@ import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.span.MyClickableSpan import jp.juggler.subwaytooter.span.MyClickableSpan
import jp.juggler.subwaytooter.span.MyClickableSpanHandler import jp.juggler.subwaytooter.span.MyClickableSpanHandler
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.* import jp.juggler.subwaytooter.util.AttachmentPicker
import jp.juggler.subwaytooter.util.AttachmentUploader
import jp.juggler.subwaytooter.util.PostAttachment
import jp.juggler.subwaytooter.util.loadLanguageList
import jp.juggler.subwaytooter.util.openBrowser
import jp.juggler.subwaytooter.view.MyEditText import jp.juggler.subwaytooter.view.MyEditText
import jp.juggler.subwaytooter.view.MyNetworkImageView import jp.juggler.subwaytooter.view.MyNetworkImageView
import jp.juggler.util.backPressed import jp.juggler.util.backPressed
@ -282,6 +314,7 @@ class ActPost : AppCompatActivity(),
R.id.btnFeaturedTag -> completionHelper.openFeaturedTagList( R.id.btnFeaturedTag -> completionHelper.openFeaturedTagList(
featuredTagCache[account?.acct?.ascii ?: ""]?.list featuredTagCache[account?.acct?.ascii ?: ""]?.list
) )
R.id.ibSchedule -> performSchedule() R.id.ibSchedule -> performSchedule()
R.id.ibScheduleReset -> resetSchedule() R.id.ibScheduleReset -> resetSchedule()
} }
@ -294,6 +327,7 @@ class ActPost : AppCompatActivity(),
views.btnPost.performClick() views.btnPost.performClick()
true true
} }
else -> false else -> false
} }
} }

View File

@ -14,7 +14,6 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import jp.juggler.subwaytooter.api.dialogOrToast
import jp.juggler.subwaytooter.databinding.ActPushMessageListBinding import jp.juggler.subwaytooter.databinding.ActPushMessageListBinding
import jp.juggler.subwaytooter.databinding.LvPushMessageBinding import jp.juggler.subwaytooter.databinding.LvPushMessageBinding
import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.dialog.actionsDialog
@ -34,6 +33,7 @@ import jp.juggler.util.data.encodeBase64Url
import jp.juggler.util.data.notBlank import jp.juggler.util.data.notBlank
import jp.juggler.util.data.notZero import jp.juggler.util.data.notZero
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.dialogOrToast
import jp.juggler.util.os.saveToDownload import jp.juggler.util.os.saveToDownload
import jp.juggler.util.time.formatLocalTime import jp.juggler.util.time.formatLocalTime
import jp.juggler.util.ui.setNavigationBack import jp.juggler.util.ui.setNavigationBack
@ -124,7 +124,7 @@ class ActPushMessageList : AppCompatActivity() {
} }
} }
if (!path.isNullOrEmpty()) { if (!path.isNullOrEmpty()) {
dialogOrToast(getString(R.string.saved_to, path)) dialogOrToast(R.string.saved_to, path)
} }
} }

View File

@ -9,6 +9,7 @@ import jp.juggler.subwaytooter.ActPost
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.ApiTask import jp.juggler.subwaytooter.api.ApiTask
import jp.juggler.subwaytooter.api.TootApiResult import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.entity.InstanceType
import jp.juggler.subwaytooter.api.entity.ServiceType import jp.juggler.subwaytooter.api.entity.ServiceType
import jp.juggler.subwaytooter.api.entity.TootAttachment import jp.juggler.subwaytooter.api.entity.TootAttachment
import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachment import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachment
@ -25,6 +26,7 @@ import jp.juggler.subwaytooter.dialog.focusPointDialog
import jp.juggler.subwaytooter.dialog.showTextInputDialog import jp.juggler.subwaytooter.dialog.showTextInputDialog
import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.util.AttachmentRequest import jp.juggler.subwaytooter.util.AttachmentRequest
import jp.juggler.subwaytooter.util.AttachmentUploader
import jp.juggler.subwaytooter.util.PostAttachment import jp.juggler.subwaytooter.util.PostAttachment
import jp.juggler.subwaytooter.view.MyNetworkImageView import jp.juggler.subwaytooter.view.MyNetworkImageView
import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchAndShowError
@ -35,11 +37,13 @@ import jp.juggler.util.data.buildJsonObject
import jp.juggler.util.data.decodeJsonArray import jp.juggler.util.data.decodeJsonArray
import jp.juggler.util.data.notEmpty import jp.juggler.util.data.notEmpty
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.dialogOrToast
import jp.juggler.util.log.showToast import jp.juggler.util.log.showToast
import jp.juggler.util.log.withCaption import jp.juggler.util.log.withCaption
import jp.juggler.util.network.toPutRequestBuilder import jp.juggler.util.network.toPutRequestBuilder
import jp.juggler.util.ui.isLiveActivity import jp.juggler.util.ui.isLiveActivity
import jp.juggler.util.ui.vg import jp.juggler.util.ui.vg
import kotlin.math.min
private val log = LogCategory("ActPostAttachment") private val log = LogCategory("ActPostAttachment")
@ -124,31 +128,64 @@ fun ActPost.addAttachment(
// onUploadEnd: () -> Unit = {}, // onUploadEnd: () -> Unit = {},
) { ) {
val account = this.account val account = this.account
val mimeType = attachmentUploader.getMimeType(uri, mimeTypeArg) val mimeType = attachmentUploader.getMimeType(uri, mimeTypeArg)?.notEmpty()
val isReply = states.inReplyToId != null val isReply = states.inReplyToId != null
val instance = account?.let { TootInstance.getCached(it) } val instance = account?.let { TootInstance.getCached(it) }
when { when {
attachmentList.size >= 4 -> showToast(false, R.string.attachment_too_many) attachmentList.size >= 4 -> {
account == null -> showToast(false, R.string.account_select_please) dialogOrToast(R.string.attachment_too_many)
mimeType?.isEmpty() != false -> showToast(false, R.string.mime_type_missing) return
!attachmentUploader.isAcceptableMimeType( }
instance,
mimeType, account == null -> {
isReply dialogOrToast(R.string.account_select_please)
) -> Unit // エラーメッセージ出力済み 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)
return
}
else -> { else -> {
saveAttachmentList() saveAttachmentList()
val pa = PostAttachment(this) val pa = PostAttachment(this)
attachmentList.add(pa) attachmentList.add(pa)
showMediaAttachment() showMediaAttachment()
val mediaConfig = instance.configuration?.jsonObject("media_attachments")
attachmentUploader.addRequest( attachmentUploader.addRequest(
AttachmentRequest( AttachmentRequest(
account, context = applicationContext,
pa, account = account,
uri, pa = pa,
mimeType, uri = uri,
isReply = isReply, mimeType = mimeType,
instance = instance,
mediaConfig = mediaConfig,
serverMaxSqPixel = mediaConfig?.int("image_matrix_limit")?.takeIf { it > 0 },
imageResizeConfig = account.getResizeConfig(),
maxBytesVideo = min(
account.getMovieMaxBytes(instance),
mediaConfig?.int("video_size_limit")
?.takeIf { it > 0 } ?: Int.MAX_VALUE,
),
maxBytesImage = min(
account.getImageMaxBytes(instance),
mediaConfig?.int("image_size_limit")
?.takeIf { it > 0 } ?: Int.MAX_VALUE,
),
// onUploadEnd = onUploadEnd // onUploadEnd = onUploadEnd
) )
) )
@ -237,7 +274,11 @@ fun ActPost.performAttachmentClick(idx: Int) {
} }
} }
if (account?.isMastodon == true) { if (account?.isMastodon == true) {
when (pa.attachment?.type) { if (pa.attachment?.isEdit == true) {
// https://github.com/tateisu/SubwayTooter/issues/237
// 既存の投稿の編集時にサムネイルを更新できるようにするのが著しく面倒くさい
// 一旦未対応とする
} else when (pa.attachment?.type) {
TootAttachmentType.Audio, TootAttachmentType.Audio,
TootAttachmentType.GIFV, TootAttachmentType.GIFV,
TootAttachmentType.Video, TootAttachmentType.Video,
@ -288,8 +329,7 @@ suspend fun ActPost.sendFocusPoint(
y: Float, y: Float,
): Boolean { ): Boolean {
val account = this.account ?: error("missing account") val account = this.account ?: error("missing account")
val isEdit = states.editStatusId != null if (attachment.isEdit) {
if (isEdit) {
attachment.focusX = x attachment.focusX = x
attachment.focusY = y attachment.focusY = y
attachment.updateFocus = formatFocusParameter(x, y) attachment.updateFocus = formatFocusParameter(x, y)
@ -334,16 +374,16 @@ private fun formatFocusParameter(x: Float, y: Float) = "%.2f,%.2f".format(x, y)
suspend fun ActPost.editAttachmentDescription( suspend fun ActPost.editAttachmentDescription(
pa: PostAttachment, pa: PostAttachment,
) { ) {
// 既存の投稿を編集中なら真 val account = this.account ?: return
val isEdit = states.editStatusId != null
val a = pa.attachment val a = pa.attachment
if (a == null) { if (a == null) {
showToast(true, R.string.attachment_description_cant_edit_while_uploading) showToast(true, R.string.attachment_description_cant_edit_while_uploading)
return return
} }
val attachmentId = pa.attachment?.id ?: return // 既存の投稿を編集中なら真
val account = this.account ?: return val isEdit = a.isEdit
val attachmentId = a.id
var bitmap: Bitmap? = null var bitmap: Bitmap? = null
try { try {
// サムネイルをロード // サムネイルをロード
@ -412,9 +452,16 @@ fun ActPost.onPickCustomThumbnailImpl(pa: PostAttachment, src: GetContentResultE
when (val account = this.account) { when (val account = this.account) {
null -> showToast(false, R.string.account_select_please) null -> showToast(false, R.string.account_select_please)
else -> launchMain { else -> launchMain {
val result = attachmentUploader.uploadCustomThumbnail(account, src, pa) if (pa.attachment?.isEdit == true) {
result?.error?.let { showToast(true, it) } showToast(
showMediaAttachment() true,
"Sorry, updateing thumbnail is not yet supported in case of editing post."
)
} else {
val result = attachmentUploader.uploadCustomThumbnail(account, src, pa)
result?.error?.let { showToast(true, it) }
showMediaAttachment()
}
} }
} }
} }

View File

@ -7,8 +7,12 @@ import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.TootApiCallback import jp.juggler.subwaytooter.api.TootApiCallback
import jp.juggler.subwaytooter.api.TootApiClient import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.TootAttachment
import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachmentJson import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachmentJson
import jp.juggler.subwaytooter.api.entity.TootPolls
import jp.juggler.subwaytooter.api.entity.TootPollsType
import jp.juggler.subwaytooter.api.entity.TootVisibility
import jp.juggler.subwaytooter.dialog.DlgDraftPicker import jp.juggler.subwaytooter.dialog.DlgDraftPicker
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.daoPostDraft import jp.juggler.subwaytooter.table.daoPostDraft
@ -19,6 +23,7 @@ import jp.juggler.util.coroutine.launchProgress
import jp.juggler.util.data.JsonException import jp.juggler.util.data.JsonException
import jp.juggler.util.data.JsonObject import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.decodeJsonObject import jp.juggler.util.data.decodeJsonObject
import jp.juggler.util.data.notEmpty
import jp.juggler.util.data.toJsonArray import jp.juggler.util.data.toJsonArray
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@ -329,7 +334,7 @@ fun ActPost.restoreDraft(draft: JsonObject) {
) )
} }
suspend fun ActPost.initializeFromRedraftStatus(account: SavedAccount, jsonText: String) { fun ActPost.initializeFromRedraftStatus(account: SavedAccount, jsonText: String) {
try { try {
val baseStatus = val baseStatus =
TootParser(this, account).status(jsonText.decodeJsonObject()) TootParser(this, account).status(jsonText.decodeJsonObject())
@ -424,7 +429,7 @@ suspend fun ActPost.initializeFromRedraftStatus(account: SavedAccount, jsonText:
} }
} }
suspend fun ActPost.initializeFromEditStatus(account: SavedAccount, jsonText: String) { fun ActPost.initializeFromEditStatus(account: SavedAccount, jsonText: String) {
try { try {
val baseStatus = val baseStatus =
TootParser(this, account).status(jsonText.decodeJsonObject()) TootParser(this, account).status(jsonText.decodeJsonObject())
@ -434,23 +439,23 @@ suspend fun ActPost.initializeFromEditStatus(account: SavedAccount, jsonText: St
states.visibility = baseStatus.visibility states.visibility = baseStatus.visibility
val srcAttachments = baseStatus.media_attachments baseStatus.media_attachments
if (srcAttachments?.isNotEmpty() == true) { ?.mapNotNull { it as? TootAttachment }
saveAttachmentList() ?.notEmpty()
this.attachmentList.clear() ?.let { srcAttachments ->
try { saveAttachmentList()
this.attachmentList.clear()
for (src in srcAttachments) { for (src in srcAttachments) {
if (src is TootAttachment) { try {
src.redraft = true src.isEdit = true
val pa = PostAttachment(src) val pa = PostAttachment(src)
pa.status = PostAttachment.Status.Ok pa.status = PostAttachment.Status.Ok
this.attachmentList.add(pa) this.attachmentList.add(pa)
} catch (ex: Throwable) {
log.e(ex, "can't initialize attachments from edit status")
} }
} }
} catch (ex: Throwable) {
log.e(ex, "can't initialize attachments from edit status")
} }
}
views.cbNSFW.isChecked = baseStatus.sensitive == true views.cbNSFW.isChecked = baseStatus.sensitive == true

View File

@ -1,15 +1,19 @@
package jp.juggler.subwaytooter.api package jp.juggler.subwaytooter.api
import android.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.action.isAndroid7TlsBug import jp.juggler.subwaytooter.action.isAndroid7TlsBug
import jp.juggler.subwaytooter.util.DecodeOptions import jp.juggler.subwaytooter.util.DecodeOptions
import jp.juggler.subwaytooter.util.ProgressResponseBody import jp.juggler.subwaytooter.util.ProgressResponseBody
import jp.juggler.util.coroutine.AppDispatchers import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.data.* import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.decodeJsonArray
import jp.juggler.util.data.decodeJsonObject
import jp.juggler.util.data.jsonObjectOf
import jp.juggler.util.data.notBlank
import jp.juggler.util.data.notEmpty
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast import jp.juggler.util.log.dialogOrToast
import jp.juggler.util.log.withCaption import jp.juggler.util.log.withCaption
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -205,6 +209,7 @@ class ResponseBeforeRead(
message = parseErrorResponse(body = errorBody), message = parseErrorResponse(body = errorBody),
) )
} }
callback != null -> callback != null ->
ProgressResponseBody.bytes(response, callback) ProgressResponseBody.bytes(response, callback)
@ -325,18 +330,6 @@ suspend fun ResponseWith<String?>.stringToJsonObject(): JsonObject =
} }
} }
fun AppCompatActivity.dialogOrToast(message: String?) {
if (message.isNullOrBlank()) return
try {
AlertDialog.Builder(this)
.setMessage(message)
.setPositiveButton(R.string.close, null)
.show()
} catch (_: Throwable) {
showToast(true, message)
}
}
fun AppCompatActivity.showApiError(ex: Throwable) { fun AppCompatActivity.showApiError(ex: Throwable) {
try { try {
log.e(ex, "showApiError") log.e(ex, "showApiError")
@ -352,6 +345,7 @@ fun AppCompatActivity.showApiError(ex: Throwable) {
null -> dialogOrToast(ex.message ?: "(??)") null -> dialogOrToast(ex.message ?: "(??)")
else -> dialogOrToast(ex.withCaption()) else -> dialogOrToast(ex.withCaption())
} }
else -> dialogOrToast(ex.withCaption()) else -> dialogOrToast(ex.withCaption())
} }
} catch (ignored: Throwable) { } catch (ignored: Throwable) {

View File

@ -44,7 +44,10 @@ class TootAttachment private constructor(
) : TootAttachmentLike { ) : TootAttachmentLike {
// 内部フラグ: 再編集で引き継いだ添付メディアなら真 // 内部フラグ: 再編集で引き継いだ添付メディアなら真
var redraft: Boolean = false var redraft = false
// 内部フラグ:編集時に既存投稿から引き継いだ添付データなら真
var isEdit = false
// 内部フラグ:編集投稿時にメディア属性を更新するなら、その値を指定する // 内部フラグ:編集投稿時にメディア属性を更新するなら、その値を指定する
var updateDescription: String? = null var updateDescription: String? = null
@ -84,6 +87,7 @@ class TootAttachment private constructor(
private const val KEY_UPDATE_DESCRIPTION = "updateDescription" private const val KEY_UPDATE_DESCRIPTION = "updateDescription"
private const val KEY_UPDATE_THUMBNAIL = "updateThumbnail" private const val KEY_UPDATE_THUMBNAIL = "updateThumbnail"
private const val KEY_UPDATE_FOCUS = "updateFocus" private const val KEY_UPDATE_FOCUS = "updateFocus"
private const val KEY_IS_EDIT = "isEdit"
private val ext_audio = arrayOf(".mpga", ".mp3", ".aac", ".ogg") private val ext_audio = arrayOf(".mpga", ".mp3", ".aac", ".ogg")
@ -130,6 +134,7 @@ class TootAttachment private constructor(
updateDescription = src.string(KEY_UPDATE_DESCRIPTION) updateDescription = src.string(KEY_UPDATE_DESCRIPTION)
updateThumbnail = src.string(KEY_UPDATE_THUMBNAIL) updateThumbnail = src.string(KEY_UPDATE_THUMBNAIL)
updateFocus = src.string(KEY_UPDATE_FOCUS) updateFocus = src.string(KEY_UPDATE_FOCUS)
isEdit = src.boolean(KEY_IS_EDIT) ?: false
} }
} }
@ -161,6 +166,7 @@ class TootAttachment private constructor(
updateDescription = src.string(KEY_UPDATE_DESCRIPTION) updateDescription = src.string(KEY_UPDATE_DESCRIPTION)
updateThumbnail = src.string(KEY_UPDATE_THUMBNAIL) updateThumbnail = src.string(KEY_UPDATE_THUMBNAIL)
updateFocus = src.string(KEY_UPDATE_FOCUS) updateFocus = src.string(KEY_UPDATE_FOCUS)
isEdit = src.boolean(KEY_IS_EDIT) ?: false
} }
} }
@ -288,6 +294,7 @@ class TootAttachment private constructor(
put(KEY_UPDATE_DESCRIPTION, updateDescription) put(KEY_UPDATE_DESCRIPTION, updateDescription)
put(KEY_UPDATE_THUMBNAIL, updateThumbnail) put(KEY_UPDATE_THUMBNAIL, updateThumbnail)
put(KEY_UPDATE_FOCUS, updateFocus) put(KEY_UPDATE_FOCUS, updateFocus)
put(KEY_IS_EDIT, isEdit)
if (focusX != 0f || focusY != 0f) { if (focusX != 0f || focusY != 0f) {
put(KEY_META, buildJsonObject { put(KEY_META, buildJsonObject {

View File

@ -217,11 +217,11 @@ class AttachmentPicker(
// Mastodon's custom thumbnail // Mastodon's custom thumbnail
fun openCustomThumbnail(pa: PostAttachment) { fun openCustomThumbnail(pa: PostAttachment) {
states.customThumbnailTargetId = pa.attachment?.id?.toString()
if (!prPickCustomThumbnail.checkOrLaunch()) return
// SAFのIntentで開く
try { try {
states.customThumbnailTargetId = pa.attachment?.id?.toString()
?: return
if (!prPickCustomThumbnail.checkOrLaunch()) return
// SAFのIntentで開く
arCustomThumbnail.launch( arCustomThumbnail.launch(
intentGetContent( intentGetContent(
false, false,

View File

@ -0,0 +1,250 @@
package jp.juggler.subwaytooter.util
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.InstanceType
import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.getStreamSize
import jp.juggler.util.log.LogCategory
import jp.juggler.util.media.ResizeConfig
import jp.juggler.util.media.VideoInfo.Companion.videoInfo
import jp.juggler.util.media.createResizedBitmap
import jp.juggler.util.media.transcodeVideo
import java.io.File
import java.io.FileOutputStream
class AttachmentRequest(
val context: Context,
val account: SavedAccount,
val pa: PostAttachment,
val uri: Uri,
var mimeType: String,
val imageResizeConfig: ResizeConfig,
val serverMaxSqPixel: Int?,
val instance: TootInstance,
val mediaConfig: JsonObject?,
val maxBytesVideo: Int,
val maxBytesImage: Int,
) {
companion object {
private val log = LogCategory("AttachmentRequest")
}
fun hasServerSupport(mimeType: String) =
mediaConfig?.jsonArray("supported_mime_types")?.contains(mimeType)
?: when (instance.instanceType) {
InstanceType.Pixelfed -> AttachmentUploader.acceptableMimeTypesPixelfed
else -> AttachmentUploader.acceptableMimeTypes
}.contains(mimeType)
suspend fun createOpener(): InputStreamOpener {
// GIFはそのまま投げる
if (mimeType == AttachmentUploader.MIME_TYPE_GIF) {
return contentUriOpener(context.contentResolver, uri, mimeType, isImage = true)
}
if (mimeType.startsWith("image")) {
// 静止画(失敗したらオリジナルデータにフォールバックする)
if (mimeType == AttachmentUploader.MIME_TYPE_JPEG ||
mimeType == AttachmentUploader.MIME_TYPE_PNG
) try {
// 回転対応が必要かもしれない
return createResizedImageOpener()
} catch (ex: Throwable) {
log.w(ex, "createResizedImageOpener failed. fall back to original image.")
}
// 静止画(変換必須)
// 例外を投げるかもしれない
return createResizedImageOpener(forcePng = true)
}
// 音声と動画のファイル区分は曖昧なので
// 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()
} 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 fun createResizedImageOpener(
forcePng: Boolean = false,
): InputStreamOpener {
val cacheDir = context.externalCacheDir
?.apply { mkdirs() }
?: error("getExternalCacheDir returns null.")
val outputMimeType = if (forcePng || mimeType == AttachmentUploader.MIME_TYPE_PNG) {
AttachmentUploader.MIME_TYPE_PNG
} else {
AttachmentUploader.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.")
pa.progress = context.getString(R.string.attachment_handling_compress)
try {
FileOutputStream(tempFile).use { os ->
if (outputMimeType == AttachmentUploader.MIME_TYPE_PNG) {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, os)
} else {
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, os)
}
}
return tempFileOpener(tempFile, outputMimeType, isImage = true)
} finally {
bitmap.recycle()
}
}
private suspend fun createResizedVideoOpener(): 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
}
// アカウント別の動画トランスコード設定
// ビットレート、フレームレート、平方ピクセル数をサーバからの情報によりさらに制限する
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()
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,
)
} finally {
if (outFile != resultFile) outFile.delete()
if (tempFile != resultFile) tempFile.delete()
}
}
private val aacMimeTypes = listOf(
"audio/aac", "audio/aacp", "audio/3gpp", "audio/3gpp2",
"audio/mp4", "audio/MP4A-LATM", "audio/mpeg4-generic"
).map { it.lowercase() }.toSet()
private fun createResizedAudioOpener(
originalBytes: Long,
): InputStreamOpener {
if (hasServerSupport("audio/aac") && aacMimeTypes.contains(mimeType.lowercase())) {
mimeType = "audio/aac"
}
// サーバ側がサポートしてる形式でサイズ以内なら
if (hasServerSupport(mimeType) && originalBytes <= maxBytesVideo) {
return contentUriOpener(
context.contentResolver,
uri,
mimeType,
isImage = false,
)
}
error("audio conversion is not yet supported.")
}
}

View File

@ -1,12 +1,11 @@
package jp.juggler.subwaytooter.util package jp.juggler.subwaytooter.util
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.SystemClock import android.os.SystemClock
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.TootApiCallback import jp.juggler.subwaytooter.api.TootApiCallback
import jp.juggler.subwaytooter.api.TootApiClient import jp.juggler.subwaytooter.api.TootApiClient
@ -23,9 +22,6 @@ import jp.juggler.util.data.*
import jp.juggler.util.log.* import jp.juggler.util.log.*
import jp.juggler.util.media.ResizeConfig import jp.juggler.util.media.ResizeConfig
import jp.juggler.util.media.ResizeType import jp.juggler.util.media.ResizeType
import jp.juggler.util.media.VideoInfo.Companion.videoInfo
import jp.juggler.util.media.createResizedBitmap
import jp.juggler.util.media.transcodeVideo
import jp.juggler.util.network.toPost import jp.juggler.util.network.toPost
import jp.juggler.util.network.toPostRequestBuilder import jp.juggler.util.network.toPostRequestBuilder
import jp.juggler.util.network.toPut import jp.juggler.util.network.toPut
@ -36,25 +32,13 @@ import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody
import okio.BufferedSink
import java.io.* import java.io.*
import java.util.concurrent.CancellationException import java.util.concurrent.CancellationException
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
import kotlin.math.min
class AttachmentRequest(
val account: SavedAccount,
val pa: PostAttachment,
val uri: Uri,
val mimeType: String,
val isReply: Boolean,
)
class AttachmentUploader( class AttachmentUploader(
contextArg: Context, activity: AppCompatActivity,
private val handler: Handler, private val handler: Handler,
) { ) {
companion object { companion object {
@ -199,7 +183,7 @@ class AttachmentUploader(
} }
} }
private val context = contextArg.applicationContext!! private val safeContext = activity.applicationContext!!
private var lastAttachmentAdd = 0L private var lastAttachmentAdd = 0L
private var lastAttachmentComplete = 0L private var lastAttachmentComplete = 0L
private var channel: Channel<AttachmentRequest>? = null private var channel: Channel<AttachmentRequest>? = null
@ -220,13 +204,13 @@ class AttachmentUploader(
when (ex) { when (ex) {
is CancellationException, is ClosedReceiveChannelException -> break is CancellationException, is ClosedReceiveChannelException -> break
else -> { else -> {
context.showToast(ex) safeContext.showToast(ex)
continue continue
} }
} }
} }
val result = try { val result = try {
if (request.pa.isCancelled) continue if (request.pa.isCancelled == true) continue
withContext(request.pa.job + AppDispatchers.IO) { withContext(request.pa.job + AppDispatchers.IO) {
request.upload() request.upload()
} }
@ -242,7 +226,7 @@ class AttachmentUploader(
when (ex) { when (ex) {
is CancellationException, is ClosedReceiveChannelException -> break is CancellationException, is ClosedReceiveChannelException -> break
else -> { else -> {
context.showToast(ex) safeContext.showToast(ex)
continue continue
} }
} }
@ -266,12 +250,12 @@ class AttachmentUploader(
fun addRequest(request: AttachmentRequest) { fun addRequest(request: AttachmentRequest) {
request.pa.progress = context.getString(R.string.attachment_handling_start) request.pa.progress = safeContext.getString(R.string.attachment_handling_start)
// アップロード開始トースト(連発しない) // アップロード開始トースト(連発しない)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (now - lastAttachmentAdd >= 5000L) { if (now - lastAttachmentAdd >= 5000L) {
context.showToast(false, R.string.attachment_uploading) safeContext.showToast(false, R.string.attachment_uploading)
} }
lastAttachmentAdd = now lastAttachmentAdd = now
@ -286,7 +270,7 @@ class AttachmentUploader(
try { try {
if (mimeType.isEmpty()) return TootApiResult("mime_type is empty.") if (mimeType.isEmpty()) return TootApiResult("mime_type is empty.")
val client = TootApiClient(context, callback = object : TootApiCallback { val client = TootApiClient(safeContext, callback = object : TootApiCallback {
override suspend fun isApiCancelled() = !coroutineContext.isActive override suspend fun isApiCancelled() = !coroutineContext.isActive
}) })
@ -296,78 +280,41 @@ class AttachmentUploader(
val (ti, tiResult) = TootInstance.get(client) val (ti, tiResult) = TootInstance.get(client)
ti ?: return tiResult 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
)
)
}
}
val mediaConfig = ti.configuration?.jsonObject("media_attachments")
val serverMaxSqPixel = mediaConfig?.int("image_matrix_limit")?.takeIf { it > 0 }
val imageResizeConfig = account.getResizeConfig()
// 入力データの変換など // 入力データの変換など
val opener = createOpener( val opener = this.createOpener()
account, val maxBytes = when (opener.isImage) {
uri, true -> maxBytesImage
mimeType, else -> maxBytesVideo
mediaConfig = mediaConfig,
imageResizeConfig = imageResizeConfig,
postAttachment = pa,
serverMaxSqPixel = serverMaxSqPixel,
)
val mediaSizeMax = when {
mimeType.startsWith("video") || mimeType.startsWith("audio") -> min(
account.getMovieMaxBytes(ti),
mediaConfig?.int("video_size_limit")
?.takeIf { it > 0 } ?: Int.MAX_VALUE,
)
else -> min(
account.getImageMaxBytes(ti),
mediaConfig?.int("image_size_limit")
?.takeIf { it > 0 } ?: Int.MAX_VALUE,
)
} }
if (opener.contentLength > maxBytes) {
if (opener.contentLength > mediaSizeMax) {
return TootApiResult( return TootApiResult(
context.getString(R.string.file_size_too_big, mediaSizeMax / 1000000) safeContext.getString(R.string.file_size_too_big, maxBytes / 1000000)
) )
} }
fun fixDocumentName(s: String): String { val isAccepteble = instance.configuration
val sLength = s.length ?.jsonObject("media_attachments")
val m = """([^\x20-\x7f])""".asciiPattern().matcher(s) ?.jsonArray("supported_mime_types")
m.reset() ?.contains(mimeType)
val sb = StringBuilder(sLength) ?: when (instance.instanceType) {
var lastEnd = 0 InstanceType.Pixelfed -> acceptableMimeTypesPixelfed
while (m.find()) { else -> acceptableMimeTypes
sb.append(s.substring(lastEnd, m.start())) }.contains(mimeType)
val escaped = m.groupEx(1)!!.encodeUTF8().encodeHex()
sb.append(escaped) if (!isAccepteble) {
lastEnd = m.end() return TootApiResult(
} safeContext.getString(R.string.mime_type_not_acceptable, mimeType)
if (lastEnd < sLength) sb.append(s.substring(lastEnd, sLength)) )
return sb.toString()
} }
val fileName = fixDocumentName(getDocumentName(context.contentResolver, uri)) val fileName = fixDocumentName(getDocumentName(safeContext.contentResolver, uri))
pa.progress = context.getString(R.string.attachment_handling_uploading, 0) pa.progress = safeContext.getString(R.string.attachment_handling_uploading, 0)
fun writeProgress(percent: Int) { fun writeProgress(percent: Int) {
if (percent < 100) { if (percent < 100) {
pa.progress = pa.progress =
context.getString(R.string.attachment_handling_uploading, percent) safeContext.getString(R.string.attachment_handling_uploading, percent)
} else { } else {
pa.progress = context.getString(R.string.attachment_handling_waiting) pa.progress = safeContext.getString(R.string.attachment_handling_waiting)
} }
} }
@ -434,7 +381,7 @@ class AttachmentUploader(
} }
// ポーリングして処理完了を待つ // ポーリングして処理完了を待つ
pa.progress = context.getString(R.string.attachment_handling_waiting_async) pa.progress = safeContext.getString(R.string.attachment_handling_waiting_async)
val id = parseItem(result?.jsonObject) { val id = parseItem(result?.jsonObject) {
tootAttachment(ServiceType.MASTODON, it) tootAttachment(ServiceType.MASTODON, it)
}?.id }?.id
@ -483,6 +430,22 @@ class AttachmentUploader(
} }
} }
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()
}
private fun handleResult(request: AttachmentRequest, result: TootApiResult?) { private fun handleResult(request: AttachmentRequest, result: TootApiResult?) {
val pa = request.pa val pa = request.pa
pa.status = when (pa.attachment) { pa.status = when (pa.attachment) {
@ -491,7 +454,7 @@ class AttachmentUploader(
when { when {
// キャンセルはトーストを出さない // キャンセルはトーストを出さない
result.error?.contains("cancel", ignoreCase = true) == true -> Unit result.error?.contains("cancel", ignoreCase = true) == true -> Unit
else -> context.showToast( else -> safeContext.showToast(
true, true,
"${result.error} ${result.response?.request?.method} ${result.response?.request?.url}" "${result.error} ${result.response?.request?.method} ${result.response?.request?.url}"
) )
@ -499,10 +462,11 @@ class AttachmentUploader(
} }
PostAttachment.Status.Error PostAttachment.Status.Error
} }
else -> { else -> {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (now - lastAttachmentComplete >= 5000L) { if (now - lastAttachmentComplete >= 5000L) {
context.showToast(false, R.string.attachment_uploaded) safeContext.showToast(false, R.string.attachment_uploaded)
} }
lastAttachmentComplete = now lastAttachmentComplete = now
@ -514,240 +478,11 @@ class AttachmentUploader(
pa.callback?.onPostAttachmentComplete(pa) pa.callback?.onPostAttachmentComplete(pa)
} }
// contentLengthの測定などで複数回オープンする必要がある
private abstract class InputStreamOpener {
abstract val mimeType: String
@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
@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)
}
}
}
}
}
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,
uri: Uri,
mimeType: String,
imageResizeConfig: ResizeConfig,
serverMaxSqPixel: Int? = null,
mediaConfig: JsonObject? = null,
postAttachment: PostAttachment? = null,
): InputStreamOpener {
when {
// GIFはそのまま投げる
mimeType == MIME_TYPE_GIF -> {
return contentUriOpener(context.contentResolver, uri, mimeType)
}
// 静止画(失敗したらオリジナルデータにフォールバックする)
mimeType == MIME_TYPE_JPEG || mimeType == MIME_TYPE_PNG -> try {
return createResizedImageOpener(
uri,
mimeType,
imageResizeConfig,
postAttachment = postAttachment,
serverMaxSqPixel = serverMaxSqPixel,
)
} catch (ex: Throwable) {
log.w(ex, "createResizedImageOpener failed. fall back to original image.")
}
// 静止画(変換必須)
// 例外を投げるかもしれない
mimeType.startsWith("image/") ->
return createResizedImageOpener(
uri,
mimeType,
imageResizeConfig,
postAttachment = postAttachment,
forcePng = true,
serverMaxSqPixel = serverMaxSqPixel,
)
// 動画のトランスコード(失敗したらオリジナルデータにフォールバックする)
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.")
}
}
return contentUriOpener(context.contentResolver, uri, mimeType)
}
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
}
// アカウント別の動画トランスコード設定
// ビットレート、フレームレート、平方ピクセル数をサーバからの情報によりさらに制限する
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()
}
}
fun getMimeType(uri: Uri, mimeTypeArg: String?): String? { fun getMimeType(uri: Uri, mimeTypeArg: String?): String? {
// image/j()pg だの image/j(e)pg だの、mime type を誤記するアプリがあまりに多い // image/j()pg だの image/j(e)pg だの、mime type を誤記するアプリがあまりに多い
// クレームで消耗するのを減らすためにファイルヘッダを確認する // クレームで消耗するのを減らすためにファイルヘッダを確認する
if (mimeTypeArg == null || mimeTypeArg.startsWith("image/")) { if (mimeTypeArg == null || mimeTypeArg.startsWith("image/")) {
val sv = findMimeTypeByFileHeader(context.contentResolver, uri) val sv = findMimeTypeByFileHeader(safeContext.contentResolver, uri)
if (sv != null) return sv if (sv != null) return sv
} }
@ -757,7 +492,7 @@ class AttachmentUploader(
} }
// ContentResolverに尋ねる // ContentResolverに尋ねる
var sv = context.contentResolver.getType(uri) var sv = safeContext.contentResolver.getType(uri)
if (sv?.isNotEmpty() == true) return sv if (sv?.isNotEmpty() == true) return sv
// gboardのステッカーではUriのクエリパラメータにmimeType引数がある // gboardのステッカーではUriのクエリパラメータにmimeType引数がある
@ -826,6 +561,7 @@ class AttachmentUploader(
else -> { else -> {
} }
} }
1 -> when (mpegVersionId) { 1 -> when (mpegVersionId) {
0, 2, 3 -> return "audio/mp3" 0, 2, 3 -> return "audio/mp3"
@ -842,52 +578,42 @@ class AttachmentUploader(
} }
/////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////
// 添付データのカスタムサムネイル // 添付データのカスタムサムネイル
suspend fun uploadCustomThumbnail( suspend fun uploadCustomThumbnail(
account: SavedAccount, account: SavedAccount,
src: GetContentResultEntry, src: GetContentResultEntry,
pa: PostAttachment, pa: PostAttachment,
): TootApiResult? = try { ): TootApiResult? = try {
context.runApiTask(account) { client -> safeContext.runApiTask(account) { client ->
val mimeType = getMimeType(src.uri, src.mimeType) val mimeType = getMimeType(src.uri, src.mimeType)?.notEmpty()
if (mimeType?.isEmpty() != false) { if (mimeType.isNullOrEmpty()) {
return@runApiTask TootApiResult(context.getString(R.string.mime_type_missing)) return@runApiTask TootApiResult(safeContext.getString(R.string.mime_type_missing))
} }
val (ti, ri) = TootInstance.get(client) val (ti, ri) = TootInstance.get(client)
ti ?: return@runApiTask ri ti ?: return@runApiTask ri
val mediaConfig = ti.configuration?.jsonObject("media_attachments")
val opener = createOpener( val ar = AttachmentRequest(
account, context = applicationContext,
src.uri, account = account,
mimeType, pa = pa,
uri = src.uri,
mimeType = mimeType,
instance = ti,
mediaConfig = mediaConfig,
imageResizeConfig = ResizeConfig(ResizeType.SquarePixel, 400), imageResizeConfig = ResizeConfig(ResizeType.SquarePixel, 400),
serverMaxSqPixel = mediaConfig?.int("image_matrix_limit")?.takeIf { it > 0 },
maxBytesImage = 1000000,
maxBytesVideo = 1000000,
) )
val opener = ar.createOpener()
val mediaSizeMax = 1000000 if (opener.contentLength > ar.maxBytesImage) {
if (opener.contentLength > mediaSizeMax) {
return@runApiTask TootApiResult( return@runApiTask TootApiResult(
getString(R.string.file_size_too_big, mediaSizeMax / 1000000) getString(R.string.file_size_too_big, ar.maxBytesImage / 1000000)
) )
} }
fun fixDocumentName(s: String): String { val fileName = fixDocumentName(getDocumentName(safeContext.contentResolver, src.uri))
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) { if (account.isMisskey) {
opener.deleteTempFile() opener.deleteTempFile()
@ -929,7 +655,7 @@ class AttachmentUploader(
): Pair<TootApiResult?, TootAttachment?> { ): Pair<TootApiResult?, TootAttachment?> {
var resultAttachment: TootAttachment? = null var resultAttachment: TootAttachment? = null
val result = try { val result = try {
context.runApiTask(account) { client -> safeContext.runApiTask(account) { client ->
if (account.isMisskey) { if (account.isMisskey) {
client.request( client.request(
"/api/drive/files/update", "/api/drive/files/update",
@ -961,28 +687,4 @@ class AttachmentUploader(
} }
return Pair(result, resultAttachment) return Pair(result, resultAttachment)
} }
fun isAcceptableMimeType(
instance: TootInstance?,
mimeType: String,
isReply: Boolean,
): Boolean {
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
}
} }

View File

@ -0,0 +1,86 @@
package jp.juggler.subwaytooter.util
import android.content.ContentResolver
import android.net.Uri
import jp.juggler.util.data.getStreamSize
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody
import okio.BufferedSink
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.io.InputStream
// contentLengthの測定などで複数回オープンする必要がある
abstract class InputStreamOpener {
abstract val mimeType: String
abstract val isImage: Boolean
@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
@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)
}
}
}
}
}
// contentResolver.openInputStream を使うOpener
fun contentUriOpener(
contentResolver: ContentResolver,
uri: Uri,
mimeType: String,
isImage: Boolean,
) = object : InputStreamOpener() {
override val mimeType = mimeType
override val isImage = isImage
@Throws(IOException::class)
override fun open(): InputStream {
return contentResolver.openInputStream(uri)
?: error("openInputStream returns null")
}
override fun deleteTempFile() = Unit
}
// 一時ファイルを使うOpener
fun tempFileOpener(
file: File,
mimeType: String,
isImage: Boolean,
) = object : InputStreamOpener() {
override val mimeType = mimeType
override val isImage = isImage
@Throws(IOException::class)
override fun open() = FileInputStream(file)
override fun deleteTempFile() {
file.delete()
}
}

View File

@ -523,8 +523,7 @@
<string name="domain_count">Nombre de dominis connectats</string> <string name="domain_count">Nombre de dominis connectats</string>
<string name="not_provided_mastodon_under_1_6">No existia abans de Mastodont 1.6</string> <string name="not_provided_mastodon_under_1_6">No existia abans de Mastodont 1.6</string>
<string name="mime_type_missing">Falta mèdia tipus MIME.</string> <string name="mime_type_missing">Falta mèdia tipus MIME.</string>
<string name="mime_type_not_acceptable">Aquest tipus de mèdia MIME %1$s no s\'accepta. <string name="mime_type_not_acceptable">Aquest tipus de mèdia MIME %1$s no s\'accepta.</string>
\nEls tipus MIME processables són imatge/jpeg, imatge/png, imatge/gif, vídeo/webm and vídeo/mp4.</string>
<string name="dont_show_timeout">No mostris la notificació \"S\'ha superat el temps d\'espera del servidor\"</string> <string name="dont_show_timeout">No mostris la notificació \"S\'ha superat el temps d\'espera del servidor\"</string>
<string name="emoji_category_people">Gent</string> <string name="emoji_category_people">Gent</string>
<string name="emoji_category_nature">Natura</string> <string name="emoji_category_nature">Natura</string>

View File

@ -906,8 +906,7 @@
<string name="keyword_filters">Schlüsselwort-Filter</string> <string name="keyword_filters">Schlüsselwort-Filter</string>
<string name="close_all_columns">Alle Spalten schließen</string> <string name="close_all_columns">Alle Spalten schließen</string>
<string name="post_button_tapped_repeatly">Der Post-Button wurde mehrfach gedrückt</string> <string name="post_button_tapped_repeatly">Der Post-Button wurde mehrfach gedrückt</string>
<string name="mime_type_not_acceptable">Der Media-MIME-Typ %1$s wird nicht unterstützt. <string name="mime_type_not_acceptable">Der Media-MIME-Typ %1$s wird nicht unterstützt.</string>
\nUnterstützte Formate sind Bild/jpeg, Bild/png, Bild/gif, Video/webm und Video/mp4.</string>
<string name="mime_type_missing">Media-MIME-Typ fehlt.</string> <string name="mime_type_missing">Media-MIME-Typ fehlt.</string>
<string name="not_provided_mastodon_under_1_6">Steht bei vor Mastodon-Version 1.6 nicht zur Verfügung</string> <string name="not_provided_mastodon_under_1_6">Steht bei vor Mastodon-Version 1.6 nicht zur Verfügung</string>
<string name="toot_count">Toots</string> <string name="toot_count">Toots</string>

View File

@ -490,8 +490,7 @@
<string name="domain_count">Nombre de domaines connectés</string> <string name="domain_count">Nombre de domaines connectés</string>
<string name="not_provided_mastodon_under_1_6">Non fourni sous Mastodon avant la 1.6</string> <string name="not_provided_mastodon_under_1_6">Non fourni sous Mastodon avant la 1.6</string>
<string name="mime_type_missing">Type MIME média manquant.</string> <string name="mime_type_missing">Type MIME média manquant.</string>
<string name="mime_type_not_acceptable">Type MIME du média %1$s nest pas acceptable. <string name="mime_type_not_acceptable">Type MIME du média %1$s nest pas acceptable.</string>
\nTypes MIME pris en charge sont les suivants : image/jpeg, image/png, image/gif, video/webm et video/mp4.</string>
<string name="dont_show_timeout">Ne pas afficher la notification \"Délai dexpiration du serveur\"</string> <string name="dont_show_timeout">Ne pas afficher la notification \"Délai dexpiration du serveur\"</string>
<string name="emoji_category_people">People</string> <string name="emoji_category_people">People</string>
<string name="emoji_category_nature">Nature</string> <string name="emoji_category_nature">Nature</string>

View File

@ -448,8 +448,7 @@
<string name="mention2">返信</string> <string name="mention2">返信</string>
<string name="menu">メニュー</string> <string name="menu">メニュー</string>
<string name="mime_type_missing">MIMEタイプが不明です。</string> <string name="mime_type_missing">MIMEタイプが不明です。</string>
<string name="mime_type_not_acceptable">MIMEタイプ %1$s には対応できません。 <string name="mime_type_not_acceptable">MIMEタイプ %1$s には対応できません。</string>
\n対応できるMIMEタイプは image/jpeg, image/png, image/gif, video/webm, video/mp4 です。</string>
<string name="minimum_column_width">カラム最小幅(デフォルト=300(dp)、アプリ再起動が必要)</string> <string name="minimum_column_width">カラム最小幅(デフォルト=300(dp)、アプリ再起動が必要)</string>
<string name="missing_available_account">この目的に使えるアカウントがありません。</string> <string name="missing_available_account">この目的に使えるアカウントがありません。</string>
<string name="missing_fcm_device_id">FCMのデバイスIDを準備できません。しばらく後に試してください。</string> <string name="missing_fcm_device_id">FCMのデバイスIDを準備できません。しばらく後に試してください。</string>

View File

@ -501,8 +501,7 @@
<string name="domain_count">연결된 도메인 수</string> <string name="domain_count">연결된 도메인 수</string>
<string name="not_provided_mastodon_under_1_6">마스토돈 1.6 이전 버전 지원 안 함</string> <string name="not_provided_mastodon_under_1_6">마스토돈 1.6 이전 버전 지원 안 함</string>
<string name="mime_type_missing">MIME 유형 없음.</string> <string name="mime_type_missing">MIME 유형 없음.</string>
<string name="mime_type_not_acceptable">MIME 유형 %1$s 을(를) 불러올 수 없습니다. <string name="mime_type_not_acceptable">MIME 유형 %1$s 을(를) 불러올 수 없습니다.</string>
\n지원되는 MIME 유형은 image/jpeg, image/png, image/gif, video/webm, video/mp4입니다.</string>
<string name="dont_show_timeout">\"서버 시간제한 초과\" 알림 보이지 않기</string> <string name="dont_show_timeout">\"서버 시간제한 초과\" 알림 보이지 않기</string>
<string name="emoji_category_people">사람들</string> <string name="emoji_category_people">사람들</string>
<string name="emoji_category_nature">자연</string> <string name="emoji_category_nature">자연</string>

View File

@ -654,8 +654,7 @@
<string name="parsing_response">Tolker svar…</string> <string name="parsing_response">Tolker svar…</string>
<string name="cant_get_web_setting_visibility">Kan ikke hente synlighetsinnstilling i nettprogram.</string> <string name="cant_get_web_setting_visibility">Kan ikke hente synlighetsinnstilling i nettprogram.</string>
<string name="not_provided_mastodon_under_1_6">Tilbys ikke i Mastodon 1.6</string> <string name="not_provided_mastodon_under_1_6">Tilbys ikke i Mastodon 1.6</string>
<string name="mime_type_not_acceptable">Mediatypen %1$s godtas ikke. <string name="mime_type_not_acceptable">Mediatypen %1$s godtas ikke.</string>
\nStøttede mediatyper: image/jpeg, image/png, image/gif, video/webm og video/mp4.</string>
<string name="disable_custom_emoji_animation">Skru av egendefinert emoji-animasjon (programomstart og gjeninnlasting av kolonne kreves)</string> <string name="disable_custom_emoji_animation">Skru av egendefinert emoji-animasjon (programomstart og gjeninnlasting av kolonne kreves)</string>
<string name="list_member_add_remove">Legg til/fjern fra lister…</string> <string name="list_member_add_remove">Legg til/fjern fra lister…</string>
<string name="cant_add_list_follow_requesting">Kan ikke legge til bruker i liste: <string name="cant_add_list_follow_requesting">Kan ikke legge til bruker i liste:

View File

@ -965,8 +965,7 @@
<string name="emoji_category_activity">活动</string> <string name="emoji_category_activity">活动</string>
<string name="emoji_category_nature">自然</string> <string name="emoji_category_nature">自然</string>
<string name="dont_show_timeout">不显示“实例超时”通知</string> <string name="dont_show_timeout">不显示“实例超时”通知</string>
<string name="mime_type_not_acceptable">%1$s 的媒体 MIME 类型未被接受。 <string name="mime_type_not_acceptable">%1$s 的媒体 MIME 类型未被接受。</string>
\n支持的 MIME 类型为 image/jpeg、image/png、image/gif、video/webm和video/mp4。</string>
<string name="mime_type_missing">缺少媒体 MIME 类型。</string> <string name="mime_type_missing">缺少媒体 MIME 类型。</string>
<string name="not_provided_mastodon_under_1_6">未提供于 Mastodon 1.6之前版本</string> <string name="not_provided_mastodon_under_1_6">未提供于 Mastodon 1.6之前版本</string>
<string name="domain_count">已连接实例数</string> <string name="domain_count">已连接实例数</string>

View File

@ -520,8 +520,7 @@
<string name="domain_count">Connected domain count</string> <string name="domain_count">Connected domain count</string>
<string name="not_provided_mastodon_under_1_6">Not provided pre Mastodon 1.6</string> <string name="not_provided_mastodon_under_1_6">Not provided pre Mastodon 1.6</string>
<string name="mime_type_missing">Missing media MIME type.</string> <string name="mime_type_missing">Missing media MIME type.</string>
<string name="mime_type_not_acceptable">The media MIME type %1$s is not accepted. <string name="mime_type_not_acceptable">The media MIME type %1$s is not accepted.</string>
\nSupported MIME types are image/jpeg, image/png, image/gif, video/webm and video/mp4.</string>
<string name="dont_show_timeout">Don\'t show \"Server timeout\" notification</string> <string name="dont_show_timeout">Don\'t show \"Server timeout\" notification</string>
<string name="emoji_category_people">People</string> <string name="emoji_category_people">People</string>
<string name="emoji_category_nature">Nature</string> <string name="emoji_category_nature">Nature</string>

View File

@ -152,12 +152,27 @@ fun Context.showToast(bLong: Boolean, caption: String?): Boolean =
fun Context.showToast(ex: Throwable, caption: String? = null): Boolean = fun Context.showToast(ex: Throwable, caption: String? = null): Boolean =
showToastImpl(this, true, ex.withCaption(caption)) showToastImpl(this, true, ex.withCaption(caption))
fun Context.showToast(bLong: Boolean, stringId: Int, vararg args: Any): Boolean = fun Context.showToast(bLong: Boolean, @StringRes stringId: Int, vararg args: Any): Boolean =
showToastImpl(this, bLong, getString(stringId, *args)) showToastImpl(this, bLong, getString(stringId, *args))
fun Context.showToast(ex: Throwable, stringId: Int, vararg args: Any): Boolean = fun Context.showToast(ex: Throwable, @StringRes stringId: Int, vararg args: Any): Boolean =
showToastImpl(this, true, ex.withCaption(resources, stringId, *args)) showToastImpl(this, true, ex.withCaption(resources, stringId, *args))
fun Activity.dialogOrToast(message: String?) {
if (message.isNullOrBlank()) return
try {
android.app.AlertDialog.Builder(this)
.setMessage(message)
.setPositiveButton(android.R.string.ok, null)
.show()
} catch (_: Throwable) {
showToast(true, message)
}
}
fun Activity.dialogOrToast(@StringRes stringId: Int, vararg args: Any) =
dialogOrToast(getString(stringId, *args))
fun AppCompatActivity.showError(ex: Throwable, caption: String? = null) { fun AppCompatActivity.showError(ex: Throwable, caption: String? = null) {
log.e(ex, caption ?: "(showError)") log.e(ex, caption ?: "(showError)")
@ -179,8 +194,6 @@ fun AppCompatActivity.showError(ex: Throwable, caption: String? = null) {
.filter { !it.isNullOrBlank() } .filter { !it.isNullOrBlank() }
.joinToString("\n") .joinToString("\n")
) )
.setPositiveButton(android.R.string.ok, null)
.show()
} catch (ignored: Throwable) { } catch (ignored: Throwable) {
showToast(ex, caption) showToast(ex, caption)
} }

View File

@ -1,8 +1,10 @@
package jp.juggler.util.media package jp.juggler.util.media
import android.content.Context
import android.media.MediaCodecList import android.media.MediaCodecList
import android.media.MediaFormat import android.media.MediaFormat
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build import android.os.Build
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
import java.io.File import java.io.File
@ -14,17 +16,24 @@ import kotlin.math.min
*/ */
@Suppress("MemberVisibilityCanBePrivate") @Suppress("MemberVisibilityCanBePrivate")
class VideoInfo( class VideoInfo(
val file: File, // val file: File,
mmr: MediaMetadataRetriever, mmr: MediaMetadataRetriever,
val bytesLength: Long,
val uri: Uri,
) { ) {
companion object { companion object {
private val log = LogCategory("VideoInfo") private val log = LogCategory("VideoInfo")
val File.videoInfo: VideoInfo val File.videoInfo: VideoInfo
get() = MediaMetadataRetriever().use { mmr -> get() = MediaMetadataRetriever().use { mmr ->
mmr.setDataSource(canonicalPath) mmr.setDataSource(canonicalPath)
VideoInfo(this, mmr) VideoInfo(mmr, length(), Uri.fromFile(canonicalFile))
}
fun Uri.videoInfo(context: Context, length: Long): VideoInfo =
MediaMetadataRetriever().use { mmr ->
mmr.setDataSource(context, this)
VideoInfo(mmr, length, this)
} }
private fun MediaMetadataRetriever.string(key: Int) = private fun MediaMetadataRetriever.string(key: Int) =
@ -152,28 +161,31 @@ class VideoInfo(
null null
} }
val actualBps by lazy { val hasAudio = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO)
val fileSize = file.length() ?.let { it == "yes" }
val hasVideo = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO)
?.let { it == "yes" }
val actualBps by lazy {
// ファイルサイズを取得できないならエラー // ファイルサイズを取得できないならエラー
if (fileSize <= 0L) return@lazy null if (bytesLength <= 0L) return@lazy null
// 時間帳が短すぎるなら算出できない // 時間帳が短すぎるなら算出できない
if (duration == null || duration < 0.1f) return@lazy null if (duration == null || duration < 0.1f) return@lazy null
// bpsを計算 // bpsを計算
fileSize.toFloat().div(duration).times(8).toInt() bytesLength.toFloat().div(duration).times(8).toInt()
} }
/** /**
* 動画のファイルサイズが十分に小さいなら真 * 動画のファイルサイズが十分に小さいなら真
*/ */
fun isSmallEnough(limitBps: Int): Boolean { fun isSmallEnough(limitBps: Int): Boolean {
val fileSize = file.length()
// ファイルサイズを取得できないならエラー // ファイルサイズを取得できないならエラー
if (fileSize <= 0L) error("too small file. ${file.canonicalPath}") if (bytesLength <= 0L) error("too small file. $uri")
// ファイルサイズが500KB以内ならビットレートを気にしない // ファイルサイズが500KB以内ならビットレートを気にしない
if (fileSize < 500_000) return true if (bytesLength < 500_000) return true
// ファイルサイズからビットレートを計算できなかったなら再エンコード必要 // ファイルサイズからビットレートを計算できなかったなら再エンコード必要
val actualBps = this.actualBps ?: return false val actualBps = this.actualBps ?: return false
@ -184,5 +196,5 @@ class VideoInfo(
} }
override fun toString() = override fun toString() =
"rotation=$rotation, size=$size, frameRatio=$frameRatio, bitrate=${actualBps ?: bitrate}, audioSampleRate=$audioSampleRate, mimeType=$mimeType, file=${file.canonicalPath}" "rotation=$rotation, size=$size, frameRatio=$frameRatio, bitrate=${actualBps ?: bitrate}, audioSampleRate=$audioSampleRate, mimeType=$mimeType, uri=$uri"
} }