投稿の編集時に添付データを追加した場合の挙動を改善
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.data.*
|
||||
import jp.juggler.util.log.LogCategory
|
||||
import jp.juggler.util.log.dialogOrToast
|
||||
import jp.juggler.util.log.showToast
|
||||
import jp.juggler.util.log.withCaption
|
||||
import jp.juggler.util.media.imageOrientation
|
||||
|
|
|
@ -17,7 +17,35 @@ import android.widget.AdapterView
|
|||
import android.widget.ArrayAdapter
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
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.TootStatus
|
||||
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.MyClickableSpanHandler
|
||||
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.MyNetworkImageView
|
||||
import jp.juggler.util.backPressed
|
||||
|
@ -282,6 +314,7 @@ class ActPost : AppCompatActivity(),
|
|||
R.id.btnFeaturedTag -> completionHelper.openFeaturedTagList(
|
||||
featuredTagCache[account?.acct?.ascii ?: ""]?.list
|
||||
)
|
||||
|
||||
R.id.ibSchedule -> performSchedule()
|
||||
R.id.ibScheduleReset -> resetSchedule()
|
||||
}
|
||||
|
@ -294,6 +327,7 @@ class ActPost : AppCompatActivity(),
|
|||
views.btnPost.performClick()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,6 @@ import androidx.recyclerview.widget.DividerItemDecoration
|
|||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import jp.juggler.subwaytooter.api.dialogOrToast
|
||||
import jp.juggler.subwaytooter.databinding.ActPushMessageListBinding
|
||||
import jp.juggler.subwaytooter.databinding.LvPushMessageBinding
|
||||
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.notZero
|
||||
import jp.juggler.util.log.LogCategory
|
||||
import jp.juggler.util.log.dialogOrToast
|
||||
import jp.juggler.util.os.saveToDownload
|
||||
import jp.juggler.util.time.formatLocalTime
|
||||
import jp.juggler.util.ui.setNavigationBack
|
||||
|
@ -124,7 +124,7 @@ class ActPushMessageList : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
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.api.ApiTask
|
||||
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.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.pref.PrefB
|
||||
import jp.juggler.subwaytooter.util.AttachmentRequest
|
||||
import jp.juggler.subwaytooter.util.AttachmentUploader
|
||||
import jp.juggler.subwaytooter.util.PostAttachment
|
||||
import jp.juggler.subwaytooter.view.MyNetworkImageView
|
||||
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.notEmpty
|
||||
import jp.juggler.util.log.LogCategory
|
||||
import jp.juggler.util.log.dialogOrToast
|
||||
import jp.juggler.util.log.showToast
|
||||
import jp.juggler.util.log.withCaption
|
||||
import jp.juggler.util.network.toPutRequestBuilder
|
||||
import jp.juggler.util.ui.isLiveActivity
|
||||
import jp.juggler.util.ui.vg
|
||||
import kotlin.math.min
|
||||
|
||||
private val log = LogCategory("ActPostAttachment")
|
||||
|
||||
|
@ -124,31 +128,64 @@ fun ActPost.addAttachment(
|
|||
// onUploadEnd: () -> Unit = {},
|
||||
) {
|
||||
val account = this.account
|
||||
val mimeType = attachmentUploader.getMimeType(uri, mimeTypeArg)
|
||||
val mimeType = attachmentUploader.getMimeType(uri, mimeTypeArg)?.notEmpty()
|
||||
val isReply = states.inReplyToId != null
|
||||
val instance = account?.let { TootInstance.getCached(it) }
|
||||
|
||||
when {
|
||||
attachmentList.size >= 4 -> showToast(false, R.string.attachment_too_many)
|
||||
account == null -> showToast(false, R.string.account_select_please)
|
||||
mimeType?.isEmpty() != false -> showToast(false, R.string.mime_type_missing)
|
||||
!attachmentUploader.isAcceptableMimeType(
|
||||
instance,
|
||||
mimeType,
|
||||
isReply
|
||||
) -> Unit // エラーメッセージ出力済み
|
||||
attachmentList.size >= 4 -> {
|
||||
dialogOrToast(R.string.attachment_too_many)
|
||||
return
|
||||
}
|
||||
|
||||
account == null -> {
|
||||
dialogOrToast(R.string.account_select_please)
|
||||
return
|
||||
}
|
||||
|
||||
mimeType == null -> {
|
||||
dialogOrToast(R.string.mime_type_missing)
|
||||
return
|
||||
}
|
||||
|
||||
instance == null -> {
|
||||
dialogOrToast("missing instance information")
|
||||
return
|
||||
}
|
||||
|
||||
instance.instanceType == InstanceType.Pixelfed && isReply -> {
|
||||
AttachmentUploader.log.e("pixelfed_does_not_allow_reply_with_media")
|
||||
dialogOrToast(R.string.pixelfed_does_not_allow_reply_with_media)
|
||||
return
|
||||
}
|
||||
|
||||
else -> {
|
||||
saveAttachmentList()
|
||||
val pa = PostAttachment(this)
|
||||
attachmentList.add(pa)
|
||||
showMediaAttachment()
|
||||
val mediaConfig = instance.configuration?.jsonObject("media_attachments")
|
||||
attachmentUploader.addRequest(
|
||||
AttachmentRequest(
|
||||
account,
|
||||
pa,
|
||||
uri,
|
||||
mimeType,
|
||||
isReply = isReply,
|
||||
context = applicationContext,
|
||||
account = account,
|
||||
pa = pa,
|
||||
uri = uri,
|
||||
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
|
||||
)
|
||||
)
|
||||
|
@ -237,7 +274,11 @@ fun ActPost.performAttachmentClick(idx: Int) {
|
|||
}
|
||||
}
|
||||
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.GIFV,
|
||||
TootAttachmentType.Video,
|
||||
|
@ -288,8 +329,7 @@ suspend fun ActPost.sendFocusPoint(
|
|||
y: Float,
|
||||
): Boolean {
|
||||
val account = this.account ?: error("missing account")
|
||||
val isEdit = states.editStatusId != null
|
||||
if (isEdit) {
|
||||
if (attachment.isEdit) {
|
||||
attachment.focusX = x
|
||||
attachment.focusY = 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(
|
||||
pa: PostAttachment,
|
||||
) {
|
||||
// 既存の投稿を編集中なら真
|
||||
val isEdit = states.editStatusId != null
|
||||
val account = this.account ?: return
|
||||
|
||||
val a = pa.attachment
|
||||
if (a == null) {
|
||||
showToast(true, R.string.attachment_description_cant_edit_while_uploading)
|
||||
return
|
||||
}
|
||||
val attachmentId = pa.attachment?.id ?: return
|
||||
val account = this.account ?: return
|
||||
// 既存の投稿を編集中なら真
|
||||
val isEdit = a.isEdit
|
||||
val attachmentId = a.id
|
||||
var bitmap: Bitmap? = null
|
||||
try {
|
||||
// サムネイルをロード
|
||||
|
@ -412,9 +452,16 @@ fun ActPost.onPickCustomThumbnailImpl(pa: PostAttachment, src: GetContentResultE
|
|||
when (val account = this.account) {
|
||||
null -> showToast(false, R.string.account_select_please)
|
||||
else -> launchMain {
|
||||
val result = attachmentUploader.uploadCustomThumbnail(account, src, pa)
|
||||
result?.error?.let { showToast(true, it) }
|
||||
showMediaAttachment()
|
||||
if (pa.attachment?.isEdit == true) {
|
||||
showToast(
|
||||
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.TootApiClient
|
||||
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.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.table.SavedAccount
|
||||
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.JsonObject
|
||||
import jp.juggler.util.data.decodeJsonObject
|
||||
import jp.juggler.util.data.notEmpty
|
||||
import jp.juggler.util.data.toJsonArray
|
||||
import jp.juggler.util.log.LogCategory
|
||||
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 {
|
||||
val baseStatus =
|
||||
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 {
|
||||
val baseStatus =
|
||||
TootParser(this, account).status(jsonText.decodeJsonObject())
|
||||
|
@ -434,23 +439,23 @@ suspend fun ActPost.initializeFromEditStatus(account: SavedAccount, jsonText: St
|
|||
|
||||
states.visibility = baseStatus.visibility
|
||||
|
||||
val srcAttachments = baseStatus.media_attachments
|
||||
if (srcAttachments?.isNotEmpty() == true) {
|
||||
saveAttachmentList()
|
||||
this.attachmentList.clear()
|
||||
try {
|
||||
baseStatus.media_attachments
|
||||
?.mapNotNull { it as? TootAttachment }
|
||||
?.notEmpty()
|
||||
?.let { srcAttachments ->
|
||||
saveAttachmentList()
|
||||
this.attachmentList.clear()
|
||||
for (src in srcAttachments) {
|
||||
if (src is TootAttachment) {
|
||||
src.redraft = true
|
||||
try {
|
||||
src.isEdit = true
|
||||
val pa = PostAttachment(src)
|
||||
pa.status = PostAttachment.Status.Ok
|
||||
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
|
||||
|
||||
|
|
|
@ -1,15 +1,19 @@
|
|||
package jp.juggler.subwaytooter.api
|
||||
|
||||
import android.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import jp.juggler.subwaytooter.R
|
||||
import jp.juggler.subwaytooter.action.isAndroid7TlsBug
|
||||
import jp.juggler.subwaytooter.util.DecodeOptions
|
||||
import jp.juggler.subwaytooter.util.ProgressResponseBody
|
||||
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.showToast
|
||||
import jp.juggler.util.log.dialogOrToast
|
||||
import jp.juggler.util.log.withCaption
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.withContext
|
||||
|
@ -205,6 +209,7 @@ class ResponseBeforeRead(
|
|||
message = parseErrorResponse(body = errorBody),
|
||||
)
|
||||
}
|
||||
|
||||
callback != null ->
|
||||
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) {
|
||||
try {
|
||||
log.e(ex, "showApiError")
|
||||
|
@ -352,6 +345,7 @@ fun AppCompatActivity.showApiError(ex: Throwable) {
|
|||
null -> dialogOrToast(ex.message ?: "(??)")
|
||||
else -> dialogOrToast(ex.withCaption())
|
||||
}
|
||||
|
||||
else -> dialogOrToast(ex.withCaption())
|
||||
}
|
||||
} catch (ignored: Throwable) {
|
||||
|
|
|
@ -44,7 +44,10 @@ class TootAttachment private constructor(
|
|||
) : TootAttachmentLike {
|
||||
|
||||
// 内部フラグ: 再編集で引き継いだ添付メディアなら真
|
||||
var redraft: Boolean = false
|
||||
var redraft = false
|
||||
|
||||
// 内部フラグ:編集時に既存投稿から引き継いだ添付データなら真
|
||||
var isEdit = false
|
||||
|
||||
// 内部フラグ:編集投稿時にメディア属性を更新するなら、その値を指定する
|
||||
var updateDescription: String? = null
|
||||
|
@ -84,6 +87,7 @@ class TootAttachment private constructor(
|
|||
private const val KEY_UPDATE_DESCRIPTION = "updateDescription"
|
||||
private const val KEY_UPDATE_THUMBNAIL = "updateThumbnail"
|
||||
private const val KEY_UPDATE_FOCUS = "updateFocus"
|
||||
private const val KEY_IS_EDIT = "isEdit"
|
||||
|
||||
private val ext_audio = arrayOf(".mpga", ".mp3", ".aac", ".ogg")
|
||||
|
||||
|
@ -130,6 +134,7 @@ class TootAttachment private constructor(
|
|||
updateDescription = src.string(KEY_UPDATE_DESCRIPTION)
|
||||
updateThumbnail = src.string(KEY_UPDATE_THUMBNAIL)
|
||||
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)
|
||||
updateThumbnail = src.string(KEY_UPDATE_THUMBNAIL)
|
||||
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_THUMBNAIL, updateThumbnail)
|
||||
put(KEY_UPDATE_FOCUS, updateFocus)
|
||||
put(KEY_IS_EDIT, isEdit)
|
||||
|
||||
if (focusX != 0f || focusY != 0f) {
|
||||
put(KEY_META, buildJsonObject {
|
||||
|
|
|
@ -217,11 +217,11 @@ class AttachmentPicker(
|
|||
// Mastodon's custom thumbnail
|
||||
|
||||
fun openCustomThumbnail(pa: PostAttachment) {
|
||||
states.customThumbnailTargetId = pa.attachment?.id?.toString()
|
||||
if (!prPickCustomThumbnail.checkOrLaunch()) return
|
||||
|
||||
// SAFのIntentで開く
|
||||
try {
|
||||
states.customThumbnailTargetId = pa.attachment?.id?.toString()
|
||||
?: return
|
||||
if (!prPickCustomThumbnail.checkOrLaunch()) return
|
||||
// SAFのIntentで開く
|
||||
arCustomThumbnail.launch(
|
||||
intentGetContent(
|
||||
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
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.SystemClock
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import jp.juggler.subwaytooter.R
|
||||
import jp.juggler.subwaytooter.api.TootApiCallback
|
||||
import jp.juggler.subwaytooter.api.TootApiClient
|
||||
|
@ -23,9 +22,6 @@ import jp.juggler.util.data.*
|
|||
import jp.juggler.util.log.*
|
||||
import jp.juggler.util.media.ResizeConfig
|
||||
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.toPostRequestBuilder
|
||||
import jp.juggler.util.network.toPut
|
||||
|
@ -36,25 +32,13 @@ import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import okio.BufferedSink
|
||||
import java.io.*
|
||||
import java.util.concurrent.CancellationException
|
||||
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(
|
||||
contextArg: Context,
|
||||
activity: AppCompatActivity,
|
||||
private val handler: Handler,
|
||||
) {
|
||||
companion object {
|
||||
|
@ -199,7 +183,7 @@ class AttachmentUploader(
|
|||
}
|
||||
}
|
||||
|
||||
private val context = contextArg.applicationContext!!
|
||||
private val safeContext = activity.applicationContext!!
|
||||
private var lastAttachmentAdd = 0L
|
||||
private var lastAttachmentComplete = 0L
|
||||
private var channel: Channel<AttachmentRequest>? = null
|
||||
|
@ -220,13 +204,13 @@ class AttachmentUploader(
|
|||
when (ex) {
|
||||
is CancellationException, is ClosedReceiveChannelException -> break
|
||||
else -> {
|
||||
context.showToast(ex)
|
||||
safeContext.showToast(ex)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
val result = try {
|
||||
if (request.pa.isCancelled) continue
|
||||
if (request.pa.isCancelled == true) continue
|
||||
withContext(request.pa.job + AppDispatchers.IO) {
|
||||
request.upload()
|
||||
}
|
||||
|
@ -242,7 +226,7 @@ class AttachmentUploader(
|
|||
when (ex) {
|
||||
is CancellationException, is ClosedReceiveChannelException -> break
|
||||
else -> {
|
||||
context.showToast(ex)
|
||||
safeContext.showToast(ex)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
@ -266,12 +250,12 @@ class AttachmentUploader(
|
|||
|
||||
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()
|
||||
if (now - lastAttachmentAdd >= 5000L) {
|
||||
context.showToast(false, R.string.attachment_uploading)
|
||||
safeContext.showToast(false, R.string.attachment_uploading)
|
||||
}
|
||||
lastAttachmentAdd = now
|
||||
|
||||
|
@ -286,7 +270,7 @@ class AttachmentUploader(
|
|||
try {
|
||||
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
|
||||
})
|
||||
|
||||
|
@ -296,78 +280,41 @@ class AttachmentUploader(
|
|||
val (ti, tiResult) = TootInstance.get(client)
|
||||
ti ?: return tiResult
|
||||
|
||||
if (ti.instanceType == InstanceType.Pixelfed) {
|
||||
if (isReply) {
|
||||
return TootApiResult(context.getString(R.string.pixelfed_does_not_allow_reply_with_media))
|
||||
}
|
||||
if (!acceptableMimeTypesPixelfed.contains(mimeType)) {
|
||||
return TootApiResult(
|
||||
context.getString(
|
||||
R.string.mime_type_not_acceptable,
|
||||
mimeType
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
val mediaConfig = ti.configuration?.jsonObject("media_attachments")
|
||||
val serverMaxSqPixel = mediaConfig?.int("image_matrix_limit")?.takeIf { it > 0 }
|
||||
val imageResizeConfig = account.getResizeConfig()
|
||||
|
||||
// 入力データの変換など
|
||||
val opener = createOpener(
|
||||
account,
|
||||
uri,
|
||||
mimeType,
|
||||
mediaConfig = mediaConfig,
|
||||
imageResizeConfig = imageResizeConfig,
|
||||
postAttachment = pa,
|
||||
serverMaxSqPixel = serverMaxSqPixel,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
val opener = this.createOpener()
|
||||
val maxBytes = when (opener.isImage) {
|
||||
true -> maxBytesImage
|
||||
else -> maxBytesVideo
|
||||
}
|
||||
|
||||
if (opener.contentLength > mediaSizeMax) {
|
||||
if (opener.contentLength > maxBytes) {
|
||||
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 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 isAccepteble = instance.configuration
|
||||
?.jsonObject("media_attachments")
|
||||
?.jsonArray("supported_mime_types")
|
||||
?.contains(mimeType)
|
||||
?: when (instance.instanceType) {
|
||||
InstanceType.Pixelfed -> acceptableMimeTypesPixelfed
|
||||
else -> acceptableMimeTypes
|
||||
}.contains(mimeType)
|
||||
|
||||
if (!isAccepteble) {
|
||||
return TootApiResult(
|
||||
safeContext.getString(R.string.mime_type_not_acceptable, mimeType)
|
||||
)
|
||||
}
|
||||
|
||||
val fileName = fixDocumentName(getDocumentName(context.contentResolver, uri))
|
||||
pa.progress = context.getString(R.string.attachment_handling_uploading, 0)
|
||||
val fileName = fixDocumentName(getDocumentName(safeContext.contentResolver, uri))
|
||||
pa.progress = safeContext.getString(R.string.attachment_handling_uploading, 0)
|
||||
fun writeProgress(percent: Int) {
|
||||
if (percent < 100) {
|
||||
pa.progress =
|
||||
context.getString(R.string.attachment_handling_uploading, percent)
|
||||
safeContext.getString(R.string.attachment_handling_uploading, percent)
|
||||
} 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) {
|
||||
tootAttachment(ServiceType.MASTODON, it)
|
||||
}?.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?) {
|
||||
val pa = request.pa
|
||||
pa.status = when (pa.attachment) {
|
||||
|
@ -491,7 +454,7 @@ class AttachmentUploader(
|
|||
when {
|
||||
// キャンセルはトーストを出さない
|
||||
result.error?.contains("cancel", ignoreCase = true) == true -> Unit
|
||||
else -> context.showToast(
|
||||
else -> safeContext.showToast(
|
||||
true,
|
||||
"${result.error} ${result.response?.request?.method} ${result.response?.request?.url}"
|
||||
)
|
||||
|
@ -499,10 +462,11 @@ class AttachmentUploader(
|
|||
}
|
||||
PostAttachment.Status.Error
|
||||
}
|
||||
|
||||
else -> {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastAttachmentComplete >= 5000L) {
|
||||
context.showToast(false, R.string.attachment_uploaded)
|
||||
safeContext.showToast(false, R.string.attachment_uploaded)
|
||||
}
|
||||
lastAttachmentComplete = now
|
||||
|
||||
|
@ -514,240 +478,11 @@ class AttachmentUploader(
|
|||
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? {
|
||||
// image/j()pg だの image/j(e)pg だの、mime type を誤記するアプリがあまりに多い
|
||||
// クレームで消耗するのを減らすためにファイルヘッダを確認する
|
||||
if (mimeTypeArg == null || mimeTypeArg.startsWith("image/")) {
|
||||
val sv = findMimeTypeByFileHeader(context.contentResolver, uri)
|
||||
val sv = findMimeTypeByFileHeader(safeContext.contentResolver, uri)
|
||||
if (sv != null) return sv
|
||||
}
|
||||
|
||||
|
@ -757,7 +492,7 @@ class AttachmentUploader(
|
|||
}
|
||||
|
||||
// ContentResolverに尋ねる
|
||||
var sv = context.contentResolver.getType(uri)
|
||||
var sv = safeContext.contentResolver.getType(uri)
|
||||
if (sv?.isNotEmpty() == true) return sv
|
||||
|
||||
// gboardのステッカーではUriのクエリパラメータにmimeType引数がある
|
||||
|
@ -826,6 +561,7 @@ class AttachmentUploader(
|
|||
else -> {
|
||||
}
|
||||
}
|
||||
|
||||
1 -> when (mpegVersionId) {
|
||||
0, 2, 3 -> return "audio/mp3"
|
||||
|
||||
|
@ -842,52 +578,42 @@ class AttachmentUploader(
|
|||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// 添付データのカスタムサムネイル
|
||||
// 添付データのカスタムサムネイル
|
||||
suspend fun uploadCustomThumbnail(
|
||||
account: SavedAccount,
|
||||
src: GetContentResultEntry,
|
||||
pa: PostAttachment,
|
||||
): TootApiResult? = try {
|
||||
context.runApiTask(account) { client ->
|
||||
val mimeType = getMimeType(src.uri, src.mimeType)
|
||||
if (mimeType?.isEmpty() != false) {
|
||||
return@runApiTask TootApiResult(context.getString(R.string.mime_type_missing))
|
||||
safeContext.runApiTask(account) { client ->
|
||||
val mimeType = getMimeType(src.uri, src.mimeType)?.notEmpty()
|
||||
if (mimeType.isNullOrEmpty()) {
|
||||
return@runApiTask TootApiResult(safeContext.getString(R.string.mime_type_missing))
|
||||
}
|
||||
|
||||
val (ti, ri) = TootInstance.get(client)
|
||||
ti ?: return@runApiTask ri
|
||||
|
||||
val opener = createOpener(
|
||||
account,
|
||||
src.uri,
|
||||
mimeType,
|
||||
val mediaConfig = ti.configuration?.jsonObject("media_attachments")
|
||||
val ar = AttachmentRequest(
|
||||
context = applicationContext,
|
||||
account = account,
|
||||
pa = pa,
|
||||
uri = src.uri,
|
||||
mimeType = mimeType,
|
||||
instance = ti,
|
||||
mediaConfig = mediaConfig,
|
||||
imageResizeConfig = ResizeConfig(ResizeType.SquarePixel, 400),
|
||||
serverMaxSqPixel = mediaConfig?.int("image_matrix_limit")?.takeIf { it > 0 },
|
||||
maxBytesImage = 1000000,
|
||||
maxBytesVideo = 1000000,
|
||||
)
|
||||
|
||||
val mediaSizeMax = 1000000
|
||||
if (opener.contentLength > mediaSizeMax) {
|
||||
val opener = ar.createOpener()
|
||||
if (opener.contentLength > ar.maxBytesImage) {
|
||||
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 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))
|
||||
val fileName = fixDocumentName(getDocumentName(safeContext.contentResolver, src.uri))
|
||||
|
||||
if (account.isMisskey) {
|
||||
opener.deleteTempFile()
|
||||
|
@ -929,7 +655,7 @@ class AttachmentUploader(
|
|||
): Pair<TootApiResult?, TootAttachment?> {
|
||||
var resultAttachment: TootAttachment? = null
|
||||
val result = try {
|
||||
context.runApiTask(account) { client ->
|
||||
safeContext.runApiTask(account) { client ->
|
||||
if (account.isMisskey) {
|
||||
client.request(
|
||||
"/api/drive/files/update",
|
||||
|
@ -961,28 +687,4 @@ class AttachmentUploader(
|
|||
}
|
||||
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="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_not_acceptable">Aquest tipus de mèdia MIME %1$s no s\'accepta.
|
||||
\nEls tipus MIME processables són imatge/jpeg, imatge/png, imatge/gif, vídeo/webm and vídeo/mp4.</string>
|
||||
<string name="mime_type_not_acceptable">Aquest tipus de mèdia MIME %1$s no s\'accepta.</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_nature">Natura</string>
|
||||
|
|
|
@ -906,8 +906,7 @@
|
|||
<string name="keyword_filters">Schlüsselwort-Filter</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="mime_type_not_acceptable">Der Media-MIME-Typ %1$s wird nicht unterstützt.
|
||||
\nUnterstützte Formate sind Bild/jpeg, Bild/png, Bild/gif, Video/webm und Video/mp4.</string>
|
||||
<string name="mime_type_not_acceptable">Der Media-MIME-Typ %1$s wird nicht unterstützt.</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="toot_count">Toots</string>
|
||||
|
|
|
@ -490,8 +490,7 @@
|
|||
<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="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.
|
||||
\nTypes MIME pris en charge sont les suivants : image/jpeg, image/png, image/gif, video/webm et video/mp4.</string>
|
||||
<string name="mime_type_not_acceptable">Type MIME du média %1$s n’est pas acceptable.</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_nature">Nature</string>
|
||||
|
|
|
@ -448,8 +448,7 @@
|
|||
<string name="mention2">返信</string>
|
||||
<string name="menu">メニュー</string>
|
||||
<string name="mime_type_missing">MIMEタイプが不明です。</string>
|
||||
<string name="mime_type_not_acceptable">MIMEタイプ %1$s には対応できません。
|
||||
\n対応できるMIMEタイプは image/jpeg, image/png, image/gif, video/webm, video/mp4 です。</string>
|
||||
<string name="mime_type_not_acceptable">MIMEタイプ %1$s には対応できません。</string>
|
||||
<string name="minimum_column_width">カラム最小幅(デフォルト=300(dp)、アプリ再起動が必要)</string>
|
||||
<string name="missing_available_account">この目的に使えるアカウントがありません。</string>
|
||||
<string name="missing_fcm_device_id">FCMのデバイスIDを準備できません。しばらく後に試してください。</string>
|
||||
|
|
|
@ -501,8 +501,7 @@
|
|||
<string name="domain_count">연결된 도메인 수</string>
|
||||
<string name="not_provided_mastodon_under_1_6">마스토돈 1.6 이전 버전 지원 안 함</string>
|
||||
<string name="mime_type_missing">MIME 유형 없음.</string>
|
||||
<string name="mime_type_not_acceptable">MIME 유형 %1$s 을(를) 불러올 수 없습니다.
|
||||
\n지원되는 MIME 유형은 image/jpeg, image/png, image/gif, video/webm, video/mp4입니다.</string>
|
||||
<string name="mime_type_not_acceptable">MIME 유형 %1$s 을(를) 불러올 수 없습니다.</string>
|
||||
<string name="dont_show_timeout">\"서버 시간제한 초과\" 알림 보이지 않기</string>
|
||||
<string name="emoji_category_people">사람들</string>
|
||||
<string name="emoji_category_nature">자연</string>
|
||||
|
|
|
@ -654,8 +654,7 @@
|
|||
<string name="parsing_response">Tolker svar…</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="mime_type_not_acceptable">Mediatypen %1$s godtas ikke.
|
||||
\nStøttede mediatyper: image/jpeg, image/png, image/gif, video/webm og video/mp4.</string>
|
||||
<string name="mime_type_not_acceptable">Mediatypen %1$s godtas ikke.</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="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_nature">自然</string>
|
||||
<string name="dont_show_timeout">不显示“实例超时”通知</string>
|
||||
<string name="mime_type_not_acceptable">%1$s 的媒体 MIME 类型未被接受。
|
||||
\n支持的 MIME 类型为 image/jpeg、image/png、image/gif、video/webm和video/mp4。</string>
|
||||
<string name="mime_type_not_acceptable">%1$s 的媒体 MIME 类型未被接受。</string>
|
||||
<string name="mime_type_missing">缺少媒体 MIME 类型。</string>
|
||||
<string name="not_provided_mastodon_under_1_6">未提供于 Mastodon 1.6之前版本</string>
|
||||
<string name="domain_count">已连接实例数</string>
|
||||
|
|
|
@ -520,8 +520,7 @@
|
|||
<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="mime_type_missing">Missing media MIME type.</string>
|
||||
<string name="mime_type_not_acceptable">The media MIME type %1$s is not accepted.
|
||||
\nSupported MIME types are image/jpeg, image/png, image/gif, video/webm and video/mp4.</string>
|
||||
<string name="mime_type_not_acceptable">The media MIME type %1$s is not accepted.</string>
|
||||
<string name="dont_show_timeout">Don\'t show \"Server timeout\" notification</string>
|
||||
<string name="emoji_category_people">People</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 =
|
||||
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))
|
||||
|
||||
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))
|
||||
|
||||
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) {
|
||||
log.e(ex, caption ?: "(showError)")
|
||||
|
||||
|
@ -179,8 +194,6 @@ fun AppCompatActivity.showError(ex: Throwable, caption: String? = null) {
|
|||
.filter { !it.isNullOrBlank() }
|
||||
.joinToString("\n")
|
||||
)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
} catch (ignored: Throwable) {
|
||||
showToast(ex, caption)
|
||||
}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
package jp.juggler.util.media
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodecList
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import jp.juggler.util.log.LogCategory
|
||||
import java.io.File
|
||||
|
@ -14,17 +16,24 @@ import kotlin.math.min
|
|||
*/
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
class VideoInfo(
|
||||
val file: File,
|
||||
// val file: File,
|
||||
mmr: MediaMetadataRetriever,
|
||||
val bytesLength: Long,
|
||||
val uri: Uri,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val log = LogCategory("VideoInfo")
|
||||
|
||||
val File.videoInfo: VideoInfo
|
||||
get() = MediaMetadataRetriever().use { mmr ->
|
||||
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) =
|
||||
|
@ -152,28 +161,31 @@ class VideoInfo(
|
|||
null
|
||||
}
|
||||
|
||||
val actualBps by lazy {
|
||||
val fileSize = file.length()
|
||||
val hasAudio = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO)
|
||||
?.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
|
||||
|
||||
// bpsを計算
|
||||
fileSize.toFloat().div(duration).times(8).toInt()
|
||||
bytesLength.toFloat().div(duration).times(8).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* 動画のファイルサイズが十分に小さいなら真
|
||||
*/
|
||||
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以内ならビットレートを気にしない
|
||||
if (fileSize < 500_000) return true
|
||||
if (bytesLength < 500_000) return true
|
||||
|
||||
// ファイルサイズからビットレートを計算できなかったなら再エンコード必要
|
||||
val actualBps = this.actualBps ?: return false
|
||||
|
@ -184,5 +196,5 @@ class VideoInfo(
|
|||
}
|
||||
|
||||
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