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

1593 lines
48 KiB
Kotlin
Raw Normal View History

package jp.juggler.subwaytooter.api
import android.content.Context
2018-01-12 10:01:25 +01:00
import android.content.SharedPreferences
import jp.juggler.subwaytooter.*
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.table.ClientInfo
import jp.juggler.subwaytooter.table.SavedAccount
2018-01-12 10:01:25 +01:00
import jp.juggler.subwaytooter.util.*
2018-12-01 00:02:18 +01:00
import jp.juggler.util.*
2018-01-12 10:01:25 +01:00
import okhttp3.*
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.util.*
2018-01-12 10:01:25 +01:00
import java.util.regex.Pattern
class TootApiClient(
2018-01-12 10:01:25 +01:00
internal val context : Context,
internal val httpClient : SimpleHttpClient = SimpleHttpClientImpl(
context,
App1.ok_http_client
),
2018-01-12 10:01:25 +01:00
internal val callback : TootApiCallback
) {
2018-01-12 10:01:25 +01:00
// 認証に関する設定を保存する
internal val pref : SharedPreferences
// インスタンスのホスト名
var instance : String? = null
// アカウントがある場合に使用する
var account : SavedAccount? = null
set(value) {
instance = value?.host
field = value
}
var currentCallCallback : CurrentCallCallback?
get() = httpClient.currentCallCallback
set(value) {
httpClient.currentCallCallback = value
}
init {
pref = Pref.pref(context)
}
companion object {
private val log = LogCategory("TootApiClient")
private const val DEFAULT_CLIENT_NAME = "SubwayTooter"
private const val REDIRECT_URL = "subwaytooter://oauth/"
// 20181225 3=>4 client credentialの取得時にもscopeの取得が必要になった
private const val AUTH_VERSION = 4
2018-01-12 10:01:25 +01:00
internal const val KEY_CLIENT_CREDENTIAL = "SubwayTooterClientCredential"
internal const val KEY_CLIENT_SCOPE = "SubwayTooterClientScope"
private const val KEY_AUTH_VERSION = "SubwayTooterAuthVersion"
const val KEY_IS_MISSKEY = "isMisskey"
const val KEY_MISSKEY_APP_SECRET = "secret"
const val KEY_API_KEY_MISSKEY = "apiKeyMisskey"
const val KEY_USER_ID = "userId"
2018-01-12 10:01:25 +01:00
private const val NO_INFORMATION = "(no information)"
private val reStartJsonArray = Pattern.compile("\\A\\s*\\[")
private val reStartJsonObject = Pattern.compile("\\A\\s*\\{")
private val reWhiteSpace = Pattern.compile("\\s+")
2018-01-12 10:01:25 +01:00
private const val mspTokenUrl = "http://mastodonsearch.jp/api/v1.0.1/utoken"
private const val mspSearchUrl = "http://mastodonsearch.jp/api/v1.0.1/cross"
private const val mspApiKey = "e53de7f66130208f62d1808672bf6320523dcd0873dc69bc"
fun getMspMaxId(array : JSONArray, old : String?) : String? {
2018-01-12 10:01:25 +01:00
// max_id の更新
val size = array.length()
if(size > 0) {
val item = array.optJSONObject(size - 1)
if(item != null) {
val sv = item.optString("msp_id")
if(sv?.isNotEmpty() == true) return sv
}
}
// MSPでは終端は分からず、何度もリトライする
return old
2018-01-12 10:01:25 +01:00
}
fun getTootsearchHits(root : JSONObject) : JSONArray? {
val hits = root.optJSONObject("hits")
return hits?.optJSONArray("hits")
}
// returns the number for "from" parameter of next page.
// returns null if no more next page.
fun getTootsearchMaxId(root : JSONObject, old : Long?) : Long? {
val size = getTootsearchHits(root)?.length() ?: 0
return when {
size <= 0 -> null
else -> (old ?: 0L) + size.toLong()
2018-01-12 10:01:25 +01:00
}
}
val DEFAULT_JSON_ERROR_PARSER = { json : JSONObject ->
val v = json.opt("error")
when(v) {
null, JSONObject.NULL -> null
else -> v.toString()
}
}
2018-01-12 10:01:25 +01:00
internal fun simplifyErrorHtml(
response : Response,
sv : String,
jsonErrorParser : (json : JSONObject) -> String? = DEFAULT_JSON_ERROR_PARSER
) : String {
// JSONObjectとして解釈できるならエラーメッセージを検出する
try {
val error_message = jsonErrorParser(sv.toJsonObject())
2018-01-12 10:01:25 +01:00
if(error_message?.isNotEmpty() == true) {
return error_message
}
} catch(ex : Throwable) {
log.e(ex, "response body is not JSON or missing 'error' attribute.")
}
// HTMLならタグの除去を試みる
val ct = response.body()?.contentType()
if(ct?.subtype() == "html") {
val decoded = DecodeOptions().decodeHTML(sv).toString()
return reWhiteSpace.matcher(decoded).replaceAll(" ").trim()
2018-01-12 10:01:25 +01:00
}
// XXX: Amazon S3 が403を返した場合にcontent-typeが?/xmlでserverがAmazonならXMLをパースしてエラーを整形することもできるが、多分必要ない
return reWhiteSpace.matcher(sv).replaceAll(" ").trim()
2018-01-12 10:01:25 +01:00
}
fun formatResponse(
response : Response,
caption : String,
bodyString : String? = null,
jsonErrorParser : (json : JSONObject) -> String? = DEFAULT_JSON_ERROR_PARSER
) : String {
val sb = StringBuilder()
try {
// body は既に読み終わっているか、そうでなければこれから読む
if(bodyString != null) {
sb.append(simplifyErrorHtml(response, bodyString, jsonErrorParser))
} else {
try {
val string = response.body()?.string()
if(string != null) {
sb.append(simplifyErrorHtml(response, string, jsonErrorParser))
}
} catch(ex : Throwable) {
log.e(ex, "missing response body.")
sb.append("(missing response body)")
}
}
if(sb.isNotEmpty()) sb.append(' ')
sb.append("(HTTP ").append(Integer.toString(response.code()))
val message = response.message()
if(message.isNotEmpty()) sb.append(' ').append(message)
2018-01-12 10:01:25 +01:00
sb.append(")")
if(caption.isNotEmpty()) {
sb.append(' ').append(caption)
}
} catch(ex : Throwable) {
log.trace(ex)
}
return sb.toString().replace("\n+".toRegex(), "\n")
}
fun getScopeString(ti : TootInstance) = when {
// ti.versionGE(TootInstance.VERSION_2_7_0_rc1) -> "read+write+follow+push+create"
ti.versionGE(TootInstance.VERSION_2_4_0_rc1) -> "read+write+follow+push"
else -> "read+write+follow"
}
fun getScopeArrayMisskey(@Suppress("UNUSED_PARAMETER") ti : TootInstance) =
JSONArray().apply {
put("account-read")
put("account-write")
2018-08-24 18:24:11 +02:00
put("note-read")
put("note-write")
2018-08-24 18:24:11 +02:00
put("reaction-read")
put("reaction-write")
put("following-read") // フォロリク申請一覧で使われていた
2018-08-24 18:24:11 +02:00
put("following-write")
put("drive-read")
put("drive-write")
put("notification-read")
put("notification-write")
2018-08-24 18:24:11 +02:00
put("favorite-read")
put("favorites-read")
put("favorite-write")
put("account/read")
put("account/write")
2018-08-24 18:24:11 +02:00
put("messaging-read")
put("messaging-write")
2018-08-24 18:24:11 +02:00
put("vote-read")
put("vote-write")
// https://github.com/syuilo/misskey/issues/2341
}
private fun encodeScopeArray(scope_array : JSONArray?) : String? {
scope_array ?: return null
val list = scope_array.toStringArrayList()
list.sort()
return list.joinToString(",")
}
private fun compareScopeArray(a : JSONArray, b : JSONArray?) : Boolean {
return encodeScopeArray(a) == encodeScopeArray(b)
}
}
@Suppress("unused")
2018-01-12 10:01:25 +01:00
internal val isApiCancelled : Boolean
get() = callback.isApiCancelled
fun publishApiProgress(s : String) {
callback.publishApiProgress(s)
}
fun publishApiProgressRatio(value : Int, max : Int) {
callback.publishApiProgressRatio(value, max)
}
2018-01-12 10:01:25 +01:00
//////////////////////////////////////////////////////////////////////
// ユーティリティ
2018-01-12 10:01:25 +01:00
// リクエストをokHttpに渡してレスポンスを取得する
internal inline fun sendRequest(
result : TootApiResult,
progressPath : String? = null,
tmpOkhttpClient : OkHttpClient? = null,
2018-01-12 10:01:25 +01:00
block : () -> Request
) : Boolean {
return try {
result.response = null
result.bodyString = null
result.data = null
val request = block()
callback.publishApiProgress(
context.getString(
R.string.request_api
, request.method()
, progressPath ?: request.url().encodedPath()
)
)
val response = httpClient.getResponse(request, tmpOkhttpClient = tmpOkhttpClient)
result.response = response
2018-01-12 10:01:25 +01:00
null == result.error
} catch(ex : Throwable) {
result.setError(
"${result.caption}: ${ex.withCaption(
context.resources,
R.string.network_error
)}"
)
2018-01-12 10:01:25 +01:00
false
}
}
2018-01-12 10:01:25 +01:00
// レスポンスがエラーかボディがカラならエラー状態を設定する
// 例外を出すかも
internal fun readBodyString(
result : TootApiResult,
progressPath : String? = null,
jsonErrorParser : (json : JSONObject) -> String? = DEFAULT_JSON_ERROR_PARSER
) : String? {
if(isApiCancelled) return null
val response = result.response !!
val request = response.request()
publishApiProgress(
context.getString(
R.string.reading_api,
request.method(),
progressPath ?: result.caption
)
)
2018-01-12 10:01:25 +01:00
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 ""
}
2018-01-12 10:01:25 +01:00
if(! response.isSuccessful || bodyString?.isEmpty() != false) {
result.error = TootApiClient.formatResponse(
response,
result.caption,
if(bodyString?.isNotEmpty() == true) bodyString else NO_INFORMATION,
jsonErrorParser
)
}
return if(result.error != null) {
null
} else {
publishApiProgress(context.getString(R.string.parsing_response))
result.bodyString = bodyString
bodyString
}
}
// レスポンスがエラーかボディがカラならエラー状態を設定する
// 例外を出すかも
private 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.error = TootApiClient.formatResponse(
response,
result.caption,
if(bodyBytes?.isNotEmpty() == true) bodyBytes.decodeUTF8() else NO_INFORMATION,
jsonErrorParser
)
}
return if(result.error != null) {
null
} else {
result.bodyString = "(binary data)"
result.data = bodyBytes
bodyBytes
}
}
private fun parseBytes(
result : TootApiResult,
progressPath : String? = null,
jsonErrorParser : (json : JSONObject) -> String? = DEFAULT_JSON_ERROR_PARSER
) : TootApiResult? {
val response = result.response !! // nullにならないはず
try {
readBodyBytes(result, progressPath, jsonErrorParser)
?: return if(isApiCancelled) null else result
} catch(ex : Throwable) {
log.trace(ex)
result.error =
formatResponse(response, result.caption, result.bodyString ?: NO_INFORMATION)
}
return result
}
2018-01-12 10:01:25 +01:00
internal fun parseString(
result : TootApiResult,
progressPath : String? = null,
jsonErrorParser : (json : JSONObject) -> String? = DEFAULT_JSON_ERROR_PARSER
) : TootApiResult? {
2018-01-12 10:01:25 +01:00
val response = result.response !! // nullにならないはず
try {
val bodyString = readBodyString(result, progressPath, jsonErrorParser)
?: return if(isApiCancelled) null else result
2018-01-12 10:01:25 +01:00
result.data = bodyString
} catch(ex : Throwable) {
log.trace(ex)
result.error =
formatResponse(response, result.caption, result.bodyString ?: NO_INFORMATION)
}
2018-01-12 10:01:25 +01:00
return result
}
2018-01-12 10:01:25 +01:00
// レスポンスからJSONデータを読む
internal fun parseJson(
result : TootApiResult,
progressPath : String? = null,
jsonErrorParser : (json : JSONObject) -> String? = DEFAULT_JSON_ERROR_PARSER
) : TootApiResult? // 引数に指定したresultそのものか、キャンセルされたらnull
{
val response = result.response !! // nullにならないはず
try {
var bodyString = readBodyString(result, progressPath, jsonErrorParser)
2018-01-12 10:01:25 +01:00
?: return if(isApiCancelled) null else result
if(bodyString.isEmpty()) {
// 204 no content は 空オブジェクトと解釈する
result.data = JSONObject()
} else if(reStartJsonArray.matcher(bodyString).find()) {
result.data = bodyString.toJsonArray()
2018-01-12 10:01:25 +01:00
} else if(reStartJsonObject.matcher(bodyString).find()) {
val json = bodyString.toJsonObject()
2018-01-12 10:01:25 +01:00
val error_message = jsonErrorParser(json)
if(error_message != null) {
result.error = error_message
} else {
result.data = json
}
} else {
// HTMLならタグを除去する
val ct = response.body()?.contentType()
if(ct?.subtype() == "html") {
val decoded = DecodeOptions().decodeHTML(bodyString).toString()
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(Integer.toString(response.code()))
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.trace(ex)
result.error =
formatResponse(response, result.caption, result.bodyString ?: NO_INFORMATION)
}
return result
}
2018-01-12 10:01:25 +01:00
//////////////////////////////////////////////////////////////////////
fun request(
path : String,
request_builder : Request.Builder = Request.Builder()
) : TootApiResult? {
2018-01-12 10:01:25 +01:00
val result = TootApiResult.makeWithCaption(instance)
if(result.error != null) return result
val account = this.account // may null
2018-01-12 10:01:25 +01:00
try {
if(! sendRequest(result) {
log.d("request: $path")
request_builder.url("https://$instance$path")
val access_token = account?.getAccessToken()
if(access_token?.isNotEmpty() == true) {
request_builder.header("Authorization", "Bearer $access_token")
}
request_builder.build()
}) return result
2018-01-12 10:01:25 +01:00
return parseJson(result)
} finally {
val error = result.error
if(error != null) log.d("error: $error")
}
}
//////////////////////////////////////////////////////////////////////
// misskey authentication
// 疑似アカウントの追加時に、インスタンスの検証を行う
private fun getInstanceInformationMisskey() : TootApiResult? {
val result = TootApiResult.makeWithCaption(instance)
if(result.error != null) return result
if(sendRequest(result) {
JSONObject().apply {
put("dummy", 1)
}
.toPostRequestBuilder()
.url("https://$instance/api/meta")
.build()
}) {
parseJson(result) ?: return null
result.jsonObject?.put(KEY_IS_MISSKEY, true)
}
return result
}
// インスタンス情報を取得する
internal fun parseInstanceInformation(result : TootApiResult?) : TootApiResult? {
val json = result?.jsonObject
if(json != null) {
val parser = TootParser(
context,
LinkHelper.newLinkHelper(instance, isMisskey = json.optBoolean(KEY_IS_MISSKEY))
)
val ti = parser.instance(json)
if(ti != null) {
result.data = ti
} else {
result.setError("can't parse data in instance information.")
}
}
return result
}
private fun getAppInfoMisskey(appId : String?) : TootApiResult? {
appId ?: return TootApiResult("missing app id")
val result = TootApiResult.makeWithCaption(instance)
if(result.error != null) return result
if(sendRequest(result) {
JSONObject().apply {
put("appId", appId)
}
.toPostRequestBuilder()
.url("https://$instance/api/app/show")
.build()
}) {
parseJson(result) ?: return null
result.jsonObject?.put(KEY_IS_MISSKEY, true)
}
return result
}
private fun prepareBrowserUrlMisskey(appSecret : String) : String? {
val result = TootApiResult.makeWithCaption(instance)
if(result.error != null) {
showToast(context, false, result.error)
return null
}
if(! sendRequest(result) {
JSONObject().apply {
put("appSecret", appSecret)
}
.toPostRequestBuilder()
.url("https://$instance/api/auth/session/generate")
.build()
}
) {
val error = result.error
if(error != null) {
showToast(context, false, error)
return null
}
return null
}
parseJson(result) ?: return null
val jsonObject = result.jsonObject
if(jsonObject == null) {
showToast(context, false, result.error)
return null
}
// {"token":"0ba88e2d-4b7d-4599-8d90-dc341a005637","url":"https://misskey.xyz/auth/0ba88e2d-4b7d-4599-8d90-dc341a005637"}
// ブラウザで開くURL
val url = jsonObject.parseString("url")
if(url?.isEmpty() != false) {
showToast(context, false, "missing 'url' in auth session response.")
return null
}
val e = PrefDevice.prefDevice(context)
.edit()
.putString(PrefDevice.LAST_AUTH_INSTANCE, instance)
.putString(PrefDevice.LAST_AUTH_SECRET, appSecret)
val account = this.account
if(account != null) {
e.putLong(PrefDevice.LAST_AUTH_DB_ID, account.db_id)
} else {
e.remove(PrefDevice.LAST_AUTH_DB_ID)
}
e.apply()
return url
}
private fun registerClientMisskey(
scope_array : JSONArray,
client_name : String
) : TootApiResult? {
val result = TootApiResult.makeWithCaption(instance)
if(result.error != null) return result
if(sendRequest(result) {
JSONObject().apply {
put("nameId", "SubwayTooter")
put("name", client_name)
put("description", "Android app for federated SNS")
put("callbackUrl", "subwaytooter://misskey/auth_callback")
put("permission", scope_array)
}
.toPostRequestBuilder()
.url("https://$instance/api/app/create")
.build()
}) {
parseJson(result) ?: return null
}
return result
}
private fun authentication1Misskey(clientNameArg : String, ti : TootInstance) : TootApiResult? {
val result = TootApiResult.makeWithCaption(this.instance)
if(result.error != null) return result
val instance = result.caption // same to instance
// クライアントIDがアプリ上に保存されているか
val client_name = if(clientNameArg.isNotEmpty()) clientNameArg else DEFAULT_CLIENT_NAME
val client_info = ClientInfo.load(instance, client_name)
// スコープ一覧を取得する
val scope_array = getScopeArrayMisskey(ti)
if(client_info != null
&& AUTH_VERSION == client_info.optInt(KEY_AUTH_VERSION)
&& client_info.optBoolean(KEY_IS_MISSKEY)
) {
val appSecret = client_info.parseString(KEY_MISSKEY_APP_SECRET)
val r2 = getAppInfoMisskey(client_info.parseString("id"))
val tmpClientInfo = r2?.jsonObject
// tmpClientInfo はsecretを含まないので保存してはいけない
if(tmpClientInfo != null // アプリが登録済みで
&& client_name == tmpClientInfo.parseString("name") // クライアント名が一致してて
&& compareScopeArray(
scope_array,
tmpClientInfo.optJSONArray("permission")
) // パーミッションが同じ
&& appSecret?.isNotEmpty() == true
) {
// クライアント情報を再利用する
result.data = prepareBrowserUrlMisskey(appSecret)
return result
} else {
// XXX appSecretを使ってクライアント情報を削除できるようにするべきだが、該当するAPIが存在しない
}
}
val r2 = registerClientMisskey(scope_array, client_name)
val jsonObject = r2?.jsonObject ?: return r2
val appSecret = jsonObject.parseString(KEY_MISSKEY_APP_SECRET)
if(appSecret?.isEmpty() != false) {
showToast(context, true, context.getString(R.string.cant_get_misskey_app_secret))
return null
}
// {
// "createdAt": "2018-08-19T00:43:10.105Z",
// "userId": null,
// "name": "Via芸",
// "nameId": "test1",
// "description": "test1",
// "permission": [
// "account-read",
// "account-write",
// "note-write",
// "reaction-write",
// "following-write",
// "drive-read",
// "drive-write",
// "notification-read",
// "notification-write"
// ],
// "callbackUrl": "test1://test1/auth_callback",
// "id": "5b78bd1ea0db0527f25815c3",
// "iconUrl": "https://misskey.xyz/files/app-default.jpg"
// }
// 2018/8/19現在、/api/app/create のレスポンスにsecretが含まれないので認証に使えない
// https://github.com/syuilo/misskey/issues/2343
jsonObject.put(KEY_IS_MISSKEY, true)
jsonObject.put(KEY_AUTH_VERSION, AUTH_VERSION)
ClientInfo.save(instance, client_name, jsonObject.toString())
result.data = prepareBrowserUrlMisskey(appSecret)
return result
}
// oAuth2認証の続きを行う
fun authentication2Misskey(clientNameArg : String, token : String) : TootApiResult? {
val result = TootApiResult.makeWithCaption(instance)
if(result.error != null) return result
val instance = result.caption // same to instance
val client_name = if(clientNameArg.isNotEmpty()) clientNameArg else DEFAULT_CLIENT_NAME
@Suppress("UNUSED_VARIABLE")
val client_info = ClientInfo.load(instance, client_name)
?: return result.setError("missing client id")
val appSecret = client_info.parseString(KEY_MISSKEY_APP_SECRET)
if(appSecret?.isEmpty() != false) {
return result.setError(context.getString(R.string.cant_get_misskey_app_secret))
}
if(! sendRequest(result) {
JSONObject().apply {
put("appSecret", appSecret)
put("token", token)
}
.toPostRequestBuilder()
.url("https://$instance/api/auth/session/userkey")
.build()
}
) {
return result
}
parseJson(result) ?: return null
val token_info = result.jsonObject ?: return result
// {"accessToken":"...","user":{…}}
val access_token = token_info.parseString("accessToken")
if(access_token?.isEmpty() != false) {
return result.setError("missing accessToken in the response.")
}
val user : JSONObject = token_info.optJSONObject("user")
?: return result.setError("missing user in the response.")
token_info.remove("user")
val apiKey = "$access_token$appSecret".encodeUTF8().digestSHA256().encodeHexLower()
// ユーザ情報を読めたならtokenInfoを保存する
EntityId.mayNull(user.parseString("id"))?.putTo(token_info, KEY_USER_ID)
token_info.put(KEY_IS_MISSKEY, true)
token_info.put(KEY_AUTH_VERSION, AUTH_VERSION)
token_info.put(KEY_API_KEY_MISSKEY, apiKey)
// tokenInfoとユーザ情報の入ったresultを返す
result.tokenInfo = token_info
result.data = user
return result
}
//////////////////////////////////////////////////////////////////////
// 疑似アカウントの追加時に、インスタンスの検証を行う
private fun getInstanceInformationMastodon() : TootApiResult? {
val result = TootApiResult.makeWithCaption(instance)
if(result.error != null) return result
if(sendRequest(result) {
Request.Builder().url("https://$instance/api/v1/instance").build()
}
) {
parseJson(result) ?: return null
}
// misskeyの事は忘れて本来のエラー情報を返す
return result
}
2018-01-12 10:01:25 +01:00
// クライアントをタンスに登録
fun registerClient(scope_string : String, clientName : String) : TootApiResult? {
2018-01-12 10:01:25 +01:00
val result = TootApiResult.makeWithCaption(this.instance)
if(result.error != null) return result
val instance = result.caption // same to instance
// OAuth2 クライアント登録
if(! sendRequest(result) {
2018-12-02 11:25:00 +01:00
("client_name=" + clientName.encodePercent()
+ "&redirect_uris=" + REDIRECT_URL.encodePercent()
+ "&scopes=$scope_string"
).toRequestBody().toPost()
.url("https://$instance/api/v1/apps")
.build()
}) return result
2018-01-12 10:01:25 +01:00
return parseJson(result)
}
2018-01-13 07:15:52 +01:00
// クライアントアプリの登録を確認するためのトークンを生成する
// oAuth2 Client Credentials の取得
// https://github.com/doorkeeper-gem/doorkeeper/wiki/Client-Credentials-flow
// このトークンはAPIを呼び出すたびに新しく生成される…
2018-01-12 10:01:25 +01:00
internal fun getClientCredential(client_info : JSONObject) : TootApiResult? {
val result = TootApiResult.makeWithCaption(this.instance)
if(result.error != null) return result
2018-01-12 10:01:25 +01:00
if(! sendRequest(result) {
val client_id = client_info.parseString("client_id")
?: return result.setError("missing client_id")
val client_secret = client_info.parseString("client_secret")
?: return result.setError("missing client_secret")
"grant_type=client_credentials&scope=read+write&client_id=${client_id.encodePercent()}&client_secret=${client_secret.encodePercent()}"
2018-12-02 11:25:00 +01:00
.toRequestBody().toPost()
.url("https://$instance/oauth/token")
.build()
}) return result
2018-01-12 10:01:25 +01:00
val r2 = parseJson(result)
val jsonObject = r2?.jsonObject ?: return r2
2018-01-12 10:01:25 +01:00
log.d("getClientCredential: ${jsonObject}")
val sv = jsonObject.parseString("access_token")
if(sv?.isNotEmpty() == true) {
result.data = sv
} else {
result.data = null
2018-01-12 10:01:25 +01:00
result.error = "missing client credential."
}
return result
}
// client_credentialがまだ有効か調べる
2018-01-12 10:01:25 +01:00
internal fun verifyClientCredential(client_credential : String) : TootApiResult? {
val result = TootApiResult.makeWithCaption(this.instance)
if(result.error != null) return result
2018-01-12 10:01:25 +01:00
if(! sendRequest(result) {
Request.Builder()
.url("https://$instance/api/v1/apps/verify_credentials")
.header("Authorization", "Bearer $client_credential")
.build()
}) return result
2018-01-12 10:01:25 +01:00
return parseJson(result)
}
// client_credentialを無効にする
private fun revokeClientCredential(
client_info : JSONObject,
client_credential : String
) : TootApiResult? {
val result = TootApiResult.makeWithCaption(this.instance)
if(result.error != null) return result
val client_id = client_info.parseString("client_id")
?: return result.setError("missing client_id")
val client_secret = client_info.parseString("client_secret")
?: return result.setError("missing client_secret")
if(! sendRequest(result) {
2018-12-02 11:25:00 +01:00
("token=" + client_credential.encodePercent()
+ "&client_id=" + client_id.encodePercent()
+ "&client_secret=" + client_secret.encodePercent()
).toRequestBody().toPost()
.url("https://$instance/oauth/revoke")
.build()
2018-12-02 11:25:00 +01:00
}) return result
return parseJson(result)
}
// 認証ページURLを作る
internal fun prepareBrowserUrl(scope_string : String, client_info : JSONObject) : String? {
val account = this.account
val client_id = client_info.parseString("client_id") ?: return null
2018-01-12 10:01:25 +01:00
return ("https://" + instance + "/oauth/authorize"
+ "?client_id=" + client_id.encodePercent()
+ "&response_type=code"
+ "&redirect_uri=" + REDIRECT_URL.encodePercent()
+ "&scope=$scope_string"
+ "&scopes=$scope_string"
+ "&state=" + (if(account != null) "db:${account.db_id}" else "host:$instance")
+ "&grant_type=authorization_code"
+ "&approval_prompt=force"
+ "&force_login=true"
// +"&access_type=offline"
)
}
private fun prepareClientMastodon(clientNameArg : String, ti : TootInstance) : TootApiResult? {
// 前準備
val result = TootApiResult.makeWithCaption(this.instance)
if(result.error != null) return result
val instance = result.caption // same to instance
// クライアントIDがアプリ上に保存されているか
val client_name = if(clientNameArg.isNotEmpty()) clientNameArg else DEFAULT_CLIENT_NAME
var client_info = ClientInfo.load(instance, client_name)
// スコープ一覧を取得する
val scope_string = getScopeString(ti)
if(client_info != null
&& AUTH_VERSION == client_info.optInt(KEY_AUTH_VERSION)
&& ! client_info.optBoolean(KEY_IS_MISSKEY)
) {
var client_credential = client_info.parseString(KEY_CLIENT_CREDENTIAL)
val old_scope = client_info.parseString(KEY_CLIENT_SCOPE)
// client_credential をまだ取得していないなら取得する
2018-01-12 10:01:25 +01:00
if(client_credential?.isEmpty() != false) {
val resultSub = getClientCredential(client_info)
client_credential = resultSub?.string
if(client_credential?.isNotEmpty() == true) {
try {
client_info.put(KEY_CLIENT_CREDENTIAL, client_credential)
ClientInfo.save(instance, client_name, client_info.toString())
} catch(ignored : JSONException) {
}
}
}
// client_credential があるならcredentialがまだ使えるか確認する
if(client_credential?.isNotEmpty() == true) {
val resultSub = verifyClientCredential(client_credential)
val currentCC = resultSub?.jsonObject
if(currentCC != null) {
var allowReuseCC = true
if(old_scope != scope_string) {
// マストドン2.4でスコープが追加された
// 取得時のスコープ指定がマッチしない(もしくは記録されていない)ならクライアント情報を再利用してはいけない
ClientInfo.delete(instance, client_name)
// client credential をタンスから消去する
revokeClientCredential(client_info, client_credential)
// XXX クライアントアプリ情報そのものはまだサーバに残っているが、明示的に消す方法は現状存在しない
allowReuseCC = false
}
if(allowReuseCC) {
// クライアント情報を再利用する
result.data = client_info
return result
}
}
}
}
val r2 = registerClient(scope_string, client_name)
client_info = r2?.jsonObject ?: return r2
// {"id":999,"redirect_uri":"urn:ietf:wg:oauth:2.0:oob","client_id":"******","client_secret":"******"}
client_info.put(KEY_AUTH_VERSION, AUTH_VERSION)
client_info.put(KEY_CLIENT_SCOPE, scope_string)
// client_credential をまだ取得していないなら取得する
var client_credential = client_info.parseString(KEY_CLIENT_CREDENTIAL)
if(client_credential?.isEmpty() != false) {
val resultSub = getClientCredential(client_info)
client_credential = resultSub?.string
if(client_credential?.isNotEmpty() == true) {
try {
client_info.put(KEY_CLIENT_CREDENTIAL, client_credential)
ClientInfo.save(instance, client_name, client_info.toString())
} catch(ignored : JSONException) {
}
}
}
ClientInfo.save(instance, client_name, client_info.toString())
result.data = client_info
2018-01-12 10:01:25 +01:00
return result
}
private fun authentication1Mastodon(
clientNameArg : String,
ti : TootInstance
) : TootApiResult? =
prepareClientMastodon(clientNameArg, ti)?.also { result ->
val client_info = result.jsonObject
if(client_info != null) {
result.data = prepareBrowserUrl(getScopeString(ti), client_info)
}
}
// 疑似アカウントの追加時に、インスタンスの検証を行う
fun getInstanceInformation() : TootApiResult? {
// misskeyのインスタンス情報を読めたら、それはmisskeyのインスタンス
val r2 = getInstanceInformationMisskey() ?: return null
if(r2.jsonObject != null) return r2
// マストドンのインスタンス情報を読めたら、それはマストドンのインスタンス
val r1 = getInstanceInformationMastodon() ?: return null
if(r1.jsonObject != null) return r1
return r1 // 通信エラーの表示ならr1でもr2でも構わないはず
}
// クライアントを登録してブラウザで開くURLを生成する
fun authentication1(clientNameArg : String) : TootApiResult? {
// misskeyのインスタンス情報
var ri = parseInstanceInformation(getInstanceInformationMisskey())
var ti = ri?.data as? TootInstance
if(ti != null && (ri?.response?.code() ?: 0) in 200 until 300) {
return authentication1Misskey(clientNameArg, ti)
}
// マストドンのインスタンス情報
ri = parseInstanceInformation(getInstanceInformationMastodon())
ti = ri?.data as? TootInstance
if(ti != null && (ri?.response?.code() ?: 0) in 200 until 300) {
return authentication1Mastodon(clientNameArg, ti)
}
return ri
}
// oAuth2認証の続きを行う
2018-01-13 07:15:52 +01:00
fun authentication2(clientNameArg : String, code : String) : TootApiResult? {
val result = TootApiResult.makeWithCaption(instance)
if(result.error != null) return result
val instance = result.caption // same to instance
val client_name = if(clientNameArg.isNotEmpty()) clientNameArg else DEFAULT_CLIENT_NAME
val client_info =
ClientInfo.load(instance, client_name) ?: return result.setError("missing client id")
2018-01-12 10:01:25 +01:00
if(! sendRequest(result) {
val scope_string = client_info.optString(KEY_CLIENT_SCOPE)
val client_id = client_info.parseString("client_id")
val client_secret = client_info.parseString("client_secret")
if(client_id == null) return result.setError("missing client_id ")
if(client_secret == null) return result.setError("missing client_secret")
val post_content = ("grant_type=authorization_code"
+ "&code=" + code.encodePercent()
+ "&client_id=" + client_id.encodePercent()
+ "&redirect_uri=" + REDIRECT_URL.encodePercent()
+ "&client_secret=" + client_secret.encodePercent()
+ "&scope=$scope_string"
+ "&scopes=$scope_string")
2018-12-02 11:25:00 +01:00
post_content.toRequestBody().toPost()
.url("https://$instance/oauth/token")
.build()
}) return result
2018-01-12 10:01:25 +01:00
val r2 = parseJson(result)
val token_info = r2?.jsonObject ?: return r2
// {"access_token":"******","token_type":"bearer","scope":"read","created_at":1492334641}
val access_token = token_info.parseString("access_token")
2018-01-13 07:15:52 +01:00
if(access_token?.isEmpty() != false) {
return result.setError("missing access_token in the response.")
}
2018-01-13 07:15:52 +01:00
return getUserCredential(access_token, token_info)
}
// アクセストークン手動入力でアカウントを更新する場合
// verify_credentialsを呼び出す
2018-01-13 07:15:52 +01:00
fun getUserCredential(
access_token : String
, tokenInfo : JSONObject = JSONObject()
, isMisskey : Boolean = false
2018-01-13 07:15:52 +01:00
) : TootApiResult? {
if(isMisskey) {
val result = TootApiResult.makeWithCaption(instance)
if(result.error != null) return result
// 認証されたアカウントのユーザ情報を取得する
if(! sendRequest(result) {
JSONObject()
.put("i", access_token)
.toPostRequestBuilder()
.url("https://$instance/api/i")
.build()
}) return result
val r2 = parseJson(result)
if(r2?.jsonObject != null) {
// ユーザ情報を読めたならtokenInfoを保存する
tokenInfo.put(KEY_AUTH_VERSION, AUTH_VERSION)
tokenInfo.put(KEY_API_KEY_MISSKEY, access_token)
tokenInfo.put(KEY_IS_MISSKEY, true)
result.tokenInfo = tokenInfo
}
return r2
} else {
val result = TootApiResult.makeWithCaption(instance)
if(result.error != null) return result
// 認証されたアカウントのユーザ情報を取得する
if(! sendRequest(result) {
Request.Builder()
.url("https://$instance/api/v1/accounts/verify_credentials")
.header("Authorization", "Bearer $access_token")
.build()
}) return result
val r2 = parseJson(result)
if(r2?.jsonObject != null) {
// ユーザ情報を読めたならtokenInfoを保存する
tokenInfo.put(KEY_AUTH_VERSION, AUTH_VERSION)
tokenInfo.put("access_token", access_token)
result.tokenInfo = tokenInfo
}
return r2
2018-01-13 07:15:52 +01:00
}
2018-01-12 10:01:25 +01:00
}
fun createUser1(clientNameArg : String) : TootApiResult? {
// misskeyのインスタンス情報
var ri = parseInstanceInformation(getInstanceInformationMisskey())
var ti = ri?.data as? TootInstance
if(ti != null && (ri?.response?.code() ?: 0) in 200 until 300) {
return TootApiResult("Misskey has no API to create new account")
}
// マストドンのインスタンス情報
ri = parseInstanceInformation(getInstanceInformationMastodon())
ti = ri?.data as? TootInstance
if(ti != null && (ri?.response?.code() ?: 0) in 200 until 300) {
if(ti.version?.matches("""\bPleroma\b""".toRegex()) == true) {
return TootApiResult("Pleroma has no API to create new account")
}
// result.jsonObject に credentialつきのclient_info を格納して返す
return prepareClientMastodon(clientNameArg, ti)
}
return ri
}
// ユーザ名入力の後に呼ばれる
fun createUser2Mastodon(
client_info : JSONObject,
username : String,
email : String,
password : String,
agreement : Boolean
) : TootApiResult? {
val client_credential = client_info.parseString(KEY_CLIENT_CREDENTIAL)
?: error("missing client credential")
val result = TootApiResult.makeWithCaption(this.instance)
if(result.error != null) return result
log.d("createUser2Mastodon: client is : ${client_info}")
if(! sendRequest(result) {
"username=${username.encodePercent()}&email=${email.encodePercent()}&password=${password.encodePercent()}&agreement=${agreement}"
.toRequestBody().toPost()
.url("https://$instance/api/v1/accounts")
.header("Authorization", "Bearer ${client_credential}")
.build()
}) return result
return parseJson(result)
}
fun searchMsp(query : String, max_id : String?) : TootApiResult? {
2018-01-12 10:01:25 +01:00
// ユーザトークンを読む
var user_token : String? = Pref.spMspUserToken(pref)
2018-01-12 10:01:25 +01:00
for(nTry in 0 until 3) {
if(callback.isApiCancelled) return null
2018-01-12 10:01:25 +01:00
// ユーザトークンがなければ取得する
if(user_token == null || user_token.isEmpty()) {
2018-01-12 10:01:25 +01:00
callback.publishApiProgress("get MSP user token...")
val result : TootApiResult = TootApiResult.makeWithCaption("Mastodon Search Portal")
if(result.error != null) return result
if(! sendRequest(result) {
Request.Builder()
.url(mspTokenUrl + "?apikey=" + mspApiKey.encodePercent())
.build()
}) return result
2018-01-12 10:01:25 +01:00
val r2 = parseJson(result) { json ->
val error = json.parseString("error")
2018-01-12 10:01:25 +01:00
if(error == null) {
null
} else {
val type = json.parseString("type")
2018-01-13 07:15:52 +01:00
"error: $type $error"
2018-01-12 10:01:25 +01:00
}
}
val jsonObject = r2?.jsonObject ?: return r2
user_token = jsonObject.optJSONObject("result")?.optString("token")
if(user_token?.isEmpty() != false) {
return result.setError("Can't get MSP user token. response=${result.bodyString}")
} else {
pref.edit().put(Pref.spMspUserToken, user_token).apply()
2018-01-12 10:01:25 +01:00
}
2018-01-11 10:31:25 +01:00
}
2018-01-12 10:01:25 +01:00
// ユーザトークンを使って検索APIを呼び出す
val result : TootApiResult = TootApiResult.makeWithCaption("Mastodon Search Portal")
if(result.error != null) return result
2018-01-12 10:01:25 +01:00
if(! sendRequest(result) {
val url = StringBuilder()
.append(mspSearchUrl)
.append("?apikey=").append(mspApiKey.encodePercent())
.append("&utoken=").append(user_token.encodePercent())
.append("&q=").append(query.encodePercent())
.append("&max=").append(max_id?.encodePercent() ?: "")
Request.Builder().url(url.toString()).build()
}) return result
2018-01-12 10:01:25 +01:00
var isUserTokenError = false
val r2 = parseJson(result) { json ->
val error = json.parseString("error")
2018-01-12 10:01:25 +01:00
if(error == null) {
null
} else {
// ユーザトークンがダメなら生成しなおす
val detail = json.optString("detail")
if("utoken" == detail) {
isUserTokenError = true
}
val type = json.parseString("type")
2018-01-12 10:01:25 +01:00
"API returns error: $type $error"
}
}
if(r2 == null || ! isUserTokenError) return r2
}
2018-01-12 10:01:25 +01:00
return TootApiResult("MSP user token retry exceeded.")
}
2018-01-12 10:01:25 +01:00
fun searchTootsearch(
query : String,
from : Long?
2018-01-12 10:01:25 +01:00
) : TootApiResult? {
val result = TootApiResult.makeWithCaption("Tootsearch")
if(result.error != null) return result
if(! sendRequest(result) {
val sb = StringBuilder()
.append("https://tootsearch.chotto.moe/api/v1/search?sort=")
.append("created_at:desc".encodePercent())
.append("&q=").append(query.encodePercent())
if(from != null) {
sb.append("&from=").append(from.toString().encodePercent())
}
Request.Builder()
.url(sb.toString())
.build()
}) return result
2018-01-12 10:01:25 +01:00
return parseJson(result)
}
2018-01-12 10:01:25 +01:00
////////////////////////////////////////////////////////////////////////
// JSONデータ以外を扱うリクエスト
2018-01-11 10:31:25 +01:00
fun http(req : Request) : TootApiResult? {
val result = TootApiResult.makeWithCaption(req.url().host())
2018-01-12 10:01:25 +01:00
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 }) {
// parseJson(result)
// }
// return result
// }
// 疑似アカウントでステータスURLからステータスIDを取得するためにHTMLを取得する
fun getHttp(url : String) : TootApiResult? {
val result = http(Request.Builder().url(url).build())
if(result != null && result.error == null) {
parseString(result)
}
return result
2018-01-12 10:01:25 +01:00
}
fun getHttpBytes(url : String) : TootApiResult? {
val result = TootApiResult.makeWithCaption(url)
if(result.error != null) return result
if(! sendRequest(result, progressPath = url) {
Request.Builder().url(url).build()
}) return result
return parseBytes(result)
}
fun webSocket(path : String, ws_listener : WebSocketListener) : TootApiResult? {
2018-01-12 10:01:25 +01:00
val result = TootApiResult.makeWithCaption(instance)
if(result.error != null) return result
val account = this.account ?: return TootApiResult("account is null")
try {
var url = "wss://$instance$path"
2018-01-13 07:15:52 +01:00
val request_builder = Request.Builder()
2018-01-12 10:01:25 +01:00
val access_token = account.getAccessToken()
if(access_token?.isNotEmpty() == true) {
val delm = if(- 1 != url.indexOf('?')) '&' else '?'
url = url + delm + "access_token=" + access_token.encodePercent()
}
2018-01-13 07:15:52 +01:00
val request = request_builder.url(url).build()
2018-01-12 10:01:25 +01:00
publishApiProgress(context.getString(R.string.request_api, request.method(), path))
val ws = httpClient.getWebSocket(request, ws_listener)
if(isApiCancelled) {
ws.cancel()
return null
}
result.data = ws
} catch(ex : Throwable) {
log.trace(ex)
result.error =
"${result.caption}: ${ex.withCaption(context.resources, R.string.network_error)}"
}
return result
}
}
// result.data に TootAccountRefを格納して返す。もしくはエラーかキャンセル
fun TootApiClient.syncAccountByUrl(accessInfo : SavedAccount, who_url : String) : TootApiResult? {
// misskey由来のアカウントURLは https://host/@user@instance などがある
val m = TootAccount.reAccountUrl.matcher(who_url)
if(m.find()) {
// val host = m.group(1)
val user = m.group(2).decodePercent()
val instance = m.groupOrNull(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(who_url)
?: return TootApiResult(context.getString(R.string.user_id_conversion_failed))
request(
"/api/users/show",
accessInfo.putMisskeyApiToken(JSONObject()).apply {
when(val delm = acct.indexOf('@')) {
- 1 -> put("username", acct)
else -> {
put("username", acct.substring(0, delm))
put("host", acct.substring(delm + 1))
}
}
}.toPostRequestBuilder()
)
?.apply {
data = parser.account(jsonObject)
if(data == null && error == null) {
setError(context.getString(R.string.user_id_conversion_failed))
}
}
} else {
request("/api/v1/search?q=${who_url.encodePercent()}&resolve=true")
?.apply {
data = parser.results(jsonObject)?.accounts?.firstOrNull()
if(data == null && error == null) {
setError(context.getString(R.string.user_id_conversion_failed))
}
}
}
}
fun TootApiClient.syncAccountByAcct(accessInfo : SavedAccount, acct : String) : TootApiResult? {
val parser = TootParser(context, accessInfo)
return if(accessInfo.isMisskey) {
request(
"/api/users/show",
accessInfo.putMisskeyApiToken()
.apply {
when(val delm = acct.indexOf('@')) {
- 1 -> put("username", acct)
else -> {
put("username", acct.substring(0, delm))
put("host", acct.substring(delm + 1))
}
}
}
.toPostRequestBuilder()
)
?.apply {
data = parser.account(jsonObject)
if(data == null && error == null) {
setError(context.getString(R.string.user_id_conversion_failed))
}
}
} else {
request("/api/v1/search?q=${acct.encodePercent()}&resolve=true")
?.apply {
data = parser.results(jsonObject)?.accounts?.firstOrNull()
if(data == null && error == null) {
setError(context.getString(R.string.user_id_conversion_failed))
}
}
}
}
fun TootApiClient.syncStatus(accessInfo : SavedAccount, urlArg : String) : TootApiResult? {
var url = urlArg
// misskey の投稿URLは外部タンスの投稿を複製したものの可能性がある
// これを投稿元タンスのURLに変換しないと、投稿の同期には使えない
val m = TootStatus.reStatusPageMisskey.matcher(urlArg)
if(m.find()) {
val host = m.group(1)
val noteId = m.group(2)
2018-12-08 07:26:19 +01:00
TootApiClient(context, callback = callback)
.apply { instance = host }
.request(
"/api/notes/show",
JSONObject()
.put("noteId", noteId)
.toPostRequestBuilder()
2018-12-08 07:26:19 +01:00
)
?.also { result ->
TootParser(
context,
LinkHelper.newLinkHelper(host, isMisskey = true),
serviceType = ServiceType.MISSKEY
)
.status(result.jsonObject)
?.apply {
if(host.equals(accessInfo.host, ignoreCase = true)) {
result.data = this
return result
}
uri.letNotEmpty { url = it }
}
}
2018-12-08 07:26:19 +01:00
?: return null // cancelled.
}
2018-12-08 07:26:19 +01:00
// 使いたいタンス上の投稿IDを取得する
val parser = TootParser(context, accessInfo)
return if(accessInfo.isMisskey) {
request(
2018-12-08 07:26:19 +01:00
"/api/ap/show",
accessInfo.putMisskeyApiToken()
.put("uri", url)
.toPostRequestBuilder()
)
?.apply {
data = parser.parseMisskeyApShow(jsonObject)
if(data == null && error == null) {
setError(context.getString(R.string.cant_sync_toot))
}
}
} else {
request("/api/v1/search?q=${url.encodePercent()}&resolve=true")
?.apply {
data = parser.results(jsonObject)?.statuses?.firstOrNull()
if(data == null && error == null) {
setError(context.getString(R.string.cant_sync_toot))
}
}
}
}
fun TootApiClient.syncStatus(
accessInfo : SavedAccount,
statusRemote : TootStatus
) : TootApiResult? {
// URL->URIの順に試す
val uriList = ArrayList<String>(2)
2018-12-08 07:26:19 +01:00
statusRemote.url.letNotEmpty {
if(it.contains("/notes/")) {
// Misskeyタンスから読んだマストドンの投稿はurlがmisskeyタンス上のものになる
// ActivityPub object id としては不適切なので使わない
} else {
uriList.add(it)
}
}
2018-12-08 07:26:19 +01:00
statusRemote.uri.letNotEmpty {
// uri の方は↑の問題はない
uriList.add(it)
}
2018-12-08 07:26:19 +01:00
if(accessInfo.isMisskey && uriList.firstOrNull()?.contains("@") == true) {
// https://github.com/syuilo/misskey/pull/2832
// @user を含むuri はMisskeyだと少し効率が悪いそうなので順序を入れ替える
uriList.reverse()
}
for(uri in uriList) {
val result = syncStatus(accessInfo, uri)
if(result == null || result.data is TootStatus) {
return result
}
}
return TootApiResult("can't resolve status URL/URI.")
2018-12-02 10:35:04 +01:00
}