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

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

View File

@ -37,6 +37,7 @@ import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.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

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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()
}
}
}
}

View File

@ -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

View File

@ -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) {

View File

@ -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 {

View File

@ -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,

View File

@ -0,0 +1,250 @@
package jp.juggler.subwaytooter.util
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.InstanceType
import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.getStreamSize
import jp.juggler.util.log.LogCategory
import jp.juggler.util.media.ResizeConfig
import jp.juggler.util.media.VideoInfo.Companion.videoInfo
import jp.juggler.util.media.createResizedBitmap
import jp.juggler.util.media.transcodeVideo
import java.io.File
import java.io.FileOutputStream
class AttachmentRequest(
val context: Context,
val account: SavedAccount,
val pa: PostAttachment,
val uri: Uri,
var mimeType: String,
val imageResizeConfig: ResizeConfig,
val serverMaxSqPixel: Int?,
val instance: TootInstance,
val mediaConfig: JsonObject?,
val maxBytesVideo: Int,
val maxBytesImage: Int,
) {
companion object {
private val log = LogCategory("AttachmentRequest")
}
fun hasServerSupport(mimeType: String) =
mediaConfig?.jsonArray("supported_mime_types")?.contains(mimeType)
?: when (instance.instanceType) {
InstanceType.Pixelfed -> AttachmentUploader.acceptableMimeTypesPixelfed
else -> AttachmentUploader.acceptableMimeTypes
}.contains(mimeType)
suspend fun createOpener(): InputStreamOpener {
// GIFはそのまま投げる
if (mimeType == AttachmentUploader.MIME_TYPE_GIF) {
return contentUriOpener(context.contentResolver, uri, mimeType, isImage = true)
}
if (mimeType.startsWith("image")) {
// 静止画(失敗したらオリジナルデータにフォールバックする)
if (mimeType == AttachmentUploader.MIME_TYPE_JPEG ||
mimeType == AttachmentUploader.MIME_TYPE_PNG
) try {
// 回転対応が必要かもしれない
return createResizedImageOpener()
} catch (ex: Throwable) {
log.w(ex, "createResizedImageOpener failed. fall back to original image.")
}
// 静止画(変換必須)
// 例外を投げるかもしれない
return createResizedImageOpener(forcePng = true)
}
// 音声と動画のファイル区分は曖昧なので
// MediaMetadataRetriever で調べる
// コンテンツの長さを調べる
val contentLength = context.contentResolver.openInputStream(uri)
?.use { getStreamSize(false, it) }
?: error("openInputStream returns null")
// 動画の一部は音声かもしれない
// データに動画や音声が含まれるか調べる
val vi = try {
uri.videoInfo(context, contentLength)
} catch (ex: Throwable) {
log.e(ex, "can't get videoInfo.")
error("can't get videoInfo. $mimeType $uri")
}
val isVideo = when {
vi.hasVideo == true -> true
vi.hasAudio == true -> false
mimeType.startsWith("video") -> true
mimeType.startsWith("audio") -> false
else -> null
}
when (isVideo) {
true -> try {
// 動画のトランスコード(失敗したらオリジナルデータにフォールバックする)
return createResizedVideoOpener()
} catch (ex: Throwable) {
log.w(
ex,
"createResizedVideoOpener failed. fall back to original data."
)
}
false -> try {
// 音声のトランスコード(失敗したらオリジナルデータにフォールバックする)
return createResizedAudioOpener(contentLength)
} catch (ex: Throwable) {
log.w(
ex,
"createResizedAudioOpener failed. fall back to original data."
)
}
null -> Unit
}
return contentUriOpener(
context.contentResolver,
uri,
mimeType,
isImage = false,
)
}
private fun createResizedImageOpener(
forcePng: Boolean = false,
): InputStreamOpener {
val cacheDir = context.externalCacheDir
?.apply { mkdirs() }
?: error("getExternalCacheDir returns null.")
val outputMimeType = if (forcePng || mimeType == AttachmentUploader.MIME_TYPE_PNG) {
AttachmentUploader.MIME_TYPE_PNG
} else {
AttachmentUploader.MIME_TYPE_JPEG
}
val tempFile = File(cacheDir, "tmp." + Thread.currentThread().id)
val bitmap = createResizedBitmap(
context,
uri,
imageResizeConfig,
skipIfNoNeedToResizeAndRotate = !forcePng,
serverMaxSqPixel = serverMaxSqPixel
) ?: error("createResizedBitmap returns null.")
pa.progress = context.getString(R.string.attachment_handling_compress)
try {
FileOutputStream(tempFile).use { os ->
if (outputMimeType == AttachmentUploader.MIME_TYPE_PNG) {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, os)
} else {
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, os)
}
}
return tempFileOpener(tempFile, outputMimeType, isImage = true)
} finally {
bitmap.recycle()
}
}
private suspend fun createResizedVideoOpener(): InputStreamOpener {
val cacheDir = context.externalCacheDir
?.apply { mkdirs() }
?: error("getExternalCacheDir returns null.")
val tempFile = File(cacheDir, "movie." + Thread.currentThread().id + ".tmp")
val outFile = File(cacheDir, "movie." + Thread.currentThread().id + ".mp4")
var resultFile: File? = null
// 入力ファイルをコピーする
(context.contentResolver.openInputStream(uri)
?: error("openInputStream returns null.")).use { inStream ->
FileOutputStream(tempFile).use { inStream.copyTo(it) }
}
try {
// 動画のメタデータを調べる
val info = tempFile.videoInfo
// サーバに指定されたファイルサイズ上限と入力動画の時間長があれば、ビットレート上限を制限する
val duration = info.duration?.takeIf { it >= 0.1f }
val limitFileSize = mediaConfig?.float("video_size_limit")?.takeIf { it >= 1f }
val limitBitrate = when {
duration != null && limitFileSize != null ->
(limitFileSize / duration).toLong()
else -> null
}
// アカウント別の動画トランスコード設定
// ビットレート、フレームレート、平方ピクセル数をサーバからの情報によりさらに制限する
val movieResizeConfig = account.getMovieResizeConfig()
.restrict(
limitBitrate = limitBitrate,
limitFrameRate = mediaConfig?.int("video_frame_rate_limit")
?.takeIf { it >= 1f },
limitSquarePixels = mediaConfig?.int("video_matrix_limit")
?.takeIf { it > 1 },
)
val result = transcodeVideo(
info,
tempFile,
outFile,
movieResizeConfig,
) {
val percent = (it * 100f).toInt()
pa.progress =
context.getString(R.string.attachment_handling_compress_ratio, percent)
}
resultFile = result
return tempFileOpener(
result,
when (result) {
tempFile -> mimeType
else -> "video/mp4"
},
isImage = false,
)
} finally {
if (outFile != resultFile) outFile.delete()
if (tempFile != resultFile) tempFile.delete()
}
}
private val aacMimeTypes = listOf(
"audio/aac", "audio/aacp", "audio/3gpp", "audio/3gpp2",
"audio/mp4", "audio/MP4A-LATM", "audio/mpeg4-generic"
).map { it.lowercase() }.toSet()
private fun createResizedAudioOpener(
originalBytes: Long,
): InputStreamOpener {
if (hasServerSupport("audio/aac") && aacMimeTypes.contains(mimeType.lowercase())) {
mimeType = "audio/aac"
}
// サーバ側がサポートしてる形式でサイズ以内なら
if (hasServerSupport(mimeType) && originalBytes <= maxBytesVideo) {
return contentUriOpener(
context.contentResolver,
uri,
mimeType,
isImage = false,
)
}
error("audio conversion is not yet supported.")
}
}

View File

@ -1,12 +1,11 @@
package jp.juggler.subwaytooter.util
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
}
}

View File

@ -0,0 +1,86 @@
package jp.juggler.subwaytooter.util
import android.content.ContentResolver
import android.net.Uri
import jp.juggler.util.data.getStreamSize
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody
import okio.BufferedSink
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.io.InputStream
// contentLengthの測定などで複数回オープンする必要がある
abstract class InputStreamOpener {
abstract val mimeType: String
abstract val isImage: Boolean
@Throws(IOException::class)
abstract fun open(): InputStream
abstract fun deleteTempFile()
val contentLength by lazy { getStreamSize(true, open()) }
// okhttpのRequestBodyにする
fun toRequestBody(onWrote: (percent: Int) -> Unit = {}) =
object : RequestBody() {
override fun contentType() = mimeType.toMediaType()
@Throws(IOException::class)
override fun contentLength(): Long = contentLength
@Throws(IOException::class)
override fun writeTo(sink: BufferedSink) {
val length = contentLength.toFloat()
open().use { inStream ->
val tmp = ByteArray(4096)
var nWrite = 0L
while (true) {
val delta = inStream.read(tmp, 0, tmp.size)
if (delta <= 0) break
sink.write(tmp, 0, delta)
nWrite += delta
val percent = (100f * nWrite.toFloat() / length).toInt()
onWrote(percent)
}
}
}
}
}
// contentResolver.openInputStream を使うOpener
fun contentUriOpener(
contentResolver: ContentResolver,
uri: Uri,
mimeType: String,
isImage: Boolean,
) = object : InputStreamOpener() {
override val mimeType = mimeType
override val isImage = isImage
@Throws(IOException::class)
override fun open(): InputStream {
return contentResolver.openInputStream(uri)
?: error("openInputStream returns null")
}
override fun deleteTempFile() = Unit
}
// 一時ファイルを使うOpener
fun tempFileOpener(
file: File,
mimeType: String,
isImage: Boolean,
) = object : InputStreamOpener() {
override val mimeType = mimeType
override val isImage = isImage
@Throws(IOException::class)
override fun open() = FileInputStream(file)
override fun deleteTempFile() {
file.delete()
}
}

View File

@ -523,8 +523,7 @@
<string name="domain_count">Nombre de dominis connectats</string>
<string name="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>

View File

@ -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>

View File

@ -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 nest 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 nest pas acceptable.</string>
<string name="dont_show_timeout">Ne pas afficher la notification \"Délai dexpiration du serveur\"</string>
<string name="emoji_category_people">People</string>
<string name="emoji_category_nature">Nature</string>

View File

@ -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>

View File

@ -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>

View File

@ -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:

View File

@ -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>

View File

@ -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>

View File

@ -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)
}

View File

@ -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"
}