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

1495 lines
55 KiB
Kotlin
Raw Normal View History

package jp.juggler.subwaytooter.api
import android.content.Context
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 java.util.*
import java.util.concurrent.atomic.AtomicReference
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 DEFAULT_CLIENT_NAME = "SubwayTooter"
private const val REDIRECT_URL = "subwaytooter://oauth/"
// 20181225 3=>4 client credentialの取得時にもscopeの取得が必要になった
// 20190147 4=>5 client id とユーザIDが同じだと同じアクセストークンが返ってくるので複数端末の利用で困る。
// AUTH_VERSIONが古いclient情報は使わない。また、インポートの対象にしない。
private const val AUTH_VERSION = 5
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" // for ClientInfo
const val KEY_MISSKEY_VERSION = "isMisskey" // for tokenInfo,TootInstance
const val KEY_MISSKEY_APP_SECRET = "secret"
const val KEY_API_KEY_MISSKEY = "apiKeyMisskey"
const val KEY_USER_ID = "userId"
private const val NO_INFORMATION = "(no information)"
private val reStartJsonArray = """\A\s*\[""".asciiPattern()
private val reStartJsonObject = """\A\s*\{""".asciiPattern()
val DEFAULT_JSON_ERROR_PARSER =
{ json: JsonObject -> json["error"]?.toString() }
fun getScopeString(ti: TootInstance?) = when {
// 古いサーバ
2021-05-09 05:17:11 +02:00
ti?.versionGE(TootInstance.VERSION_2_4_0_rc1) == false -> "read+write+follow"
// 新しいサーバか、AUTHORIZED_FETCH(3.0.0以降)によりサーバ情報を取得できなかった
else -> "read+write+follow+push"
// 過去の試行錯誤かな
2020-12-07 13:23:14 +01:00
// ti.versionGE(TootInstance.VERSION_2_7_0_rc1) -> "read+write+follow+push+create"
}
fun getScopeArrayMisskey(@Suppress("UNUSED_PARAMETER") ti: TootInstance) =
JsonArray().apply {
if (ti.versionGE(TootInstance.MISSKEY_VERSION_11)) {
// https://github.com/syuilo/misskey/blob/master/src/server/api/kinds.ts
arrayOf(
"read:account",
"write:account",
"read:blocks",
"write:blocks",
"read:drive",
"write:drive",
"read:favorites",
"write:favorites",
"read:following",
"write:following",
"read:messaging",
"write:messaging",
"read:mutes",
"write:mutes",
"write:notes",
"read:notifications",
"write:notifications",
"read:reactions",
"write:reactions",
"write:votes"
)
2020-12-07 13:23:14 +01:00
} else {
// https://github.com/syuilo/misskey/issues/2341
arrayOf(
"account-read",
"account-write",
"account/read",
"account/write",
"drive-read",
"drive-write",
"favorite-read",
"favorite-write",
"favorites-read",
"following-read",
"following-write",
"messaging-read",
"messaging-write",
"note-read",
"note-write",
"notification-read",
"notification-write",
"reaction-read",
"reaction-write",
"vote-read",
"vote-write"
)
2020-12-07 13:23:14 +01:00
}
// APIのエラーを回避するため、重複を排除する
.toMutableSet()
.forEach { add(it) }
}
private fun encodeScopeArray(scope_array: JsonArray?): String? {
scope_array ?: return null
val list = scope_array.stringArrayList()
list.sort()
return list.joinToString(",")
}
private fun compareScopeArray(a: JsonArray, b: JsonArray?): Boolean {
return encodeScopeArray(a) == encodeScopeArray(b)
}
2021-05-11 08:12:43 +02:00
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)
2020-12-07 13:23:14 +01:00
}
// 認証に関する設定を保存する
internal val pref = context.pref()
// インスタンスのホスト名
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
}
2020-12-07 13:23:14 +01:00
@Suppress("unused")
internal val isApiCancelled: Boolean
get() = callback.isApiCancelled
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,
tmpOkhttpClient: 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, tmpOkhttpClient = tmpOkhttpClient)
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? {
2020-12-07 13:23:14 +01:00
if (isApiCancelled) return null
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 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) {
2021-05-11 08:12:43 +02:00
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
}
}
// レスポンスがエラーかボディがカラならエラー状態を設定する
// 例外を出すかも
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
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
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
} catch (ex: Throwable) {
log.trace(ex)
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
result.data = bodyString
} catch (ex: Throwable) {
log.trace(ex)
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データを読む
internal suspend fun parseJson(
result: TootApiResult,
progressPath: String? = null,
jsonErrorParser: (json: JsonObject) -> String? = DEFAULT_JSON_ERROR_PARSER
): TootApiResult? // 引数に指定したresultそのものか、キャンセルされたらnull
2020-12-07 13:23:14 +01:00
{
val response = result.response!! // nullにならないはず
try {
var bodyString = readBodyString(result, progressPath, jsonErrorParser)
?: return if (isApiCancelled) null else result
if (bodyString.isEmpty()) {
// 204 no content は 空オブジェクトと解釈する
result.data = JsonObject()
} else if (reStartJsonArray.matcher(bodyString).find()) {
result.data = bodyString.decodeJsonArray()
} else if (reStartJsonObject.matcher(bodyString).find()) {
val json = bodyString.decodeJsonObject()
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()
.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.trace(ex)
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,
request_builder: Request.Builder = Request.Builder(),
2021-05-09 05:17:11 +02:00
forceAccessToken: String? = null,
): TootApiResult? {
2020-12-07 13:23:14 +01:00
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
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
request_builder.url(url)
2020-12-07 13:23:14 +01:00
2021-05-09 05:17:11 +02:00
(forceAccessToken ?: account?.getAccessToken())
?.notEmpty()?.let { request_builder.header("Authorization", "Bearer $it") }
2020-12-07 13:23:14 +01:00
request_builder.build()
.also { log.d("request: ${it.method} $url") }
2020-12-07 13:23:14 +01:00
}) return result
return parseJson(result)
} finally {
val error = result.error
if (error != null) log.d("error: $error")
}
}
//////////////////////////////////////////////////////////////////////
// misskey authentication
private suspend fun getAppInfoMisskey(appId: String?): TootApiResult? {
2020-12-07 13:23:14 +01:00
appId ?: return TootApiResult("missing app id")
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
if (result.error != null) return result
if (sendRequest(result) {
JsonObject().apply {
put("appId", appId)
}
.toPostRequestBuilder()
.url("https://${apiHost?.ascii}/api/app/show")
.build()
}) {
parseJson(result) ?: return null
result.jsonObject?.put(KEY_IS_MISSKEY, true)
}
return result
}
private suspend fun prepareBrowserUrlMisskey(appSecret: String): String? {
2020-12-07 13:23:14 +01:00
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
if (result.error != null) {
context.showToast(false, result.error)
return null
}
if (!sendRequest(result) {
JsonObject().apply {
put("appSecret", appSecret)
}
.toPostRequestBuilder()
.url("https://${apiHost?.ascii}/api/auth/session/generate")
.build()
}
) {
val error = result.error
if (error != null) {
context.showToast(false, error)
return null
}
return null
}
parseJson(result) ?: return null
val jsonObject = result.jsonObject
if (jsonObject == null) {
context.showToast(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.string("url")
if (url?.isEmpty() != false) {
context.showToast(false, "missing 'url' in auth session response.")
return null
}
val e = PrefDevice.prefDevice(context)
.edit()
.putString(PrefDevice.LAST_AUTH_INSTANCE, apiHost?.ascii)
.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 suspend fun registerClientMisskey(
scope_array: JsonArray,
client_name: String
): TootApiResult? {
2020-12-07 13:23:14 +01:00
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
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://${apiHost?.ascii}/api/app/create")
.build()
}) {
parseJson(result) ?: return null
}
return result
}
private suspend fun authentication1Misskey(
clientNameArg: String,
ti: TootInstance
): TootApiResult? {
2020-12-07 13:23:14 +01:00
val result = TootApiResult.makeWithCaption(this.apiHost?.pretty)
if (result.error != null) return result
val instance = result.caption // same to instance
// クライアントIDがアプリ上に保存されているか
val client_name = clientNameArg.notEmpty() ?: DEFAULT_CLIENT_NAME
val client_info = ClientInfo.load(instance, client_name)
// スコープ一覧を取得する
val scope_array = getScopeArrayMisskey(ti)
if (client_info != null
&& AUTH_VERSION == client_info.int(KEY_AUTH_VERSION)
&& client_info.boolean(KEY_IS_MISSKEY) == true
) {
val appSecret = client_info.string(KEY_MISSKEY_APP_SECRET)
val r2 = getAppInfoMisskey(client_info.string("id"))
val tmpClientInfo = r2?.jsonObject
// tmpClientInfo はsecretを含まないので保存してはいけない
when {
// アプリが登録済みで
// クライアント名が一致してて
// パーミッションが同じ
tmpClientInfo != null
&& client_name == tmpClientInfo.string("name")
&& compareScopeArray(scope_array, tmpClientInfo["permission"].cast())
&& 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.string(KEY_MISSKEY_APP_SECRET)
if (appSecret?.isEmpty() != false) {
context.showToast(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[KEY_IS_MISSKEY] = true
jsonObject[KEY_AUTH_VERSION] = AUTH_VERSION
ClientInfo.save(instance, client_name, jsonObject.toString())
result.data = prepareBrowserUrlMisskey(appSecret)
return result
}
// oAuth2認証の続きを行う
suspend fun authentication2Misskey(
clientNameArg: String,
token: String,
misskeyVersion: Int
): TootApiResult? {
2020-12-07 13:23:14 +01:00
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
if (result.error != null) return result
val instance = result.caption // same to instance
val client_name = clientNameArg.notEmpty() ?: DEFAULT_CLIENT_NAME
@Suppress("UNUSED_VARIABLE")
val client_info = ClientInfo.load(instance, client_name)
?: return result.setError("missing client id")
val appSecret = client_info.string(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.string("accessToken")
if (access_token?.isEmpty() != false) {
return result.setError("missing accessToken in the response.")
}
val user = token_info["user"].cast<JsonObject>()
?: return result.setError("missing user in the response.")
token_info.remove("user")
val apiKey = "$access_token$appSecret".encodeUTF8().digestSHA256().encodeHexLower()
// ユーザ情報を読めたならtokenInfoを保存する
EntityId.mayNull(user.string("id"))?.putTo(token_info, KEY_USER_ID)
token_info[KEY_MISSKEY_VERSION] = misskeyVersion
token_info[KEY_AUTH_VERSION] = AUTH_VERSION
token_info[KEY_API_KEY_MISSKEY] = apiKey
// tokenInfoとユーザ情報の入ったresultを返す
result.tokenInfo = token_info
result.data = user
return result
}
//////////////////////////////////////////////////////////////////////
// クライアントをタンスに登録
suspend fun registerClient(scope_string: String, clientName: String): TootApiResult? {
2020-12-07 13:23:14 +01:00
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
if (result.error != null) return result
val instance = result.caption // same to instance
// OAuth2 クライアント登録
if (!sendRequest(result) {
("client_name=" + clientName.encodePercent()
+ "&redirect_uris=" + REDIRECT_URL.encodePercent()
+ "&scopes=$scope_string"
).toFormRequestBody().toPost()
.url("https://$instance/api/v1/apps")
.build()
}) return result
return parseJson(result)
}
// クライアントアプリの登録を確認するためのトークンを生成する
// oAuth2 Client Credentials の取得
// https://github.com/doorkeeper-gem/doorkeeper/wiki/Client-Credentials-flow
// このトークンはAPIを呼び出すたびに新しく生成される…
internal suspend fun getClientCredential(client_info: JsonObject): TootApiResult? {
2020-12-07 13:23:14 +01:00
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
if (result.error != null) return result
if (!sendRequest(result) {
val client_id = client_info.string("client_id")
?: return result.setError("missing client_id")
val client_secret = client_info.string("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()}"
.toFormRequestBody().toPost()
.url("https://${apiHost?.ascii}/oauth/token")
.build()
}) return result
val r2 = parseJson(result)
val jsonObject = r2?.jsonObject ?: return r2
log.d("getClientCredential: ${jsonObject}")
val sv = jsonObject.string("access_token")?.notEmpty()
if (sv != null) {
result.data = sv
} else {
result.data = null
result.error = "missing client credential."
}
return result
}
// client_credentialがまだ有効か調べる
internal suspend fun verifyClientCredential(client_credential: String): TootApiResult? {
2020-12-07 13:23:14 +01:00
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
if (result.error != null) return result
if (!sendRequest(result) {
Request.Builder()
.url("https://${apiHost?.ascii}/api/v1/apps/verify_credentials")
.header("Authorization", "Bearer $client_credential")
.build()
}) return result
return parseJson(result)
}
// client_credentialを無効にする
private suspend fun revokeClientCredential(
client_info: JsonObject,
client_credential: String
): TootApiResult? {
2020-12-07 13:23:14 +01:00
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
if (result.error != null) return result
val client_id = client_info.string("client_id")
?: return result.setError("missing client_id")
val client_secret = client_info.string("client_secret")
?: return result.setError("missing client_secret")
if (!sendRequest(result) {
("token=" + client_credential.encodePercent()
+ "&client_id=" + client_id.encodePercent()
+ "&client_secret=" + client_secret.encodePercent()
).toFormRequestBody().toPost()
.url("https://${apiHost?.ascii}/oauth/revoke")
.build()
}) 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.string("client_id") ?: return null
val state = StringBuilder()
.append((if (account != null) "db:${account.db_id}" else "host:${apiHost?.ascii}"))
.append(',')
.append("random:${System.currentTimeMillis()}")
.toString()
return ("https://${apiHost?.ascii}/oauth/authorize"
+ "?client_id=" + client_id.encodePercent()
+ "&response_type=code"
+ "&redirect_uri=" + REDIRECT_URL.encodePercent()
+ "&scope=$scope_string"
+ "&scopes=$scope_string"
+ "&state=" + state.encodePercent()
+ "&grant_type=authorization_code"
+ "&approval_prompt=force"
+ "&force_login=true"
// +"&access_type=offline"
)
}
private suspend fun prepareClientMastodon(
clientNameArg: String,
ti: TootInstance?,
forceUpdateClient: Boolean = false
): TootApiResult? {
2020-12-07 13:23:14 +01:00
// 前準備
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
if (result.error != null) return result
val instance = result.caption // same to instance
// クライアントIDがアプリ上に保存されているか
val client_name = clientNameArg.notEmpty() ?: DEFAULT_CLIENT_NAME
var client_info = ClientInfo.load(instance, client_name)
// スコープ一覧を取得する
val scope_string = getScopeString(ti)
when {
AUTH_VERSION != client_info?.int(KEY_AUTH_VERSION) -> {
// 古いクライアント情報は使わない。削除もしない。
}
client_info.boolean(KEY_IS_MISSKEY) == true -> {
// Misskeyにはclient情報をまだ利用できるかどうか調べる手段がないので、再利用しない
}
else -> {
val old_scope = client_info.string(KEY_CLIENT_SCOPE)
// client_credential をまだ取得していないなら取得する
var client_credential = client_info.string(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[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) {
if (old_scope != scope_string || forceUpdateClient) {
// マストドン2.4でスコープが追加された
// 取得時のスコープ指定がマッチしない(もしくは記録されていない)ならクライアント情報を再利用してはいけない
ClientInfo.delete(instance, client_name)
// client credential をタンスから消去する
revokeClientCredential(client_info, client_credential)
// XXX クライアントアプリ情報そのものはまだサーバに残っているが、明示的に消す方法は現状存在しない
} else {
// クライアント情報を再利用する
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[KEY_AUTH_VERSION] = AUTH_VERSION
client_info[KEY_CLIENT_SCOPE] = scope_string
// client_credential をまだ取得していないなら取得する
var client_credential = client_info.string(KEY_CLIENT_CREDENTIAL)
if (client_credential?.isEmpty() != false) {
val resultSub = getClientCredential(client_info)
client_credential = resultSub?.string
if (client_credential?.isEmpty() != false) return resultSub
client_info[KEY_CLIENT_CREDENTIAL] = client_credential
}
try {
ClientInfo.save(instance, client_name, client_info.toString())
} catch (ignored: JsonException) {
}
result.data = client_info
return result
}
private suspend fun authentication1Mastodon(
clientNameArg: String,
ti: TootInstance?,
forceUpdateClient: Boolean = false
): TootApiResult? {
2020-12-07 13:23:14 +01:00
if (ti?.instanceType == InstanceType.Pixelfed) {
2020-12-07 13:23:14 +01:00
return TootApiResult("currently Pixelfed instance is not supported.")
}
return prepareClientMastodon(clientNameArg, ti, forceUpdateClient)?.also { result ->
val client_info = result.jsonObject
if (client_info != null) {
result.data = prepareBrowserUrl(getScopeString(ti), client_info)
}
}
}
// クライアントを登録してブラウザで開くURLを生成する
suspend fun authentication1(
clientNameArg: String,
forceUpdateClient: Boolean = false
): TootApiResult? {
2020-12-07 13:23:14 +01:00
val (ti, ri) = TootInstance.get(this)
log.i("authentication1: instance info version=${ti?.version} misskeyVersion=${ti?.misskeyVersion} responseCode=${ri?.response?.code}")
return if (ti == null) when (ri?.response?.code) {
// https://github.com/tateisu/SubwayTooter/issues/155
// Mastodon's WHITELIST_MODE
401 -> authentication1Mastodon(clientNameArg, null, forceUpdateClient)
else -> ri
} else when {
2020-12-07 13:23:14 +01:00
ti.misskeyVersion > 0 -> authentication1Misskey(clientNameArg, ti)
else -> authentication1Mastodon(clientNameArg, ti, forceUpdateClient)
}
}
// oAuth2認証の続きを行う
suspend fun authentication2Mastodon(
clientNameArg: String,
code: String,
outAccessToken: AtomicReference<String>
): TootApiResult? {
2020-12-07 13:23:14 +01:00
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
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")
if (!sendRequest(result) {
val scope_string = client_info.string(KEY_CLIENT_SCOPE)
val client_id = client_info.string("client_id")
val client_secret = client_info.string("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")
post_content.toFormRequestBody().toPost()
.url("https://$instance/oauth/token")
.build()
}) return result
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.string("access_token")
if (access_token?.isEmpty() != false) {
return result.setError("missing access_token in the response.")
}
outAccessToken.set(access_token)
2020-12-07 13:23:14 +01:00
return getUserCredential(access_token, token_info)
}
// アクセストークン手動入力でアカウントを更新する場合、アカウントの情報を取得する
suspend fun getUserCredential(
access_token: String, tokenInfo: JsonObject = JsonObject(), misskeyVersion: Int = 0
): TootApiResult? {
2020-12-07 13:23:14 +01:00
if (misskeyVersion > 0) {
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
if (result.error != null) return result
// 認証されたアカウントのユーザ情報を取得する
if (!sendRequest(result) {
JsonObject().apply {
put("i", access_token)
}
.toPostRequestBuilder()
.url("https://${apiHost?.ascii}/api/i")
.build()
}) return result
val r2 = parseJson(result)
if (r2?.jsonObject != null) {
// ユーザ情報を読めたならtokenInfoを保存する
tokenInfo[KEY_AUTH_VERSION] = AUTH_VERSION
tokenInfo[KEY_API_KEY_MISSKEY] = access_token
tokenInfo[KEY_MISSKEY_VERSION] = misskeyVersion
result.tokenInfo = tokenInfo
}
return r2
} else {
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
if (result.error != null) return result
// 認証されたアカウントのユーザ情報を取得する
if (!sendRequest(result) {
Request.Builder()
.url("https://${apiHost?.ascii}/api/v1/accounts/verify_credentials")
.header("Authorization", "Bearer $access_token")
.build()
}) return result
val r2 = parseJson(result)
if (r2?.jsonObject != null) {
// ユーザ情報を読めたならtokenInfoを保存する
tokenInfo[KEY_AUTH_VERSION] = AUTH_VERSION
tokenInfo["access_token"] = access_token
result.tokenInfo = tokenInfo
}
return r2
}
}
suspend fun createUser1(clientNameArg: String): TootApiResult? {
2020-12-07 13:23:14 +01:00
val (ti, ri) = TootInstance.get(this)
2020-12-07 13:23:14 +01:00
ti ?: return ri
return when (ti.instanceType) {
2021-02-15 08:45:22 +01:00
InstanceType.Misskey ->
TootApiResult("Misskey has no API to create new account")
2021-02-15 08:45:22 +01:00
InstanceType.Pleroma ->
TootApiResult("Pleroma has no API to create new account")
2021-02-15 08:45:22 +01:00
InstanceType.Pixelfed ->
TootApiResult("Pixelfed has no API to create new account")
2020-12-07 13:23:14 +01:00
else ->
prepareClientMastodon(clientNameArg, ti)
// result.JsonObject に credentialつきのclient_info を格納して返す
}
}
// ユーザ名入力の後に呼ばれる
suspend fun createUser2Mastodon(
client_info: JsonObject,
username: String,
email: String,
password: String,
agreement: Boolean,
reason: String?
): TootApiResult? {
2020-12-07 13:23:14 +01:00
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
if (result.error != null) return result
log.d("createUser2Mastodon: client is : ${client_info}")
val client_credential = client_info.string(KEY_CLIENT_CREDENTIAL)
?: return result.setError("createUser2Mastodon(): missing client credential")
if (!sendRequest(result) {
val params = ArrayList<String>().apply {
add("username=${username.encodePercent()}")
add("email=${email.encodePercent()}")
add("password=${password.encodePercent()}")
add("agreement=${agreement}")
if (reason?.isNotEmpty() == true) add("reason=${reason.encodePercent()}")
}
params
.joinToString("&").toFormRequestBody().toPost()
.url("https://${apiHost?.ascii}/api/v1/accounts")
.header("Authorization", "Bearer ${client_credential}")
.build()
}) return result
return parseJson(result)
}
////////////////////////////////////////////////////////////////////////
// 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,
ws_listener: WebSocketListener
): Pair<TootApiResult?, WebSocket?> {
2020-12-07 13:23:14 +01:00
var ws: WebSocket? = null
val result = TootApiResult.makeWithCaption(apiHost?.pretty)
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 request_builder = Request.Builder()
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 {
2020-12-21 03:13:03 +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()}"
}
2020-12-07 13:23:14 +01:00
}
val request = request_builder.url(url).build()
publishApiProgress(context.getString(R.string.request_api, request.method, path))
ws = httpClient.getWebSocket(request, ws_listener)
if (isApiCancelled) {
ws.cancel()
return Pair(null, null)
}
} catch (ex: Throwable) {
log.trace(ex)
result.error =
"${result.caption}: ${ex.withCaption(context.resources, R.string.network_error)}"
}
return Pair(result, ws)
}
}
// query: query_string after ? ( ? itself is excluded )
suspend fun TootApiClient.requestMastodonSearch(
parser: TootParser,
query: String
2020-12-07 13:23:14 +01:00
): Pair<TootApiResult?, TootResults?> {
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,
who_url: String
2020-12-07 13:23:14 +01:00
): Pair<TootApiResult?, TootAccountRef?> {
// misskey由来のアカウントURLは https://host/@user@instance などがある
val m = TootAccount.reAccountUrl.matcher(who_url)
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(who_url)
?: 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=${who_url.encodePercent()}&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.encodePercent()}&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),
serviceType = ServiceType.MISSKEY
)
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.encodePercent()}&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
}