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

780 lines
27 KiB
Kotlin
Raw Normal View History

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
2018-01-12 10:01:25 +01:00
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
2018-01-12 10:01:25 +01:00
import okhttp3.*
import okhttp3.internal.closeQuietly
class TootApiClient(
val context: Context,
val httpClient: SimpleHttpClient =
SimpleHttpClientImpl(context, App1.ok_http_client),
val callback: TootApiCallback,
) {
2020-12-07 13:23:14 +01:00
companion object {
private val log = LogCategory("TootApiClient")
private const val NO_INFORMATION = "(no information)"
val reStartJsonArray = """\A\s*\[""".asciiRegex()
val reStartJsonObject = """\A\s*\{""".asciiRegex()
2020-12-07 13:23:14 +01:00
val DEFAULT_JSON_ERROR_PARSER =
{ json: JsonObject -> json["error"]?.toString() }
2021-05-11 08:12:43 +02:00
fun formatResponse(
response: Response,
caption: String = "?",
bodyString: String? = null,
2021-05-11 08:12:43 +02:00
) = 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,
2021-05-11 08:12:43 +02:00
) = TootApiResult(
response = response,
caption = caption,
).simplifyErrorHtml(bodyString, jsonErrorParser)
2020-12-07 13:23:14 +01:00
}
// 認証に関する設定を保存する
// インスタンスのホスト名
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()
2020-12-07 13:23:14 +01:00
suspend fun publishApiProgress(s: String) {
2020-12-07 13:23:14 +01:00
callback.publishApiProgress(s)
}
suspend fun publishApiProgressRatio(value: Int, max: Int) {
2020-12-07 13:23:14 +01:00
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
)
}"
)
2020-12-07 13:23:14 +01:00
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
2020-12-07 13:23:14 +01:00
val request = response.request
publishApiProgress(
context.getString(
R.string.reading_api,
request.method,
progressPath ?: result.caption
)
)
2020-12-07 13:23:14 +01:00
val bodyString = response.body?.string()
if (isApiCancelled()) return null
2020-12-07 13:23:14 +01:00
// Misskey の /api/notes/favorites/create は 204(no content)を返す。ボディはカラになる。
if (bodyString?.isEmpty() != false && response.code in 200 until 300) {
result.bodyString = ""
return ""
}
2020-12-07 13:23:14 +01:00
if (!response.isSuccessful || bodyString?.isEmpty() != false) {
result.parseErrorResponse(
bodyString?.notEmpty() ?: NO_INFORMATION,
jsonErrorParser
)
}
2020-12-07 13:23:14 +01:00
return if (result.error != null) {
null
} else {
publishApiProgress(context.getString(R.string.parsing_response))
result.bodyString = bodyString
bodyString
}
} finally {
response.body?.closeQuietly()
2020-12-07 13:23:14 +01:00
}
}
// レスポンスがエラーかボディがカラならエラー状態を設定する
// 例外を出すかも
private suspend fun readBodyBytes(
result: TootApiResult,
progressPath: String? = null,
jsonErrorParser: (json: JsonObject) -> String? = DEFAULT_JSON_ERROR_PARSER,
): ByteArray? {
2020-12-07 13:23:14 +01:00
if (isApiCancelled()) return null
2020-12-07 13:23:14 +01:00
val response = result.response!!
val request = response.request
publishApiProgress(
context.getString(
R.string.reading_api,
request.method,
progressPath ?: result.caption
)
)
2020-12-07 13:23:14 +01:00
val bodyBytes = response.body?.bytes()
if (isApiCancelled()) return null
2020-12-07 13:23:14 +01:00
if (!response.isSuccessful || bodyBytes?.isEmpty() != false) {
2021-05-11 08:12:43 +02:00
result.parseErrorResponse(
bodyBytes?.notEmpty()?.decodeUTF8() ?: NO_INFORMATION,
jsonErrorParser
)
2020-12-07 13:23:14 +01:00
}
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? {
2020-12-07 13:23:14 +01:00
try {
readBodyBytes(result, progressPath, jsonErrorParser)
?: return if (isApiCancelled()) null else result
2020-12-07 13:23:14 +01:00
} catch (ex: Throwable) {
log.e(ex, "parseBytes failed.")
2021-05-11 08:12:43 +02:00
result.parseErrorResponse(result.bodyString ?: NO_INFORMATION)
2020-12-07 13:23:14 +01:00
}
return result
}
internal suspend fun parseString(
result: TootApiResult,
progressPath: String? = null,
jsonErrorParser: (json: JsonObject) -> String? = DEFAULT_JSON_ERROR_PARSER,
): TootApiResult? {
2020-12-07 13:23:14 +01:00
try {
val bodyString = readBodyString(result, progressPath, jsonErrorParser)
?: return if (isApiCancelled()) null else result
2020-12-07 13:23:14 +01:00
result.data = bodyString
} catch (ex: Throwable) {
log.e(ex, "parseString failed.")
2021-05-11 08:12:43 +02:00
result.parseErrorResponse(result.bodyString ?: NO_INFORMATION)
2020-12-07 13:23:14 +01:00
}
return result
}
// レスポンスからJSONデータを読む
// 引数に指定したresultそのものか、キャンセルされたらnull を返す
internal suspend fun parseJson(
result: TootApiResult,
progressPath: String? = null,
jsonErrorParser: (json: JsonObject) -> String? = DEFAULT_JSON_ERROR_PARSER,
): TootApiResult? {
2020-12-07 13:23:14 +01:00
try {
var bodyString = readBodyString(result, progressPath, jsonErrorParser)
when {
bodyString == null ->
return if (isApiCancelled()) null else result
2020-12-07 13:23:14 +01:00
// 204 no content は 空オブジェクトと解釈する
bodyString.isEmpty() -> {
result.data = JsonObject()
2020-12-07 13:23:14 +01:00
}
reStartJsonArray.find(bodyString) != null -> {
result.data = bodyString.decodeJsonArray()
2020-12-07 13:23:14 +01:00
}
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)
2020-12-07 13:23:14 +01:00
if (sb.isNotEmpty()) sb.append(' ')
sb.append("(HTTP ").append(response.code.toString())
2020-12-07 13:23:14 +01:00
val message = response.message
if (message.isNotEmpty()) sb.append(' ').append(message)
2020-12-07 13:23:14 +01:00
sb.append(")")
2020-12-07 13:23:14 +01:00
val url = response.request.url.toString()
if (url.isNotEmpty()) sb.append(' ').append(url)
2020-12-07 13:23:14 +01:00
result.error = sb.toString()
}
2020-12-07 13:23:14 +01:00
}
} catch (ex: Throwable) {
log.e(ex, "parseJson failed.")
2021-05-11 08:12:43 +02:00
result.parseErrorResponse(result.bodyString ?: NO_INFORMATION)
2020-12-07 13:23:14 +01:00
}
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(),
2021-05-09 05:17:11 +02:00
forceAccessToken: String? = null,
): TootApiResult? {
val result = TootApiResult.makeWithCaption(apiHost)
2020-12-07 13:23:14 +01:00
if (result.error != null) return result
val account = this.account // may null
try {
if (!sendRequest(result) {
val url = "https://${apiHost?.ascii}$path"
2020-12-07 13:23:14 +01:00
requestBuilder.url(url)
2020-12-07 13:23:14 +01:00
(forceAccessToken ?: account?.bearerAccessToken)?.notEmpty()?.let {
requestBuilder.header("Authorization", "Bearer $it")
}
2020-12-07 13:23:14 +01:00
requestBuilder.build()
.also { log.d("request: ${it.method} $url") }
}
) return result
2020-12-07 13:23:14 +01:00
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)
2020-12-07 13:23:14 +01:00
}
}
suspend fun verifyAccount(
accessToken: String,
outTokenInfo: JsonObject?,
misskeyVersion: Int = 0,
) = AuthBase.findAuthForVerifyAccount(this, misskeyVersion)
.verifyAccount(accessToken, outTokenInfo, misskeyVersion)
2020-12-07 13:23:14 +01:00
////////////////////////////////////////////////////////////////////////
// JSONデータ以外を扱うリクエスト
suspend fun http(req: Request): TootApiResult {
2020-12-07 13:23:14 +01:00
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? {
2020-12-07 13:23:14 +01:00
val result = http(Request.Builder().url(url).build())
return if (result.error != null) result else parseString(result)
2020-12-07 13:23:14 +01:00
}
suspend fun getHttpBytes(url: String): Pair<TootApiResult?, ByteArray?> {
2020-12-07 13:23:14 +01:00
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?> {
2020-12-07 13:23:14 +01:00
var ws: WebSocket? = null
val result = TootApiResult.makeWithCaption(apiHost)
2020-12-07 13:23:14 +01:00
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()
2020-12-07 13:23:14 +01:00
if (account.isMisskey) {
2020-12-21 03:13:03 +01:00
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()}"
2020-12-21 03:13:03 +01:00
}
2020-12-07 13:23:14 +01:00
}
val request = requestBuilder.url(url).build()
2020-12-07 13:23:14 +01:00
publishApiProgress(context.getString(R.string.request_api, request.method, path))
ws = httpClient.getWebSocket(request, wsListener)
if (isApiCancelled()) {
2020-12-07 13:23:14 +01:00
ws.cancel()
return Pair(null, null)
}
} catch (ex: Throwable) {
log.e(ex, "webSocket failed.")
2020-12-07 13:23:14 +01:00
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 = "",
2020-12-07 13:23:14 +01:00
): 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"
}"
2020-12-07 13:23:14 +01:00
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,
2020-12-07 13:23:14 +01:00
): Pair<TootApiResult?, TootAccountRef?> {
// misskey由来のアカウントURLは https://host/@user@instance などがある
val m = TootAccount.reAccountUrl.matcher(whoUrl)
2020-12-07 13:23:14 +01:00
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)
2020-12-07 13:23:14 +01:00
?: return Pair(
TootApiResult(context.getString(R.string.user_id_conversion_failed)),
null
)
2020-12-07 13:23:14 +01:00
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()
)
2020-12-07 13:23:14 +01:00
?.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,
)
2020-12-07 13:23:14 +01:00
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,
2020-12-07 13:23:14 +01:00
): Pair<TootApiResult?, TootAccountRef?> = syncAccountByAcct(accessInfo, Acct.parse(acctArg))
suspend fun TootApiClient.syncAccountByAcct(
accessInfo: SavedAccount,
acct: Acct,
2020-12-07 13:23:14 +01:00
): 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()
)
2020-12-07 13:23:14 +01:00
?.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,
)
2020-12-07 13:23:14 +01:00
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,
2020-12-07 13:23:14 +01:00
): 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()
)
2020-12-07 13:23:14 +01:00
?.also { result ->
TootParser(
context,
linkHelper = LinkHelper.create(host, misskeyVersion = 10),
)
2020-12-07 13:23:14 +01:00
.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()
)
2020-12-07 13:23:14 +01:00
?.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,
)
2020-12-07 13:23:14 +01:00
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,
2020-12-07 13:23:14 +01:00
): 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)
2018-12-02 10:35:04 +01:00
}