SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/actpost/ActPostAttachment.kt

439 lines
15 KiB
Kotlin

package jp.juggler.subwaytooter.actpost
import android.graphics.Bitmap
import android.net.Uri
import android.text.InputType
import android.view.View
import androidx.appcompat.app.AlertDialog
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.ServiceType
import jp.juggler.subwaytooter.api.entity.TootAttachment
import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachment
import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachmentJson
import jp.juggler.subwaytooter.api.entity.TootAttachmentType
import jp.juggler.subwaytooter.api.entity.parseItem
import jp.juggler.subwaytooter.api.runApiTask
import jp.juggler.subwaytooter.calcIconRound
import jp.juggler.subwaytooter.defaultColorIcon
import jp.juggler.subwaytooter.dialog.actionsDialog
import jp.juggler.subwaytooter.dialog.decodeAttachmentBitmap
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.PostAttachment
import jp.juggler.subwaytooter.view.MyNetworkImageView
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.data.CharacterGroup
import jp.juggler.util.data.GetContentResultEntry
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")
// AppStateに保存する
fun ActPost.saveAttachmentList() {
if (!isMultiWindowPost) appState.attachmentList = this.attachmentList
}
fun ActPost.decodeAttachments(sv: String) {
attachmentList.clear()
try {
sv.decodeJsonArray().objectList().forEach {
try {
attachmentList.add(PostAttachment(tootAttachmentJson(it)))
} catch (ex: Throwable) {
log.e(ex, "can't parse TootAttachment.")
}
}
} catch (ex: Throwable) {
log.e(ex, "decodeAttachments failed.")
}
}
fun ActPost.showMediaAttachment() {
if (isFinishing) return
views.llAttachment.vg(attachmentList.isNotEmpty())
ivMedia.forEachIndexed { i, v -> showMediaAttachmentOne(v, i) }
}
fun ActPost.showMediaAttachmentProgress() {
val mergedProgress = attachmentList
.mapNotNull { it.progress.notEmpty() }
.joinToString("\n")
views.tvAttachmentProgress
.vg(mergedProgress.isNotEmpty())
?.text = mergedProgress
}
fun ActPost.showMediaAttachmentOne(iv: MyNetworkImageView, idx: Int) {
if (idx >= attachmentList.size) {
iv.visibility = View.GONE
} else {
iv.visibility = View.VISIBLE
val pa = attachmentList[idx]
val a = pa.attachment
when {
a == null || pa.status != PostAttachment.Status.Ok -> {
iv.setDefaultImage(defaultColorIcon(this, R.drawable.ic_upload))
iv.setErrorImage(defaultColorIcon(this, R.drawable.ic_clip))
iv.setImageUrl(calcIconRound(iv.layoutParams.width), null)
}
else -> {
val defaultIconId = when (a.type) {
TootAttachmentType.Image -> R.drawable.ic_image
TootAttachmentType.Video,
TootAttachmentType.GIFV,
-> R.drawable.ic_videocam
TootAttachmentType.Audio -> R.drawable.ic_music_note
else -> R.drawable.ic_clip
}
iv.setDefaultImage(defaultColorIcon(this, defaultIconId))
iv.setErrorImage(defaultColorIcon(this, defaultIconId))
iv.setImageUrl(calcIconRound(iv.layoutParams.width), a.preview_url)
}
}
}
}
fun ActPost.openAttachment() {
when {
attachmentList.size >= 4 -> showToast(false, R.string.attachment_too_many)
account == null -> showToast(false, R.string.account_select_please)
else -> attachmentPicker.openPicker()
}
}
fun ActPost.addAttachment(
uri: Uri,
mimeTypeArg: String? = null,
) {
val account = this.account
if (account == null) {
dialogOrToast(R.string.account_select_please)
return
} else if (attachmentList.size >= 4) {
dialogOrToast(R.string.attachment_too_many)
return
}
saveAttachmentList()
val pa = PostAttachment(this)
attachmentList.add(pa)
showMediaAttachment()
attachmentUploader.addRequest(
AttachmentRequest(
context = applicationContext,
account = account,
pa = pa,
uri = uri,
mimeTypeArg = mimeTypeArg,
isReply = states.inReplyToId != null,
imageResizeConfig = account.getResizeConfig(),
maxBytesVideo = { instance, mediaConfig ->
min(
account.getMovieMaxBytes(instance),
mediaConfig?.int("video_size_limit")
?.takeIf { it > 0 } ?: Int.MAX_VALUE,
)
},
maxBytesImage = { instance, mediaConfig ->
min(
account.getImageMaxBytes(instance),
mediaConfig?.int("image_size_limit")
?.takeIf { it > 0 } ?: Int.MAX_VALUE,
)
},
)
)
}
fun ActPost.onPostAttachmentCompleteImpl(pa: PostAttachment) {
// この添付メディアはリストにない
if (!attachmentList.contains(pa)) {
log.w("onPostAttachmentComplete: not in attachment list.")
return
}
when (pa.status) {
PostAttachment.Status.Error -> {
log.w("onPostAttachmentComplete: upload failed.")
attachmentList.remove(pa)
showMediaAttachment()
}
PostAttachment.Status.Progress -> {
// アップロード中…?
log.w("onPostAttachmentComplete: ?? status=${pa.status}")
}
PostAttachment.Status.Ok -> {
when (val a = pa.attachment) {
null -> log.w("onPostAttachmentComplete: upload complete, but missing attachment entity.")
else -> {
// アップロード完了
log.i("onPostAttachmentComplete: upload complete.")
// 投稿欄の末尾に追記する
if (PrefB.bpAppendAttachmentUrlToContent.value) {
appendArrachmentUrl(a)
}
}
}
showMediaAttachment()
}
}
}
/**
* 添付メディアのURLを編集中テキストに挿入する
*/
private fun ActPost.appendArrachmentUrl(a: TootAttachment) {
// text_url is not provided on recent mastodon.
val textUrl = a.text_url ?: a.url
if (textUrl == null) {
log.w("missing attachment.textUrl")
return
}
val e = views.etContent.editableText
if (e == null) {
// 編注中テキストにアクセスできないなら先頭に空白とURLを置いて、先頭を選択する
val appendText = " $textUrl"
views.etContent.setText(appendText)
views.etContent.setSelection(0, 0)
} else {
// 末尾に空白とURLを置く。選択位置は変わらない。
val selStart = views.etContent.selectionStart
val selEnd = views.etContent.selectionEnd
if (e.lastOrNull()?.let { CharacterGroup.isWhitespace(it.code) } != true) {
e.append(" ")
}
e.append(textUrl)
views.etContent.setSelection(selStart, selEnd)
}
}
// 添付した画像をタップ
fun ActPost.performAttachmentClick(idx: Int) {
launchAndShowError {
val pa = attachmentList.elementAtOrNull(idx)
?: error("can't get attachment item[$idx].")
actionsDialog(getString(R.string.media_attachment)) {
action(getString(R.string.set_description)) {
editAttachmentDescription(pa)
}
if (pa.attachment?.canFocus == true) {
action(getString(R.string.set_focus_point)) {
openFocusPoint(pa)
}
}
if (account?.isMastodon == true) {
if (pa.attachment?.isEdit == true) {
// https://github.com/tateisu/SubwayTooter/issues/237
// 既存の投稿の編集時にサムネイルを更新できるようにするのが著しく面倒くさい
// 一旦未対応とする
} else when (pa.attachment?.type) {
TootAttachmentType.Audio,
TootAttachmentType.GIFV,
TootAttachmentType.Video,
-> action(getString(R.string.custom_thumbnail)) {
attachmentPicker.openCustomThumbnail(pa)
}
else -> Unit
}
}
action(getString(R.string.delete)) {
deleteAttachment(pa)
}
}
}
}
fun ActPost.deleteAttachment(pa: PostAttachment) {
AlertDialog.Builder(this)
.setTitle(R.string.confirm_delete_attachment)
.setPositiveButton(R.string.ok) { _, _ ->
try {
pa.isCancelled = true
pa.status = PostAttachment.Status.Error
pa.job.cancel()
attachmentList.remove(pa)
} catch (ignored: Throwable) {
}
showMediaAttachment()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
suspend fun ActPost.openFocusPoint(pa: PostAttachment) {
val attachment = pa.attachment ?: return
focusPointDialog(
attachment = attachment,
callback = { x, y -> sendFocusPoint(pa, attachment, x, y) }
)
}
suspend fun ActPost.sendFocusPoint(
pa: PostAttachment,
attachment: TootAttachment,
x: Float,
y: Float,
): Boolean {
val account = this.account ?: error("missing account")
if (attachment.isEdit) {
attachment.focusX = x
attachment.focusY = y
attachment.updateFocus = formatFocusParameter(x, y)
showToast(false, R.string.applied_when_post)
showMediaAttachment()
return true
}
var resultAttachment: TootAttachment? = null
val result = runApiTask(account, progressStyle = ApiTask.PROGRESS_NONE) { client ->
try {
client.request(
"/api/v1/media/${attachment.id}",
buildJsonObject {
put("focus", formatFocusParameter(x, y))
}.toPutRequestBuilder()
)?.also { result ->
resultAttachment = parseItem(result.jsonObject) {
tootAttachment(ServiceType.MASTODON, it)
}
}
} catch (ex: Throwable) {
TootApiResult(ex.withCaption("set focus point failed."))
}
}
result ?: return true
return when (val newAttachment = resultAttachment) {
null -> {
showToast(true, result.error)
false
}
else -> {
pa.attachment = newAttachment
true
}
}
}
private fun formatFocusParameter(x: Float, y: Float) = "%.2f,%.2f".format(x, y)
suspend fun ActPost.editAttachmentDescription(
pa: PostAttachment,
) {
val account = this.account ?: return
val a = pa.attachment
if (a == null) {
showToast(true, R.string.attachment_description_cant_edit_while_uploading)
return
}
// 既存の投稿を編集中なら真
val isEdit = a.isEdit
val attachmentId = a.id
var bitmap: Bitmap? = null
try {
// サムネイルをロード
val url = a.preview_url
if (url != null) {
val result = runApiTask { client ->
try {
val (result, data) = client.getHttpBytes(url)
data?.let {
bitmap = decodeAttachmentBitmap(it, 1024)
?: return@runApiTask TootApiResult("image decode failed.")
}
result
} catch (ex: Throwable) {
TootApiResult(ex.withCaption("preview loading failed."))
}
}
result ?: return
if (!isLiveActivity) return
result.error?.let {
showToast(true, result.error ?: "error")
// not exit
}
}
// ダイアログを表示
showTextInputDialog(
title = getString(R.string.attachment_description),
bitmap = bitmap,
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE,
initialText = a.description,
onEmptyText = { showToast(true, R.string.description_empty) },
) { text ->
if (isEdit) {
a.description = text
a.updateDescription = text
showToast(false, R.string.applied_when_post)
showMediaAttachment()
true
} else {
val (result, newAttachment) = attachmentUploader.setAttachmentDescription(
account,
attachmentId,
text
)
when {
result == null -> true
newAttachment == null -> {
result.error?.let { showToast(true, it) }
false
}
else -> {
pa.attachment = newAttachment
showMediaAttachment()
true
}
}
}
}
} finally {
bitmap?.recycle()
}
}
fun ActPost.onPickCustomThumbnailImpl(pa: PostAttachment, src: GetContentResultEntry) {
when (val account = this.account) {
null -> showToast(false, R.string.account_select_please)
else -> launchMain {
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()
}
}
}
}