diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActMediaViewer.kt b/app/src/main/java/jp/juggler/subwaytooter/ActMediaViewer.kt index 693894f9..35bb74dc 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActMediaViewer.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActMediaViewer.kt @@ -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 diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt b/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt index 6214d937..5b1bc493 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt @@ -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 } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActPushMessageList.kt b/app/src/main/java/jp/juggler/subwaytooter/ActPushMessageList.kt index a08abf85..7f7d77a3 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActPushMessageList.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActPushMessageList.kt @@ -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) } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostAttachment.kt b/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostAttachment.kt index f397afcd..5f6154b5 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostAttachment.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostAttachment.kt @@ -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() + } } } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostDrafts.kt b/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostDrafts.kt index c7f4076a..600d6302 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostDrafts.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostDrafts.kt @@ -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 diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/TootApiClientExt.kt b/app/src/main/java/jp/juggler/subwaytooter/api/TootApiClientExt.kt index 499bfdc4..376faf12 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/TootApiClientExt.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/TootApiClientExt.kt @@ -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.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) { diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachment.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachment.kt index 1860fa40..9f1d321d 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachment.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAttachment.kt @@ -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 { diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentPicker.kt b/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentPicker.kt index b73aaaf6..c94bb145 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentPicker.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentPicker.kt @@ -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, diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentRequest.kt b/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentRequest.kt new file mode 100644 index 00000000..98158c78 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentRequest.kt @@ -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.") + } +} diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentUploader.kt b/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentUploader.kt index 7a570408..ec83f245 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentUploader.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/AttachmentUploader.kt @@ -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? = 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 { 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 - } } diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/InputStreamOpener.kt b/app/src/main/java/jp/juggler/subwaytooter/util/InputStreamOpener.kt new file mode 100644 index 00000000..e7081d8c --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/util/InputStreamOpener.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index cbedbf7a..c34b5bcd 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -523,8 +523,7 @@ Nombre de dominis connectats No existia abans de Mastodont 1.6 Falta mèdia tipus MIME. - 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. + Aquest tipus de mèdia MIME %1$s no s\'accepta. No mostris la notificació \"S\'ha superat el temps d\'espera del servidor\" Gent Natura diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7e984b8a..e4b6506d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -906,8 +906,7 @@ Schlüsselwort-Filter Alle Spalten schließen Der Post-Button wurde mehrfach gedrückt - 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. + Der Media-MIME-Typ %1$s wird nicht unterstützt. Media-MIME-Typ fehlt. Steht bei vor Mastodon-Version 1.6 nicht zur Verfügung Toots diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4d93a21b..af4926e0 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -490,8 +490,7 @@ Nombre de domaines connectés Non fourni sous Mastodon avant la 1.6 Type MIME média manquant. - 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. + Type MIME du média %1$s n’est pas acceptable. Ne pas afficher la notification \"Délai d’expiration du serveur\" People Nature diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 57110a15..eac88521 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -448,8 +448,7 @@ 返信 メニュー MIMEタイプが不明です。 - MIMEタイプ %1$s には対応できません。 -\n対応できるMIMEタイプは image/jpeg, image/png, image/gif, video/webm, video/mp4 です。 + MIMEタイプ %1$s には対応できません。 カラム最小幅(デフォルト=300(dp)、アプリ再起動が必要) この目的に使えるアカウントがありません。 FCMのデバイスIDを準備できません。しばらく後に試してください。 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index fe07f5d8..719fe187 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -501,8 +501,7 @@ 연결된 도메인 수 마스토돈 1.6 이전 버전 지원 안 함 MIME 유형 없음. - MIME 유형 %1$s 을(를) 불러올 수 없습니다. -\n지원되는 MIME 유형은 image/jpeg, image/png, image/gif, video/webm, video/mp4입니다. + MIME 유형 %1$s 을(를) 불러올 수 없습니다. \"서버 시간제한 초과\" 알림 보이지 않기 사람들 자연 diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index bb2772d1..b3022293 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -654,8 +654,7 @@ Tolker svar… Kan ikke hente synlighetsinnstilling i nettprogram. Tilbys ikke i Mastodon 1.6 - Mediatypen %1$s godtas ikke. -\nStøttede mediatyper: image/jpeg, image/png, image/gif, video/webm og video/mp4. + Mediatypen %1$s godtas ikke. Skru av egendefinert emoji-animasjon (programomstart og gjeninnlasting av kolonne kreves) Legg til/fjern fra lister… Kan ikke legge til bruker i liste: diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 095e1ad6..2f074e99 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -965,8 +965,7 @@ 活动 自然 不显示“实例超时”通知 - %1$s 的媒体 MIME 类型未被接受。 -\n支持的 MIME 类型为 image/jpeg、image/png、image/gif、video/webm和video/mp4。 + %1$s 的媒体 MIME 类型未被接受。 缺少媒体 MIME 类型。 未提供于 Mastodon 1.6之前版本 已连接实例数 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 34f923f3..063b8af1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -520,8 +520,7 @@ Connected domain count Not provided pre Mastodon 1.6 Missing media MIME type. - The media MIME type %1$s is not accepted. -\nSupported MIME types are image/jpeg, image/png, image/gif, video/webm and video/mp4. + The media MIME type %1$s is not accepted. Don\'t show \"Server timeout\" notification People Nature diff --git a/base/src/main/java/jp/juggler/util/log/ToastUtils.kt b/base/src/main/java/jp/juggler/util/log/ToastUtils.kt index 0d658c00..e7cb02d7 100644 --- a/base/src/main/java/jp/juggler/util/log/ToastUtils.kt +++ b/base/src/main/java/jp/juggler/util/log/ToastUtils.kt @@ -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) } diff --git a/base/src/main/java/jp/juggler/util/media/VideoInfo.kt b/base/src/main/java/jp/juggler/util/media/VideoInfo.kt index 8cfa1226..d0744f9b 100644 --- a/base/src/main/java/jp/juggler/util/media/VideoInfo.kt +++ b/base/src/main/java/jp/juggler/util/media/VideoInfo.kt @@ -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" }