SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/api/TootApiClient.kt

780 lines
27 KiB
Kotlin
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package jp.juggler.subwaytooter.api
import android.content.Context
import android.net.Uri
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.auth.AuthBase
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.*
import jp.juggler.util.data.*
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.withCaption
import jp.juggler.util.network.toPostRequestBuilder
import okhttp3.*
import okhttp3.internal.closeQuietly
class TootApiClient(
val context: Context,
val httpClient: SimpleHttpClient =
SimpleHttpClientImpl(context, App1.ok_http_client),
val callback: TootApiCallback,
) {
companion object {
private val log = LogCategory("TootApiClient")
private const val NO_INFORMATION = "(no information)"
val reStartJsonArray = """\A\s*\[""".asciiRegex()
val reStartJsonObject = """\A\s*\{""".asciiRegex()
val DEFAULT_JSON_ERROR_PARSER =
{ json: JsonObject -> json["error"]?.toString() }
fun formatResponse(
response: Response,
caption: String = "?",
bodyString: String? = null,
) = TootApiResult(
response = response,
caption = caption,
bodyString = bodyString
).apply { parseErrorResponse() }.error ?: "(null)"
fun simplifyErrorHtml(
response: Response,
caption: String = "?",
bodyString: String = response.body?.string() ?: "",
jsonErrorParser: (json: JsonObject) -> String? = DEFAULT_JSON_ERROR_PARSER,
) = TootApiResult(
response = response,
caption = caption,
).simplifyErrorHtml(bodyString, jsonErrorParser)
}
// 認証に関する設定を保存する
// インスタンスのホスト名
var apiHost: Host? = null
// アカウントがある場合に使用する
var account: SavedAccount? = null
set(value) {
apiHost = value?.apiHost
field = value
}
var currentCallCallback: (Call) -> Unit
get() = httpClient.onCallCreated
set(value) {
httpClient.onCallCreated = value
}
internal suspend fun isApiCancelled() = callback.isApiCancelled()
suspend fun publishApiProgress(s: String) {
callback.publishApiProgress(s)
}
suspend fun publishApiProgressRatio(value: Int, max: Int) {
callback.publishApiProgressRatio(value, max)
}
//////////////////////////////////////////////////////////////////////
// ユーティリティ
// リクエストをokHttpに渡してレスポンスを取得する
// internal inline fun sendRequest(
// result: TootApiResult,
// progressPath: String? = null,
// tmpOkhttpClient: OkHttpClient? = null,
// block: () -> Request
// ): Boolean {
// return try {
// result.response = null
// result.bodyString = null
// result.data = null
//
// val request = block()
//
// result.requestInfo = "${request.method} ${progressPath ?: request.url.encodedPath}"
//
// callback.publishApiProgress(
// context.getString(
// R.string.request_api, request.method, progressPath ?: request.url.encodedPath
// )
// )
//
// val response = httpClient.getResponse(request, tmpOkhttpClient = tmpOkhttpClient)
// result.response = response
//
// null == result.error
//
// } catch (ex: Throwable) {
// result.setError(
// "${result.caption}: ${
// ex.withCaption(
// context.resources,
// R.string.network_error
// )
// }"
// )
// false
// }
// }
// リクエストをokHttpに渡してレスポンスを取得する
suspend inline fun sendRequest(
result: TootApiResult,
progressPath: String? = null,
overrideClient: OkHttpClient? = null,
block: () -> Request,
): Boolean {
result.response = null
result.bodyString = null
result.data = null
val request = block()
return try {
result.requestInfo = "${request.method} ${progressPath ?: request.url.encodedPath}"
callback.publishApiProgress(
context.getString(
R.string.request_api, request.method, progressPath ?: request.url.encodedPath
)
)
val response = httpClient.getResponse(request, overrideClient = overrideClient)
result.response = response
null == result.error
} catch (ex: Throwable) {
result.setError(
"${result.caption}: ${
ex.withCaption(
context.resources,
R.string.network_error
)
}"
)
false
}
}
// レスポンスがエラーかボディがカラならエラー状態を設定する
// 例外を出すかも
internal suspend fun readBodyString(
result: TootApiResult,
progressPath: String? = null,
jsonErrorParser: (json: JsonObject) -> String? = DEFAULT_JSON_ERROR_PARSER,
): String? {
val response = result.response ?: return null
try {
if (isApiCancelled()) return null
val request = response.request
publishApiProgress(
context.getString(
R.string.reading_api,
request.method,
progressPath ?: result.caption
)
)
val bodyString = response.body?.string()
if (isApiCancelled()) return null
// Misskey の /api/notes/favorites/create は 204(no content)を返す。ボディはカラになる。
if (bodyString?.isEmpty() != false && response.code in 200 until 300) {
result.bodyString = ""
return ""
}
if (!response.isSuccessful || bodyString?.isEmpty() != false) {
result.parseErrorResponse(
bodyString?.notEmpty() ?: NO_INFORMATION,
jsonErrorParser
)
}
return if (result.error != null) {
null
} else {
publishApiProgress(context.getString(R.string.parsing_response))
result.bodyString = bodyString
bodyString
}
} finally {
response.body?.closeQuietly()
}
}
// レスポンスがエラーかボディがカラならエラー状態を設定する
// 例外を出すかも
private suspend fun readBodyBytes(
result: TootApiResult,
progressPath: String? = null,
jsonErrorParser: (json: JsonObject) -> String? = DEFAULT_JSON_ERROR_PARSER,
): ByteArray? {
if (isApiCancelled()) return null
val response = result.response!!
val request = response.request
publishApiProgress(
context.getString(
R.string.reading_api,
request.method,
progressPath ?: result.caption
)
)
val bodyBytes = response.body?.bytes()
if (isApiCancelled()) return null
if (!response.isSuccessful || bodyBytes?.isEmpty() != false) {
result.parseErrorResponse(
bodyBytes?.notEmpty()?.decodeUTF8() ?: NO_INFORMATION,
jsonErrorParser
)
}
return if (result.error != null) {
null
} else {
result.bodyString = "(binary data)"
result.data = bodyBytes
bodyBytes
}
}
private suspend fun parseBytes(
result: TootApiResult,
progressPath: String? = null,
jsonErrorParser: (json: JsonObject) -> String? = DEFAULT_JSON_ERROR_PARSER,
): TootApiResult? {
try {
readBodyBytes(result, progressPath, jsonErrorParser)
?: return if (isApiCancelled()) null else result
} catch (ex: Throwable) {
log.e(ex, "parseBytes failed.")
result.parseErrorResponse(result.bodyString ?: NO_INFORMATION)
}
return result
}
internal suspend fun parseString(
result: TootApiResult,
progressPath: String? = null,
jsonErrorParser: (json: JsonObject) -> String? = DEFAULT_JSON_ERROR_PARSER,
): TootApiResult? {
try {
val bodyString = readBodyString(result, progressPath, jsonErrorParser)
?: return if (isApiCancelled()) null else result
result.data = bodyString
} catch (ex: Throwable) {
log.e(ex, "parseString failed.")
result.parseErrorResponse(result.bodyString ?: NO_INFORMATION)
}
return result
}
// レスポンスからJSONデータを読む
// 引数に指定したresultそのものか、キャンセルされたらnull を返す
internal suspend fun parseJson(
result: TootApiResult,
progressPath: String? = null,
jsonErrorParser: (json: JsonObject) -> String? = DEFAULT_JSON_ERROR_PARSER,
): TootApiResult? {
try {
var bodyString = readBodyString(result, progressPath, jsonErrorParser)
when {
bodyString == null ->
return if (isApiCancelled()) null else result
// 204 no content は 空オブジェクトと解釈する
bodyString.isEmpty() -> {
result.data = JsonObject()
}
reStartJsonArray.find(bodyString) != null -> {
result.data = bodyString.decodeJsonArray()
}
reStartJsonObject.find(bodyString) != null -> {
val json = bodyString.decodeJsonObject()
val errorMessage = jsonErrorParser(json)
if (errorMessage != null) {
result.error = errorMessage
} else {
result.data = json
}
}
else -> {
val response = result.response!! // nullにならないはず
// HTMLならタグを除去する
val ct = response.body?.contentType()
if (ct?.subtype == "html") {
val decoded = DecodeOptions().decodeHTML(bodyString).toString()
.replace("""[\s ]+""".toRegex(), " ")
bodyString = decoded
}
val sb = StringBuilder()
.append(context.getString(R.string.response_not_json))
.append(' ')
.append(bodyString)
if (sb.isNotEmpty()) sb.append(' ')
sb.append("(HTTP ").append(response.code.toString())
val message = response.message
if (message.isNotEmpty()) sb.append(' ').append(message)
sb.append(")")
val url = response.request.url.toString()
if (url.isNotEmpty()) sb.append(' ').append(url)
result.error = sb.toString()
}
}
} catch (ex: Throwable) {
log.e(ex, "parseJson failed.")
result.parseErrorResponse(result.bodyString ?: NO_INFORMATION)
}
return result
}
//////////////////////////////////////////////////////////////////////
// fun request(
// path: String,
// request_builder: Request.Builder = Request.Builder()
// ): TootApiResult? {
// val result = TootApiResult.makeWithCaption(apiHost?.pretty)
// if (result.error != null) return result
//
// val account = this.account // may null
//
// try {
// if (!sendRequest(result) {
//
// log.d("request: $path")
//
// request_builder.url("https://${apiHost?.ascii}$path")
//
// val access_token = account?.getAccessToken()
// if (access_token?.isNotEmpty() == true) {
// request_builder.header("Authorization", "Bearer $access_token")
// }
//
// request_builder.build()
//
// }) return result
//
// return parseJson(result)
// } finally {
// val error = result.error
// if (error != null) log.d("error: $error")
// }
// }
//
suspend fun request(
path: String,
requestBuilder: Request.Builder = Request.Builder(),
forceAccessToken: String? = null,
): TootApiResult? {
val result = TootApiResult.makeWithCaption(apiHost)
if (result.error != null) return result
val account = this.account // may null
try {
if (!sendRequest(result) {
val url = "https://${apiHost?.ascii}$path"
requestBuilder.url(url)
(forceAccessToken ?: account?.bearerAccessToken)?.notEmpty()?.let {
requestBuilder.header("Authorization", "Bearer $it")
}
requestBuilder.build()
.also { log.d("request: ${it.method} $url") }
}
) return result
return parseJson(result)
} finally {
val error = result.error
if (error != null) log.d("error: $error")
}
}
/**
* クライアントを登録してブラウザで開くURLを生成する
* 成功したら TootApiResult.data にURL文字列を格納すること
*/
suspend fun authStep1(
forceUpdateClient: Boolean = false,
): Uri {
val (ti, ri) = TootInstance.get(this)
// 情報が取れなくても続ける
log.i("authentication1: instance info version=${ti?.version} misskeyVersion=${ti?.misskeyVersionMajor} responseCode=${ri?.response?.code}")
return when (val auth = AuthBase.findAuthForAuthStep1(this, ti, ri)) {
null -> error("can't get server information. ${ri?.error}")
else -> auth.authStep1(ti, forceUpdateClient)
}
}
suspend fun verifyAccount(
accessToken: String,
outTokenInfo: JsonObject?,
misskeyVersion: Int = 0,
) = AuthBase.findAuthForVerifyAccount(this, misskeyVersion)
.verifyAccount(accessToken, outTokenInfo, misskeyVersion)
////////////////////////////////////////////////////////////////////////
// JSONデータ以外を扱うリクエスト
suspend fun http(req: Request): TootApiResult {
val result = TootApiResult.makeWithCaption(req.url.host)
if (result.error != null) return result
sendRequest(result, progressPath = null) { req }
return result
}
// fun requestJson(req : Request) : TootApiResult? {
// val result = TootApiResult.makeWithCaption(req.url().host())
// if(result.error != null) return result
// if(sendRequest(result, progressPath = null) { req }) {
// decodeJsonValue(result)
// }
// return result
// }
// 疑似アカウントでステータスURLからステータスIDを取得するためにHTMLを取得する
suspend fun getHttp(url: String): TootApiResult? {
val result = http(Request.Builder().url(url).build())
return if (result.error != null) result else parseString(result)
}
suspend fun getHttpBytes(url: String): Pair<TootApiResult?, ByteArray?> {
val result = TootApiResult.makeWithCaption(url)
if (result.error != null) return Pair(result, null)
if (!sendRequest(result, progressPath = url) {
Request.Builder().url(url).build()
}) {
return Pair(result, null)
}
val r2 = parseBytes(result)
return Pair(r2, r2?.data as? ByteArray)
}
suspend fun webSocket(
path: String,
wsListener: WebSocketListener,
): Pair<TootApiResult?, WebSocket?> {
var ws: WebSocket? = null
val result = TootApiResult.makeWithCaption(apiHost)
if (result.error != null) return Pair(result, null)
val account = this.account ?: return Pair(TootApiResult("account is null"), null)
try {
var url = "wss://${apiHost?.ascii}$path"
val requestBuilder = Request.Builder()
if (account.isMisskey) {
val accessToken = account.misskeyApiToken
if (accessToken?.isNotEmpty() == true) {
val delm = if (-1 != url.indexOf('?')) '&' else '?'
url = "$url${delm}i=${accessToken.encodePercent()}"
}
} else {
account.bearerAccessToken.notEmpty()?.let {
val delm = if (url.contains('?')) '&' else '?'
url = "$url${delm}access_token=${it.encodePercent()}"
}
}
val request = requestBuilder.url(url).build()
publishApiProgress(context.getString(R.string.request_api, request.method, path))
ws = httpClient.getWebSocket(request, wsListener)
if (isApiCancelled()) {
ws.cancel()
return Pair(null, null)
}
} catch (ex: Throwable) {
log.e(ex, "webSocket failed.")
result.error =
"${result.caption}: ${ex.withCaption(context.resources, R.string.network_error)}"
}
return Pair(result, ws)
}
fun copy() = TootApiClient(
context,
httpClient,
callback
).also { dst ->
dst.account = account
dst.apiHost = apiHost
}
}
suspend fun TootApiClient.requestMastodonSearch(
parser: TootParser,
// 検索文字列
q: String,
// リモートサーバの情報を解決するなら真
resolve: Boolean,
// ギャップ読み込み時の追加パラメータ
extra: String = "",
): Pair<TootApiResult?, TootResults?> {
if (q.all { CharacterGroup.isWhitespace(it.code) }) {
return Pair(null, null)
}
val query = "q=${q.encodePercent()}&resolve=$resolve${
if (extra.isEmpty()) "" else "&$extra"
}"
var searchApiVersion = 2
var apiResult = request("/api/v2/search?$query")
?: return Pair(null, null)
if ((apiResult.response?.code ?: 0) in 400 until 500) {
searchApiVersion = 1
apiResult = request("/api/v1/search?$query")
?: return Pair(null, null)
}
val searchResult = parser.results(apiResult.jsonObject)
searchResult?.searchApiVersion = searchApiVersion
return Pair(apiResult, searchResult)
}
// result.data に TootAccountRefを格納して返す。もしくはエラーかキャンセル
suspend fun TootApiClient.syncAccountByUrl(
accessInfo: SavedAccount,
whoUrl: String,
): Pair<TootApiResult?, TootAccountRef?> {
// misskey由来のアカウントURLは https://host/@user@instance などがある
val m = TootAccount.reAccountUrl.matcher(whoUrl)
if (m.find()) {
// val host = m.group(1)
val user = m.groupEx(2)!!.decodePercent()
val instance = m.groupEx(3)?.decodePercent()
if (instance?.isNotEmpty() == true) {
return this.syncAccountByUrl(accessInfo, "https://$instance/@$user")
}
}
val parser = TootParser(context, accessInfo)
return if (accessInfo.isMisskey) {
val acct = TootAccount.getAcctFromUrl(whoUrl)
?: return Pair(
TootApiResult(context.getString(R.string.user_id_conversion_failed)),
null
)
var ar: TootAccountRef? = null
val result = request(
"/api/users/show",
accessInfo.putMisskeyApiToken().apply {
put("username", acct.username)
acct.host?.let { put("host", it.ascii) }
}.toPostRequestBuilder()
)
?.apply {
ar = TootAccountRef.mayNull(parser, parser.account(jsonObject))
if (ar == null && error == null) {
setError(context.getString(R.string.user_id_conversion_failed))
}
}
Pair(result, ar)
} else {
val (apiResult, searchResult) = requestMastodonSearch(
parser,
q = whoUrl,
resolve = true,
)
val ar = searchResult?.accounts?.firstOrNull()
if (apiResult != null && apiResult.error == null && ar == null) {
apiResult.setError(context.getString(R.string.user_id_conversion_failed))
}
Pair(apiResult, ar)
}
}
suspend fun TootApiClient.syncAccountByAcct(
accessInfo: SavedAccount,
acctArg: String,
): Pair<TootApiResult?, TootAccountRef?> = syncAccountByAcct(accessInfo, Acct.parse(acctArg))
suspend fun TootApiClient.syncAccountByAcct(
accessInfo: SavedAccount,
acct: Acct,
): Pair<TootApiResult?, TootAccountRef?> {
val parser = TootParser(context, accessInfo)
return if (accessInfo.isMisskey) {
var ar: TootAccountRef? = null
val result = request(
"/api/users/show",
accessInfo.putMisskeyApiToken()
.apply {
if (acct.isValid) put("username", acct.username)
if (acct.host != null) put("host", acct.host.ascii)
}
.toPostRequestBuilder()
)
?.apply {
ar = TootAccountRef.mayNull(parser, parser.account(jsonObject))
if (ar == null && error == null) {
setError(context.getString(R.string.user_id_conversion_failed))
}
}
Pair(result, ar)
} else {
val (apiResult, searchResult) = requestMastodonSearch(
parser,
q = acct.ascii,
resolve = true,
)
val ar = searchResult?.accounts?.firstOrNull()
if (apiResult != null && apiResult.error == null && ar == null) {
apiResult.setError(context.getString(R.string.user_id_conversion_failed))
}
Pair(apiResult, ar)
}
}
suspend fun TootApiClient.syncStatus(
accessInfo: SavedAccount,
urlArg: String,
): Pair<TootApiResult?, TootStatus?> {
var url = urlArg
// misskey の投稿URLは外部タンスの投稿を複製したものの可能性がある
// これを投稿元タンスのURLに変換しないと、投稿の同期には使えない
val m = TootStatus.reStatusPageMisskey.matcher(urlArg)
if (m.find()) {
val host = Host.parse(m.groupEx(1)!!)
val noteId = m.groupEx(2)
TootApiClient(context, callback = callback)
.apply { apiHost = host }
.request(
"/api/notes/show",
JsonObject().apply {
put("noteId", noteId)
}
.toPostRequestBuilder()
)
?.also { result ->
TootParser(
context,
linkHelper = LinkHelper.create(host, misskeyVersion = 10),
)
.status(result.jsonObject)
?.apply {
if (accessInfo.matchHost(host)) {
return Pair(result, this)
}
uri.letNotEmpty { url = it }
}
}
?: return Pair(null, null) // cancelled.
}
// 使いたいタンス上の投稿IDを取得する
val parser = TootParser(context, accessInfo)
return if (accessInfo.isMisskey) {
var targetStatus: TootStatus? = null
val result = request(
"/api/ap/show",
accessInfo.putMisskeyApiToken().apply {
put("uri", url)
}
.toPostRequestBuilder()
)
?.apply {
targetStatus = parser.parseMisskeyApShow(jsonObject) as? TootStatus
if (targetStatus == null && error == null) {
setError(context.getString(R.string.cant_sync_toot))
}
}
Pair(result, targetStatus)
} else {
val (apiResult, searchResult) = requestMastodonSearch(
parser,
q = url,
resolve = true,
)
val targetStatus = searchResult?.statuses?.firstOrNull()
if (apiResult != null && apiResult.error == null && targetStatus == null) {
apiResult.setError(context.getString(R.string.cant_sync_toot))
}
Pair(apiResult, targetStatus)
}
}
suspend fun TootApiClient.syncStatus(
accessInfo: SavedAccount,
statusRemote: TootStatus,
): Pair<TootApiResult?, TootStatus?> {
// URL->URIの順に試す
val uriList = ArrayList<String>(2)
statusRemote.url.letNotEmpty {
when {
it.contains("/notes/") -> {
// Misskeyタンスから読んだマストドンの投稿はurlがmisskeyタンス上のものになる
// ActivityPub object id としては不適切なので使わない
}
else -> uriList.add(it)
}
}
statusRemote.uri.letNotEmpty {
// uri の方は↑の問題はない
uriList.add(it)
}
if (accessInfo.isMisskey && uriList.firstOrNull()?.contains("@") == true) {
// https://github.com/syuilo/misskey/pull/2832
// @user を含むuri はMisskeyだと少し効率が悪いそうなので順序を入れ替える
uriList.reverse()
}
for (uri in uriList) {
val pair = syncStatus(accessInfo, uri)
if (pair.second != null || pair.first == null) {
return pair
}
}
return Pair(TootApiResult("can't resolve status URL/URI."), null)
}