投稿の編集時に添付データを追加した場合の挙動を改善
This commit is contained in:
parent
0dd2bd3252
commit
332b4dc5a0
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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.")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 n’est pas acceptable.
|
<string name="mime_type_not_acceptable">Type MIME du média %1$s n’est 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 d’expiration du serveur\"</string>
|
<string name="dont_show_timeout">Ne pas afficher la notification \"Délai d’expiration 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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue