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

511 lines
17 KiB
Kotlin
Raw Normal View History

package jp.juggler.subwaytooter.api
import android.content.Context
import android.net.Uri
import org.json.JSONException
import org.json.JSONObject
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.table.ClientInfo
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.LogCategory
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.util.Utils
import okhttp3.Call
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response
import okhttp3.WebSocketListener
import org.json.JSONArray
class TootApiClient(
private val context : Context,
private val callback : TootApiCallback
) {
companion object {
private val log = LogCategory("TootApiClient")
private val ok_http_client = App1.ok_http_client
val MEDIA_TYPE_FORM_URL_ENCODED = MediaType.parse("application/x-www-form-urlencoded")
val MEDIA_TYPE_JSON = MediaType.parse("application/json;charset=UTF-8")
private const val DEFAULT_CLIENT_NAME = "SubwayTooter"
private const val KEY_CLIENT_CREDENTIAL = "SubwayTooterClientCredential"
private const val KEY_AUTH_VERSION = "SubwayTooterAuthVersion"
private const val AUTH_VERSION = 1
private const val REDIRECT_URL = "subwaytooter://oauth/"
}
interface CurrentCallCallback {
fun onCallCreated(call : Call)
}
private var call_callback : CurrentCallCallback? = null
// インスタンスのホスト名
var instance : String? = null
// アカウントがある場合に使用する
var account : SavedAccount? = null
@Suppress("unused")
val isApiCancelled : Boolean
get() = callback.isApiCancelled
val isCancelled : Boolean
get() = callback.isApiCancelled
fun publishApiProgress(s : String) {
callback.publishApiProgress(s)
}
fun publishApiProgressRatio(value : Int, max : Int) {
callback.publishApiProgressRatio(value, max)
}
fun setCurrentCallCallback(call_callback : CurrentCallCallback) {
this.call_callback = call_callback
}
// アカウント追加時に使用する
fun setInstance(instance : String?) : TootApiClient {
this.instance = instance
return this
}
fun setAccount(account : SavedAccount) : TootApiClient {
this.account = account
this.instance = account.host
return this
}
@JvmOverloads
fun request(path : String, request_builder : Request.Builder = Request.Builder()) : TootApiResult? {
log.d("request: $path")
val result = request_sub(path, request_builder)
val error = result?.error
if(error != null) log.d("error: $error")
return result
}
private fun request_sub(path : String, request_builder : Request.Builder) : TootApiResult? {
val result = TootApiResult.makeWithCaption(instance)
if(result.error != null) return result
val instance = result.caption // same to instance
val account = this.account ?: return result.setError("account is null")
val access_token = account.getAccessToken()
val response = try {
request_builder.url("https://" + instance + path)
if(access_token != null && access_token.isNotEmpty()) {
request_builder.header("Authorization", "Bearer " + access_token)
}
sendRequest(request_builder.build())
} catch(ex : Throwable) {
log.trace(ex)
return result.setError(instance + ": " + Utils.formatError(ex, context.resources, R.string.network_error))
}
return readJson(result, response)
}
fun webSocket(path : String, request_builder : Request.Builder, ws_listener : WebSocketListener) : TootApiResult? {
val result = TootApiResult.makeWithCaption(instance)
if(result.error != null) return result
val instance = result.caption // same to instance
try {
val account = this.account ?: return TootApiResult("account is null")
val access_token = account.getAccessToken()
var url = "wss://" + instance + path
if(access_token != null && access_token.isNotEmpty()) {
val delm = if(- 1 != url.indexOf('?')) '&' else '?'
url = url + delm + "access_token=" + Uri.encode(access_token)
}
request_builder.url(url)
val request = request_builder.build()
callback.publishApiProgress(context.getString(R.string.request_api, request.method(), path))
val ws = ok_http_client.newWebSocket(request, ws_listener)
if(callback.isApiCancelled) {
ws.cancel()
return null
}
result.data = ws
} catch(ex : Throwable) {
log.trace(ex)
result.error = instance + ": " + Utils.formatError(ex, context.resources, R.string.network_error)
}
return result
}
// 疑似アカウントの追加時に、インスタンスの検証を行う
fun checkInstance() : TootApiResult? {
val result = TootApiResult.makeWithCaption(instance)
if(result.error != null) return result
val instance = result.caption // same to instance
val response = try {
val request = Request.Builder().url("https://$instance/api/v1/instance").build()
sendRequest(request)
} catch(ex : Throwable) {
log.trace(ex)
return result.setError(instance + ": " + Utils.formatError(ex, context.resources, R.string.network_error))
}
return readJson(result, response)
}
// クライアントアプリの登録を確認するためのトークンを生成する
// oAuth2 Client Credentials の取得
// https://github.com/doorkeeper-gem/doorkeeper/wiki/Client-Credentials-flow
// このトークンはAPIを呼び出すたびに新しく生成される…
private fun getClientCredential(client_info : JSONObject) : TootApiResult? {
val result = TootApiResult.makeWithCaption(this.instance)
if(result.error != null) return result
val instance = result.caption
val response = try {
val request = Request.Builder()
.url("https://$instance/oauth/token")
.post(RequestBody.create(MEDIA_TYPE_FORM_URL_ENCODED, "grant_type=client_credentials"
+ "&client_id=" + Uri.encode(client_info.optString("client_id"))
+ "&client_secret=" + Uri.encode(client_info.optString("client_secret"))
))
.build()
sendRequest(request)
} catch(ex : Throwable) {
log.trace(ex)
return result.setError("getClientCredential: " + instance + ": " + Utils.formatError(ex, context.resources, R.string.network_error))
}
val r2 = readJson(result, response)
val jsonObject = r2?.jsonObject ?: return r2
val sv = Utils.optStringX(jsonObject, "access_token")
if(sv?.isNotEmpty() == true) {
result.data = sv
} else {
result.data = null
result.error = "getClientCredential: API returns empty client_credential."
}
return result
}
// client_credentialがまだ有効か調べる
private fun verifyClientCredential(client_credential : String) : TootApiResult? {
val result = TootApiResult.makeWithCaption(this.instance)
if(result.error != null) return result
val instance = result.caption // same to instance
val response = try {
val request = Request.Builder()
.url("https://$instance/api/v1/apps/verify_credentials")
.header("Authorization", "Bearer $client_credential")
.build()
sendRequest(request)
} catch(ex : Throwable) {
log.trace(ex)
return result.setError("$instance: " + Utils.formatError(ex, context.resources, R.string.network_error))
}
return readJson(result, response)
}
private fun prepareBrowserUrl(client_info : JSONObject) : String {
val account = this.account
// 認証ページURLを作る
val browser_url = ("https://" + instance + "/oauth/authorize"
+ "?client_id=" + Uri.encode(Utils.optStringX(client_info, "client_id"))
+ "&response_type=code"
+ "&redirect_uri=" + Uri.encode(REDIRECT_URL)
+ "&scope=read write follow"
+ "&scopes=read write follow"
+ "&state=" + (if(account != null) "db:" + account.db_id else "host:" + instance)
+ "&grant_type=authorization_code"
+ "&approval_prompt=force"
// +"&access_type=offline"
)
return browser_url
}
// クライアントを登録してブラウザで開くURLを生成する
fun authorize1(clientNameArg : String) : 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)
if(client_info != null) {
var client_credential = Utils.optStringX(client_info, KEY_CLIENT_CREDENTIAL)
// client_credential をまだ取得していないなら取得する
if(client_credential == null || client_credential.isEmpty()) {
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)
if(resultSub?.jsonObject != null) {
result.data = prepareBrowserUrl(client_info)
return result
}
}
}
// OAuth2 クライアント登録
val response = try {
val request = Request.Builder()
.url("https://$instance/api/v1/apps")
.post(RequestBody.create(MEDIA_TYPE_FORM_URL_ENCODED, "client_name=" + Uri.encode(client_name)
+ "&redirect_uris=" + Uri.encode(REDIRECT_URL)
+ "&scopes=read write follow"
))
.build()
sendRequest(request)
} catch(ex : Throwable) {
log.trace(ex)
return result.setError(instance + ": " + Utils.formatError(ex, context.resources, R.string.network_error))
}
val r2 = readJson(result, response)
val jsonObject = r2?.jsonObject ?: return r2
// {"id":999,"redirect_uri":"urn:ietf:wg:oauth:2.0:oob","client_id":"******","client_secret":"******"}
jsonObject.put(KEY_AUTH_VERSION, AUTH_VERSION)
ClientInfo.save(instance, client_name, jsonObject.toString())
result.data = prepareBrowserUrl(jsonObject)
return result
}
// oAuth2認証の続きを行う
fun authorize2(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")
var response = try {
val post_content = ("grant_type=authorization_code"
+ "&code=" + Uri.encode(code)
+ "&client_id=" + Uri.encode(Utils.optStringX(client_info, "client_id"))
+ "&redirect_uri=" + Uri.encode(REDIRECT_URL)
+ "&client_secret=" + Uri.encode(Utils.optStringX(client_info, "client_secret"))
+ "&scope=read write follow"
+ "&scopes=read write follow")
val request = Request.Builder()
.url("https://$instance/oauth/token")
.post(RequestBody.create(MEDIA_TYPE_FORM_URL_ENCODED, post_content))
.build()
sendRequest(request)
} catch(ex : Throwable) {
log.trace(ex)
return result.setError(instance + ": " + Utils.formatError(ex, context.resources, R.string.network_error))
}
val token_info : JSONObject
val r2 = readJson(result, response)
val jsonObject = r2?.jsonObject ?: return r2
// {"access_token":"******","token_type":"bearer","scope":"read","created_at":1492334641}
jsonObject.put(KEY_AUTH_VERSION, AUTH_VERSION)
token_info = jsonObject
result.token_info = jsonObject
val access_token = Utils.optStringX(token_info, "access_token")
if(access_token == null || access_token.isEmpty()) {
return result.setError("missing access_token in the response.")
}
response = try {
// 認証されたアカウントのユーザ名を取得する
val request = Request.Builder()
.url("https://$instance/api/v1/accounts/verify_credentials")
.header("Authorization", "Bearer $access_token")
.build()
sendRequest(request)
} catch(ex : Throwable) {
log.trace(ex)
return result.setError(instance + ": " + Utils.formatError(ex, context.resources, R.string.network_error))
}
return readJson(result, response)
}
// アクセストークン手動入力でアカウントを更新する場合
// verify_credentialsを呼び出す
fun checkAccessToken(access_token : String) : TootApiResult? {
val result = TootApiResult.makeWithCaption(instance)
if(result.error != null) return result
val token_info = JSONObject()
val response = try {
// 指定されたアクセストークンを使って token_info を捏造する
token_info.put("access_token", access_token)
// 認証されたアカウントのユーザ名を取得する
val request = Request.Builder()
.url("https://$instance/api/v1/accounts/verify_credentials")
.header("Authorization", "Bearer $access_token")
.build()
sendRequest(request)
} catch(ex : Throwable) {
log.trace(ex)
return result.setError(instance + ": " + Utils.formatError(ex, context.resources, R.string.network_error))
}
val r2 = readJson(result, response)
r2?.jsonObject ?: return r2
// credentialを読めたならtoken_infoを保存したい
result.token_info = token_info
return result
}
fun getHttp(url : String) : TootApiResult? {
val result = TootApiResult.makeWithCaption(url)
if(result.error != null) return result
val response = try {
sendRequest(Request.Builder().url(url).build(), url)
} catch(ex : Throwable) {
log.trace(ex)
return result.setError(url + ": " + Utils.formatError(ex, context.resources, R.string.network_error))
}
try {
if(callback.isApiCancelled) return null
2018-01-11 10:31:25 +01:00
val request = response.request()
if( request != null ){
callback.publishApiProgress(context.getString(R.string.reading_api, request.method(), url))
}
result.readBodyString(response)
2018-01-11 10:31:25 +01:00
if(callback.isApiCancelled) return null
2018-01-11 10:31:25 +01:00
callback.publishApiProgress(context.getString(R.string.parsing_response))
if(result.isErrorOrEmptyBody()) return result
result.data = result.bodyString
} catch(ex : Throwable) {
log.trace(ex)
result.error = Utils.formatResponse(response, result.caption, result.bodyString ?: "no information")
}
return result
}
private fun sendRequest(request : Request, showPath : String? = null) : Response {
callback.publishApiProgress(context.getString(R.string.request_api, request.method(), showPath ?: request.url().encodedPath()))
val call = ok_http_client.newCall(request)
call_callback?.onCallCreated(call)
return call.execute()
}
private fun readJson(result : TootApiResult, response : Response) : TootApiResult? {
try {
if(callback.isApiCancelled) return null
2018-01-11 10:31:25 +01:00
val request = response.request()
if( request != null ){
callback.publishApiProgress(context.getString(R.string.reading_api, request.method(), request.url().encodedPath()))
}
result.readBodyString(response)
2018-01-11 10:31:25 +01:00
if(callback.isApiCancelled) return null
2018-01-11 10:31:25 +01:00
callback.publishApiProgress(context.getString(R.string.parsing_response))
if(result.isErrorOrEmptyBody()) return result
val bodyString = result.bodyString
if(bodyString?.startsWith("[") == true) {
result.data = JSONArray(bodyString)
} else if(bodyString?.startsWith("{") == true) {
val json = JSONObject(bodyString)
val error = Utils.optStringX(json, "error")
if(error != null) {
result.error = "API returns error: $error"
} else {
result.data = json
}
} else {
result.error = context.getString(R.string.response_not_json) + "\n" + bodyString
}
} catch(ex : Throwable) {
log.trace(ex)
result.error = Utils.formatResponse(response, result.caption, result.bodyString ?: "no information")
}
return result
}
// private fun parseResponse(tokenInfo : JSONObject?, response : Response) : TootApiResult? {
// try {
// if(callback.isApiCancelled) return null
//
// if(! response.isSuccessful) {
// return TootApiResult(response, Utils.formatResponse(response, instance ?: "(no instance)"))
// }
//
// val bodyString = response.body()?.string() ?: throw RuntimeException("missing response body.")
// if(callback.isApiCancelled) return null
//
// callback.publishApiProgress(context.getString(R.string.parsing_response))
// return if(bodyString.startsWith("{")) {
//
// val obj = JSONObject(bodyString)
//
// val error = Utils.optStringX(obj, "error")
//
// if(error != null)
// TootApiResult(context.getString(R.string.api_error, error))
// else
// TootApiResult(response, tokenInfo, bodyString, obj)
//
// } else if(bodyString.startsWith("[")) {
// val array = JSONArray(bodyString)
// TootApiResult(response, tokenInfo, bodyString, array)
// } else {
// TootApiResult(response, Utils.formatResponse(response, instance ?: "(no instance)", bodyString))
// }
// } catch(ex : Throwable) {
// TootApiClient.log.trace(ex)
// return TootApiResult(Utils.formatError(ex, "API data error"))
// }
//
// }
}