package jp.juggler.subwaytooter.util import android.os.SystemClock import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.PrefB import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.Styler import jp.juggler.subwaytooter.api.* import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.dialog.DlgConfirm import jp.juggler.subwaytooter.emoji.CustomEmoji import jp.juggler.subwaytooter.span.MyClickableSpan import jp.juggler.subwaytooter.table.AcctColor import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.TagSet import jp.juggler.util.* import kotlinx.coroutines.Job import kotlinx.coroutines.delay import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.lang.ref.WeakReference import java.util.* interface PostCompleteCallback { fun onPostComplete(targetAccount: SavedAccount, status: TootStatus) fun onScheduledPostComplete(targetAccount: SavedAccount) } 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?, enqueteItemsArg: List?, val pollType: TootPollsType?, val pollExpireSeconds: Int, val pollHideTotals: Boolean, val pollMultipleChoice: Boolean, val scheduledAt: Long, val scheduledId: EntityId?, val redraftStatusId: EntityId?, val emojiMapCustom: HashMap?, var useQuoteToot: Boolean, val callback: PostCompleteCallback, ) { companion object { private val log = LogCategory("PostImpl") private val reAscii = """[\x00-\x7f]""".asciiPattern() private val reNotAscii = """[^\x00-\x7f]""".asciiPattern() private var lastPostTapped: Long = 0L private var postJob: WeakReference? = null } private val attachmentList = attachmentListArg?.mapNotNull { it.attachment }?.notEmpty() // null だと投票を作成しない。空リストはエラー private val enqueteItems = enqueteItemsArg?.filter { it.isNotEmpty() } private var visibilityChecked: TootVisibility? = null var bConfirmTag: Boolean = false var bConfirmAccount: Boolean = false var bConfirmRedraft: Boolean = false var bConfirmTagCharacter: Boolean = false private val choiceMaxChars = when { account.isMisskey -> 15 pollType == TootPollsType.FriendsNico -> 15 else -> 25 // TootPollsType.Mastodon } private fun preCheckPollItemOne(list: List, idx: Int, item: String): Boolean { // 選択肢が長すぎる val cpCount = item.codePointCount(0, item.length) if (cpCount > choiceMaxChars) { val over = cpCount - choiceMaxChars activity.showToast(true, R.string.enquete_item_too_long, idx + 1, over) return false } // 他の項目と重複している if ((0 until idx).any { list[it] == item }) { activity.showToast(true, R.string.enquete_item_duplicate, idx + 1) return false } return true } private fun preCheck(): Boolean { if (content.isEmpty() && attachmentList == null) { activity.showToast(true, R.string.post_error_contents_empty) return false } // nullはCWチェックなしを示す // nullじゃなくてカラならエラー if (spoilerText != null && spoilerText.isEmpty()) { activity.showToast(true, R.string.post_error_contents_warning_empty) return false } if (enqueteItems != null) { if (enqueteItems.size < 2) { activity.showToast(true, R.string.enquete_item_is_empty, enqueteItems.size + 1) return false } enqueteItems.forEachIndexed { i, v -> if (!preCheckPollItemOne(enqueteItems, i, v)) return false } } if (scheduledAt != 0L && account.isMisskey) { activity.showToast(true, "misskey has no scheduled status API") return false } return true } private fun confirm(): Boolean { if (!bConfirmAccount) { DlgConfirm.open( activity, activity.getString(R.string.confirm_post_from, AcctColor.getNickname(account)), object : DlgConfirm.Callback { override var isConfirmEnabled: Boolean get() = account.confirm_post set(bv) { account.confirm_post = bv account.saveSetting() } override fun onOK() { bConfirmAccount = true run() } }) return false } if (!bConfirmTagCharacter && PrefB.bpWarnHashtagAsciiAndNonAscii(App1.pref)) { val tags = TootTag.findHashtags(content, account.isMisskey) val badTags = tags ?.filter { val hasAscii = reAscii.matcher(it).find() val hasNotAscii = reNotAscii.matcher(it).find() hasAscii && hasNotAscii } ?.map { "#$it" } if (badTags?.isNotEmpty() == true) { AlertDialog.Builder(activity) .setCancelable(true) .setMessage( activity.getString( R.string.hashtag_contains_ascii_and_not_ascii, badTags.joinToString(", ") ) ) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok) { _, _ -> bConfirmTagCharacter = true run() } .show() return false } } if (!bConfirmTag) { val isMisskey = account.isMisskey if (!visibilityArg.isTagAllowed(isMisskey)) { val tags = TootTag.findHashtags(content, isMisskey) if (tags != null) { log.d("findHashtags ${tags.joinToString(",")}") AlertDialog.Builder(activity) .setCancelable(true) .setMessage(R.string.hashtag_and_visibility_not_match) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok) { _, _ -> bConfirmTag = true run() } .show() return false } } } if (!bConfirmRedraft && redraftStatusId != null) { AlertDialog.Builder(activity) .setCancelable(true) .setMessage(R.string.delete_base_status_before_toot) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok) { _, _ -> bConfirmRedraft = true run() } .show() return false } if (!bConfirmRedraft && scheduledId != null) { AlertDialog.Builder(activity) .setCancelable(true) .setMessage(R.string.delete_scheduled_status_before_update) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok) { _, _ -> bConfirmRedraft = true run() } .show() return false } return true } var resultStatus: TootStatus? = null var resultCredentialTmp: TootAccount? = null var resultScheduledStatusSucceeded = false private suspend fun getCredential( client: TootApiClient, parser: TootParser, ): TootApiResult? { return client.request("/api/v1/accounts/verify_credentials")?.also { result -> resultCredentialTmp = parser.account(result.jsonObject) } } private suspend fun getWebVisibility( client: TootApiClient, parser: TootParser, instance: TootInstance, ): TootVisibility? { if (account.isMisskey || instance.versionGE(TootInstance.VERSION_1_6)) return null val r2 = getCredential(client, parser) val credentialTmp = resultCredentialTmp ?: errorApiResult(r2) val privacy = credentialTmp.source?.privacy ?: errorApiResult(activity.getString(R.string.cant_get_web_setting_visibility)) return TootVisibility.parseMastodon(privacy) // may null, not error } private fun checkServerHasVisibility( actual: TootVisibility?, extra: TootVisibility, instance: TootInstance, checkFun: (TootInstance) -> Boolean, ) { if (actual != extra || checkFun(instance)) return val strVisibility = Styler.getVisibilityString(activity, account.isMisskey, extra) errorApiResult(activity.getString(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) { val r = client.request( "/api/drive/files/update", account.putMisskeyApiToken().apply { put("fileId", a.id.toString()) put("isSensitive", true) } .toPostRequestBuilder() ) if (r == null || r.error != null) errorApiResult(r) } } 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) { 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"] = jsonArray { for (a in attachmentList) { if (a.redraft && !instance.versionGE(TootInstance.VERSION_2_4_1)) continue add(a.id.toString()) } } } if (enqueteItems != null) { if (pollType == TootPollsType.Mastodon) { json["poll"] = jsonObject { 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)) { errorApiResult(activity.getString(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-%02d %02d:%02d:%02d", 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) ) 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(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) { TagSet.saveList(System.currentTimeMillis(), tagList, 0, count) } } } fun run() { if (!preCheck()) return if (!confirm()) return // 投稿中に再度投稿ボタンが押された if (postJob?.get()?.isActive == true) { activity.showToast(false, R.string.post_button_tapped_repeatly) return } // ボタン連打判定 val now = SystemClock.elapsedRealtime() val delta = now - lastPostTapped lastPostTapped = now if (delta < 1000L) { activity.showToast(false, R.string.post_button_tapped_repeatly) return } postJob = launchMain { activity.runApiTask( account, progressSetup = { it.setCanceledOnTouchOutside(false) }, ) { client -> // 全ての確認を終えたらバックグラウンドでの処理を開始する val (instance, ri) = TootInstance.get(client) instance ?: return@runApiTask ri if (instance.instanceType == InstanceType.Pixelfed) { // Pixelfedは返信に画像を添付できない if (inReplyToId != null && attachmentList != null) { return@runApiTask TootApiResult(getString(R.string.pixelfed_does_not_allow_reply_with_media)) } // Pixelfedの返信ではない投稿は画像添付が必須 if (inReplyToId == null && attachmentList == null) { return@runApiTask TootApiResult(getString(R.string.pixelfed_does_not_allow_post_without_media)) } } val parser = TootParser(this, account) this@PostImpl.visibilityChecked = try { checkVisibility(client, parser, instance) // may null } catch (ex: TootApiResultException) { return@runApiTask ex.result } // 元の投稿を削除する 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: TootApiResultException) { return@runApiTask ex.result } catch (ex: JsonException) { log.trace(ex) log.e(ex, "status encoding failed.") } val bodyString = json.toString() val requestBuilder = bodyString.toRequestBody(MEDIA_TYPE_JSON).toPost() if (!PrefB.bpDontDuplicationCheck(App1.pref)) { val digest = (bodyString + account.acct.ascii).digestSHA256Hex() requestBuilder.header("Idempotency-Key", digest) } if (account.isMisskey) { client.request("/api/notes/create", requestBuilder) } else { client.request("/api/v1/statuses", requestBuilder) }?.also { result -> val jsonObject = result.jsonObject if (scheduledAt != 0L && jsonObject != null) { // {"id":"3","scheduled_at":"2019-01-06T07:08:00.000Z","media_attachments":[]} resultScheduledStatusSucceeded = true return@runApiTask result } else { val status = parser.status( when { account.isMisskey -> jsonObject?.jsonObject("createdNote") ?: jsonObject else -> jsonObject } ) resultStatus = status saveStatusTag(status) } } }?.let { result -> val status = resultStatus val scheduledStatusSucceeded = resultScheduledStatusSucceeded when { scheduledStatusSucceeded -> callback.onScheduledPostComplete(account) // 連投してIdempotency が同じだった場合もエラーにはならず、ここを通る status != null -> callback.onPostComplete(account, status) else -> activity.showToast(true, result.error) } } }.wrapWeakReference } }