Mastodon 4.0以降で単語フィルタの削除に失敗していた

This commit is contained in:
tateisu 2024-01-04 10:40:09 +09:00
parent 77d780881c
commit 2ed3ebe0f1
15 changed files with 362 additions and 276 deletions

View File

@ -164,7 +164,7 @@ dependencies {
implementation(project(":anko")) implementation(project(":anko"))
implementation(fileTree(mapOf("dir" to "src/main/libs", "include" to arrayOf("*.aar")))) implementation(fileTree(mapOf("dir" to "src/main/libs", "include" to arrayOf("*.aar"))))
"fcmImplementation"("com.google.firebase:firebase-messaging:23.3.1") "fcmImplementation"("com.google.firebase:firebase-messaging:23.4.0")
"fcmImplementation"("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:${Vers.kotlinxCoroutinesVersion}") "fcmImplementation"("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:${Vers.kotlinxCoroutinesVersion}")
// implementation "org.conscrypt:conscrypt-android:$conscryptVersion" // implementation "org.conscrypt:conscrypt-android:$conscryptVersion"
@ -272,25 +272,26 @@ tasks.register<Detekt>("detektAll") {
) )
) )
// val kotlinFiles = "**/*.kt"
// include(kotlinFiles)
val resourceFiles = "**/resources/**" val resourceFiles = "**/resources/**"
val buildFiles = "**/build/**" val buildFiles = "**/build/**"
exclude(resourceFiles, buildFiles) exclude(resourceFiles, buildFiles)
reports { reports {
val buildDir = layout.buildDirectory fun reportLocationByExt(ext: String) =
layout.buildDirectory
xml.required.set(false) .file("reports/detekt/st-${name}.$ext")
xml.outputLocation.set(file("$buildDir/reports/detekt/st-${name}.xml")) .get()
.asFile
html.required.set(true)
html.outputLocation.set(file("$buildDir/reports/detekt/st-${name}.html"))
txt.required.set(true) txt.required.set(true)
txt.outputLocation.set(file("$buildDir/reports/detekt/st-${name}.txt")) txt.outputLocation.set(reportLocationByExt("txt"))
sarif.required.set(true) html.required.set(true)
sarif.outputLocation.set(file("$buildDir/reports/detekt/st-${name}.sarif")) html.outputLocation.set(reportLocationByExt("html"))
xml.required.set(false)
xml.outputLocation.set(reportLocationByExt("xml"))
sarif.required.set(false)
sarif.outputLocation.set(reportLocationByExt("sarif"))
} }
} }

View File

@ -79,12 +79,14 @@ class ActCallback : AppCompatActivity() {
sharedIntent.set(intent) sharedIntent.set(intent)
} }
} }
forbidUriFromApp(intent) -> { forbidUriFromApp(intent) -> {
// last_uriをクリアする // last_uriをクリアする
lastUri.set(null) lastUri.set(null)
// ダイアログを閉じるまで画面遷移しない // ダイアログを閉じるまで画面遷移しない
return return
} }
else -> { else -> {
val uri = intent.data val uri = intent.data
if (uri != null) { if (uri != null) {
@ -123,7 +125,7 @@ class ActCallback : AppCompatActivity() {
val type = src.type val type = src.type
if (type.isMediaMimeType()) { if (type.isMediaMimeType()) {
when (action){ when (action) {
Intent.ACTION_VIEW -> { Intent.ACTION_VIEW -> {
src.data?.let { uriOriginal -> src.data?.let { uriOriginal ->
try { try {
@ -137,6 +139,7 @@ class ActCallback : AppCompatActivity() {
} }
} }
} }
Intent.ACTION_SEND -> { Intent.ACTION_SEND -> {
var uri = src.getStreamUriExtra() var uri = src.getStreamUriExtra()
?: return src // text/plainの場合 ?: return src // text/plainの場合
@ -152,7 +155,8 @@ class ActCallback : AppCompatActivity() {
log.e(ex, "remake failed. src=$src") log.e(ex, "remake failed. src=$src")
} }
} }
Intent.ACTION_SEND_MULTIPLE -> {
Intent.ACTION_SEND_MULTIPLE -> {
val listUri = src.getStreamUriListExtra() val listUri = src.getStreamUriListExtra()
?: return null ?: return null
val listDst = ArrayList<Uri>() val listDst = ArrayList<Uri>()

View File

@ -247,7 +247,7 @@ class ActKeywordFilter : AppCompatActivity() {
if (result?.response?.code == 404) { if (result?.response?.code == 404) {
// try v1 // try v1
result = client.request("${ApiPath.PATH_FILTERS}/$filterId") result = client.request("${ApiPath.PATH_FILTERS_V1}/$filterId")
result?.jsonObject?.let { result?.jsonObject?.let {
try { try {
resultFilter = TootFilter(it) resultFilter = TootFilter(it)
@ -391,12 +391,12 @@ class ActKeywordFilter : AppCompatActivity() {
return runApiTask(account) { client -> return runApiTask(account) { client ->
if (filterId == null) { if (filterId == null) {
client.request( client.request(
ApiPath.PATH_FILTERS, ApiPath.PATH_FILTERS_V1,
params.toPostRequestBuilder() params.toPostRequestBuilder()
) )
} else { } else {
client.request( client.request(
"${ApiPath.PATH_FILTERS}/$filterId", "${ApiPath.PATH_FILTERS_V1}/$filterId",
params.toRequestBody().toPut() params.toRequestBody().toPut()
) )
} }

View File

@ -7,11 +7,17 @@ import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.actmain.addColumn import jp.juggler.subwaytooter.actmain.addColumn
import jp.juggler.subwaytooter.actmain.reloadAccountSetting import jp.juggler.subwaytooter.actmain.reloadAccountSetting
import jp.juggler.subwaytooter.actmain.showColumnMatchAccount import jp.juggler.subwaytooter.actmain.showColumnMatchAccount
import jp.juggler.subwaytooter.api.* import jp.juggler.subwaytooter.api.ApiTask
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.Acct import jp.juggler.subwaytooter.api.entity.Acct
import jp.juggler.subwaytooter.api.entity.EntityId import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.TootStatus import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.api.entity.TootVisibility import jp.juggler.subwaytooter.api.entity.TootVisibility
import jp.juggler.subwaytooter.api.errorApiResult
import jp.juggler.subwaytooter.api.runApiTask2
import jp.juggler.subwaytooter.api.syncStatus
import jp.juggler.subwaytooter.column.ColumnType import jp.juggler.subwaytooter.column.ColumnType
import jp.juggler.subwaytooter.column.findStatus import jp.juggler.subwaytooter.column.findStatus
import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm
@ -27,6 +33,7 @@ import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.data.JsonObject import jp.juggler.util.data.JsonObject
import jp.juggler.util.log.showToast import jp.juggler.util.log.showToast
import jp.juggler.util.network.toPostRequestBuilder import jp.juggler.util.network.toPostRequestBuilder
import kotlinx.coroutines.CancellationException
import kotlin.math.max import kotlin.math.max
private class BoostImpl( private class BoostImpl(
@ -47,8 +54,6 @@ private class BoostImpl(
private val isPrivateToot = accessInfo.isMastodon && private val isPrivateToot = accessInfo.isMastodon &&
statusArg.visibility == TootVisibility.PrivateFollowers statusArg.visibility == TootVisibility.PrivateFollowers
private var bConfirmed = false
private fun preCheck(): Boolean { private fun preCheck(): Boolean {
// アカウントからステータスにブースト操作を行っているなら、何もしない // アカウントからステータスにブースト操作を行っているなら、何もしない
@ -73,15 +78,15 @@ private class BoostImpl(
} else { } else {
val (result, status) = client.syncStatus(accessInfo, statusArg) val (result, status) = client.syncStatus(accessInfo, statusArg)
when { when {
result == null -> throw CancellationException()
status == null -> errorApiResult(result) status == null -> errorApiResult(result)
status.reblogged -> errorApiResult(getString(R.string.already_boosted)) status.reblogged -> errorApiResult(R.string.already_boosted)
else -> status else -> status
} }
} }
// ブースト結果をUIに反映させる // ブースト結果をUIに反映させる
private fun after(result: TootApiResult?, newStatus: TootStatus?, unrenoteId: EntityId?) { private fun after(newStatus: TootStatus?, unrenoteId: EntityId?) {
result ?: return // cancelled.
when { when {
// Misskeyでunrenoteに成功した // Misskeyでunrenoteに成功した
unrenoteId != null -> { unrenoteId != null -> {
@ -143,36 +148,20 @@ private class BoostImpl(
} }
callback() callback()
} }
else -> activity.showToast(true, result.error)
} }
} }
suspend fun boostApi(client: TootApiClient, targetStatus: TootStatus): TootApiResult? = suspend fun boostApi(client: TootApiClient, targetStatus: TootStatus): TootApiResult =
if (accessInfo.isMisskey) { when {
if (!bSet) { accessInfo.isMisskey -> when {
val myRenoteId = targetStatus.myRenoteId ?: errorApiResult("missing renote id.") // misskey, create renote
bSet -> client.requestOrThrow(
client.request(
"/api/notes/delete",
accessInfo.putMisskeyApiToken().apply {
put("noteId", myRenoteId.toString())
put("renoteId", targetStatus.id.toString())
}.toPostRequestBuilder()
)?.also {
if (it.response?.code == 204) {
resultUnrenoteId = myRenoteId
}
}
} else {
client.request(
"/api/notes/create", "/api/notes/create",
accessInfo.putMisskeyApiToken().apply { accessInfo.putMisskeyApiToken().apply {
put("renoteId", targetStatus.id.toString()) put("renoteId", targetStatus.id.toString())
}.toPostRequestBuilder() }.toPostRequestBuilder()
)?.also { result -> ).apply {
val jsonObject = result.jsonObject jsonObject?.let { jsonObject ->
if (jsonObject != null) {
val outerStatus = val outerStatus =
parser.status(jsonObject.jsonObject("createdNote") ?: jsonObject) parser.status(jsonObject.jsonObject("createdNote") ?: jsonObject)
val innerStatus = outerStatus?.reblog ?: outerStatus val innerStatus = outerStatus?.reblog ?: outerStatus
@ -184,73 +173,84 @@ private class BoostImpl(
resultStatus = innerStatus resultStatus = innerStatus
} }
} }
} // misskey, delete renote
} else { else -> {
val b = JsonObject().apply { val myRenoteId = targetStatus.myRenoteId
if (visibility != null) put("visibility", visibility.strMastodon) ?: error("missing renote id.")
}.toPostRequestBuilder()
client.request( client.requestOrThrow(
"/api/notes/delete",
accessInfo.putMisskeyApiToken().apply {
put("noteId", myRenoteId.toString())
put("renoteId", targetStatus.id.toString())
}.toPostRequestBuilder()
).apply {
if (response?.code == 204) {
resultUnrenoteId = myRenoteId
}
}
}
}
// mastodon, reblog or unreblog
else -> client.requestOrThrow(
"/api/v1/statuses/${targetStatus.id}/${if (bSet) "reblog" else "unreblog"}", "/api/v1/statuses/${targetStatus.id}/${if (bSet) "reblog" else "unreblog"}",
b requestBuilder = JsonObject().apply {
)?.also { result -> if (visibility != null) put("visibility", visibility.strMastodon)
}.toPostRequestBuilder()
).apply {
// reblogはreblogを表すStatusを返す // reblogはreblogを表すStatusを返す
// unreblogはreblogしたStatusを返す // unreblogはreblogしたStatusを返す
val s = parser.status(result.jsonObject) val s = parser.status(jsonObject)
resultStatus = s?.reblog ?: s resultStatus = s?.reblog ?: s
} }
} }
fun run() { fun run() = activity.launchAndShowError {
activity.launchAndShowError { if (!preCheck()) return@launchAndShowError
if (!preCheck()) return@launchAndShowError
if (!bConfirmed) { val confirmMessage = activity.getString(
activity.confirm( when {
activity.getString( !bSet -> R.string.confirm_unboost_from
when { isPrivateToot -> R.string.confirm_boost_private_from
!bSet -> R.string.confirm_unboost_from visibility == TootVisibility.PrivateFollowers ->
isPrivateToot -> R.string.confirm_boost_private_from R.string.confirm_private_boost_from
visibility == TootVisibility.PrivateFollowers -> R.string.confirm_private_boost_from
else -> R.string.confirm_boost_from else -> R.string.confirm_boost_from
}, },
daoAcctColor.getNickname(accessInfo) daoAcctColor.getNickname(accessInfo)
), )
when (bSet) {
true -> accessInfo.confirmBoost activity.confirm(
else -> accessInfo.confirmUnboost message = confirmMessage,
} isConfirmEnabled = when (bSet) {
) { newConfirmEnabled -> true -> accessInfo.confirmBoost
when (bSet) { else -> accessInfo.confirmUnboost
true -> accessInfo.confirmBoost = newConfirmEnabled
else -> accessInfo.confirmUnboost = newConfirmEnabled
}
daoSavedAccount.save(accessInfo)
activity.reloadAccountSetting(accessInfo)
}
} }
) { newConfirmEnabled ->
when (bSet) {
true -> accessInfo.confirmBoost = newConfirmEnabled
else -> accessInfo.confirmUnboost = newConfirmEnabled
}
daoSavedAccount.save(accessInfo)
activity.reloadAccountSetting(accessInfo)
}
// ブースト表示を更新中にする // ブースト表示を更新中にする
activity.appState.setBusyBoost(accessInfo, statusArg) activity.appState.setBusyBoost(accessInfo, statusArg)
activity.showColumnMatchAccount(accessInfo) activity.showColumnMatchAccount(accessInfo)
try {
val result = activity.runApiTask2(
activity.runApiTask( accessInfo = accessInfo,
accessInfo, progressStyle = ApiTask.PROGRESS_NONE
progressStyle = ApiTask.PROGRESS_NONE ) { client ->
) { client -> boostApi(client, syncStatus(client))
try { }
val targetStatus = syncStatus(client) // カラムデータの書き換え
boostApi(client, targetStatus) after(resultStatus, resultUnrenoteId)
} catch (ex: TootApiResultException) { } finally {
ex.result
}
}
// 更新中状態をリセット // 更新中状態をリセット
activity.appState.resetBusyBoost(accessInfo, statusArg) activity.appState.resetBusyBoost(accessInfo, statusArg)
// カラムデータの書き換え // 失敗やキャンセルの場合でも表示を更新する
after(result, resultStatus, resultUnrenoteId)
// result == null の場合でも更新中表示の解除が必要になる
activity.showColumnMatchAccount(accessInfo) activity.showColumnMatchAccount(accessInfo)
} }
} }

View File

@ -3,15 +3,19 @@ package jp.juggler.subwaytooter.action
import jp.juggler.subwaytooter.ActKeywordFilter import jp.juggler.subwaytooter.ActKeywordFilter
import jp.juggler.subwaytooter.ActMain import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.ApiPath
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.TootApiResultException
import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.TootFilter import jp.juggler.subwaytooter.api.entity.TootFilter
import jp.juggler.subwaytooter.api.runApiTask import jp.juggler.subwaytooter.api.runApiTask2
import jp.juggler.subwaytooter.column.onFilterDeleted import jp.juggler.subwaytooter.column.onFilterDeleted
import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm
import jp.juggler.subwaytooter.dialog.actionsDialog import jp.juggler.subwaytooter.dialog.actionsDialog
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.log.showToast import jp.juggler.util.log.showToast
import okhttp3.Request
// private val log = LogCategory("Action_Filter") // private val log = LogCategory("Action_Filter")
@ -30,39 +34,56 @@ fun ActMain.openFilterMenu(accessInfo: SavedAccount, item: TootFilter?) {
} }
} }
fun ActMain.filterDelete( suspend fun TootApiClient.filterDelete(filterId: EntityId): TootApiResult {
accessInfo: SavedAccount, for (path in arrayOf(
filter: TootFilter, "/api/v2/filters/${filterId}",
bConfirmed: Boolean = false, "/api/v1/filters/${filterId}",
) { )) {
launchAndShowError { try {
if (!bConfirmed) { return requestOrThrow(path = path)
confirm(R.string.filter_delete_confirm, filter.displayString) } catch (ex: TootApiResultException) {
} when (ex.result?.response?.code) {
404 -> continue
var resultFilterList: List<TootFilter>? = null else -> throw ex
runApiTask(accessInfo) { client ->
var result =
client.request("/api/v1/filters/${filter.id}", Request.Builder().delete())
if (result != null && result.error == null) {
result = client.request("/api/v1/filters")
val jsonArray = result?.jsonArray
if (jsonArray != null) resultFilterList = TootFilter.parseList(jsonArray)
}
result
}?.let { result ->
when (val filterList = resultFilterList) {
null -> showToast(false, result.error)
else -> {
showToast(false, R.string.delete_succeeded)
for (column in appState.columnList) {
if (column.accessInfo == accessInfo) {
column.onFilterDeleted(filter, filterList)
}
}
}
} }
} }
} }
error("missing filter APIs.")
}
suspend fun TootApiClient.filterLoad(): List<TootFilter> {
for (path in arrayOf(
ApiPath.PATH_FILTERS_V2,
ApiPath.PATH_FILTERS_V1,
)) {
try {
val jsonArray = requestOrThrow(path).jsonArray
?: error("API response has no jsonArray.")
return TootFilter.parseList(jsonArray)
?: error("TootFilter.parseList returns null.")
} catch (ex: TootApiResultException) {
when (ex.result?.response?.code) {
404 -> continue
else -> throw ex
}
}
}
error("missing filter APIs.")
}
fun ActMain.filterDelete(
accessInfo: SavedAccount,
filter: TootFilter,
) = launchAndShowError {
confirm(R.string.filter_delete_confirm, filter.displayString)
val newFilters = runApiTask2(accessInfo) { client ->
client.filterDelete(filter.id)
client.filterLoad()
}
showToast(false, R.string.delete_succeeded)
for (column in appState.columnList) {
if (column.accessInfo == accessInfo) {
column.onFilterDeleted(filter, newFilters)
}
}
} }

View File

@ -41,7 +41,7 @@ object ApiPath {
// リストではなくオブジェクトを返すAPI // リストではなくオブジェクトを返すAPI
const val PATH_STATUSES = "/api/v1/statuses/%s" // 1:status_id const val PATH_STATUSES = "/api/v1/statuses/%s" // 1:status_id
const val PATH_FILTERS = "/api/v1/filters" const val PATH_FILTERS_V1 = "/api/v1/filters"
const val PATH_FILTERS_V2 = "/api/v2/filters" const val PATH_FILTERS_V2 = "/api/v2/filters"
const val PATH_MISSKEY_PROFILE_FOLLOWING = "/api/users/following" const val PATH_MISSKEY_PROFILE_FOLLOWING = "/api/users/following"

View File

@ -5,16 +5,42 @@ import android.net.Uri
import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.auth.AuthBase import jp.juggler.subwaytooter.api.auth.AuthBase
import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.entity.Acct
import jp.juggler.subwaytooter.api.entity.Host
import jp.juggler.subwaytooter.api.entity.TootAccount
import jp.juggler.subwaytooter.api.entity.TootAccountRef
import jp.juggler.subwaytooter.api.entity.TootAccountRef.Companion.tootAccountRefOrNull import jp.juggler.subwaytooter.api.entity.TootAccountRef.Companion.tootAccountRefOrNull
import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.api.entity.TootResults
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.* import jp.juggler.subwaytooter.util.DecodeOptions
import jp.juggler.util.data.* import jp.juggler.subwaytooter.util.LinkHelper
import jp.juggler.subwaytooter.util.SimpleHttpClient
import jp.juggler.subwaytooter.util.SimpleHttpClientImpl
import jp.juggler.subwaytooter.util.matchHost
import jp.juggler.util.data.CharacterGroup
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.asciiRegex
import jp.juggler.util.data.decodeJsonArray
import jp.juggler.util.data.decodeJsonObject
import jp.juggler.util.data.decodePercent
import jp.juggler.util.data.decodeUTF8
import jp.juggler.util.data.encodePercent
import jp.juggler.util.data.groupEx
import jp.juggler.util.data.letNotEmpty
import jp.juggler.util.data.notEmpty
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.withCaption import jp.juggler.util.log.withCaption
import jp.juggler.util.network.toPostRequestBuilder import jp.juggler.util.network.toPostRequestBuilder
import okhttp3.* import kotlinx.coroutines.CancellationException
import okhttp3.Call
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
class TootApiClient( class TootApiClient(
@ -358,7 +384,7 @@ class TootApiClient(
////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////
// fun request( // fun request(
// path: String, // path: String,
// request_builder: Request.Builder = Request.Builder() // request_builder: Request.Builder = Request.Builder()
// ): TootApiResult? { // ): TootApiResult? {
@ -391,6 +417,26 @@ class TootApiClient(
// } // }
// //
/**
* requestと同じだがキャンセルやエラー発生時に例外を投げる
*/
suspend fun requestOrThrow(
path: String,
requestBuilder: Request.Builder = Request.Builder(),
forceAccessToken: String? = null,
): TootApiResult {
val result = request(
path = path,
requestBuilder = requestBuilder,
forceAccessToken = forceAccessToken,
)
when {
result == null -> throw CancellationException()
!result.error.isNullOrBlank() -> errorApiResult(result)
else -> return result
}
}
suspend fun request( suspend fun request(
path: String, path: String,
requestBuilder: Request.Builder = Request.Builder(), requestBuilder: Request.Builder = Request.Builder(),

View File

@ -1,12 +1,18 @@
package jp.juggler.subwaytooter.api package jp.juggler.subwaytooter.api
import android.content.Context
import androidx.annotation.StringRes
class TootApiResultException(val result: TootApiResult?) : class TootApiResultException(val result: TootApiResult?) :
Exception(result?.error ?: "cancelled.") { Exception(result?.error ?: "cancelled.") {
constructor(error: String) : this(TootApiResult(error)) constructor(error: String) : this(TootApiResult(error))
} }
fun errorApiResult(result: TootApiResult?): Nothing = fun errorApiResult(result: TootApiResult): Nothing =
throw TootApiResultException(result) throw TootApiResultException(result)
fun errorApiResult(error: String): Nothing = fun errorApiResult(error: String): Nothing =
throw TootApiResultException(error) throw TootApiResultException(error)
fun Context.errorApiResult(@StringRes stringId: Int, vararg args: Any?): Nothing =
errorApiResult(getString(stringId, *args))

View File

@ -8,7 +8,6 @@ import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.runApiTask import jp.juggler.subwaytooter.api.runApiTask
import jp.juggler.subwaytooter.table.* import jp.juggler.subwaytooter.table.*
import jp.juggler.util.*
import jp.juggler.util.coroutine.launchMain import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.data.WordTrieTree import jp.juggler.util.data.WordTrieTree
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
@ -433,7 +432,7 @@ suspend fun Column.loadFilter2(client: TootApiClient): List<TootFilter>? {
if (getFilterContext() == null) return null if (getFilterContext() == null) return null
var result = client.request(ApiPath.PATH_FILTERS_V2) var result = client.request(ApiPath.PATH_FILTERS_V2)
if (result?.response?.code == 404) { if (result?.response?.code == 404) {
result = client.request(ApiPath.PATH_FILTERS) result = client.request(ApiPath.PATH_FILTERS_V1)
} }
val jsonArray = result?.jsonArray ?: return null val jsonArray = result?.jsonArray ?: return null
@ -511,7 +510,7 @@ fun reloadFilter(context: Context, accessInfo: SavedAccount) {
) { client -> ) { client ->
var result = client.request(ApiPath.PATH_FILTERS_V2) var result = client.request(ApiPath.PATH_FILTERS_V2)
if (result?.response?.code == 404) { if (result?.response?.code == 404) {
result = client.request(ApiPath.PATH_FILTERS) result = client.request(ApiPath.PATH_FILTERS_V1)
} }
result?.jsonArray?.let { result?.jsonArray?.let {
resultList = TootFilter.parseList(it) resultList = TootFilter.parseList(it)

View File

@ -10,7 +10,6 @@ import jp.juggler.subwaytooter.columnviewholder.scrollToTop
import jp.juggler.subwaytooter.notification.injectData import jp.juggler.subwaytooter.notification.injectData
import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.util.OpenSticker import jp.juggler.subwaytooter.util.OpenSticker
import jp.juggler.util.*
import jp.juggler.util.coroutine.runOnMainLooper import jp.juggler.util.coroutine.runOnMainLooper
import jp.juggler.util.coroutine.runOnMainLooperDelayed import jp.juggler.util.coroutine.runOnMainLooperDelayed
import jp.juggler.util.data.JsonArray import jp.juggler.util.data.JsonArray
@ -902,7 +901,7 @@ class ColumnTask_Loading(
suspend fun getFilterList(client: TootApiClient): TootApiResult? { suspend fun getFilterList(client: TootApiClient): TootApiResult? {
var result = client.request(ApiPath.PATH_FILTERS_V2) var result = client.request(ApiPath.PATH_FILTERS_V2)
if (result?.response?.code == 404) { if (result?.response?.code == 404) {
result = client.request(ApiPath.PATH_FILTERS) result = client.request(ApiPath.PATH_FILTERS_V1)
} }
if (result != null) { if (result != null) {
val src = TootFilter.parseList(result.jsonArray) val src = TootFilter.parseList(result.jsonArray)

View File

@ -3,8 +3,20 @@ package jp.juggler.subwaytooter.util
import android.os.SystemClock import android.os.SystemClock
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.* import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.entity.* 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.dialog.DlgConfirm.confirm
import jp.juggler.subwaytooter.emoji.CustomEmoji import jp.juggler.subwaytooter.emoji.CustomEmoji
import jp.juggler.subwaytooter.getVisibilityString import jp.juggler.subwaytooter.getVisibilityString
@ -14,19 +26,31 @@ import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.daoAcctColor import jp.juggler.subwaytooter.table.daoAcctColor
import jp.juggler.subwaytooter.table.daoSavedAccount import jp.juggler.subwaytooter.table.daoSavedAccount
import jp.juggler.subwaytooter.table.daoTagHistory import jp.juggler.subwaytooter.table.daoTagHistory
import jp.juggler.util.*
import jp.juggler.util.coroutine.AppDispatchers import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.data.* import jp.juggler.util.data.JsonArray
import jp.juggler.util.log.* 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.MEDIA_TYPE_JSON
import jp.juggler.util.network.toPostRequestBuilder import jp.juggler.util.network.toPostRequestBuilder
import jp.juggler.util.ui.*
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.util.* import java.util.Calendar
import java.util.GregorianCalendar
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
sealed class PostResult { sealed class PostResult {
@ -110,36 +134,22 @@ class PostImpl(
} }
} }
private var resultStatus: TootStatus? = null // may null, not error
private var resultCredentialTmp: TootAccount? = null
private 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( private suspend fun getWebVisibility(
client: TootApiClient, client: TootApiClient,
parser: TootParser, parser: TootParser,
instance: TootInstance, instance: TootInstance,
): TootVisibility? { ): TootVisibility? = when {
if (account.isMisskey || instance.versionGE(TootInstance.VERSION_1_6)) return null account.isMisskey -> null
instance.versionGE(TootInstance.VERSION_1_6) -> null
val r2 = getCredential(client, parser) else -> {
val privacy = parser.account(
val credentialTmp = resultCredentialTmp client.requestOrThrow("/api/v1/accounts/verify_credentials")
?: errorApiResult(r2) .jsonObject
)?.source?.privacy
val privacy = credentialTmp.source?.privacy ?: error(R.string.cant_get_web_setting_visibility)
?: errorApiResult(activity.getString(R.string.cant_get_web_setting_visibility)) TootVisibility.parseMastodon(privacy)
}
return TootVisibility.parseMastodon(privacy)
// may null, not error
} }
private fun checkServerHasVisibility( private fun checkServerHasVisibility(
@ -150,12 +160,7 @@ class PostImpl(
) { ) {
if (actual != extra || checkFun(instance)) return if (actual != extra || checkFun(instance)) return
val strVisibility = extra.getVisibilityString(account.isMisskey) val strVisibility = extra.getVisibilityString(account.isMisskey)
errorApiResult( activity.errorApiResult(R.string.server_has_no_support_of_visibility, strVisibility)
activity.getString(
R.string.server_has_no_support_of_visibility,
strVisibility
)
)
} }
private suspend fun checkVisibility( private suspend fun checkVisibility(
@ -205,7 +210,6 @@ class PostImpl(
"/api/v1/scheduled_statuses/$scheduledId", "/api/v1/scheduled_statuses/$scheduledId",
Request.Builder().delete() Request.Builder().delete()
) )
log.d("delete old scheduled status. result=$result") log.d("delete old scheduled status. result=$result")
delay(2000L) delay(2000L)
} }
@ -271,15 +275,13 @@ class PostImpl(
// Misskeyの場合、NSFWするにはアップロード済みの画像を drive/files/update で更新する // Misskeyの場合、NSFWするにはアップロード済みの画像を drive/files/update で更新する
if (bNSFW) { if (bNSFW) {
val r = client.request( client.requestOrThrow(
"/api/drive/files/update", "/api/drive/files/update",
account.putMisskeyApiToken().apply { account.putMisskeyApiToken().apply {
put("fileId", a.id.toString()) put("fileId", a.id.toString())
put("isSensitive", true) put("isSensitive", true)
} }.toPostRequestBuilder()
.toPostRequestBuilder()
) )
if (r == null || r.error != null) errorApiResult(r)
} }
} }
if (array.isNotEmpty()) json["mediaIds"] = array if (array.isNotEmpty()) json["mediaIds"] = array
@ -361,7 +363,7 @@ class PostImpl(
if (scheduledAt != 0L) { if (scheduledAt != 0L) {
if (!instance.versionGE(TootInstance.VERSION_2_7_0_rc1)) { if (!instance.versionGE(TootInstance.VERSION_2_7_0_rc1)) {
errorApiResult(activity.getString(R.string.scheduled_status_requires_mastodon_2_7_0)) activity.errorApiResult(R.string.scheduled_status_requires_mastodon_2_7_0)
} }
// UTCの日時を渡す // UTCの日時を渡す
val c = GregorianCalendar.getInstance(TimeZone.getTimeZone("UTC")) val c = GregorianCalendar.getInstance(TimeZone.getTimeZone("UTC"))
@ -505,32 +507,28 @@ class PostImpl(
isPosting.set(true) isPosting.set(true)
return try { return try {
withContext(AppDispatchers.MainImmediate) { withContext(AppDispatchers.MainImmediate) {
activity.runApiTask( val (status, scheduled) = activity.runApiTask2(
account, accessInfo = account,
progressSetup = { it.setCanceledOnTouchOutside(false) }, progressSetup = { it.setCanceledOnTouchOutside(false) },
) { client -> ) { client ->
val (instance, ri) = TootInstance.get(client) val instance = TootInstance.getOrThrow(client)
instance ?: return@runApiTask ri
if (instance.instanceType == InstanceType.Pixelfed) { if (instance.instanceType == InstanceType.Pixelfed) {
// Pixelfedは返信に画像を添付できない // Pixelfedは返信に画像を添付できない
if (inReplyToId != null && attachmentList != null) { if (inReplyToId != null && attachmentList != null) {
return@runApiTask TootApiResult(getString(R.string.pixelfed_does_not_allow_reply_with_media)) error(R.string.pixelfed_does_not_allow_reply_with_media)
} }
// Pixelfedの返信ではない投稿は画像添付が必須 // Pixelfedの返信ではない投稿は画像添付が必須
if (inReplyToId == null && attachmentList == null) { if (inReplyToId == null && attachmentList == null) {
return@runApiTask TootApiResult(getString(R.string.pixelfed_does_not_allow_post_without_media)) error(R.string.pixelfed_does_not_allow_post_without_media)
} }
} }
val parser = TootParser(this, account) val parser = TootParser(this, account)
this@PostImpl.visibilityChecked = try { // may null
checkVisibility(client, parser, instance) // may null this@PostImpl.visibilityChecked = checkVisibility(client, parser, instance)
} catch (ex: TootApiResultException) {
return@runApiTask ex.result
}
if (redraftStatusId != null) { if (redraftStatusId != null) {
// 元の投稿を削除する // 元の投稿を削除する
@ -546,10 +544,8 @@ class PostImpl(
} else { } else {
encodeParamsMastodon(json, instance) encodeParamsMastodon(json, instance)
} }
} catch (ex: TootApiResultException) {
return@runApiTask ex.result
} catch (ex: JsonException) { } catch (ex: JsonException) {
log.e(ex, "status encoding failed.") throw IllegalStateException("encoding status failed.", ex)
} }
val bodyString = json.toString() val bodyString = json.toString()
@ -567,67 +563,66 @@ class PostImpl(
} }
} }
when { try {
account.isMisskey -> client.request( val result = when {
"/api/notes/create", account.isMisskey -> client.requestOrThrow(
createRequestBuilder() "/api/notes/create",
) createRequestBuilder()
)
editStatusId != null -> client.request( editStatusId != null -> client.requestOrThrow(
"/api/v1/statuses/$editStatusId", "/api/v1/statuses/$editStatusId",
createRequestBuilder(isPut = true) createRequestBuilder(isPut = true)
) )
else -> client.request( else -> client.requestOrThrow(
"/api/v1/statuses", "/api/v1/statuses",
createRequestBuilder() createRequestBuilder()
) )
}?.also { result -> }
val jsonObject = result.jsonObject val jsonObject = result.jsonObject
when {
if (scheduledAt != 0L && jsonObject != null) { // 予約投稿完了
// {"id":"3","scheduled_at":"2019-01-06T07:08:00.000Z","media_attachments":[]} scheduledAt != 0L && jsonObject != null -> {
resultScheduledStatusSucceeded = true // {"id":"3","scheduled_at":"2019-01-06T07:08:00.000Z","media_attachments":[]}
return@runApiTask result Pair(null, true)
} else { }
val status = parser.status(
when {
account.isMisskey -> jsonObject?.jsonObject("createdNote")
?: jsonObject
else -> jsonObject // 通常投稿完了
} else -> {
) val status = parser.status(
resultStatus = status when {
saveStatusTag(status) 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
} }
} }
}.let { result -> }
if (result == null) throw CancellationException() when {
scheduled -> PostResult.Scheduled(account)
val status = resultStatus status == null ->
when { error("can't parse status in API result.")
resultScheduledStatusSucceeded ->
PostResult.Scheduled(account)
// 連投してIdempotency が同じだった場合もエラーにはならず、ここを通る // 連投してIdempotency が同じだった場合もエラーにはならず、ここを通る
status != null -> else -> PostResult.Normal(account, status)
PostResult.Normal(account, status)
else -> {
val e = result.error
error(
when {
e.isNullOrBlank() -> "(missing error detail)"
e.contains("HTTP 404") ->
"$e\n${activity.getString(R.string.post_404_desc)}"
else -> e
}
)
}
}
} }
} }
} finally { } finally {

View File

@ -7,7 +7,12 @@ import jp.juggler.util.log.showError
import jp.juggler.util.log.showToast import jp.juggler.util.log.showToast
import jp.juggler.util.ui.ProgressDialogEx import jp.juggler.util.ui.ProgressDialogEx
import jp.juggler.util.ui.dismissSafe import jp.juggler.util.ui.dismissSafe
import kotlinx.coroutines.* import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
@ -68,6 +73,7 @@ fun AppCompatActivity.launchAndShowError(
is CancellationException -> { is CancellationException -> {
log.w(errorCaption ?: "launchAndShowError cancelled.") log.w(errorCaption ?: "launchAndShowError cancelled.")
} }
else -> { else -> {
log.e(ex, errorCaption ?: "launchAndShowError failed.") log.e(ex, errorCaption ?: "launchAndShowError failed.")
showError(ex, errorCaption) showError(ex, errorCaption)

View File

@ -9,7 +9,8 @@ import android.util.Base64
import androidx.core.net.toUri import androidx.core.net.toUri
import jp.juggler.util.log.LogCategory import jp.juggler.util.log.LogCategory
import java.security.MessageDigest import java.security.MessageDigest
import java.util.* import java.util.LinkedList
import java.util.Locale
import java.util.regex.Matcher import java.util.regex.Matcher
import java.util.regex.Pattern import java.util.regex.Pattern

View File

@ -1,6 +1,7 @@
package jp.juggler.util.os package jp.juggler.util.os
import android.content.Context import android.content.Context
import androidx.annotation.StringRes
/** /**
* インストゥルメントテストのContextは * インストゥルメントテストのContextは
@ -8,4 +9,12 @@ import android.content.Context
* この場合は元のcontextを補うのがベストだろう * この場合は元のcontextを補うのがベストだろう
*/ */
val Context.applicationContextSafe: Context val Context.applicationContextSafe: Context
get() = applicationContext ?: this get() = try {
applicationContext ?: this
} catch (ex: Throwable) {
// applicationContextへのアクセスは例外を出すことがある
this
}
fun Context.error(@StringRes resId: Int, vararg args: Any?): Nothing =
error(getString(resId, *args))

View File

@ -392,18 +392,17 @@ fun AppCompatActivity.setNavigationBack(toolbar: Toolbar) =
onBackPressedDispatcher.onBackPressed() onBackPressedDispatcher.onBackPressed()
} }
val Float.roundPixels get() = (this + 0.5f).toInt()
fun DisplayMetrics.dpFloat(src: Float) = (density * src)
fun DisplayMetrics.dpFloat(src: Int) = (density * src.toFloat())
fun Resources.dpFloat(src: Float) = displayMetrics.dpFloat(src)
fun Resources.dpFloat(src: Int) = displayMetrics.dpFloat(src)
fun Context.dpFloat(src: Float) = resources.dpFloat(src)
fun Context.dpFloat(src: Int) = resources.dpFloat(src)
val Float.roundPixels get()= (this+0.5f).toInt() fun DisplayMetrics.dp(src: Float) = (density * src).roundPixels
fun DisplayMetrics.dpFloat(src:Float) = (density* src) fun DisplayMetrics.dp(src: Int) = (density * src.toFloat()).roundPixels
fun DisplayMetrics.dpFloat(src:Int) = (density*src.toFloat()) fun Resources.dp(src: Float) = displayMetrics.dp(src)
fun Resources.dpFloat(src:Float) = displayMetrics.dpFloat(src) fun Resources.dp(src: Int) = displayMetrics.dp(src)
fun Resources.dpFloat(src:Int) = displayMetrics.dpFloat(src) fun Context.dp(src: Float) = resources.dp(src)
fun Context.dpFloat(src:Float) = resources.dpFloat(src) fun Context.dp(src: Int) = resources.dp(src)
fun Context.dpFloat(src:Int) = resources.dpFloat(src)
fun DisplayMetrics.dp(src:Float) = (density* src).roundPixels
fun DisplayMetrics.dp(src:Int) = (density*src.toFloat()).roundPixels
fun Resources.dp(src:Float) = displayMetrics.dp(src)
fun Resources.dp(src:Int) = displayMetrics.dp(src)
fun Context.dp(src:Float) = resources.dp(src)
fun Context.dp(src:Int) = resources.dp(src)