SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/util/PostImpl.kt

633 lines
24 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package jp.juggler.subwaytooter.util
import android.os.SystemClock
import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootApiResultException
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.InstanceCapability
import jp.juggler.subwaytooter.api.entity.InstanceType
import jp.juggler.subwaytooter.api.entity.TootAccount
import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.api.entity.TootPollsType
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.api.entity.TootTag
import jp.juggler.subwaytooter.api.entity.TootVisibility
import jp.juggler.subwaytooter.api.errorApiResult
import jp.juggler.subwaytooter.api.runApiTask2
import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm
import jp.juggler.subwaytooter.emoji.CustomEmoji
import jp.juggler.subwaytooter.getVisibilityString
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.span.MyClickableSpan
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.daoAcctColor
import jp.juggler.subwaytooter.table.daoSavedAccount
import jp.juggler.subwaytooter.table.daoTagHistory
import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.data.JsonArray
import jp.juggler.util.data.JsonException
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.buildJsonArray
import jp.juggler.util.data.buildJsonObject
import jp.juggler.util.data.digestSHA256Hex
import jp.juggler.util.data.groupEx
import jp.juggler.util.data.jsonObjectOf
import jp.juggler.util.data.notEmpty
import jp.juggler.util.data.toJsonArray
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.errorString
import jp.juggler.util.log.showToast
import jp.juggler.util.network.MEDIA_TYPE_JSON
import jp.juggler.util.network.toPostRequestBuilder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Calendar
import java.util.GregorianCalendar
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.atomic.AtomicBoolean
sealed class PostResult {
class Normal(
val targetAccount: SavedAccount,
val status: TootStatus,
) : PostResult()
class Scheduled(
val targetAccount: SavedAccount,
) : PostResult()
}
@Suppress("LongParameterList")
class PostImpl(
val activity: AppCompatActivity,
val account: SavedAccount,
val content: String,
// nullはCWチェックなしを示す // nullじゃなくてカラならエラー
val spoilerText: String?,
val visibilityArg: TootVisibility,
val bNSFW: Boolean,
val inReplyToId: EntityId?,
val attachmentListArg: List<PostAttachment>?,
enqueteItemsArg: List<String>?,
val pollType: TootPollsType?,
val pollExpireSeconds: Int,
val pollHideTotals: Boolean,
val pollMultipleChoice: Boolean,
val scheduledAt: Long,
val scheduledId: EntityId?,
val redraftStatusId: EntityId?,
val editStatusId: EntityId?,
val emojiMapCustom: HashMap<String, CustomEmoji>?,
var useQuoteToot: Boolean,
var lang: String,
) {
companion object {
private val log = LogCategory("PostImpl")
// ハッシュタグ内部の、半角数字以外のASCII文字にマッチする正規表現
// 単体テストで使うのでpublig
val reTagAsciiNotNumber = """[\x00-\x2f\x3a-\x7f]""".toRegex()
// ハッシュタグ内部の、非ASCII文字にマッチする正規表現
// 単体テストで使うのでpublig
val reTagNonAscii = """[^\x00-\x7f]""".toRegex()
private var lastPostTapped: Long = 0L
private val isPosting = AtomicBoolean(false)
}
private val attachmentList = attachmentListArg?.mapNotNull { it.attachment }?.notEmpty()
// null だと投票を作成しない。空リストはエラー
private val enqueteItems = enqueteItemsArg?.filter { it.isNotEmpty() }
private var visibilityChecked: TootVisibility? = null
private val choiceMaxChars = when {
account.isMisskey -> 15
pollType == TootPollsType.FriendsNico -> 15
else -> 40 // TootPollsType.Mastodon
}
private fun preCheckPollItemOne(list: List<String>, idx: Int, item: String) {
// 選択肢が長すぎる
val cpCount = item.codePointCount(0, item.length)
if (cpCount > choiceMaxChars) {
val over = cpCount - choiceMaxChars
activity.errorString(R.string.enquete_item_too_long, idx + 1, over)
}
// 他の項目と重複している
if ((0 until idx).any { list[it] == item }) {
activity.errorString(R.string.enquete_item_duplicate, idx + 1)
}
}
// may null, not error
private suspend fun getWebVisibility(
client: TootApiClient,
parser: TootParser,
instance: TootInstance,
): TootVisibility? = when {
account.isMisskey -> null
instance.versionGE(TootInstance.VERSION_1_6) -> null
else -> {
val privacy = parser.account(
client.requestOrThrow("/api/v1/accounts/verify_credentials")
.jsonObject
)?.source?.privacy
?: error(R.string.cant_get_web_setting_visibility)
TootVisibility.parseMastodon(privacy)
}
}
private fun checkServerHasVisibility(
actual: TootVisibility?,
extra: TootVisibility,
instance: TootInstance,
checkFun: (TootInstance) -> Boolean,
) {
if (actual != extra || checkFun(instance)) return
val strVisibility = extra.getVisibilityString(account.isMisskey)
activity.errorApiResult(R.string.server_has_no_support_of_visibility, strVisibility)
}
private suspend fun checkVisibility(
client: TootApiClient,
parser: TootParser,
instance: TootInstance,
): TootVisibility? {
val v = when (visibilityArg) {
TootVisibility.WebSetting -> getWebVisibility(client, parser, instance)
else -> visibilityArg
}
checkServerHasVisibility(
v,
TootVisibility.Mutual,
instance,
InstanceCapability::visibilityMutual
)
checkServerHasVisibility(
v,
TootVisibility.Limited,
instance,
InstanceCapability::visibilityLimited
)
return v
}
private suspend fun deleteStatus(client: TootApiClient) {
val result = if (account.isMisskey) {
client.request(
"/api/notes/delete",
account.putMisskeyApiToken(JsonObject()).apply {
put("noteId", redraftStatusId)
}.toPostRequestBuilder()
)
} else {
client.request(
"/api/v1/statuses/$redraftStatusId",
Request.Builder().delete()
)
}
log.d("delete redraft. result=$result")
delay(2000L)
}
private suspend fun deleteScheduledStatus(client: TootApiClient) {
val result = client.request(
"/api/v1/scheduled_statuses/$scheduledId",
Request.Builder().delete()
)
log.d("delete old scheduled status. result=$result")
delay(2000L)
}
private suspend fun encodeParamsMisskey(json: JsonObject, client: TootApiClient) {
account.putMisskeyApiToken(json)
json["viaMobile"] = true
json["text"] = EmojiDecoder.decodeShortCode(content, emojiMapCustom = emojiMapCustom)
spoilerText?.notEmpty()?.let {
json["cw"] = EmojiDecoder.decodeShortCode(it, emojiMapCustom = emojiMapCustom)
}
inReplyToId?.toString()?.let {
json[if (useQuoteToot) "renoteId" else "replyId"] = it
}
when (val visibility = visibilityChecked) {
null -> Unit
TootVisibility.DirectSpecified, TootVisibility.DirectPrivate -> {
val userIds = JsonArray()
val m = TootAccount.reMisskeyMentionPost.matcher(content)
while (m.find()) {
val username = m.groupEx(1)
val host = m.groupEx(2) // may null
client.request(
"/api/users/show",
account.putMisskeyApiToken().apply {
username.notEmpty()?.let { put("username", it) }
host.notEmpty()?.let { put("host", it) }
}.toPostRequestBuilder()
)?.let { result ->
result.jsonObject?.string("id").notEmpty()?.let { userIds.add(it) }
}
}
json["visibility"] = when {
userIds.isNotEmpty() -> {
json["visibleUserIds"] = userIds
"specified"
}
account.misskeyVersion >= 11 -> "specified"
else -> "private"
}
}
else -> {
val localVis = visibility.strMisskey.replace("^local-".toRegex(), "")
if (localVis != visibility.strMisskey) json["localOnly"] = true
json["visibility"] = localVis
}
}
if (attachmentList != null) {
val array = JsonArray()
for (a in attachmentList) {
// Misskeyは画像の再利用に問題がないので redraftとバージョンのチェックは行わない
array.add(a.id.toString())
// Misskeyの場合、NSFWするにはアップロード済みの画像を drive/files/update で更新する
if (bNSFW) {
client.requestOrThrow(
"/api/drive/files/update",
account.putMisskeyApiToken().apply {
put("fileId", a.id.toString())
put("isSensitive", true)
}.toPostRequestBuilder()
)
}
}
if (array.isNotEmpty()) json["mediaIds"] = array
}
if (enqueteItems?.isNotEmpty() == true) {
val choices = JsonArray().apply {
for (item in enqueteItems) {
val text = EmojiDecoder.decodeShortCode(item, emojiMapCustom = emojiMapCustom)
if (text.isEmpty()) continue
add(text)
}
}
if (choices.isNotEmpty()) {
json["poll"] = jsonObjectOf("choices" to choices)
}
}
}
private fun encodeParamsMastodon(json: JsonObject, instance: TootInstance) {
when (val lang = lang.trim()) {
// Web設定に従うなら指定しない
SavedAccount.LANG_WEB, "" -> Unit
// 端末の言語コード
SavedAccount.LANG_DEVICE ->
json["language"] = Locale.getDefault().language
// その他
else ->
json["language"] = lang
}
visibilityChecked?.let { json["visibility"] = it.strMastodon }
json["status"] = EmojiDecoder.decodeShortCode(content, emojiMapCustom = emojiMapCustom)
json["sensitive"] = bNSFW
json["spoiler_text"] =
EmojiDecoder.decodeShortCode(spoilerText ?: "", emojiMapCustom = emojiMapCustom)
inReplyToId?.toString()
?.let { json[if (useQuoteToot) "quote_id" else "in_reply_to_id"] = it }
if (attachmentList != null) {
json["media_ids"] = buildJsonArray {
for (a in attachmentList) {
if (a.redraft && !instance.versionGE(TootInstance.VERSION_2_4_1)) continue
add(a.id.toString())
}
}
attachmentList.mapNotNull { a ->
buildJsonObject {
put("id", a.id.toString())
a.updateDescription?.let { put("description", it) }
a.updateThumbnail?.let { put("thumbnail", it) }
a.updateFocus?.let { put("focus", it) }
}.takeIf { it.keys.size >= 2 }
}.notEmpty()?.toJsonArray()?.let {
json["media_attributes"] = it
}
}
if (enqueteItems != null) {
if (pollType == TootPollsType.Mastodon) {
json["poll"] = buildJsonObject {
put("multiple", pollMultipleChoice)
put("hide_totals", pollHideTotals)
put("expires_in", pollExpireSeconds)
put("options", enqueteItems.map {
EmojiDecoder.decodeShortCode(it, emojiMapCustom = emojiMapCustom)
}.toJsonArray())
}
} else {
json["isEnquete"] = true
json["enquete_items"] = enqueteItems.map {
EmojiDecoder.decodeShortCode(it, emojiMapCustom = emojiMapCustom)
}.toJsonArray()
}
}
if (scheduledAt != 0L) {
if (!instance.versionGE(TootInstance.VERSION_2_7_0_rc1)) {
activity.errorApiResult(R.string.scheduled_status_requires_mastodon_2_7_0)
}
// UTCの日時を渡す
val c = GregorianCalendar.getInstance(TimeZone.getTimeZone("UTC"))
c.timeInMillis = scheduledAt
val sv = String.format(
Locale.JAPANESE,
"%d-%02d-%02dT%02d:%02d:%02d.%03dZ",
c.get(Calendar.YEAR),
c.get(Calendar.MONTH) + 1,
c.get(Calendar.DAY_OF_MONTH),
c.get(Calendar.HOUR_OF_DAY),
c.get(Calendar.MINUTE),
c.get(Calendar.SECOND),
c.get(Calendar.MILLISECOND)
)
json["scheduled_at"] = sv
}
}
private fun saveStatusTag(status: TootStatus?) {
status ?: return
val s = status.decoded_content
val spanList = s.getSpans(0, s.length, MyClickableSpan::class.java)
if (spanList != null) {
val tagList = ArrayList<String?>(spanList.size)
for (span in spanList) {
val start = s.getSpanStart(span)
val end = s.getSpanEnd(span)
val text = s.subSequence(start, end).toString()
if (text.startsWith("#")) {
tagList.add(text.substring(1))
}
}
val count = tagList.size
if (count > 0) {
daoTagHistory.saveList(System.currentTimeMillis(), tagList, 0, count)
}
}
}
suspend fun runSuspend(): PostResult {
if (account.isMisskey) {
attachmentList
?.notEmpty()
?.map { it.id.toString() }
?.takeIf { it != it.distinct() }
?.let {
activity.errorString(R.string.post_error_attachments_duplicated)
}
}
if (content.isEmpty() && attachmentList == null) {
activity.errorString(R.string.post_error_contents_empty)
}
// nullはCWチェックなしを示す // nullじゃなくてカラならエラー
if (spoilerText != null && spoilerText.isEmpty()) {
activity.errorString(R.string.post_error_contents_warning_empty)
}
if (!enqueteItems.isNullOrEmpty()) {
if (enqueteItems.size < 2) {
activity.errorString(R.string.enquete_item_is_empty, enqueteItems.size + 1)
}
enqueteItems.forEachIndexed { i, v ->
preCheckPollItemOne(enqueteItems, i, v)
}
}
if (scheduledAt != 0L && account.isMisskey) {
error("misskey has no scheduled status API")
}
if (PrefB.bpWarnHashtagAsciiAndNonAscii.value) {
TootTag.findHashtags(content, account.isMisskey)
?.filter {
// タグがASCII文字(半角数字を除く)と非ASCII文字の両方を含むか
val hasAscii = reTagAsciiNotNumber.containsMatchIn(it)
val hasNonAscii = reTagNonAscii.containsMatchIn(it)
hasAscii && hasNonAscii
}?.map { "#$it" }
?.notEmpty()
?.let { badTags ->
activity.confirm(
R.string.hashtag_contains_ascii_and_not_ascii,
badTags.joinToString(", ")
)
}
}
val isMisskey = account.isMisskey
if (!visibilityArg.isTagAllowed(isMisskey)) {
TootTag.findHashtags(content, isMisskey)
?.notEmpty()
?.let { tags ->
log.d("findHashtags ${tags.joinToString(",")}")
activity.confirm(
R.string.hashtag_and_visibility_not_match
)
}
}
if (redraftStatusId != null) {
activity.confirm(R.string.delete_base_status_before_toot)
}
if (scheduledId != null) {
activity.confirm(R.string.delete_scheduled_status_before_update)
}
activity.confirm(
activity.getString(
R.string.confirm_post_from,
daoAcctColor.getNickname(account)
),
account.confirmPost
) { newConfirmEnabled ->
account.confirmPost = newConfirmEnabled
daoSavedAccount.save(account)
}
// ボタン連打判定
val now = SystemClock.elapsedRealtime()
val delta = now - lastPostTapped
lastPostTapped = now
if (delta < 1000L) {
log.e("lastPostTapped within 1 sec!")
activity.showToast(false, R.string.post_button_tapped_repeatly)
throw CancellationException("post_button_tapped_repeatly")
}
// 投稿中に再度投稿ボタンが押された
if (isPosting.get()) {
log.e("other postJob is active!")
activity.showToast(false, R.string.post_button_tapped_repeatly)
throw CancellationException("preCheck failed.")
}
// 全ての確認を終えたらバックグラウンドでの処理を開始する
isPosting.set(true)
return try {
withContext(AppDispatchers.MainImmediate) {
val (status, scheduled) = activity.runApiTask2(
accessInfo = account,
progressSetup = { it.setCanceledOnTouchOutside(false) },
) { client ->
val instance = TootInstance.getOrThrow(client)
if (instance.instanceType == InstanceType.Pixelfed) {
// Pixelfedは返信に画像を添付できない
if (inReplyToId != null && attachmentList != null) {
error(R.string.pixelfed_does_not_allow_reply_with_media)
}
// Pixelfedの返信ではない投稿は画像添付が必須
if (inReplyToId == null && attachmentList == null) {
error(R.string.pixelfed_does_not_allow_post_without_media)
}
}
val parser = TootParser(this, account)
// may null
this@PostImpl.visibilityChecked = checkVisibility(client, parser, instance)
if (redraftStatusId != null) {
// 元の投稿を削除する
deleteStatus(client)
} else if (scheduledId != null) {
deleteScheduledStatus(client)
}
val json = JsonObject()
try {
if (account.isMisskey) {
encodeParamsMisskey(json, client)
} else {
encodeParamsMastodon(json, instance)
}
} catch (ex: JsonException) {
throw IllegalStateException("encoding status failed.", ex)
}
val bodyString = json.toString()
fun createRequestBuilder(isPut: Boolean = false): Request.Builder {
val requestBody = bodyString.toRequestBody(MEDIA_TYPE_JSON)
return when {
isPut -> Request.Builder().put(requestBody)
else -> Request.Builder().post(requestBody)
}.also {
if (!PrefB.bpDontDuplicationCheck.value) {
val digest = (bodyString + account.acct.ascii).digestSHA256Hex()
it.header("Idempotency-Key", digest)
}
}
}
try {
val result = when {
account.isMisskey -> client.requestOrThrow(
"/api/notes/create",
createRequestBuilder()
)
editStatusId != null -> client.requestOrThrow(
"/api/v1/statuses/$editStatusId",
createRequestBuilder(isPut = true)
)
else -> client.requestOrThrow(
"/api/v1/statuses",
createRequestBuilder()
)
}
val jsonObject = result.jsonObject
when {
// 予約投稿完了
scheduledAt != 0L && jsonObject != null -> {
// {"id":"3","scheduled_at":"2019-01-06T07:08:00.000Z","media_attachments":[]}
Pair(null, true)
}
// 通常投稿完了
else -> {
val status = parser.status(
when {
account.isMisskey ->
jsonObject?.jsonObject("createdNote")
?: jsonObject
else -> jsonObject
}
)?.also { saveStatusTag(it) }
Pair(status, false)
}
}
} catch (ex: TootApiResultException) {
val errorMessage = ex.result?.error
when {
errorMessage.isNullOrBlank() -> error("(missing error detail)")
errorMessage.contains("HTTP 404") ->
error("$ex\n${activity.getString(R.string.post_404_desc)}")
else -> throw ex
}
}
}
when {
scheduled -> PostResult.Scheduled(account)
status == null ->
error("can't parse status in API result.")
// 連投してIdempotency が同じだった場合もエラーにはならず、ここを通る
else -> PostResult.Normal(account, status)
}
}
} finally {
isPosting.set(false)
}
}
}